diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..4edfd5f
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,27 @@
+Dear Copilot,
+
+## Project Overview
+
+BottleCRM is a dynamic, SaaS CRM platform designed to streamline the entire CRM needs of startups and enterprises. Built with modern web technologies, it offers a seamless experience for users through robust role-based access control (RBAC). Each user role is equipped with tailored functionalities to enhance efficiency, engagement, and management, ensuring a streamlined and secure business process.
+
+user types we have
+
+- Org
+ - user(s)
+ - Admin
+- super admin - anyone with @micropyramid.com email to manage whole platform
+
+## Project Context
+
+BottleCRM is a modern CRM application built with:
+- **Framework**: SvelteKit 2.21.x, Svelte 5.1, Prisma
+- **Styling**: tailwind 4.1.x css
+- **Database**: postgresql
+- **Icons**: lucide icons
+- **Form Validation**: zod
+
+## Important Notes
+- We need to ensure access control is strictly enforced based on user roles.
+- No record should be accessible unless the user or the org has the appropriate permissions.
+- When implementing forms in sveltekit A form label must be associated with a control
+- svelte 5+ style coding standards should be followed
\ No newline at end of file
diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml
new file mode 100644
index 0000000..92afc5d
--- /dev/null
+++ b/.github/workflows/build-deploy.yml
@@ -0,0 +1,56 @@
+name: Build and Deploy (Docker)
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: ghcr.io/${{ github.repository }}:latest
+
+ - name: Setup SSH
+ uses: webfactory/ssh-agent@v0.8.0
+ with:
+ ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
+
+ - name: Add host key to known_hosts
+ run: |
+ mkdir -p ~/.ssh
+ ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
+
+ - name: Deploy on server (pull and restart container)
+ run: |
+ ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} "docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} && \
+ docker pull ghcr.io/${{ github.repository }}:latest && \
+ docker stop svelte-crm || true && docker rm svelte-crm || true && \
+ docker run -d --name svelte-crm --restart always -p 3000:3000 \
+ -e NODE_ENV=production \
+ -e DATABASE_URL=\"${{ secrets.DATABASE_URL }}\" \
+ --env-file ${{ secrets.ENV_FILE_PATH:-/home/${{ secrets.SERVER_USER }}/.env }} \
+ ghcr.io/${{ github.repository }}:latest"
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 42381fc..c54de0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,26 +1,14 @@
+dist
node_modules
-
-# Output
-.output
-.vercel
-.netlify
+CLAUDE.md
+.cursor
+.vscode
+.idea
.wrangler
-/.svelte-kit
-/build
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Env
+.svelte-kit
+build
+.dev.vars
.env
-.env.*
-!.env.example
-!.env.test
-
-# Vite
-vite.config.js.timestamp-*
-vite.config.ts.timestamp-*
-
-generated/*
-src/generated/*
+.claude
+local-state
+.turbo/
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..6ebefa2
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,23 @@
+{
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true,
+ "[javascript]": {
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true
+ },
+ "[svelte]": {
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true
+ },
+ "github.copilot.chat.codeGeneration.instructions": [
+ {
+ "file": "prisma/schema.prisma",
+ },
+ {
+ "file": "src/hooks.server.js",
+ },
+ {
+ "file": "src/lib/prisma.js",
+ },
+ ]
+}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ea4eca6
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,178 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+BottleCRM is a multi-tenant SaaS CRM platform built as a monorepo with SvelteKit, designed for startups and enterprises with role-based access control (RBAC). The application features organization-based multi-tenancy with strict data isolation enforced at the database level.
+
+## Technology Stack
+
+- **Frontend**: SvelteKit 2.x with Svelte 5.x (TypeScript)
+- **Styling**: TailwindCSS 4.x
+- **Database**: PostgreSQL with Drizzle ORM
+- **Authentication**: Better Auth with organization plugin
+- **Icons**: Lucide Svelte
+- **Validation**: Zod
+- **Package Manager**: pnpm (v10.0.0)
+- **Build Tool**: Turbo (monorepo management)
+- **Deployment**: Cloudflare Workers/Pages
+
+## Monorepo Structure
+
+```
+├── apps/
+│ ├── web/ # SvelteKit frontend application
+│ └── api/ # Node.js API service (optional)
+├── shared/
+│ ├── database/ # Drizzle ORM schema and migrations
+│ └── constants/ # Shared constants across apps
+└── supabase/ # Supabase configuration (if used)
+```
+
+## Development Commands
+
+### Monorepo Root Commands
+```bash
+# Install dependencies
+pnpm install
+
+# Development (all apps)
+pnpm run dev
+
+# Build (all apps)
+pnpm run build
+
+# Web app specific
+pnpm run web:dev
+pnpm run web:build
+pnpm run web:preview
+
+# API app specific
+pnpm run api:dev
+pnpm run api:build
+```
+
+### Database Commands
+```bash
+# Generate SQL and types
+pnpm run db:generate
+
+# Run migrations (local)
+pnpm run db:migrate:local
+
+# Run migrations (production)
+pnpm run db:migrate:prod
+
+# Generate, migrate, build in one command (local)
+pnpm run db:gmb:local
+
+# Database studio UI
+pnpm run db:studio
+```
+
+### Web App Commands (from apps/web/)
+```bash
+# Type checking
+pnpm run check
+pnpm run check:watch
+
+# Linting and formatting
+pnpm run lint
+pnpm run format
+```
+
+## Architecture Overview
+
+### Multi-Tenant Structure
+- **Organizations**: Top-level tenant containers with complete data isolation
+- **Members**: Users belong to organizations with specific roles (member/admin)
+- **Sessions**: Track active organization via `activeOrganizationId`
+- **Super Admin**: Platform-wide access (determined by business logic, not email domain)
+
+### Core CRM Entities
+- **Leads**: Initial prospects that can be converted to Accounts/Contacts/Opportunities
+- **Accounts** (`crm_account`): Company/organization records
+- **Contacts**: Individual people associated with accounts
+- **Opportunities**: Sales deals with pipeline stages and forecast categories
+- **Tasks/Events**: Activity management linked to various entities
+- **Cases**: Customer support tickets with priority and status tracking
+- **Products/Quotes**: Product catalog and professional quotation system
+
+### Authentication & Authorization
+- **Better Auth**: Session-based authentication with JWT plugin support
+- **Organization Context**: Active organization stored in session (`activeOrganizationId`)
+- **Route Protection** in `apps/web/src/hooks.server.ts`:
+ - `/app/*` routes require authentication and organization membership
+ - `/admin/*` routes require authentication (additional checks in route logic)
+ - `/org` route for organization selection post-login
+- **Database Integration**: Drizzle adapter for Better Auth tables
+
+### Data Access Control
+- All CRM queries must filter by `organizationId`
+- Organization membership verified through `member` table
+- Strict foreign key constraints enforce data integrity
+- Audit logging tracks all data modifications
+
+### Route Structure
+- `(site)`: Public marketing pages
+- `(no-layout)`: Authentication pages (login, org selection)
+- `(app)`: Main CRM application (requires auth + active org)
+- `(admin)`: Platform administration
+
+### Key Files
+- `apps/web/src/hooks.server.ts`: Authentication setup and route guards
+- `apps/web/src/lib/auth.ts`: Better Auth configuration
+- `shared/database/src/schema/`: Database schema definitions
+ - `base.ts`: Authentication tables (user, session, organization, member)
+ - `app.ts`: CRM-specific tables
+ - `enums.ts`: PostgreSQL enums for type safety
+
+## Environment Configuration
+
+### Local Development (.dev.vars)
+Create `apps/web/.dev.vars` for local development:
+```env
+DATABASE_URL="postgresql://postgres:password@localhost:5432/bottlecrm?schema=public"
+BASE_URL="http://localhost:5173"
+GOOGLE_CLIENT_ID=""
+GOOGLE_CLIENT_SECRET=""
+```
+
+### Production (wrangler.jsonc)
+Configure in `apps/web/wrangler.jsonc` under `vars` section or use Cloudflare Secrets.
+
+## Database Schema Patterns
+
+### Entity Conventions
+- Primary keys: `id` (UUID via `randomUUID()`)
+- Timestamps: `createdAt`, `updatedAt` with defaults
+- Soft deletes: `isDeleted`, `deletedAt`, `deletedById`
+- Organization scoping: `organizationId` foreign key
+- Owner tracking: `ownerId` references user
+
+### Enum Usage
+All enums defined in `shared/database/src/schema/enums.ts`:
+- `leadStatus`, `leadSource`
+- `opportunityStage`, `opportunityType`
+- `taskStatus`, `taskPriority`
+- `caseStatus`, `quoteStatus`
+- Industry, rating, and other business enums
+
+## Form Development
+- Use proper TypeScript types for form data
+- Implement Zod schemas for validation
+- Ensure all form controls have associated labels
+- Follow existing patterns in the codebase
+
+## Testing Strategy
+- Run `pnpm run check` before committing
+- Ensure `pnpm run lint` passes
+- Build verification with `pnpm run build`
+
+## Security Requirements
+- Never expose cross-organization data
+- Always include `organizationId` in queries
+- Validate organization membership before data access
+- Use Drizzle's parameterized queries
+- Audit sensitive operations
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..48456ae
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,151 @@
+# Contributing to BottleCRM
+
+Thank you for your interest in contributing to BottleCRM! We're excited to have you join our community of developers working to make high-quality CRM software accessible to everyone.
+
+This document provides guidelines and instructions for contributing to the project.
+
+## Table of Contents
+
+- [Code of Conduct](#code-of-conduct)
+- [Getting Started](#getting-started)
+- [Development Workflow](#development-workflow)
+- [Pull Request Process](#pull-request-process)
+- [Reporting Bugs](#reporting-bugs)
+- [Feature Requests](#feature-requests)
+- [Coding Standards](#coding-standards)
+- [Community](#community)
+
+## Code of Conduct
+
+We are committed to providing a welcoming and inspiring community for all.
+
+## Getting Started
+
+### Prerequisites
+
+- Node.js (v16 or newer)
+- npm, pnpm, or yarn package manager
+- Git
+- A database (PostgreSQL recommended)
+
+### Setting Up Local Development
+
+1. Fork the repository on GitHub
+2. Clone your fork locally:
+ ```bash
+ git clone https://github.com/YOUR_USERNAME/bottlecrm.git
+ cd bottlecrm
+ ```
+3. Install dependencies:
+ ```bash
+ npm install
+ # or
+ pnpm install
+ # or
+ yarn
+ ```
+4. Configure your environment variables:
+ - Copy `.env.example` to `.env`
+ - Update the variables as needed for your local environment
+5. Run database migrations:
+ ```bash
+ npx prisma migrate dev
+ ```
+6. Start the development server:
+ ```bash
+ npm run dev
+ ```
+
+## Development Workflow
+
+1. Create a new branch for your work:
+ ```bash
+ git checkout -b feature/your-feature-name
+ # or
+ git checkout -b fix/issue-you-are-fixing
+ ```
+
+2. Make your changes and commit them using descriptive commit messages:
+ ```bash
+ git commit -m "feat: add new feature X that does Y"
+ ```
+ We follow the [Conventional Commits](https://www.conventionalcommits.org/) standard for commit messages.
+
+3. Push your branch to GitHub:
+ ```bash
+ git push origin feature/your-feature-name
+ ```
+
+4. Create a pull request from your branch to the main project repository.
+
+## Pull Request Process
+
+1. Ensure your code follows the project's coding standards.
+2. Update the documentation as needed.
+3. Add tests for new functionality.
+4. Ensure the test suite passes by running:
+ ```bash
+ npm run test
+ ```
+5. Your pull request will be reviewed by maintainers, who may request changes or provide feedback.
+6. Once approved, your pull request will be merged by a maintainer.
+
+## Reporting Bugs
+
+Please report bugs by opening an issue on our GitHub repository. When filing a bug report, please include:
+
+- A clear and descriptive title
+- Steps to reproduce the issue
+- Expected behavior
+- Actual behavior
+- Screenshots (if applicable)
+- Environment information (OS, browser, etc.)
+
+## Feature Requests
+
+We welcome suggestions for new features! To suggest a feature:
+
+1. Check if the feature has already been requested or is in development.
+2. Open a new issue describing:
+ - The feature you'd like to see
+ - The problem it solves
+ - How it should work
+ - Why it would be valuable to most users
+
+## Coding Standards
+
+- We use ESLint and Prettier for code formatting and linting.
+- Run `npm run lint` before submitting pull requests.
+- Write meaningful comments and documentation.
+- Follow the existing code style and patterns.
+- Write tests for new functionality.
+
+### Svelte Component Guidelines
+
+- Each component should have a clear, single responsibility.
+- Use Svelte's reactivity system effectively.
+- Keep components reasonably sized; consider breaking large components into smaller ones.
+- Use TypeScript for type safety when possible.
+
+### API Development Guidelines
+
+- Follow RESTful principles.
+- Return consistent response structures.
+- Handle errors gracefully and return appropriate status codes.
+- Document new endpoints.
+
+## Community
+
+Join our community to discuss the project, get help, or just hang out with other BottleCRM contributors:
+
+- [GitHub Discussions](https://github.com/yourusername/bottlecrm/discussions)
+- [Community Forum](#) (coming soon)
+- [Discord Server](#) (coming soon)
+
+## License
+
+By contributing to BottleCRM, you agree that your contributions will be licensed under the project's [MIT License](LICENSE).
+
+---
+
+Thank you for contributing to make CRM software accessible to everyone! ❤️
diff --git a/DEV.md b/DEV.md
new file mode 100644
index 0000000..e643f4c
--- /dev/null
+++ b/DEV.md
@@ -0,0 +1,68 @@
+## BottleCRM Dev Guide
+
+- Never use `$app` from SvelteKit. See: https://kit.svelte.dev/docs/packaging#best-practices
+
+### Monorepo
+
+- Package manager: pnpm
+- Workspaces:
+ - `apps/web` (SvelteKit)
+ - `apps/api` (Express/Node)
+ - `shared/database` (Drizzle ORM + Drizzle Kit migrations)
+
+### Node/Tooling
+
+- Node: `nvm use 22.13.0`
+- Install: `pnpm install`
+
+### Database (Drizzle)
+
+- Generate SQL and types: `pnpm --filter @opensource-startup-crm/database db:generate`
+- Dev migrations: `pnpm --filter @opensource-startup-crm/database db:migrate:local`
+- Prod migrations: `pnpm --filter @opensource-startup-crm/database db:migrate:prod`
+- Studio: `pnpm --filter @opensource-startup-crm/database db:studio`
+
+#### Drizzle workflow
+
+- Edit schema in `shared/database/src/schema/*`.
+- Generate migration + types:
+ - From repo root: `pnpm db:generate`
+ - Or inside package: `pnpm --filter @opensource-startup-crm/database db:generate`
+- Apply migrations locally:
+ - From root: `pnpm db:migrate:local`
+- Apply migrations in prod:
+ - From root: `pnpm db:migrate:prod`
+- One-shot (generate + migrate + build):
+ - Local: `pnpm --filter @opensource-startup-crm/database db:gmb:local`
+ - Prod: `pnpm --filter @opensource-startup-crm/database db:gmb:prod`
+
+Notes
+
+- Drizzle config: `shared/database/drizzle.config.ts`
+- Migrations output: `shared/database/migrations/`
+- Ensure database env vars are set before running commands (wrangler `.dev.vars` or system env).
+
+### Development
+
+- Run Web (SvelteKit): `pnpm --filter @opensource-startup-crm/web dev`
+- Run API: `pnpm --filter @opensource-startup-crm/api dev`
+
+Or use root scripts:
+
+- Web dev: `pnpm web:dev`
+- Web build: `pnpm web:build`
+- Web preview: `pnpm web:preview`
+- API dev: `pnpm api:dev`
+- API build: `pnpm api:build`
+
+### Lint/Type/Build
+
+- Type check: `pnpm --filter @opensource-startup-crm/web check`
+- Lint: `pnpm --filter @opensource-startup-crm/web lint`
+- Build: `pnpm --filter @opensource-startup-crm/web build`
+
+### Pre-commit checklist
+
+- `pnpm -r run lint`
+- `pnpm -r run build`
+- `pnpm --filter @opensource-startup-crm/web check`
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..fca0550
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,22 @@
+# syntax=docker/dockerfile:1
+
+FROM node:22-alpine AS builder
+WORKDIR /app
+COPY package.json pnpm-lock.yaml ./
+# Install pnpm using the official installation script
+RUN wget -qO- https://get.pnpm.io/install.sh | sh - && \
+ export PATH="/root/.local/share/pnpm:$PATH" && \
+ pnpm install --frozen-lockfile
+COPY . .
+RUN export PATH="/root/.local/share/pnpm:$PATH" && pnpm run build && npx prisma generate
+
+FROM node:22-alpine
+WORKDIR /app
+ENV NODE_ENV=production
+COPY --from=builder /app/package.json ./
+COPY --from=builder /app/pnpm-lock.yaml ./
+COPY --from=builder /app/build ./build
+COPY --from=builder /app/prisma ./prisma
+COPY --from=builder /app/node_modules ./node_modules
+EXPOSE 3000
+CMD ["node", "build"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8d75e40
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 MicroPyramid
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
index b5b2950..18852a5 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,248 @@
-# sv
+# BottleCRM: Free and Open Source Customer Relationship Management
-Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
+
Powerful, Modern Multi-Tenant CRM for Everyone
+
-## Creating a project
+BottleCRM is a free, open-source Customer Relationship Management solution designed to help small and medium businesses effectively manage their customer relationships. Built with modern technologies and enterprise-grade multi-tenancy, it offers a comprehensive set of features without the enterprise price tag.
-If you're seeing this, you've probably already done this step. Congrats!
+## ✨ Key Highlights
+
+- **Multi-Tenant Architecture**: Secure organization-based data isolation
+- **Role-Based Access Control**: Granular permissions for users and admins
+- **Modern Technology Stack**: Built with SvelteKit 2.x, Svelte 5.x, and PostgreSQL
+- **Mobile-First Design**: Responsive interface optimized for all devices
+
+## 🚀 Core Features
+
+### Sales & Lead Management
+
+- **Lead Management**: Track and nurture leads from initial contact to conversion
+- **Account Management**: Maintain detailed records of customer accounts and organizations
+- **Contact Management**: Store and organize all your customer contact information
+- **Opportunity Management**: Track deals through your sales pipeline with customizable stages
+
+### Customer Support
+
+- **Case Management**: Handle customer support cases and track resolution
+- **Solution Knowledge Base**: Maintain searchable solutions for common issues
+- **Multi-Channel Support**: Handle cases from various origins (email, web, phone)
+
+### Productivity & Collaboration
+
+- **Task Management**: Never miss a follow-up with built-in task tracking
+- **Event Management**: Schedule and manage meetings and activities
+- **Board Management**: Trello-like kanban boards for project tracking
+- **Comment System**: Collaborate with team members on records
+
+### Sales Tools
+
+- **Quote Management**: Generate professional quotes with line items
+- **Product Catalog**: Maintain product inventory with pricing
+- **Sales Pipeline**: Visual opportunity tracking with probability scoring
+
+### Administrative Features
+
+- **User Management**: Add team members with appropriate role assignments
+- **Organization Management**: Multi-tenant structure with data isolation
+- **Audit Logging**: Complete activity tracking for compliance
+- **Super Admin Panel**: Platform-wide management for system administrators
+
+## 🔮 Coming Soon
+
+- **Invoice Management**: Create, send, and track invoices (in development)
+- **Email Integration**: Connect your email accounts for seamless communication
+- **Analytics Dashboard**: Make data-driven decisions with powerful reporting tools
+- **API Integration**: REST API for third-party integrations
+
+## 🖥️ Technology Stack
+
+- **Frontend**: SvelteKit 2.x, Svelte 5.x, TailwindCSS 4.x
+- **Backend**: Node.js (API), Drizzle ORM/Drizzle Kit (queries, schema, migrations)
+- **Database**: PostgreSQL (recommended) with multi-tenant schema
+- **Authentication**: Session-based authentication with organization membership
+- **Icons**: Lucide Svelte icon library
+- **Validation**: Zod for type-safe form validation
+
+## 🚀 Getting Started
+
+### Prerequisites
+
+- **Node.js**: v22.13.0 (use nvm for version management)
+- **Package Manager**: pnpm (recommended)
+- **Database**: PostgreSQL (required for multi-tenancy features)
+
+### Installation (Monorepo)
+
+1. **Clone the repository:**
+
+```bash
+git clone https://github.com/micropyramid/svelte-crm.git
+cd svelte-crm
+```
+
+2. **Set up Node.js version:**
```bash
-# create a new project in the current directory
-npx sv create
+nvm use 22.13.0
+```
+
+3. **Install dependencies (monorepo):**
-# create a new project in my-app
-npx sv create my-app
+```bash
+pnpm install
```
-## Developing
+4. **Configure environment variables (Wrangler + .dev.vars):**
-Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+For local development (recommended), create a `.dev.vars` file inside the app that needs the vars (e.g., `apps/web/.dev.vars`). Example:
```bash
-npm run dev
+# apps/web/.dev.vars
+DATABASE_URL="postgresql://postgres:password@localhost:5432/bottlecrm?schema=public"
+JWT_SECRET=""
+GOOGLE_CLIENT_ID=""
+GOOGLE_CLIENT_SECRET=""
+```
-# or start the server and open the app in a new browser tab
-npm run dev -- --open
+To define variables for preview/production with Cloudflare Wrangler, add them to `wrangler.jsonc` under `vars` (e.g., `apps/web/wrangler.jsonc`):
+
+```jsonc
+{
+ // ...existing config
+ "vars": {
+ "DATABASE_URL": "postgresql://...",
+ "JWT_SECRET": "",
+ "GOOGLE_CLIENT_ID": "",
+ "GOOGLE_CLIENT_SECRET": ""
+ }
+}
```
-## Building
+For production, best practice is to use Cloudflare's Secrets Store and Hyperdrive.
+
+Notes
+
+- `.dev.vars` is not committed and is used by `wrangler dev` for local runs.
+- For non-Cloudflare processes (e.g., Node API), export env vars via your shell or a process manager as needed.
-To create a production version of your app:
+5. **Set up the database (shared/database/ Drizzle):**
```bash
-npm run build
+# Generate SQL and types
+pnpm --filter @opensource-startup-crm/database db:generate
+
+# Run database migrations (dev)
+pnpm --filter @opensource-startup-crm/database db:migrate:local
+
+
```
-You can preview the production build with `npm run preview`.
+6. **Start development servers:**
+
+```bash
+# Web (SvelteKit)
+pnpm --filter @opensource-startup-crm/web dev
+
+# API (Node/Express)
+pnpm --filter @opensource-startup-crm/api dev
+```
+
+### Development Workflow
+
+Before committing code, ensure quality checks pass:
+
+```bash
+# Type checking
+pnpm run check
+
+# Linting and formatting
+pnpm run lint
+
+# Build verification
+pnpm run build
+```
+
+### Production Deployment (Monorepo + Drizzle)
+
+```bash
+# Set Node.js version
+nvm use 22.13.0
+
+# Generate SQL and types
+pnpm --filter @opensource-startup-crm/database db:generate
+
+# Run production migrations
+pnpm --filter @opensource-startup-crm/database db:migrate:prod
+
+# Build applications
+pnpm --filter @opensource-startup-crm/web build
+pnpm --filter @opensource-startup-crm/api build
+
+# Preview web
+pnpm --filter @opensource-startup-crm/web preview
+```
+
+## 🏗️ Architecture & Security
+
+### Multi-Tenant Design
+
+- **Organization Isolation**: Complete data separation between organizations
+- **Role-Based Access**: Users can have different roles across organizations
+- **Session Management**: Secure cookie-based authentication with organization context
+
+### User Roles
+
+- **User**: Standard access to organization data
+- **Admin**: Organization-level administrative privileges
+- **Super Admin**: Platform-wide access (requires @micropyramid.com email)
+
+### Data Security
+
+- All database queries are organization-scoped
+- Strict permission validation on all routes
+- Audit logging for compliance and tracking
+
+## 📁 Project Structure
+
+```
+src/
+├── routes/
+│ ├── (site)/ # Public marketing pages
+│ ├── (no-layout)/ # Authentication pages
+│ ├── (app)/ # Main CRM application
+│ └── (admin)/ # Super admin panel
+├── lib/
+│ ├── stores/ # Svelte stores for state management
+│ ├── data/ # Static data and configurations
+│ └── utils/ # Utility functions
+└── hooks.server.js # Authentication and route protection
+```
+
+## 💬 Community and Feedback
+
+We love to hear from our users! Please share your feedback, report bugs, or suggest new features:
+
+- **Issues**: Open an issue on GitHub for bugs and feature requests
+- **Discussions**: Join community discussions for general questions
+- **Pull Requests**: Contribute code improvements and new features
+
+## 🤝 Contributing
+
+We welcome contributions of all kinds! See our [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get started.
+
+### Development Guidelines
+
+- Follow existing code patterns and conventions
+- Ensure all forms have proper accessibility (labels associated with controls)
+- Never use `$app` imports from SvelteKit (see packaging best practices)
+- Always filter database queries by organization membership
+- Add appropriate error handling and validation
+
+## 📄 License
+
+BottleCRM is open source software [licensed as MIT](LICENSE).
+
+---
-> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+_Built with ❤️ for small businesses everywhere. We believe quality CRM software should be accessible to everyone._
diff --git a/apps/api/README.md b/apps/api/README.md
new file mode 100644
index 0000000..74e4fce
--- /dev/null
+++ b/apps/api/README.md
@@ -0,0 +1,137 @@
+# BottleCRM API
+
+Express.js API for BottleCRM with JWT authentication, Swagger documentation, and configurable request logging.
+
+## Features
+
+- **Google OAuth Authentication**: Secure Google Sign-In for mobile apps
+- **Multi-tenant**: Organization-based data isolation using existing Prisma schema
+- **Swagger Documentation**: Interactive API documentation at `/api-docs`
+- **Request Logging**: Configurable input/output HTTP request logging
+- **Security**: Helmet, CORS, rate limiting
+- **Organization Access Control**: Ensures users can only access their organization's data
+
+## Quick Start
+
+1. The required environment variables are already added to your existing `.env` file.
+
+2. **Generate a secure JWT secret** (required for production):
+```bash
+# Using Node.js
+node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
+
+# Using OpenSSL (if available)
+openssl rand -hex 32
+
+# Using online generator (for development only)
+# Visit: https://generate-secret.vercel.app/32
+```
+
+3. Update your `.env` file with the generated secret:
+```env
+JWT_SECRET=your-generated-secret-key-here
+```
+
+4. Start the API server:
+```bash
+# Development with auto-reload
+pnpm run api:dev
+
+# Production
+pnpm run api:start
+```
+
+5. Visit Swagger documentation:
+```
+http://localhost:3001/api-docs
+```
+
+## Authentication
+
+1. **Google Login**: POST `/api/auth/google`
+ - Request: `{ "idToken": "google-id-token-from-mobile-app" }`
+ - Response: `{ "token": "jwt-token", "user": {...} }`
+
+2. **Use Token**: Include in Authorization header:
+ ```
+ Authorization: Bearer
+ ```
+
+3. **Select Organization**: Include organization ID in header:
+ ```
+ X-Organization-ID:
+ ```
+
+## API Endpoints
+
+### Authentication
+- `POST /api/auth/google` - Google OAuth mobile login
+- `GET /api/auth/me` - Get current user profile
+
+### Leads
+- `GET /api/leads` - Get organization leads (paginated)
+- `GET /api/leads/:id` - Get lead by ID
+- `POST /api/leads` - Create new lead
+
+### Accounts
+- `GET /api/accounts` - Get organization accounts
+- `POST /api/accounts` - Create new account
+
+### Contacts
+- `GET /api/contacts` - Get organization contacts
+- `POST /api/contacts` - Create new contact
+
+### Opportunities
+- `GET /api/opportunities` - Get organization opportunities
+- `POST /api/opportunities` - Create new opportunity
+
+## Configuration
+
+### Environment Variables
+
+- `API_PORT`: Server port (default: 3001)
+- `JWT_SECRET`: Secret key for JWT tokens (required) - **Generate using the commands above**
+- `JWT_EXPIRES_IN`: Token expiration time (default: 24h)
+- `FRONTEND_URL`: Frontend URL for CORS (default: http://localhost:5173)
+
+### Logging Configuration
+
+- `LOG_LEVEL`: Logging level (info, debug, error)
+- `ENABLE_REQUEST_LOGGING`: Enable/disable request logging (true/false)
+- `LOG_REQUEST_BODY`: Log request bodies (true/false)
+- `LOG_RESPONSE_BODY`: Log response bodies (true/false)
+
+### Security Features
+
+- **Rate Limiting**: 100 requests per 15 minutes per IP
+- **Helmet**: Security headers
+- **CORS**: Cross-origin request handling
+- **JWT Validation**: Token verification on protected routes
+- **Organization Isolation**: Users can only access their organization's data
+
+## Data Access Control
+
+All API endpoints enforce organization-based access control:
+
+1. **Authentication Required**: All endpoints (except login) require valid JWT token
+2. **Organization Header**: Protected endpoints require `X-Organization-ID` header
+3. **Membership Validation**: User must be a member of the specified organization
+4. **Data Filtering**: All database queries are filtered by organization ID
+
+## Development
+
+The API uses the same Prisma schema as the main SvelteKit application, ensuring data consistency and leveraging existing:
+
+- Database models and relationships
+- Organization-based multi-tenancy
+- User role management (ADMIN/USER)
+- Super admin access (@micropyramid.com domain)
+
+## Testing with Swagger
+
+Access the interactive API documentation at `http://localhost:3001/api-docs` to:
+
+1. Test authentication endpoints
+2. Explore available endpoints
+3. Test API calls with different parameters
+4. View request/response schemas
\ No newline at end of file
diff --git a/apps/api/config/logger.js b/apps/api/config/logger.js
new file mode 100644
index 0000000..fed578c
--- /dev/null
+++ b/apps/api/config/logger.js
@@ -0,0 +1,22 @@
+export const createLogger = () => {
+ return {
+ info: (message, meta) => {
+ console.log(`[INFO] ${message}`);
+ if (meta) {
+ console.log(JSON.stringify(meta, null, 2));
+ }
+ },
+ error: (message, meta) => {
+ console.error(`[ERROR] ${message}`);
+ if (meta) {
+ console.error(JSON.stringify(meta, null, 2));
+ }
+ },
+ warn: (message, meta) => {
+ console.warn(`[WARN] ${message}`);
+ if (meta) {
+ console.warn(JSON.stringify(meta, null, 2));
+ }
+ }
+ };
+};
\ No newline at end of file
diff --git a/apps/api/lib/db.js b/apps/api/lib/db.js
new file mode 100644
index 0000000..2642881
--- /dev/null
+++ b/apps/api/lib/db.js
@@ -0,0 +1,12 @@
+import { getDb, schema } from '@opensource-startup-crm/database';
+
+export const db = getDb({
+ DATABASE_URL: process.env.DATABASE_URL,
+ DEV_DATABASE_URL: process.env.DEV_DATABASE_URL,
+ ENV_TYPE: process.env.ENV_TYPE,
+ HYPERDRIVE: process.env.HYPERDRIVE
+});
+
+export { schema };
+
+
diff --git a/apps/api/middleware/auth.js b/apps/api/middleware/auth.js
new file mode 100644
index 0000000..41cb850
--- /dev/null
+++ b/apps/api/middleware/auth.js
@@ -0,0 +1,103 @@
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+import { db, schema } from '../lib/db.js';
+import { eq, innerJoin } from 'drizzle-orm';
+
+// Verify Better Auth JWTs using JWKS
+export const verifyToken = async (req, res, next) => {
+ try {
+ const token = req.header('Authorization')?.replace('Bearer ', '');
+ if (!token) {
+ return res.status(401).json({ error: 'Access denied. No token provided.' });
+ }
+
+ const baseUrl = process.env.PUBLIC_APP_URL || 'http://localhost:5173';
+ const jwks = createRemoteJWKSet(new URL(`${baseUrl}/api/auth/jwks`));
+
+ const { payload } = await jwtVerify(token, jwks, {
+ // audience / issuer can be configured if you set them in Better Auth
+ });
+
+ const userId = payload.sub;
+ if (!userId) {
+ return res.status(401).json({ error: 'Invalid token: missing subject.' });
+ }
+
+ // Fetch user and memberships via Drizzle
+ const [user] = await db
+ .select({
+ id: schema.user.id,
+ email: schema.user.email,
+ name: schema.user.name,
+ image: schema.user.image
+ })
+ .from(schema.user)
+ .where(eq(schema.user.id, userId));
+
+ if (!user) {
+ return res.status(401).json({ error: 'User not found.' });
+ }
+
+ const memberships = await db
+ .select({
+ organizationId: schema.member.organizationId,
+ role: schema.member.role,
+ organization: {
+ id: schema.organization.id,
+ name: schema.organization.name,
+ domain: schema.organization.domain
+ }
+ })
+ .from(schema.member)
+ .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId))
+ .where(eq(schema.member.userId, userId));
+
+ req.user = user;
+ req.userId = user.id;
+ req.memberships = memberships;
+ next();
+ } catch (error) {
+ console.error('Token verification error:', error);
+ return res.status(401).json({ error: 'Token validation failed.' });
+ }
+};
+
+export const requireOrganization = async (req, res, next) => {
+ try {
+ const organizationId = req.header('X-Organization-ID');
+
+ if (!organizationId) {
+ return res.status(400).json({ error: 'Organization ID is required in X-Organization-ID header.' });
+ }
+
+ const userOrg = (req.memberships || []).find(
+ (uo) => uo.organizationId === organizationId
+ );
+
+ if (!userOrg) {
+ return res.status(403).json({ error: 'Access denied to this organization.' });
+ }
+
+ req.organizationId = organizationId;
+ req.userRole = userOrg.role;
+ req.organization = userOrg.organization;
+ next();
+ } catch (error) {
+ return res.status(500).json({ error: 'Internal server error.' });
+ }
+};
+
+export const requireRole = (roles) => {
+ return (req, res, next) => {
+ if (!roles.includes(req.userRole)) {
+ return res.status(403).json({ error: 'Insufficient permissions.' });
+ }
+ next();
+ };
+};
+
+export const requireSuperAdmin = (req, res, next) => {
+ if (!req.user.email.endsWith('@micropyramid.com')) {
+ return res.status(403).json({ error: 'Super admin access required.' });
+ }
+ next();
+};
\ No newline at end of file
diff --git a/apps/api/middleware/errorHandler.js b/apps/api/middleware/errorHandler.js
new file mode 100644
index 0000000..b8af078
--- /dev/null
+++ b/apps/api/middleware/errorHandler.js
@@ -0,0 +1,24 @@
+import { createLogger } from '../config/logger.js';
+
+const logger = createLogger();
+
+export const errorHandler = (err, req, res, next) => {
+ logger.error('Unhandled Error', {
+ error: err.message,
+ stack: err.stack,
+ method: req.method,
+ url: req.url,
+ userId: req.user?.id,
+ organizationId: req.organizationId,
+ timestamp: new Date().toISOString(),
+ });
+
+ if (process.env.NODE_ENV === 'production') {
+ res.status(500).json({ error: 'Internal server error' });
+ } else {
+ res.status(500).json({
+ error: err.message,
+ stack: err.stack
+ });
+ }
+};
\ No newline at end of file
diff --git a/apps/api/middleware/requestLogger.js b/apps/api/middleware/requestLogger.js
new file mode 100644
index 0000000..d6989d8
--- /dev/null
+++ b/apps/api/middleware/requestLogger.js
@@ -0,0 +1,76 @@
+export const requestLogger = (req, res, next) => {
+ const start = Date.now();
+
+ const originalSend = res.send;
+ const originalJson = res.json;
+
+ let responseBody = null;
+ let requestBody = null;
+
+ if (req.body && Object.keys(req.body).length > 0) {
+ requestBody = { ...req.body };
+ if (requestBody.password) requestBody.password = '[REDACTED]';
+ if (requestBody.token) requestBody.token = '[REDACTED]';
+ }
+
+ res.send = function(body) {
+ responseBody = body;
+ return originalSend.call(this, body);
+ };
+
+ res.json = function(body) {
+ responseBody = body;
+ return originalJson.call(this, body);
+ };
+
+ res.on('finish', () => {
+ const duration = Date.now() - start;
+
+ const logData = {
+ method: req.method,
+ url: req.url,
+ statusCode: res.statusCode,
+ duration: `${duration}ms`,
+ userAgent: req.get('User-Agent'),
+ ip: req.ip,
+ timestamp: new Date().toISOString(),
+ };
+
+ if (process.env.LOG_REQUEST_BODY === 'true' && requestBody) {
+ logData.requestBody = requestBody;
+ }
+
+ if (process.env.LOG_RESPONSE_BODY === 'true' && responseBody) {
+ try {
+ logData.responseBody = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody;
+ } catch (e) {
+ logData.responseBody = responseBody;
+ }
+ }
+
+ if (req.user) {
+ logData.userId = req.user.id;
+ logData.userEmail = req.user.email;
+ }
+
+ if (req.organizationId) {
+ logData.organizationId = req.organizationId;
+ }
+
+ console.log(`\n=== HTTP REQUEST LOG ===`);
+ console.log(`${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`);
+
+ if (requestBody) {
+ console.log('REQUEST BODY:', JSON.stringify(requestBody, null, 2));
+ }
+
+ if (responseBody) {
+ console.log('RESPONSE BODY:', JSON.stringify(responseBody, null, 2));
+ }
+
+ console.log('FULL LOG DATA:', JSON.stringify(logData, null, 2));
+ console.log(`=== END LOG ===\n`);
+ });
+
+ next();
+};
\ No newline at end of file
diff --git a/apps/api/package.json b/apps/api/package.json
new file mode 100644
index 0000000..47ce6f0
--- /dev/null
+++ b/apps/api/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@opensource-startup-crm/api",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "nodemon server.js",
+ "start": "node server.js"
+ },
+ "devDependencies": {
+ "@better-auth/cli": "^1.3.4",
+ "@types/bcryptjs": "^3.0.0",
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.3",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/morgan": "^1.9.10",
+ "@types/swagger-jsdoc": "^6.0.4",
+ "@types/swagger-ui-express": "^4.1.8",
+ "drizzle-kit": "^0.31.4",
+ "drizzle-orm": "^0.44.4",
+ "globals": "^16.3.0",
+ "nodemon": "^3.1.10",
+ "typescript": "^5.8.3"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "esbuild"
+ ]
+ },
+ "dependencies": {
+ "@opensource-startup-crm/database": "workspace:*",
+ "axios": "^1.11.0",
+ "bcryptjs": "^3.0.2",
+ "better-auth": "^1.3.4",
+ "cors": "^2.8.5",
+ "date-fns": "^4.1.0",
+ "dotenv": "^17.2.1",
+ "express": "^5.1.0",
+ "express-rate-limit": "^8.0.1",
+ "google-auth-library": "^10.2.0",
+ "helmet": "^8.1.0",
+ "jose": "^5.9.6",
+ "jsonwebtoken": "^9.0.2",
+ "libphonenumber-js": "^1.12.10",
+ "marked": "^16.1.1",
+ "morgan": "^1.10.1",
+ "postgres": "^3.4.7",
+ "swagger-jsdoc": "^6.2.8",
+ "swagger-ui-express": "^5.0.1",
+ "uuid": "^11.1.0",
+ "winston": "^3.17.0",
+ "zod": "^4.0.8"
+ }
+}
diff --git a/apps/api/routes/accounts.js b/apps/api/routes/accounts.js
new file mode 100644
index 0000000..cf61584
--- /dev/null
+++ b/apps/api/routes/accounts.js
@@ -0,0 +1,155 @@
+import express from 'express';
+import { db, schema } from '../lib/db.js';
+import { desc, eq } from 'drizzle-orm';
+import { verifyToken, requireOrganization } from '../middleware/auth.js';
+
+const router = express.Router();
+
+router.use(verifyToken);
+router.use(requireOrganization);
+
+/**
+ * @swagger
+ * components:
+ * schemas:
+ * Account:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * name:
+ * type: string
+ * industry:
+ * type: string
+ * phone:
+ * type: string
+ * email:
+ * type: string
+ * website:
+ * type: string
+ * createdAt:
+ * type: string
+ * format: date-time
+ */
+
+/**
+ * @swagger
+ * /accounts:
+ * get:
+ * summary: Get all accounts for organization
+ * tags: [Accounts]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: List of accounts
+ */
+router.get('/', async (req, res) => {
+ try {
+ const accounts = await db
+ .select({
+ id: schema.crmAccount.id,
+ name: schema.crmAccount.name,
+ industry: schema.crmAccount.industry,
+ phone: schema.crmAccount.phone,
+ email: schema.crmAccount.website, // no email field in schema; website kept
+ website: schema.crmAccount.website,
+ createdAt: schema.crmAccount.createdAt,
+ owner: {
+ id: schema.user.id,
+ name: schema.user.name,
+ email: schema.user.email
+ }
+ })
+ .from(schema.crmAccount)
+ .leftJoin(schema.user, eq(schema.user.id, schema.crmAccount.ownerId))
+ .where(eq(schema.crmAccount.organizationId, req.organizationId))
+ .orderBy(desc(schema.crmAccount.createdAt));
+
+ res.json({ accounts });
+ } catch (error) {
+ console.error('Get accounts error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /accounts:
+ * post:
+ * summary: Create a new account
+ * tags: [Accounts]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - name
+ * properties:
+ * name:
+ * type: string
+ * industry:
+ * type: string
+ * phone:
+ * type: string
+ * email:
+ * type: string
+ * website:
+ * type: string
+ * responses:
+ * 201:
+ * description: Account created successfully
+ */
+router.post('/', async (req, res) => {
+ try {
+ const { name, industry, phone, email, website } = req.body;
+
+ if (!name) {
+ return res.status(400).json({ error: 'Account name is required' });
+ }
+
+ const [account] = await db
+ .insert(schema.crmAccount)
+ .values({
+ name,
+ industry,
+ phone,
+ website,
+ organizationId: req.organizationId,
+ ownerId: req.userId
+ })
+ .returning({
+ id: schema.crmAccount.id,
+ name: schema.crmAccount.name,
+ industry: schema.crmAccount.industry,
+ phone: schema.crmAccount.phone,
+ website: schema.crmAccount.website
+ });
+
+ const [owner] = await db
+ .select({ id: schema.user.id, name: schema.user.name, email: schema.user.email })
+ .from(schema.user)
+ .where(eq(schema.user.id, req.userId));
+
+ const response = { ...account, owner };
+
+ res.status(201).json(response);
+ } catch (error) {
+ console.error('Create account error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/apps/api/routes/auth.js b/apps/api/routes/auth.js
new file mode 100644
index 0000000..e9c84f6
--- /dev/null
+++ b/apps/api/routes/auth.js
@@ -0,0 +1,296 @@
+import express from 'express';
+import jwt from 'jsonwebtoken';
+import { OAuth2Client } from 'google-auth-library';
+import { verifyToken } from '../middleware/auth.js';
+import { db, schema } from '../lib/db.js';
+import { eq, ilike, and } from 'drizzle-orm';
+
+const router = express.Router();
+const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
+
+/**
+ * @swagger
+ * components:
+ * schemas:
+ * GoogleLoginRequest:
+ * type: object
+ * required:
+ * - idToken
+ * properties:
+ * idToken:
+ * type: string
+ * description: Google ID token from mobile app
+ * LoginResponse:
+ * type: object
+ * properties:
+ * token:
+ * type: string
+ * user:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * email:
+ * type: string
+ * firstName:
+ * type: string
+ * lastName:
+ * type: string
+ * profileImage:
+ * type: string
+ * organizations:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * name:
+ * type: string
+ * role:
+ * type: string
+ */
+
+
+/**
+ * @swagger
+ * /auth/me:
+ * get:
+ * summary: Get current user profile
+ * tags: [Authentication]
+ * responses:
+ * 200:
+ * description: User profile
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * user:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * email:
+ * type: string
+ * firstName:
+ * type: string
+ * lastName:
+ * type: string
+ * organizations:
+ * type: array
+ * 401:
+ * description: Unauthorized
+ */
+router.get('/me', verifyToken, async (req, res) => {
+ try {
+ const userResponse = {
+ id: req.user.id,
+ email: req.user.email,
+ firstName: req.user.firstName,
+ lastName: req.user.lastName,
+ organizations: req.user.userOrganizations.map(uo => ({
+ id: uo.organization.id,
+ name: uo.organization.name,
+ role: uo.role
+ }))
+ };
+
+ res.json({ user: userResponse });
+ } catch (error) {
+ console.error('Profile error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /auth/google:
+ * post:
+ * summary: Google OAuth mobile login
+ * tags: [Authentication]
+ * security: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/GoogleLoginRequest'
+ * responses:
+ * 200:
+ * description: Login successful
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/LoginResponse'
+ * 400:
+ * description: Invalid Google token or user not found
+ * 500:
+ * description: Server error
+ */
+router.post('/google', async (req, res) => {
+ try {
+ const { idToken } = req.body;
+
+ if (!idToken) {
+ return res.status(400).json({ error: 'Google ID token is required' });
+ }
+
+ // Support both web and mobile client IDs
+ const audiences = [
+ process.env.GOOGLE_CLIENT_ID
+ ];
+
+ const ticket = await googleClient.verifyIdToken({
+ idToken,
+ audience: audiences
+ });
+
+ const payload = ticket.getPayload();
+
+ if (!payload || !payload.email) {
+ return res.status(400).json({ error: 'Invalid Google token' });
+ }
+
+ // Upsert user via Drizzle using onConflictDoUpdate
+ const now = new Date();
+ const fallbackName = (payload.name || `${payload.given_name || ''} ${payload.family_name || ''}`.trim() || (payload.email?.split('@')[0] || 'User')).trim();
+ const updateSet = {
+ image: payload.picture,
+ lastLogin: now
+ };
+ if (payload.name) Object.assign(updateSet, { name: payload.name });
+
+ const [user] = await db
+ .insert(schema.user)
+ .values({
+ email: payload.email,
+ name: fallbackName,
+ image: payload.picture,
+ lastLogin: now
+ })
+ .onConflictDoUpdate({ target: schema.user.email, set: updateSet })
+ .returning({
+ id: schema.user.id,
+ email: schema.user.email,
+ name: schema.user.name,
+ image: schema.user.image
+ });
+
+ // Create JWT token for API access
+ const JWTtoken = jwt.sign(
+ { userId: user.id },
+ process.env.JWT_SECRET,
+ { expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
+ );
+
+ // Calculate expiration date
+ const expiresIn = process.env.JWT_EXPIRES_IN || '24h';
+ const expirationHours = expiresIn.includes('h') ? parseInt(expiresIn) : 24;
+ const expiresAt = new Date(Date.now() + expirationHours * 60 * 60 * 1000);
+
+ // Note: We no longer store JWTs in DB; Better Auth issues/validates via JWKS.
+
+ // Format response to match SvelteKit patterns
+ const userResponse = {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ profileImage: user.image,
+ };
+
+ // Fetch organizations memberships
+ const orgs = await db
+ .select({
+ id: schema.organization.id,
+ name: schema.organization.name,
+ role: schema.member.role
+ })
+ .from(schema.member)
+ .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId))
+ .where(eq(schema.member.userId, user.id));
+
+ res.json({
+ success: true,
+ JWTtoken,
+ user: userResponse,
+ organizations: orgs.map((o) => ({ id: o.id, name: o.name, role: o.role }))
+ });
+ } catch (error) {
+ console.error('Google login error:', error);
+ if (error.message && error.message.includes('Invalid token')) {
+ return res.status(400).json({ error: 'Invalid Google token' });
+ }
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /auth/logout:
+ * post:
+ * summary: Logout and revoke current JWT token
+ * tags: [Authentication]
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 200:
+ * description: Successfully logged out
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * message:
+ * type: string
+ * 401:
+ * description: Unauthorized
+ */
+router.post('/logout', verifyToken, async (req, res) => {
+ try {
+ // Better Auth JWTs cannot be revoked server-side here; instruct clients to drop the token.
+ res.json({ success: true, message: 'Logged out (client should discard token).' });
+ } catch (error) {
+ console.error('Logout error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /auth/revoke-all:
+ * post:
+ * summary: Revoke all JWT tokens for current user
+ * tags: [Authentication]
+ * security:
+ * - bearerAuth: []
+ * responses:
+ * 200:
+ * description: Successfully revoked all tokens
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * message:
+ * type: string
+ * revokedCount:
+ * type: integer
+ * 401:
+ * description: Unauthorized
+ */
+router.post('/revoke-all', verifyToken, async (req, res) => {
+ try {
+ // Not applicable with stateless JWT validation. Return success.
+ res.json({ success: true, message: 'All tokens considered revoked (stateless JWT).' });
+ } catch (error) {
+ console.error('Revoke all tokens error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/apps/api/routes/contacts.js b/apps/api/routes/contacts.js
new file mode 100644
index 0000000..3660936
--- /dev/null
+++ b/apps/api/routes/contacts.js
@@ -0,0 +1,372 @@
+import express from 'express';
+import { db, schema } from '../lib/db.js';
+import { and, desc, eq } from 'drizzle-orm';
+import { verifyToken, requireOrganization } from '../middleware/auth.js';
+
+const router = express.Router();
+
+router.use(verifyToken);
+router.use(requireOrganization);
+
+/**
+ * @swagger
+ * /contacts:
+ * get:
+ * summary: Get all contacts for organization
+ * tags: [Contacts]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: List of contacts
+ */
+router.get('/', async (req, res) => {
+ try {
+ // Single query with joins for owner and primary related account
+ const rows = await db
+ .select({
+ id: schema.contact.id,
+ firstName: schema.contact.firstName,
+ lastName: schema.contact.lastName,
+ email: schema.contact.email,
+ phone: schema.contact.phone,
+ title: schema.contact.title,
+ department: schema.contact.department,
+ createdAt: schema.contact.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email,
+ relContactId: schema.accountContactRelationship.contactId,
+ relAccountId: schema.crmAccount.id,
+ relAccountName: schema.crmAccount.name
+ })
+ .from(schema.contact)
+ .leftJoin(schema.user, eq(schema.user.id, schema.contact.ownerId))
+ .leftJoin(
+ schema.accountContactRelationship,
+ and(
+ eq(schema.accountContactRelationship.contactId, schema.contact.id),
+ eq(schema.accountContactRelationship.isPrimary, true)
+ )
+ )
+ .leftJoin(
+ schema.crmAccount,
+ and(
+ eq(schema.crmAccount.id, schema.accountContactRelationship.accountId),
+ eq(schema.crmAccount.organizationId, req.organizationId)
+ )
+ )
+ .where(eq(schema.contact.organizationId, req.organizationId))
+ .orderBy(desc(schema.contact.createdAt));
+
+ // Group by contact
+ const byId = new Map();
+ for (const r of rows) {
+ if (!byId.has(r.id)) {
+ byId.set(r.id, {
+ id: r.id,
+ firstName: r.firstName,
+ lastName: r.lastName,
+ email: r.email,
+ phone: r.phone,
+ title: r.title,
+ department: r.department,
+ createdAt: r.createdAt,
+ relatedAccounts: [],
+ owner: r.ownerId ? { id: r.ownerId, name: r.ownerName, email: r.ownerEmail } : null
+ });
+ }
+ if (r.relAccountId) {
+ byId.get(r.id).relatedAccounts.push({ account: { id: r.relAccountId, name: r.relAccountName } });
+ }
+ }
+
+ res.json({ contacts: Array.from(byId.values()) });
+ } catch (error) {
+ console.error('Get contacts error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /contacts:
+ * post:
+ * summary: Create a new contact
+ * tags: [Contacts]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - firstName
+ * - lastName
+ * properties:
+ * firstName:
+ * type: string
+ * lastName:
+ * type: string
+ * email:
+ * type: string
+ * phone:
+ * type: string
+ * title:
+ * type: string
+ * department:
+ * type: string
+ * street:
+ * type: string
+ * city:
+ * type: string
+ * state:
+ * type: string
+ * postalCode:
+ * type: string
+ * country:
+ * type: string
+ * description:
+ * type: string
+ * accountId:
+ * type: string
+ * description: UUID of the account to associate with this contact
+ * responses:
+ * 201:
+ * description: Contact created successfully
+ * 400:
+ * description: Validation error
+ */
+router.post('/', async (req, res) => {
+ try {
+ const { firstName, lastName, email, phone, title, department, street, city, state, postalCode, country, description, accountId } = req.body;
+
+ if (!firstName || !lastName) {
+ return res.status(400).json({ error: 'First name and last name are required' });
+ }
+
+ // Validate account if provided
+ if (accountId) {
+ const [account] = await db
+ .select({ id: schema.crmAccount.id })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), eq(schema.crmAccount.organizationId, req.organizationId)));
+ if (!account) return res.status(400).json({ error: 'Account not found in your organization' });
+ }
+
+ // Check for duplicate email within the organization if email is provided
+ if (email) {
+ const [existingContact] = await db
+ .select({ id: schema.contact.id })
+ .from(schema.contact)
+ .where(and(eq(schema.contact.email, email), eq(schema.contact.organizationId, req.organizationId)));
+ if (existingContact) return res.status(400).json({ error: 'A contact with this email already exists in this organization' });
+ }
+
+ // Create the contact
+ const [contact] = await db
+ .insert(schema.contact)
+ .values({
+ firstName,
+ lastName,
+ email: email || null,
+ phone: phone || null,
+ title: title || null,
+ department: department || null,
+ street: street || null,
+ city: city || null,
+ state: state || null,
+ postalCode: postalCode || null,
+ country: country || null,
+ description: description || null,
+ organizationId: req.organizationId,
+ ownerId: req.userId
+ })
+ .returning({ id: schema.contact.id });
+
+ // Create account-contact relationship if accountId is provided
+ if (accountId) {
+ await db.insert(schema.accountContactRelationship).values({
+ accountId,
+ contactId: contact.id,
+ isPrimary: true
+ });
+ }
+
+ // Fetch the created contact with relationships (single joined query)
+ const createdRows = await db
+ .select({
+ id: schema.contact.id,
+ firstName: schema.contact.firstName,
+ lastName: schema.contact.lastName,
+ email: schema.contact.email,
+ phone: schema.contact.phone,
+ title: schema.contact.title,
+ department: schema.contact.department,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email,
+ relAccountId: schema.crmAccount.id,
+ relAccountName: schema.crmAccount.name
+ })
+ .from(schema.contact)
+ .leftJoin(schema.user, eq(schema.user.id, schema.contact.ownerId))
+ .leftJoin(schema.accountContactRelationship, eq(schema.accountContactRelationship.contactId, schema.contact.id))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.accountContactRelationship.accountId))
+ .where(eq(schema.contact.id, contact.id));
+
+ const base = createdRows[0];
+ const response = {
+ id: base.id,
+ firstName: base.firstName,
+ lastName: base.lastName,
+ email: base.email,
+ phone: base.phone,
+ title: base.title,
+ department: base.department,
+ ownerId: base.ownerId,
+ ownerName: base.ownerName,
+ ownerEmail: base.ownerEmail,
+ relatedAccounts: createdRows
+ .filter((r) => r.relAccountId)
+ .map((r) => ({ account: { id: r.relAccountId, name: r.relAccountName } }))
+ };
+
+ res.status(201).json(response);
+ } catch (error) {
+ console.error('Create contact error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /contacts/{id}:
+ * get:
+ * summary: Get a specific contact by ID
+ * tags: [Contacts]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: Contact ID
+ * responses:
+ * 200:
+ * description: Contact details
+ * 404:
+ * description: Contact not found
+ */
+router.get('/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const [row] = await db
+ .select({
+ id: schema.contact.id,
+ firstName: schema.contact.firstName,
+ lastName: schema.contact.lastName,
+ email: schema.contact.email,
+ phone: schema.contact.phone,
+ title: schema.contact.title,
+ department: schema.contact.department,
+ description: schema.contact.description,
+ street: schema.contact.street,
+ city: schema.contact.city,
+ state: schema.contact.state,
+ postalCode: schema.contact.postalCode,
+ country: schema.contact.country,
+ createdAt: schema.contact.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email,
+ orgId: schema.organization.id,
+ orgName: schema.organization.name
+ })
+ .from(schema.contact)
+ .leftJoin(schema.user, eq(schema.user.id, schema.contact.ownerId))
+ .leftJoin(schema.organization, eq(schema.organization.id, schema.contact.organizationId))
+ .where(and(eq(schema.contact.id, id), eq(schema.contact.organizationId, req.organizationId)));
+
+ if (!row) return res.status(404).json({ error: 'Contact not found' });
+
+ // Fetch related accounts via single join query and aggregate
+ const rows = await db
+ .select({
+ id: schema.contact.id,
+ firstName: schema.contact.firstName,
+ lastName: schema.contact.lastName,
+ email: schema.contact.email,
+ phone: schema.contact.phone,
+ title: schema.contact.title,
+ department: schema.contact.department,
+ description: schema.contact.description,
+ street: schema.contact.street,
+ city: schema.contact.city,
+ state: schema.contact.state,
+ postalCode: schema.contact.postalCode,
+ country: schema.contact.country,
+ createdAt: schema.contact.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email,
+ orgId: schema.organization.id,
+ orgName: schema.organization.name,
+ relAccountId: schema.crmAccount.id,
+ relAccountName: schema.crmAccount.name,
+ relAccountType: schema.crmAccount.type,
+ relAccountWebsite: schema.crmAccount.website,
+ relAccountPhone: schema.crmAccount.phone
+ })
+ .from(schema.contact)
+ .leftJoin(schema.user, eq(schema.user.id, schema.contact.ownerId))
+ .leftJoin(schema.organization, eq(schema.organization.id, schema.contact.organizationId))
+ .leftJoin(schema.accountContactRelationship, eq(schema.accountContactRelationship.contactId, schema.contact.id))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.accountContactRelationship.accountId))
+ .where(and(eq(schema.contact.id, id), eq(schema.contact.organizationId, req.organizationId)));
+
+ const baseRow = rows[0];
+ const response = {
+ id: baseRow.id,
+ firstName: baseRow.firstName,
+ lastName: baseRow.lastName,
+ email: baseRow.email,
+ phone: baseRow.phone,
+ title: baseRow.title,
+ department: baseRow.department,
+ description: baseRow.description,
+ street: baseRow.street,
+ city: baseRow.city,
+ state: baseRow.state,
+ postalCode: baseRow.postalCode,
+ country: baseRow.country,
+ createdAt: baseRow.createdAt,
+ relatedAccounts: rows.filter((r) => r.relAccountId).map((r) => ({ account: { id: r.relAccountId, name: r.relAccountName, type: r.relAccountType, website: r.relAccountWebsite, phone: r.relAccountPhone } })),
+ owner: baseRow.ownerId ? { id: baseRow.ownerId, name: baseRow.ownerName, email: baseRow.ownerEmail } : null,
+ organization: baseRow.orgId ? { id: baseRow.orgId, name: baseRow.orgName } : null
+ };
+
+ res.json(response);
+ } catch (error) {
+ console.error('Get contact details error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/apps/api/routes/dashboard.js b/apps/api/routes/dashboard.js
new file mode 100644
index 0000000..55432bd
--- /dev/null
+++ b/apps/api/routes/dashboard.js
@@ -0,0 +1,309 @@
+import express from 'express';
+import { verifyToken, requireOrganization } from '../middleware/auth.js';
+import { db, schema } from '../lib/db.js';
+import { and, desc, eq, gte, not, sql } from 'drizzle-orm';
+
+const router = express.Router();
+
+/**
+ * @swagger
+ * components:
+ * schemas:
+ * DashboardMetrics:
+ * type: object
+ * properties:
+ * totalLeads:
+ * type: integer
+ * description: Number of active leads
+ * totalOpportunities:
+ * type: integer
+ * description: Number of open opportunities
+ * totalAccounts:
+ * type: integer
+ * description: Number of active accounts
+ * totalContacts:
+ * type: integer
+ * description: Number of contacts
+ * pendingTasks:
+ * type: integer
+ * description: Number of pending tasks for the user
+ * opportunityRevenue:
+ * type: number
+ * description: Total pipeline value
+ * DashboardRecentData:
+ * type: object
+ * properties:
+ * leads:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * firstName:
+ * type: string
+ * lastName:
+ * type: string
+ * company:
+ * type: string
+ * status:
+ * type: string
+ * createdAt:
+ * type: string
+ * format: date-time
+ * opportunities:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * name:
+ * type: string
+ * amount:
+ * type: number
+ * account:
+ * type: object
+ * properties:
+ * name:
+ * type: string
+ * createdAt:
+ * type: string
+ * format: date-time
+ * tasks:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * subject:
+ * type: string
+ * status:
+ * type: string
+ * priority:
+ * type: string
+ * dueDate:
+ * type: string
+ * format: date-time
+ * activities:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * action:
+ * type: string
+ * entityType:
+ * type: string
+ * description:
+ * type: string
+ * timestamp:
+ * type: string
+ * format: date-time
+ * user:
+ * type: object
+ * properties:
+ * name:
+ * type: string
+ * DashboardResponse:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * metrics:
+ * $ref: '#/components/schemas/DashboardMetrics'
+ * recentData:
+ * $ref: '#/components/schemas/DashboardRecentData'
+ */
+
+/**
+ * @swagger
+ * /dashboard:
+ * get:
+ * summary: Get dashboard data with metrics and recent activity
+ * tags: [Dashboard]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * description: Organization ID
+ * responses:
+ * 200:
+ * description: Dashboard data retrieved successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/DashboardResponse'
+ * 400:
+ * description: Missing organization ID
+ * 401:
+ * description: Unauthorized
+ * 403:
+ * description: Access denied to organization
+ * 500:
+ * description: Internal server error
+ */
+router.get('/', verifyToken, requireOrganization, async (req, res) => {
+ try {
+ const userId = req.userId;
+ const organizationId = req.organizationId;
+
+ // Fetch dashboard metrics - parallel execution for performance
+ const [countsRow] = await db
+ .select({
+ totalLeads: count(),
+ totalOpportunities: db.$count(schema.opportunity, and(eq(schema.opportunity.organizationId, organizationId), not(eq(schema.opportunity.stage, 'CLOSED_WON')))),
+ totalAccounts: db.$count(schema.crmAccount, and(eq(schema.crmAccount.organizationId, organizationId), eq(schema.crmAccount.isActive, true))),
+ totalContacts: db.$count(schema.contact, eq(schema.contact.organizationId, organizationId)),
+ pendingTasks: db.$count(schema.task, and(eq(schema.task.organizationId, organizationId), not(eq(schema.task.status, 'Completed')), eq(schema.task.ownerId, userId)))
+ }).from(schema.lead).where(and(eq(schema.lead.organizationId, organizationId), eq(schema.lead.isConverted, false)));
+
+ const recentLeads = await db
+ .select({ id: schema.lead.id, firstName: schema.lead.firstName, lastName: schema.lead.lastName, company: schema.lead.company, status: schema.lead.status, createdAt: schema.lead.createdAt })
+ .from(schema.lead)
+ .where(eq(schema.lead.organizationId, organizationId))
+ .orderBy(desc(schema.lead.createdAt))
+ .limit(5);
+
+ const recentOpportunities = await db
+ .select({ id: schema.opportunity.id, name: schema.opportunity.name, amount: schema.opportunity.amount, account: { name: schema.crmAccount.name } })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .where(eq(schema.opportunity.organizationId, organizationId))
+ .orderBy(desc(schema.opportunity.createdAt))
+ .limit(5);
+
+ const upcomingTasks = await db
+ .select({ id: schema.task.id, subject: schema.task.subject, status: schema.task.status, priority: schema.task.priority, dueDate: schema.task.dueDate })
+ .from(schema.task)
+ .where(and(eq(schema.task.organizationId, organizationId), eq(schema.task.ownerId, userId), not(eq(schema.task.status, 'Completed')), gte(schema.task.dueDate, new Date())))
+ .orderBy(desc(schema.task.dueDate))
+ .limit(5);
+
+ const recentActivities = await db
+ .select({ id: schema.auditLog.id, action: schema.auditLog.action, entityType: schema.auditLog.entityType, description: schema.auditLog.description, timestamp: schema.auditLog.timestamp, user: { name: schema.user.name } })
+ .from(schema.auditLog)
+ .leftJoin(schema.user, eq(schema.user.id, schema.auditLog.userId))
+ .where(eq(schema.auditLog.organizationId, organizationId))
+ .orderBy(desc(schema.auditLog.timestamp))
+ .limit(10);
+
+ // Calculate opportunity revenue
+ const [{ sumAmount }] = await db
+ .select({ sumAmount: sql`sum(${schema.opportunity.amount})` })
+ .from(schema.opportunity)
+ .where(eq(schema.opportunity.organizationId, organizationId));
+
+ const response = {
+ success: true,
+ metrics: {
+ totalLeads: countsRow.totalLeads,
+ totalOpportunities: countsRow.totalOpportunities,
+ totalAccounts: countsRow.totalAccounts,
+ totalContacts: countsRow.totalContacts,
+ pendingTasks: countsRow.pendingTasks,
+ opportunityRevenue: Number(sumAmount || 0)
+ },
+ recentData: {
+ leads: recentLeads,
+ opportunities: recentOpportunities,
+ tasks: upcomingTasks,
+ activities: recentActivities
+ }
+ };
+
+ res.json(response);
+ } catch (error) {
+ console.error('Dashboard API error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to load dashboard data'
+ });
+ }
+});
+
+/**
+ * @swagger
+ * /dashboard/metrics:
+ * get:
+ * summary: Get dashboard metrics only (lightweight endpoint)
+ * tags: [Dashboard]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * description: Organization ID
+ * responses:
+ * 200:
+ * description: Dashboard metrics retrieved successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * metrics:
+ * $ref: '#/components/schemas/DashboardMetrics'
+ * 400:
+ * description: Missing organization ID
+ * 401:
+ * description: Unauthorized
+ * 403:
+ * description: Access denied to organization
+ * 500:
+ * description: Internal server error
+ */
+router.get('/metrics', verifyToken, requireOrganization, async (req, res) => {
+ try {
+ const userId = req.userId;
+ const organizationId = req.organizationId;
+
+ // Fetch only metrics for lightweight response
+ const [countsRow2] = await db
+ .select({
+ totalLeads: db.$count(schema.lead, and(eq(schema.lead.organizationId, organizationId), eq(schema.lead.isConverted, false))),
+ totalOpportunities: db.$count(schema.opportunity, and(eq(schema.opportunity.organizationId, organizationId), not(eq(schema.opportunity.stage, 'CLOSED_WON')))),
+ totalAccounts: db.$count(schema.crmAccount, and(eq(schema.crmAccount.organizationId, organizationId), eq(schema.crmAccount.isActive, true))),
+ totalContacts: db.$count(schema.contact, eq(schema.contact.organizationId, organizationId)),
+ pendingTasks: db.$count(schema.task, and(eq(schema.task.organizationId, organizationId), not(eq(schema.task.status, 'Completed')), eq(schema.task.ownerId, userId)))
+ });
+
+ const [{ sumAmount: sumAmount2 }] = await db
+ .select({ sumAmount: sql`sum(${schema.opportunity.amount})` })
+ .from(schema.opportunity)
+ .where(eq(schema.opportunity.organizationId, organizationId));
+
+ const response = {
+ success: true,
+ metrics: {
+ totalLeads: countsRow2.totalLeads,
+ totalOpportunities: countsRow2.totalOpportunities,
+ totalAccounts: countsRow2.totalAccounts,
+ totalContacts: countsRow2.totalContacts,
+ pendingTasks: countsRow2.pendingTasks,
+ opportunityRevenue: Number(sumAmount2 || 0)
+ }
+ };
+
+ res.json(response);
+ } catch (error) {
+ console.error('Dashboard metrics API error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to load dashboard metrics'
+ });
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/apps/api/routes/leads.js b/apps/api/routes/leads.js
new file mode 100644
index 0000000..519e533
--- /dev/null
+++ b/apps/api/routes/leads.js
@@ -0,0 +1,558 @@
+import express from 'express';
+import { verifyToken, requireOrganization } from '../middleware/auth.js';
+import { db, schema } from '../lib/db.js';
+import { and, desc, eq, ilike, not, sql } from 'drizzle-orm';
+
+const router = express.Router();
+
+router.use(verifyToken);
+router.use(requireOrganization);
+
+/**
+ * @swagger
+ * /leads/metadata:
+ * get:
+ * summary: Get leads metadata (enums, options, etc.)
+ * tags: [Leads]
+ * responses:
+ * 200:
+ * description: Leads metadata
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * leadStatuses:
+ * type: array
+ * items:
+ * type: string
+ * leadSources:
+ * type: array
+ * items:
+ * type: string
+ * ratings:
+ * type: array
+ * items:
+ * type: string
+ * industries:
+ * type: array
+ * items:
+ * type: string
+ */
+router.get('/metadata', async (req, res) => {
+ try {
+ const metadata = {
+ leadStatuses: [
+ 'NEW',
+ 'PENDING',
+ 'CONTACTED',
+ 'QUALIFIED',
+ 'UNQUALIFIED',
+ 'CONVERTED'
+ ],
+ leadSources: [
+ 'WEB',
+ 'PHONE_INQUIRY',
+ 'PARTNER_REFERRAL',
+ 'COLD_CALL',
+ 'TRADE_SHOW',
+ 'EMPLOYEE_REFERRAL',
+ 'ADVERTISEMENT',
+ 'OTHER'
+ ],
+ ratings: [
+ 'Hot',
+ 'Warm',
+ 'Cold'
+ ],
+ industries: [
+ 'Technology',
+ 'Healthcare',
+ 'Finance',
+ 'Education',
+ 'Manufacturing',
+ 'Retail',
+ 'Real Estate',
+ 'Consulting',
+ 'Media',
+ 'Transportation',
+ 'Energy',
+ 'Government',
+ 'Non-profit',
+ 'Other'
+ ]
+ };
+
+ res.json(metadata);
+ } catch (error) {
+ console.error('Get leads metadata error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * components:
+ * schemas:
+ * Lead:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * firstName:
+ * type: string
+ * lastName:
+ * type: string
+ * email:
+ * type: string
+ * phone:
+ * type: string
+ * company:
+ * type: string
+ * title:
+ * type: string
+ * status:
+ * type: string
+ * enum: [NEW, PENDING, CONTACTED, QUALIFIED, UNQUALIFIED, CONVERTED]
+ * leadSource:
+ * type: string
+ * enum: [WEB, PHONE_INQUIRY, PARTNER_REFERRAL, COLD_CALL, TRADE_SHOW, EMPLOYEE_REFERRAL, ADVERTISEMENT, OTHER]
+ * industry:
+ * type: string
+ * rating:
+ * type: string
+ * enum: [Hot, Warm, Cold]
+ * description:
+ * type: string
+ * isConverted:
+ * type: boolean
+ * convertedAt:
+ * type: string
+ * format: date-time
+ * createdAt:
+ * type: string
+ * format: date-time
+ * updatedAt:
+ * type: string
+ * format: date-time
+ */
+
+/**
+ * @swagger
+ * /leads:
+ * get:
+ * summary: Get all leads for organization
+ * tags: [Leads]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: page
+ * schema:
+ * type: integer
+ * default: 1
+ * - in: query
+ * name: limit
+ * schema:
+ * type: integer
+ * default: 10
+ * - in: query
+ * name: search
+ * schema:
+ * type: string
+ * description: Search by name, email, or company
+ * - in: query
+ * name: status
+ * schema:
+ * type: string
+ * enum: [NEW, PENDING, CONTACTED, QUALIFIED, UNQUALIFIED, CONVERTED]
+ * description: Filter by lead status
+ * - in: query
+ * name: leadSource
+ * schema:
+ * type: string
+ * enum: [WEB, PHONE_INQUIRY, PARTNER_REFERRAL, COLD_CALL, TRADE_SHOW, EMPLOYEE_REFERRAL, ADVERTISEMENT, OTHER]
+ * description: Filter by lead source
+ * - in: query
+ * name: industry
+ * schema:
+ * type: string
+ * description: Filter by industry
+ * - in: query
+ * name: rating
+ * schema:
+ * type: string
+ * enum: [Hot, Warm, Cold]
+ * description: Filter by rating
+ * - in: query
+ * name: converted
+ * schema:
+ * type: boolean
+ * description: Filter by conversion status
+ * responses:
+ * 200:
+ * description: List of leads
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * leads:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Lead'
+ * pagination:
+ * type: object
+ */
+router.get('/', async (req, res) => {
+ try {
+ const page = parseInt(req.query.page) || 1;
+ const limit = parseInt(req.query.limit) || 10;
+ const skip = (page - 1) * limit;
+
+ const {
+ search,
+ status,
+ leadSource,
+ industry,
+ rating,
+ converted
+ } = req.query;
+
+ // Build where conditions
+ const conditions = [eq(schema.lead.organizationId, req.organizationId)];
+ if (search) {
+ const q = `%${search}%`;
+ conditions.push(sql`${schema.lead.firstName} ILIKE ${q} OR ${schema.lead.lastName} ILIKE ${q} OR ${schema.lead.email} ILIKE ${q} OR ${schema.lead.company} ILIKE ${q}`);
+ }
+ if (status) conditions.push(eq(schema.lead.status, status));
+ if (leadSource) conditions.push(eq(schema.lead.leadSource, leadSource));
+ if (industry) conditions.push(ilike(schema.lead.industry, `%${industry}%`));
+ if (rating) conditions.push(eq(schema.lead.rating, rating));
+ if (converted !== undefined) conditions.push(eq(schema.lead.isConverted, converted === 'true'));
+
+ const rows = await db
+ .select({
+ id: schema.lead.id,
+ firstName: schema.lead.firstName,
+ lastName: schema.lead.lastName,
+ email: schema.lead.email,
+ phone: schema.lead.phone,
+ company: schema.lead.company,
+ title: schema.lead.title,
+ status: schema.lead.status,
+ leadSource: schema.lead.leadSource,
+ industry: schema.lead.industry,
+ rating: schema.lead.rating,
+ description: schema.lead.description,
+ isConverted: schema.lead.isConverted,
+ createdAt: schema.lead.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email
+ })
+ .from(schema.lead)
+ .leftJoin(schema.user, eq(schema.user.id, schema.lead.ownerId))
+ .where(and(...conditions))
+ .orderBy(desc(schema.lead.createdAt))
+ .limit(limit)
+ .offset(skip);
+
+ const [{ count: total }] = await db
+ .select({ count: sql`count(*)`.as('count') })
+ .from(schema.lead)
+ .where(and(...conditions));
+
+ // Calculate pagination info
+ const totalPages = Math.ceil(total / limit);
+ const hasNext = page < totalPages;
+ const hasPrev = page > 1;
+
+ res.json({
+ success: true,
+ leads: rows.map((r) => ({
+ id: r.id,
+ firstName: r.firstName,
+ lastName: r.lastName,
+ email: r.email,
+ phone: r.phone,
+ company: r.company,
+ title: r.title,
+ status: r.status,
+ leadSource: r.leadSource,
+ industry: r.industry,
+ rating: r.rating,
+ description: r.description,
+ isConverted: r.isConverted,
+ createdAt: r.createdAt,
+ owner: r.ownerId ? { id: r.ownerId, name: r.ownerName, email: r.ownerEmail } : null
+ })),
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages,
+ hasNext,
+ hasPrev
+ }
+ });
+ } catch (error) {
+ console.error('Get leads error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /leads/{id}:
+ * get:
+ * summary: Get lead by ID
+ * tags: [Leads]
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Lead details
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Lead'
+ * 404:
+ * description: Lead not found
+ */
+router.get('/:id', async (req, res) => {
+ try {
+ const [lead] = await db
+ .select({
+ id: schema.lead.id,
+ firstName: schema.lead.firstName,
+ lastName: schema.lead.lastName,
+ email: schema.lead.email,
+ phone: schema.lead.phone,
+ company: schema.lead.company,
+ title: schema.lead.title,
+ status: schema.lead.status,
+ leadSource: schema.lead.leadSource,
+ industry: schema.lead.industry,
+ rating: schema.lead.rating,
+ description: schema.lead.description,
+ isConverted: schema.lead.isConverted,
+ createdAt: schema.lead.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email
+ })
+ .from(schema.lead)
+ .leftJoin(schema.user, eq(schema.user.id, schema.lead.ownerId))
+ .where(and(eq(schema.lead.id, req.params.id), eq(schema.lead.organizationId, req.organizationId)))
+ .limit(1);
+
+ if (!lead) {
+ return res.status(404).json({ error: 'Lead not found' });
+ }
+
+ if (!lead) return res.status(404).json({ error: 'Lead not found' });
+ res.json({
+ id: lead.id,
+ firstName: lead.firstName,
+ lastName: lead.lastName,
+ email: lead.email,
+ phone: lead.phone,
+ company: lead.company,
+ title: lead.title,
+ status: lead.status,
+ leadSource: lead.leadSource,
+ industry: lead.industry,
+ rating: lead.rating,
+ description: lead.description,
+ isConverted: lead.isConverted,
+ createdAt: lead.createdAt,
+ owner: lead.ownerId ? { id: lead.ownerId, name: lead.ownerName, email: lead.ownerEmail } : null
+ });
+ } catch (error) {
+ console.error('Get lead error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /leads:
+ * post:
+ * summary: Create a new lead
+ * tags: [Leads]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - firstName
+ * - lastName
+ * - email
+ * properties:
+ * firstName:
+ * type: string
+ * lastName:
+ * type: string
+ * email:
+ * type: string
+ * phone:
+ * type: string
+ * company:
+ * type: string
+ * title:
+ * type: string
+ * status:
+ * type: string
+ * enum: [NEW, PENDING, CONTACTED, QUALIFIED, UNQUALIFIED, CONVERTED]
+ * leadSource:
+ * type: string
+ * enum: [WEB, PHONE_INQUIRY, PARTNER_REFERRAL, COLD_CALL, TRADE_SHOW, EMPLOYEE_REFERRAL, ADVERTISEMENT, OTHER]
+ * industry:
+ * type: string
+ * rating:
+ * type: string
+ * enum: [Hot, Warm, Cold]
+ * description:
+ * type: string
+ * responses:
+ * 201:
+ * description: Lead created successfully
+ * 400:
+ * description: Invalid input
+ */
+router.post('/', async (req, res) => {
+ try {
+ const {
+ firstName,
+ lastName,
+ email,
+ phone,
+ company,
+ title,
+ status,
+ leadSource,
+ industry,
+ rating,
+ description
+ } = req.body;
+
+ if (!firstName || !lastName || !email) {
+ return res.status(400).json({ error: 'First name, last name, and email are required' });
+ }
+
+ // Validate email format
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return res.status(400).json({ error: 'Invalid email format' });
+ }
+
+ // Validate status if provided
+ const validStatuses = ['NEW', 'PENDING', 'CONTACTED', 'QUALIFIED', 'UNQUALIFIED', 'CONVERTED'];
+ if (status && !validStatuses.includes(status)) {
+ return res.status(400).json({ error: 'Invalid status value' });
+ }
+
+ // Validate leadSource if provided
+ const validSources = ['WEB', 'PHONE_INQUIRY', 'PARTNER_REFERRAL', 'COLD_CALL', 'TRADE_SHOW', 'EMPLOYEE_REFERRAL', 'ADVERTISEMENT', 'OTHER'];
+ if (leadSource && !validSources.includes(leadSource)) {
+ return res.status(400).json({ error: 'Invalid lead source value' });
+ }
+
+ // Validate rating if provided
+ const validRatings = ['Hot', 'Warm', 'Cold'];
+ if (rating && !validRatings.includes(rating)) {
+ return res.status(400).json({ error: 'Invalid rating value' });
+ }
+
+ const [inserted] = await db
+ .insert(schema.lead)
+ .values({
+ firstName: firstName.trim(),
+ lastName: lastName.trim(),
+ email: email.trim().toLowerCase(),
+ phone: phone?.trim() || null,
+ company: company?.trim() || null,
+ title: title?.trim() || null,
+ status: status || 'PENDING',
+ leadSource: leadSource || null,
+ industry: industry?.trim() || null,
+ rating: rating || null,
+ description: description?.trim() || null,
+ organizationId: req.organizationId,
+ ownerId: req.userId
+ })
+ .returning({ id: schema.lead.id });
+
+ const [row] = await db
+ .select({
+ id: schema.lead.id,
+ firstName: schema.lead.firstName,
+ lastName: schema.lead.lastName,
+ email: schema.lead.email,
+ phone: schema.lead.phone,
+ company: schema.lead.company,
+ title: schema.lead.title,
+ status: schema.lead.status,
+ leadSource: schema.lead.leadSource,
+ industry: schema.lead.industry,
+ rating: schema.lead.rating,
+ description: schema.lead.description,
+ isConverted: schema.lead.isConverted,
+ createdAt: schema.lead.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email
+ })
+ .from(schema.lead)
+ .leftJoin(schema.user, eq(schema.user.id, schema.lead.ownerId))
+ .where(eq(schema.lead.id, inserted.id))
+ .limit(1);
+
+ res.status(201).json({
+ id: row.id,
+ firstName: row.firstName,
+ lastName: row.lastName,
+ email: row.email,
+ phone: row.phone,
+ company: row.company,
+ title: row.title,
+ status: row.status,
+ leadSource: row.leadSource,
+ industry: row.industry,
+ rating: row.rating,
+ description: row.description,
+ isConverted: row.isConverted,
+ createdAt: row.createdAt,
+ owner: row.ownerId ? { id: row.ownerId, name: row.ownerName, email: row.ownerEmail } : null
+ });
+ } catch (error) {
+ console.error('Create lead error:', error);
+ if (error.code === 'P2002') {
+ return res.status(409).json({ error: 'A lead with this email already exists in this organization' });
+ }
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/apps/api/routes/opportunities.js b/apps/api/routes/opportunities.js
new file mode 100644
index 0000000..1237eb2
--- /dev/null
+++ b/apps/api/routes/opportunities.js
@@ -0,0 +1,172 @@
+import express from 'express';
+import { verifyToken, requireOrganization } from '../middleware/auth.js';
+import { db, schema } from '../lib/db.js';
+import { and, desc, eq } from 'drizzle-orm';
+
+const router = express.Router();
+
+router.use(verifyToken);
+router.use(requireOrganization);
+
+/**
+ * @swagger
+ * /opportunities:
+ * get:
+ * summary: Get all opportunities for organization
+ * tags: [Opportunities]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: List of opportunities
+ */
+router.get('/', async (req, res) => {
+ try {
+ const rows = await db
+ .select({
+ id: schema.opportunity.id,
+ name: schema.opportunity.name,
+ amount: schema.opportunity.amount,
+ stage: schema.opportunity.stage,
+ closeDate: schema.opportunity.closeDate,
+ createdAt: schema.opportunity.createdAt,
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email
+ })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .leftJoin(schema.user, eq(schema.user.id, schema.opportunity.ownerId))
+ .where(eq(schema.opportunity.organizationId, req.organizationId))
+ .orderBy(desc(schema.opportunity.createdAt));
+
+ res.json({
+ opportunities: rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ amount: r.amount,
+ stage: r.stage,
+ closeDate: r.closeDate,
+ createdAt: r.createdAt,
+ account: r.accountId ? { id: r.accountId, name: r.accountName } : null,
+ owner: r.ownerId ? { id: r.ownerId, name: r.ownerName, email: r.ownerEmail } : null
+ }))
+ });
+ } catch (error) {
+ console.error('Get opportunities error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /opportunities:
+ * post:
+ * summary: Create a new opportunity
+ * tags: [Opportunities]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - name
+ * - amount
+ * - closeDate
+ * - stage
+ * properties:
+ * name:
+ * type: string
+ * amount:
+ * type: number
+ * closeDate:
+ * type: string
+ * format: date
+ * stage:
+ * type: string
+ * accountId:
+ * type: string
+ * responses:
+ * 201:
+ * description: Opportunity created successfully
+ */
+router.post('/', async (req, res) => {
+ try {
+ const { name, amount, closeDate, stage, accountId } = req.body;
+
+ if (!name || !amount || !closeDate || !stage) {
+ return res.status(400).json({ error: 'Name, amount, close date, and stage are required' });
+ }
+
+ if (accountId) {
+ const [account] = await db
+ .select({ id: schema.crmAccount.id })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), eq(schema.crmAccount.organizationId, req.organizationId)))
+ .limit(1);
+ if (!account) return res.status(400).json({ error: 'Account not found in your organization' });
+ }
+
+ const [inserted] = await db
+ .insert(schema.opportunity)
+ .values({
+ name,
+ amount: parseFloat(amount),
+ closeDate: new Date(closeDate),
+ stage,
+ accountId: accountId || null,
+ organizationId: req.organizationId,
+ ownerId: req.userId
+ })
+ .returning({ id: schema.opportunity.id });
+
+ const [row] = await db
+ .select({
+ id: schema.opportunity.id,
+ name: schema.opportunity.name,
+ amount: schema.opportunity.amount,
+ stage: schema.opportunity.stage,
+ closeDate: schema.opportunity.closeDate,
+ createdAt: schema.opportunity.createdAt,
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email
+ })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .leftJoin(schema.user, eq(schema.user.id, schema.opportunity.ownerId))
+ .where(eq(schema.opportunity.id, inserted.id))
+ .limit(1);
+
+ res.status(201).json({
+ id: row.id,
+ name: row.name,
+ amount: row.amount,
+ stage: row.stage,
+ closeDate: row.closeDate,
+ createdAt: row.createdAt,
+ account: row.accountId ? { id: row.accountId, name: row.accountName } : null,
+ owner: row.ownerId ? { id: row.ownerId, name: row.ownerName, email: row.ownerEmail } : null
+ });
+ } catch (error) {
+ console.error('Create opportunity error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/apps/api/routes/organizations.js b/apps/api/routes/organizations.js
new file mode 100644
index 0000000..80690f1
--- /dev/null
+++ b/apps/api/routes/organizations.js
@@ -0,0 +1,417 @@
+import express from 'express';
+import { verifyToken } from '../middleware/auth.js';
+import { db, schema } from '../lib/db.js';
+import { and, eq, ilike, sql } from 'drizzle-orm';
+
+const router = express.Router();
+
+/**
+ * @swagger
+ * components:
+ * schemas:
+ * Organization:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * name:
+ * type: string
+ * domain:
+ * type: string
+ * logo:
+ * type: string
+ * website:
+ * type: string
+ * industry:
+ * type: string
+ * description:
+ * type: string
+ * isActive:
+ * type: boolean
+ * createdAt:
+ * type: string
+ * format: date-time
+ * updatedAt:
+ * type: string
+ * format: date-time
+ * userRole:
+ * type: string
+ * enum: [ADMIN, USER]
+ * CreateOrganizationRequest:
+ * type: object
+ * required:
+ * - name
+ * properties:
+ * name:
+ * type: string
+ * domain:
+ * type: string
+ * logo:
+ * type: string
+ * website:
+ * type: string
+ * industry:
+ * type: string
+ * description:
+ * type: string
+ */
+
+/**
+ * @swagger
+ * /organizations:
+ * get:
+ * summary: Get organizations list for the authenticated user
+ * tags: [Organizations]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: query
+ * name: page
+ * schema:
+ * type: integer
+ * minimum: 1
+ * default: 1
+ * description: Page number for pagination
+ * - in: query
+ * name: limit
+ * schema:
+ * type: integer
+ * minimum: 1
+ * maximum: 100
+ * default: 10
+ * description: Number of organizations per page
+ * - in: query
+ * name: search
+ * schema:
+ * type: string
+ * description: Search term to filter organizations by name
+ * - in: query
+ * name: industry
+ * schema:
+ * type: string
+ * description: Filter by industry
+ * - in: query
+ * name: active
+ * schema:
+ * type: boolean
+ * description: Filter by active status
+ * responses:
+ * 200:
+ * description: List of organizations
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * organizations:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Organization'
+ * pagination:
+ * type: object
+ * properties:
+ * page:
+ * type: integer
+ * limit:
+ * type: integer
+ * total:
+ * type: integer
+ * totalPages:
+ * type: integer
+ * hasNext:
+ * type: boolean
+ * hasPrev:
+ * type: boolean
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Internal server error
+ */
+router.get('/', verifyToken, async (req, res) => {
+ try {
+ const userId = req.userId;
+ const {
+ page = 1,
+ limit = 10,
+ search,
+ industry,
+ active
+ } = req.query;
+
+ // Validate pagination parameters
+ const pageNum = Math.max(1, parseInt(page) || 1);
+ const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 10));
+ const skip = (pageNum - 1) * limitNum;
+
+ // Build where clause for filtering
+ let whereClause = {
+ users: {
+ some: {
+ userId: userId
+ }
+ }
+ };
+
+ // Add search filter
+ if (search) {
+ whereClause.name = {
+ contains: search,
+ mode: 'insensitive'
+ };
+ }
+
+ // Add industry filter
+ if (industry) {
+ whereClause.industry = {
+ contains: industry,
+ mode: 'insensitive'
+ };
+ }
+
+ // Add active status filter
+ if (active !== undefined) {
+ whereClause.isActive = active === 'true';
+ }
+
+ // Get organizations with user role
+ // Build Drizzle conditions
+ const conditions = [eq(schema.member.userId, userId)];
+ if (search) {
+ conditions.push(ilike(schema.organization.name, `%${search}%`));
+ }
+ if (industry) {
+ conditions.push(ilike(schema.organization.industry, `%${industry}%`));
+ }
+ if (active !== undefined) {
+ conditions.push(eq(schema.organization.isActive, active === 'true'));
+ }
+
+ const organizations = await db
+ .select({
+ id: schema.organization.id,
+ name: schema.organization.name,
+ domain: schema.organization.domain,
+ logo: schema.organization.logo,
+ website: schema.organization.website,
+ industry: schema.organization.industry,
+ description: schema.organization.description,
+ isActive: schema.organization.isActive,
+ createdAt: schema.organization.createdAt,
+ updatedAt: schema.organization.updatedAt,
+ userRole: schema.member.role,
+ joinedAt: schema.member.createdAt
+ })
+ .from(schema.organization)
+ .innerJoin(schema.member, eq(schema.member.organizationId, schema.organization.id))
+ .where(and(...conditions))
+ .orderBy(schema.organization.name)
+ .limit(limitNum)
+ .offset(skip);
+
+ const [{ count: totalCount }] = await db
+ .select({ count: sql`count(distinct ${schema.organization.id})`.as('count') })
+ .from(schema.organization)
+ .innerJoin(schema.member, eq(schema.member.organizationId, schema.organization.id))
+ .where(and(...conditions));
+
+ // Format response
+ const formattedOrganizations = organizations.map(org => ({
+ id: org.id,
+ name: org.name,
+ domain: org.domain,
+ logo: org.logo,
+ website: org.website,
+ industry: org.industry,
+ description: org.description,
+ isActive: org.isActive,
+ createdAt: org.createdAt,
+ updatedAt: org.updatedAt,
+ userRole: (org.userRole || 'user').toUpperCase(),
+ joinedAt: org.joinedAt
+ }));
+
+ // Calculate pagination info
+ const totalPages = Math.ceil(totalCount / limitNum);
+ const hasNext = pageNum < totalPages;
+ const hasPrev = pageNum > 1;
+
+ res.json({
+ success: true,
+ organizations: formattedOrganizations,
+ pagination: {
+ page: pageNum,
+ limit: limitNum,
+ total: totalCount,
+ totalPages,
+ hasNext,
+ hasPrev
+ }
+ });
+
+ } catch (error) {
+ console.error('Organizations list error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Internal server error'
+ });
+ }
+});
+
+/**
+ * @swagger
+ * /organizations:
+ * post:
+ * summary: Create a new organization
+ * tags: [Organizations]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/CreateOrganizationRequest'
+ * responses:
+ * 201:
+ * description: Organization created successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * organization:
+ * $ref: '#/components/schemas/Organization'
+ * 400:
+ * description: Bad request - validation error
+ * 401:
+ * description: Unauthorized
+ * 409:
+ * description: Organization with this name already exists
+ * 500:
+ * description: Internal server error
+ */
+router.post('/', verifyToken, async (req, res) => {
+ try {
+ const userId = req.userId;
+ const {
+ name,
+ domain,
+ logo,
+ website,
+ industry,
+ description
+ } = req.body;
+
+ // Validate required fields
+ if (!name || !name.trim()) {
+ return res.status(400).json({
+ success: false,
+ error: 'Organization name is required'
+ });
+ }
+
+ // Check if organization with this name already exists (case-insensitive)
+ const existing = await db
+ .select({ id: schema.organization.id })
+ .from(schema.organization)
+ .where(ilike(schema.organization.name, name.trim()))
+ .limit(1);
+
+ if (existing.length > 0) {
+ return res.status(409).json({
+ success: false,
+ error: 'Organization with this name already exists'
+ });
+ }
+
+ // Validate website URL format if provided
+ if (website && website.trim()) {
+ try {
+ new URL(website.trim());
+ } catch (error) {
+ return res.status(400).json({
+ success: false,
+ error: 'Invalid website URL format'
+ });
+ }
+ }
+
+ // Create organization
+ const [orgInserted] = await db
+ .insert(schema.organization)
+ .values({
+ name: name.trim(),
+ domain: domain?.trim() || null,
+ logo: logo?.trim() || null,
+ website: website?.trim() || null,
+ industry: industry?.trim() || null,
+ description: description?.trim() || null,
+ isActive: true
+ })
+ .returning({ id: schema.organization.id, createdAt: schema.organization.createdAt, updatedAt: schema.organization.updatedAt });
+
+ // Add current user as admin member
+ await db
+ .insert(schema.member)
+ .values({
+ organizationId: orgInserted.id,
+ userId: userId,
+ role: 'admin'
+ });
+
+ // Fetch organization back with membership info for response
+ const [organization] = await db
+ .select({
+ id: schema.organization.id,
+ name: schema.organization.name,
+ domain: schema.organization.domain,
+ logo: schema.organization.logo,
+ website: schema.organization.website,
+ industry: schema.organization.industry,
+ description: schema.organization.description,
+ isActive: schema.organization.isActive,
+ createdAt: schema.organization.createdAt,
+ updatedAt: schema.organization.updatedAt,
+ userRole: schema.member.role,
+ joinedAt: schema.member.createdAt
+ })
+ .from(schema.organization)
+ .innerJoin(schema.member, eq(schema.member.organizationId, schema.organization.id))
+ .where(and(eq(schema.organization.id, orgInserted.id), eq(schema.member.userId, userId)))
+ .limit(1);
+
+ // Format response
+ const formattedOrganization = {
+ id: organization.id,
+ name: organization.name,
+ domain: organization.domain,
+ logo: organization.logo,
+ website: organization.website,
+ industry: organization.industry,
+ description: organization.description,
+ isActive: organization.isActive,
+ createdAt: organization.createdAt,
+ updatedAt: organization.updatedAt,
+ userRole: (organization.userRole || 'admin').toUpperCase(),
+ joinedAt: organization.joinedAt
+ };
+
+ res.status(201).json({
+ success: true,
+ organization: formattedOrganization
+ });
+
+ } catch (error) {
+ console.error('Organization creation error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Internal server error'
+ });
+ }
+});
+
+export default router;
diff --git a/apps/api/routes/tasks.js b/apps/api/routes/tasks.js
new file mode 100644
index 0000000..3301523
--- /dev/null
+++ b/apps/api/routes/tasks.js
@@ -0,0 +1,1033 @@
+import express from 'express';
+import { verifyToken, requireOrganization } from '../middleware/auth.js';
+import { db, schema } from '../lib/db.js';
+import { and, desc, eq, ilike, not, sql } from 'drizzle-orm';
+
+const router = express.Router();
+
+router.use(verifyToken);
+router.use(requireOrganization);
+
+/**
+ * @swagger
+ * components:
+ * schemas:
+ * Task:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * subject:
+ * type: string
+ * status:
+ * type: string
+ * enum: [Not Started, In Progress, Completed, Deferred, Waiting]
+ * priority:
+ * type: string
+ * enum: [High, Normal, Low]
+ * dueDate:
+ * type: string
+ * format: date-time
+ * description:
+ * type: string
+ * createdAt:
+ * type: string
+ * format: date-time
+ * updatedAt:
+ * type: string
+ * format: date-time
+ */
+
+/**
+ * @swagger
+ * /tasks:
+ * get:
+ * summary: Get all tasks for organization
+ * tags: [Tasks]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: status
+ * schema:
+ * type: string
+ * description: Filter tasks by status
+ * - in: query
+ * name: priority
+ * schema:
+ * type: string
+ * description: Filter tasks by priority
+ * - in: query
+ * name: ownerId
+ * schema:
+ * type: string
+ * description: Filter tasks by owner ID
+ * - in: query
+ * name: accountId
+ * schema:
+ * type: string
+ * description: Filter tasks by account ID
+ * - in: query
+ * name: contactId
+ * schema:
+ * type: string
+ * description: Filter tasks by contact ID
+ * - in: query
+ * name: leadId
+ * schema:
+ * type: string
+ * description: Filter tasks by lead ID
+ * - in: query
+ * name: opportunityId
+ * schema:
+ * type: string
+ * description: Filter tasks by opportunity ID
+ * - in: query
+ * name: caseId
+ * schema:
+ * type: string
+ * description: Filter tasks by case ID
+ * - in: query
+ * name: limit
+ * schema:
+ * type: integer
+ * default: 50
+ * description: Limit number of results
+ * - in: query
+ * name: offset
+ * schema:
+ * type: integer
+ * default: 0
+ * description: Offset for pagination
+ * responses:
+ * 200:
+ * description: List of tasks
+ */
+router.get('/', async (req, res) => {
+ try {
+ const {
+ status,
+ priority,
+ ownerId,
+ accountId,
+ contactId,
+ leadId,
+ opportunityId,
+ caseId,
+ limit = 50,
+ offset = 0
+ } = req.query;
+
+ // Build where clause for filtering
+ const where = {
+ organizationId: req.organizationId,
+ ...(status && { status }),
+ ...(priority && { priority }),
+ ...(ownerId && { ownerId }),
+ ...(accountId && { accountId }),
+ ...(contactId && { contactId }),
+ ...(leadId && { leadId }),
+ ...(opportunityId && { opportunityId }),
+ ...(caseId && { caseId })
+ };
+
+ // Build conditions for Drizzle
+ const conditions = [eq(schema.task.organizationId, req.organizationId)];
+ if (status) conditions.push(eq(schema.task.status, status));
+ if (priority) conditions.push(eq(schema.task.priority, priority));
+ if (ownerId) conditions.push(eq(schema.task.ownerId, ownerId));
+ if (accountId) conditions.push(eq(schema.task.accountId, accountId));
+ if (contactId) conditions.push(eq(schema.task.contactId, contactId));
+ if (leadId) conditions.push(eq(schema.task.leadId, leadId));
+ if (opportunityId) conditions.push(eq(schema.task.opportunityId, opportunityId));
+ if (caseId) conditions.push(eq(schema.task.caseId, caseId));
+
+ const rows = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ dueDate: schema.task.dueDate,
+ createdAt: schema.task.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email,
+ createdById: schema.user.id,
+ createdByName: schema.user.name,
+ createdByEmail: schema.user.email,
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name,
+ accountType: schema.crmAccount.type,
+ contactId: schema.contact.id,
+ contactFirstName: schema.contact.firstName,
+ contactLastName: schema.contact.lastName,
+ contactEmail: schema.contact.email,
+ leadId: schema.lead.id,
+ leadFirstName: schema.lead.firstName,
+ leadLastName: schema.lead.lastName,
+ leadEmail: schema.lead.email,
+ leadCompany: schema.lead.company,
+ opportunityId: schema.opportunity.id,
+ opportunityName: schema.opportunity.name,
+ opportunityAmount: schema.opportunity.amount,
+ caseId: schema.caseTable.id,
+ caseNumber: schema.caseTable.caseNumber,
+ caseSubject: schema.caseTable.subject,
+ caseStatus: schema.caseTable.status
+ })
+ .from(schema.task)
+ .leftJoin(schema.user, eq(schema.user.id, schema.task.ownerId))
+ .leftJoin(schema.user.as('creator'), eq(schema.user.as('creator').id, schema.task.createdById))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.task.accountId))
+ .leftJoin(schema.contact, eq(schema.contact.id, schema.task.contactId))
+ .leftJoin(schema.lead, eq(schema.lead.id, schema.task.leadId))
+ .leftJoin(schema.opportunity, eq(schema.opportunity.id, schema.task.opportunityId))
+ .leftJoin(schema.caseTable, eq(schema.caseTable.id, schema.task.caseId))
+ .where(and(...conditions))
+ .orderBy(desc(schema.task.createdAt))
+ .limit(parseInt(limit))
+ .offset(parseInt(offset));
+
+ const [{ count: totalCount }] = await db
+ .select({ count: sql`count(*)`.as('count') })
+ .from(schema.task)
+ .where(and(...conditions));
+
+ const tasks = rows.map((r) => ({
+ id: r.id,
+ subject: r.subject,
+ status: r.status,
+ priority: r.priority,
+ dueDate: r.dueDate,
+ createdAt: r.createdAt,
+ owner: r.ownerId ? { id: r.ownerId, name: r.ownerName, email: r.ownerEmail } : null,
+ createdBy: r.createdById ? { id: r.createdById, name: r.createdByName, email: r.createdByEmail } : null,
+ account: r.accountId ? { id: r.accountId, name: r.accountName, type: r.accountType } : null,
+ contact: r.contactId ? { id: r.contactId, firstName: r.contactFirstName, lastName: r.contactLastName, email: r.contactEmail } : null,
+ lead: r.leadId ? { id: r.leadId, firstName: r.leadFirstName, lastName: r.leadLastName, email: r.leadEmail, company: r.leadCompany } : null,
+ opportunity: r.opportunityId ? { id: r.opportunityId, name: r.opportunityName, amount: r.opportunityAmount, status: r.status } : null,
+ case: r.caseId ? { id: r.caseId, caseNumber: r.caseNumber, subject: r.caseSubject, status: r.caseStatus } : null
+ }));
+
+ res.json({
+ tasks,
+ pagination: {
+ total: Number(totalCount || 0),
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ hasMore: parseInt(offset) + parseInt(limit) < Number(totalCount || 0)
+ }
+ });
+ } catch (error) {
+ console.error('Get tasks error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /tasks:
+ * post:
+ * summary: Create a new task
+ * tags: [Tasks]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - subject
+ * properties:
+ * subject:
+ * type: string
+ * status:
+ * type: string
+ * enum: [Not Started, In Progress, Completed, Deferred, Waiting]
+ * default: Not Started
+ * priority:
+ * type: string
+ * enum: [High, Normal, Low]
+ * default: Normal
+ * dueDate:
+ * type: string
+ * format: date-time
+ * description:
+ * type: string
+ * ownerId:
+ * type: string
+ * description: UUID of the user who owns this task
+ * accountId:
+ * type: string
+ * description: UUID of the related account
+ * contactId:
+ * type: string
+ * description: UUID of the related contact
+ * leadId:
+ * type: string
+ * description: UUID of the related lead
+ * opportunityId:
+ * type: string
+ * description: UUID of the related opportunity
+ * caseId:
+ * type: string
+ * description: UUID of the related case
+ * responses:
+ * 201:
+ * description: Task created successfully
+ * 400:
+ * description: Validation error
+ */
+router.post('/', async (req, res) => {
+ try {
+ const {
+ subject,
+ status = 'Not Started',
+ priority = 'Normal',
+ dueDate,
+ description,
+ ownerId,
+ accountId,
+ contactId,
+ leadId,
+ opportunityId,
+ caseId
+ } = req.body;
+
+ if (!subject) {
+ return res.status(400).json({ error: 'Subject is required' });
+ }
+
+ // Validate status
+ const validStatuses = ['Not Started', 'In Progress', 'Completed', 'Deferred', 'Waiting'];
+ if (status && !validStatuses.includes(status)) {
+ return res.status(400).json({ error: 'Invalid status. Valid options: ' + validStatuses.join(', ') });
+ }
+
+ // Validate priority
+ const validPriorities = ['High', 'Normal', 'Low'];
+ if (priority && !validPriorities.includes(priority)) {
+ return res.status(400).json({ error: 'Invalid priority. Valid options: ' + validPriorities.join(', ') });
+ }
+
+ // Validate owner exists in organization
+ let owner = null;
+ const finalOwnerId = ownerId || req.userId; // Default to current user if no owner specified
+
+ const [member] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, finalOwnerId), eq(schema.member.organizationId, req.organizationId)))
+ .limit(1);
+
+ if (!member) {
+ return res.status(400).json({ error: 'Owner must be a member of this organization' });
+ }
+
+ // Validate related entities if provided
+ if (accountId) {
+ const [account] = await db
+ .select({ id: schema.crmAccount.id })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), eq(schema.crmAccount.organizationId, req.organizationId)))
+ .limit(1);
+ if (!account) {
+ return res.status(400).json({ error: 'Account not found in your organization' });
+ }
+ }
+
+ if (contactId) {
+ const [contact] = await db
+ .select({ id: schema.contact.id })
+ .from(schema.contact)
+ .where(and(eq(schema.contact.id, contactId), eq(schema.contact.organizationId, req.organizationId)))
+ .limit(1);
+ if (!contact) {
+ return res.status(400).json({ error: 'Contact not found in your organization' });
+ }
+ }
+
+ if (leadId) {
+ const [lead] = await db
+ .select({ id: schema.lead.id })
+ .from(schema.lead)
+ .where(and(eq(schema.lead.id, leadId), eq(schema.lead.organizationId, req.organizationId)))
+ .limit(1);
+ if (!lead) {
+ return res.status(400).json({ error: 'Lead not found in your organization' });
+ }
+ }
+
+ if (opportunityId) {
+ const [opp] = await db
+ .select({ id: schema.opportunity.id })
+ .from(schema.opportunity)
+ .where(and(eq(schema.opportunity.id, opportunityId), eq(schema.opportunity.organizationId, req.organizationId)))
+ .limit(1);
+ const opportunity = opp;
+ if (!opportunity) {
+ return res.status(400).json({ error: 'Opportunity not found in your organization' });
+ }
+ }
+
+ if (caseId) {
+ const [caseRecord] = await db
+ .select({ id: schema.caseTable.id })
+ .from(schema.caseTable)
+ .where(and(eq(schema.caseTable.id, caseId), eq(schema.caseTable.organizationId, req.organizationId)))
+ .limit(1);
+ if (!caseRecord) {
+ return res.status(400).json({ error: 'Case not found in your organization' });
+ }
+ }
+
+ // Create the task
+ const [inserted] = await db
+ .insert(schema.task)
+ .values({
+ subject,
+ status,
+ priority,
+ dueDate: dueDate ? new Date(dueDate) : null,
+ description: description || null,
+ ownerId: finalOwnerId,
+ createdById: req.userId,
+ organizationId: req.organizationId,
+ accountId: accountId || null,
+ contactId: contactId || null,
+ leadId: leadId || null,
+ opportunityId: opportunityId || null,
+ caseId: caseId || null
+ })
+ .returning({ id: schema.task.id });
+
+ const [row] = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ dueDate: schema.task.dueDate,
+ createdAt: schema.task.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email,
+ createdById: schema.user.id,
+ createdByName: schema.user.name,
+ createdByEmail: schema.user.email,
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name,
+ accountType: schema.crmAccount.type,
+ contactId: schema.contact.id,
+ contactFirstName: schema.contact.firstName,
+ contactLastName: schema.contact.lastName,
+ contactEmail: schema.contact.email,
+ leadId: schema.lead.id,
+ leadFirstName: schema.lead.firstName,
+ leadLastName: schema.lead.lastName,
+ leadEmail: schema.lead.email,
+ leadCompany: schema.lead.company,
+ opportunityId: schema.opportunity.id,
+ opportunityName: schema.opportunity.name,
+ opportunityAmount: schema.opportunity.amount,
+ caseId: schema.caseTable.id,
+ caseNumber: schema.caseTable.caseNumber,
+ caseSubject: schema.caseTable.subject,
+ caseStatus: schema.caseTable.status
+ })
+ .from(schema.task)
+ .leftJoin(schema.user, eq(schema.user.id, schema.task.ownerId))
+ .leftJoin(schema.user.as('creator'), eq(schema.user.as('creator').id, schema.task.createdById))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.task.accountId))
+ .leftJoin(schema.contact, eq(schema.contact.id, schema.task.contactId))
+ .leftJoin(schema.lead, eq(schema.lead.id, schema.task.leadId))
+ .leftJoin(schema.opportunity, eq(schema.opportunity.id, schema.task.opportunityId))
+ .leftJoin(schema.caseTable, eq(schema.caseTable.id, schema.task.caseId))
+ .where(eq(schema.task.id, inserted.id))
+ .limit(1);
+
+ res.status(201).json({
+ id: row.id,
+ subject: row.subject,
+ status: row.status,
+ priority: row.priority,
+ dueDate: row.dueDate,
+ createdAt: row.createdAt,
+ owner: row.ownerId ? { id: row.ownerId, name: row.ownerName, email: row.ownerEmail } : null,
+ createdBy: row.createdById ? { id: row.createdById, name: row.createdByName, email: row.createdByEmail } : null,
+ account: row.accountId ? { id: row.accountId, name: row.accountName, type: row.accountType } : null,
+ contact: row.contactId ? { id: row.contactId, firstName: row.contactFirstName, lastName: row.contactLastName, email: row.contactEmail } : null,
+ lead: row.leadId ? { id: row.leadId, firstName: row.leadFirstName, lastName: row.leadLastName, email: row.leadEmail, company: row.leadCompany } : null,
+ opportunity: row.opportunityId ? { id: row.opportunityId, name: row.opportunityName, amount: row.opportunityAmount, status: row.status } : null,
+ case: row.caseId ? { id: row.caseId, caseNumber: row.caseNumber, subject: row.caseSubject, status: row.caseStatus } : null
+ });
+ } catch (error) {
+ console.error('Create task error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /tasks/{id}:
+ * get:
+ * summary: Get a specific task by ID
+ * tags: [Tasks]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: Task ID
+ * responses:
+ * 200:
+ * description: Task details
+ * 404:
+ * description: Task not found
+ */
+router.get('/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const rows = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ dueDate: schema.task.dueDate,
+ description: schema.task.description,
+ createdAt: schema.task.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email,
+ createdById: schema.user.id,
+ createdByName: schema.user.name,
+ createdByEmail: schema.user.email,
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name,
+ accountType: schema.crmAccount.type,
+ accountWebsite: schema.crmAccount.website,
+ accountPhone: schema.crmAccount.phone,
+ contactId: schema.contact.id,
+ contactFirstName: schema.contact.firstName,
+ contactLastName: schema.contact.lastName,
+ contactEmail: schema.contact.email,
+ contactPhone: schema.contact.phone,
+ contactTitle: schema.contact.title,
+ leadId: schema.lead.id,
+ leadFirstName: schema.lead.firstName,
+ leadLastName: schema.lead.lastName,
+ leadEmail: schema.lead.email,
+ leadPhone: schema.lead.phone,
+ leadCompany: schema.lead.company,
+ leadStatus: schema.lead.status,
+ opportunityId: schema.opportunity.id,
+ opportunityName: schema.opportunity.name,
+ opportunityAmount: schema.opportunity.amount,
+ opportunityStatus: schema.opportunity.stage,
+ opportunityStage: schema.opportunity.stage,
+ opportunityCloseDate: schema.opportunity.closeDate,
+ caseId: schema.caseTable.id,
+ caseNumber: schema.caseTable.caseNumber,
+ caseSubject: schema.caseTable.subject,
+ caseStatus: schema.caseTable.status,
+ casePriority: schema.caseTable.priority,
+ orgId: schema.organization.id,
+ orgName: schema.organization.name
+ })
+ .from(schema.task)
+ .leftJoin(schema.user, eq(schema.user.id, schema.task.ownerId))
+ .leftJoin(schema.user.as('creator'), eq(schema.user.as('creator').id, schema.task.createdById))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.task.accountId))
+ .leftJoin(schema.contact, eq(schema.contact.id, schema.task.contactId))
+ .leftJoin(schema.lead, eq(schema.lead.id, schema.task.leadId))
+ .leftJoin(schema.opportunity, eq(schema.opportunity.id, schema.task.opportunityId))
+ .leftJoin(schema.caseTable, eq(schema.caseTable.id, schema.task.caseId))
+ .leftJoin(schema.organization, eq(schema.organization.id, schema.task.organizationId))
+ .where(and(eq(schema.task.id, id), eq(schema.task.organizationId, req.organizationId)))
+ .limit(1);
+
+ if (!rows.length) return res.status(404).json({ error: 'Task not found' });
+
+ const [base] = rows;
+
+ // Load comments separately with author; joining all can be heavy
+ const comments = await db
+ .select({
+ id: schema.comment.id,
+ body: schema.comment.body,
+ isPrivate: schema.comment.isPrivate,
+ createdAt: schema.comment.createdAt,
+ authorId: schema.user.id,
+ authorName: schema.user.name,
+ authorEmail: schema.user.email
+ })
+ .from(schema.comment)
+ .leftJoin(schema.user, eq(schema.user.id, schema.comment.authorId))
+ .where(eq(schema.comment.taskId, id))
+ .orderBy(desc(schema.comment.createdAt));
+
+ res.json({
+ id: base.id,
+ subject: base.subject,
+ status: base.status,
+ priority: base.priority,
+ dueDate: base.dueDate,
+ description: base.description,
+ createdAt: base.createdAt,
+ owner: base.ownerId ? { id: base.ownerId, name: base.ownerName, email: base.ownerEmail } : null,
+ createdBy: base.createdById ? { id: base.createdById, name: base.createdByName, email: base.createdByEmail } : null,
+ account: base.accountId ? { id: base.accountId, name: base.accountName, type: base.accountType, website: base.accountWebsite, phone: base.accountPhone } : null,
+ contact: base.contactId ? { id: base.contactId, firstName: base.contactFirstName, lastName: base.contactLastName, email: base.contactEmail, phone: base.contactPhone, title: base.contactTitle } : null,
+ lead: base.leadId ? { id: base.leadId, firstName: base.leadFirstName, lastName: base.leadLastName, email: base.leadEmail, phone: base.leadPhone, company: base.leadCompany, status: base.leadStatus } : null,
+ opportunity: base.opportunityId ? { id: base.opportunityId, name: base.opportunityName, amount: base.opportunityAmount, status: base.opportunityStatus, stage: base.opportunityStage, closeDate: base.opportunityCloseDate } : null,
+ case: base.caseId ? { id: base.caseId, caseNumber: base.caseNumber, subject: base.caseSubject, status: base.caseStatus, priority: base.casePriority } : null,
+ organization: base.orgId ? { id: base.orgId, name: base.orgName } : null,
+ comments: comments.map((c) => ({ id: c.id, body: c.body, isPrivate: c.isPrivate, createdAt: c.createdAt, author: c.authorId ? { id: c.authorId, name: c.authorName, email: c.authorEmail } : null }))
+ });
+ } catch (error) {
+ console.error('Get task details error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /tasks/{id}:
+ * put:
+ * summary: Update a specific task
+ * tags: [Tasks]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: Task ID
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * subject:
+ * type: string
+ * status:
+ * type: string
+ * enum: [Not Started, In Progress, Completed, Deferred, Waiting]
+ * priority:
+ * type: string
+ * enum: [High, Normal, Low]
+ * dueDate:
+ * type: string
+ * format: date-time
+ * description:
+ * type: string
+ * ownerId:
+ * type: string
+ * description: UUID of the user who owns this task
+ * accountId:
+ * type: string
+ * description: UUID of the related account
+ * contactId:
+ * type: string
+ * description: UUID of the related contact
+ * leadId:
+ * type: string
+ * description: UUID of the related lead
+ * opportunityId:
+ * type: string
+ * description: UUID of the related opportunity
+ * caseId:
+ * type: string
+ * description: UUID of the related case
+ * responses:
+ * 200:
+ * description: Task updated successfully
+ * 400:
+ * description: Validation error
+ * 404:
+ * description: Task not found
+ */
+router.put('/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const {
+ subject,
+ status,
+ priority,
+ dueDate,
+ description,
+ ownerId,
+ accountId,
+ contactId,
+ leadId,
+ opportunityId,
+ caseId
+ } = req.body;
+
+ // Check if task exists and belongs to organization
+ const [existingTask] = await db
+ .select({ id: schema.task.id })
+ .from(schema.task)
+ .where(and(eq(schema.task.id, id), eq(schema.task.organizationId, req.organizationId)))
+ .limit(1);
+
+ if (!existingTask) {
+ return res.status(404).json({ error: 'Task not found' });
+ }
+
+ // Validate status if provided
+ if (status) {
+ const validStatuses = ['Not Started', 'In Progress', 'Completed', 'Deferred', 'Waiting'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({ error: 'Invalid status. Valid options: ' + validStatuses.join(', ') });
+ }
+ }
+
+ // Validate priority if provided
+ if (priority) {
+ const validPriorities = ['High', 'Normal', 'Low'];
+ if (!validPriorities.includes(priority)) {
+ return res.status(400).json({ error: 'Invalid priority. Valid options: ' + validPriorities.join(', ') });
+ }
+ }
+
+ // Validate owner exists in organization if provided
+ if (ownerId) {
+ const [member] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, ownerId), eq(schema.member.organizationId, req.organizationId)))
+ .limit(1);
+
+ if (!member) {
+ return res.status(400).json({ error: 'Owner must be a member of this organization' });
+ }
+ }
+
+ // Validate related entities if provided
+ if (accountId !== undefined) {
+ if (accountId) {
+ const [account] = await db
+ .select({ id: schema.crmAccount.id })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), eq(schema.crmAccount.organizationId, req.organizationId)))
+ .limit(1);
+ if (!account) {
+ return res.status(400).json({ error: 'Account not found in your organization' });
+ }
+ }
+ }
+
+ if (contactId !== undefined) {
+ if (contactId) {
+ const [contact] = await db
+ .select({ id: schema.contact.id })
+ .from(schema.contact)
+ .where(and(eq(schema.contact.id, contactId), eq(schema.contact.organizationId, req.organizationId)))
+ .limit(1);
+ if (!contact) {
+ return res.status(400).json({ error: 'Contact not found in your organization' });
+ }
+ }
+ }
+
+ if (leadId !== undefined) {
+ if (leadId) {
+ const [lead] = await db
+ .select({ id: schema.lead.id })
+ .from(schema.lead)
+ .where(and(eq(schema.lead.id, leadId), eq(schema.lead.organizationId, req.organizationId)))
+ .limit(1);
+ if (!lead) {
+ return res.status(400).json({ error: 'Lead not found in your organization' });
+ }
+ }
+ }
+
+ if (opportunityId !== undefined) {
+ if (opportunityId) {
+ const [opp] = await db
+ .select({ id: schema.opportunity.id })
+ .from(schema.opportunity)
+ .where(and(eq(schema.opportunity.id, opportunityId), eq(schema.opportunity.organizationId, req.organizationId)))
+ .limit(1);
+ const opportunity = opp;
+ if (!opportunity) {
+ return res.status(400).json({ error: 'Opportunity not found in your organization' });
+ }
+ }
+ }
+
+ if (caseId !== undefined) {
+ if (caseId) {
+ const [caseRecord] = await db
+ .select({ id: schema.caseTable.id })
+ .from(schema.caseTable)
+ .where(and(eq(schema.caseTable.id, caseId), eq(schema.caseTable.organizationId, req.organizationId)))
+ .limit(1);
+ if (!caseRecord) {
+ return res.status(400).json({ error: 'Case not found in your organization' });
+ }
+ }
+ }
+
+ // Build update data object
+ const updateData = {};
+ if (subject !== undefined) updateData.subject = subject;
+ if (status !== undefined) updateData.status = status;
+ if (priority !== undefined) updateData.priority = priority;
+ if (dueDate !== undefined) updateData.dueDate = dueDate ? new Date(dueDate) : null;
+ if (description !== undefined) updateData.description = description;
+ if (ownerId !== undefined) updateData.ownerId = ownerId;
+ if (accountId !== undefined) updateData.accountId = accountId;
+ if (contactId !== undefined) updateData.contactId = contactId;
+ if (leadId !== undefined) updateData.leadId = leadId;
+ if (opportunityId !== undefined) updateData.opportunityId = opportunityId;
+ if (caseId !== undefined) updateData.caseId = caseId;
+
+ // Update the task
+ const [updated] = await db
+ .update(schema.task)
+ .set(updateData)
+ .where(eq(schema.task.id, id))
+ .returning({ id: schema.task.id });
+
+ const [row2] = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ dueDate: schema.task.dueDate,
+ createdAt: schema.task.createdAt,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email,
+ createdById: schema.user.id,
+ createdByName: schema.user.name,
+ createdByEmail: schema.user.email,
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name,
+ accountType: schema.crmAccount.type,
+ contactId: schema.contact.id,
+ contactFirstName: schema.contact.firstName,
+ contactLastName: schema.contact.lastName,
+ contactEmail: schema.contact.email,
+ leadId: schema.lead.id,
+ leadFirstName: schema.lead.firstName,
+ leadLastName: schema.lead.lastName,
+ leadEmail: schema.lead.email,
+ leadCompany: schema.lead.company,
+ opportunityId: schema.opportunity.id,
+ opportunityName: schema.opportunity.name,
+ opportunityAmount: schema.opportunity.amount,
+ caseId: schema.caseTable.id,
+ caseNumber: schema.caseTable.caseNumber,
+ caseSubject: schema.caseTable.subject,
+ caseStatus: schema.caseTable.status
+ })
+ .from(schema.task)
+ .leftJoin(schema.user, eq(schema.user.id, schema.task.ownerId))
+ .leftJoin(schema.user.as('creator'), eq(schema.user.as('creator').id, schema.task.createdById))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.task.accountId))
+ .leftJoin(schema.contact, eq(schema.contact.id, schema.task.contactId))
+ .leftJoin(schema.lead, eq(schema.lead.id, schema.task.leadId))
+ .leftJoin(schema.opportunity, eq(schema.opportunity.id, schema.task.opportunityId))
+ .leftJoin(schema.caseTable, eq(schema.caseTable.id, schema.task.caseId))
+ .where(eq(schema.task.id, updated.id))
+ .limit(1);
+
+ res.json({
+ id: row2.id,
+ subject: row2.subject,
+ status: row2.status,
+ priority: row2.priority,
+ dueDate: row2.dueDate,
+ createdAt: row2.createdAt,
+ owner: row2.ownerId ? { id: row2.ownerId, name: row2.ownerName, email: row2.ownerEmail } : null,
+ createdBy: row2.createdById ? { id: row2.createdById, name: row2.createdByName, email: row2.createdByEmail } : null,
+ account: row2.accountId ? { id: row2.accountId, name: row2.accountName, type: row2.accountType } : null,
+ contact: row2.contactId ? { id: row2.contactId, firstName: row2.contactFirstName, lastName: row2.contactLastName, email: row2.contactEmail } : null,
+ lead: row2.leadId ? { id: row2.leadId, firstName: row2.leadFirstName, lastName: row2.leadLastName, email: row2.leadEmail, company: row2.leadCompany } : null,
+ opportunity: row2.opportunityId ? { id: row2.opportunityId, name: row2.opportunityName, amount: row2.opportunityAmount, status: row2.status } : null,
+ case: row2.caseId ? { id: row2.caseId, caseNumber: row2.caseNumber, subject: row2.caseSubject, status: row2.caseStatus } : null
+ });
+ } catch (error) {
+ console.error('Update task error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /tasks/{id}:
+ * delete:
+ * summary: Delete a specific task
+ * tags: [Tasks]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: Task ID
+ * responses:
+ * 200:
+ * description: Task deleted successfully
+ * 404:
+ * description: Task not found
+ */
+router.delete('/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ // Check if task exists and belongs to organization
+ const [existingTask] = await db
+ .select({ id: schema.task.id })
+ .from(schema.task)
+ .where(and(eq(schema.task.id, id), eq(schema.task.organizationId, req.organizationId)))
+ .limit(1);
+
+ if (!existingTask) {
+ return res.status(404).json({ error: 'Task not found' });
+ }
+
+ // Delete the task (this will also cascade delete related comments due to schema relationship)
+ await db.delete(schema.task).where(eq(schema.task.id, id));
+
+ res.json({ message: 'Task deleted successfully' });
+ } catch (error) {
+ console.error('Delete task error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * @swagger
+ * /tasks/{id}/comments:
+ * post:
+ * summary: Add a comment to a task
+ * tags: [Tasks]
+ * parameters:
+ * - in: header
+ * name: X-Organization-ID
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: Task ID
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - body
+ * properties:
+ * body:
+ * type: string
+ * isPrivate:
+ * type: boolean
+ * default: false
+ * responses:
+ * 201:
+ * description: Comment added successfully
+ * 404:
+ * description: Task not found
+ */
+router.post('/:id/comments', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { body, isPrivate = false } = req.body;
+
+ if (!body) {
+ return res.status(400).json({ error: 'Comment body is required' });
+ }
+
+ // Check if task exists and belongs to organization
+ const [existingTask] = await db
+ .select({ id: schema.task.id })
+ .from(schema.task)
+ .where(and(eq(schema.task.id, id), eq(schema.task.organizationId, req.organizationId)))
+ .limit(1);
+
+ if (!existingTask) {
+ return res.status(404).json({ error: 'Task not found' });
+ }
+
+ // Create the comment
+ const [inserted] = await db
+ .insert(schema.comment)
+ .values({
+ body,
+ isPrivate,
+ authorId: req.userId,
+ organizationId: req.organizationId,
+ taskId: id
+ })
+ .returning({ id: schema.comment.id });
+
+ const [comment] = await db
+ .select({
+ id: schema.comment.id,
+ body: schema.comment.body,
+ isPrivate: schema.comment.isPrivate,
+ createdAt: schema.comment.createdAt,
+ authorId: schema.user.id,
+ authorName: schema.user.name,
+ authorEmail: schema.user.email
+ })
+ .from(schema.comment)
+ .leftJoin(schema.user, eq(schema.user.id, schema.comment.authorId))
+ .where(eq(schema.comment.id, inserted.id))
+ .limit(1);
+
+ res.status(201).json({
+ id: comment.id,
+ body: comment.body,
+ isPrivate: comment.isPrivate,
+ createdAt: comment.createdAt,
+ author: comment.authorId ? { id: comment.authorId, name: comment.authorName, email: comment.authorEmail } : null
+ });
+ } catch (error) {
+ console.error('Add task comment error:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+export default router;
diff --git a/apps/web/.env.example b/apps/web/.env.example
new file mode 100644
index 0000000..a431deb
--- /dev/null
+++ b/apps/web/.env.example
@@ -0,0 +1,18 @@
+GOOGLE_CLIENT_ID="your-google-client-id-here"
+GOOGLE_CLIENT_SECRET="your-google-client-secret-here"
+GOOGLE_LOGIN_DOMAIN="http://localhost:5173"
+DATABASE_URL="postgresql://username:password@localhost:5432/bottlecrm?schema=public"
+
+# API Configuration
+API_PORT=3001
+JWT_SECRET=your-super-secure-jwt-secret-key-change-this-in-production
+JWT_EXPIRES_IN=24h
+FRONTEND_URL=http://localhost:5173
+
+# Logging Configuration
+ENABLE_REQUEST_LOGGING=true
+LOG_REQUEST_BODY=false
+LOG_RESPONSE_BODY=false
+
+# Environment
+NODE_ENV=development
\ No newline at end of file
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
new file mode 100644
index 0000000..7f47028
--- /dev/null
+++ b/apps/web/.gitignore
@@ -0,0 +1,28 @@
+node_modules
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
+generated/*
+src/generated/*
+.github/prompts/*
+supabase
\ No newline at end of file
diff --git a/.npmrc b/apps/web/.npmrc
similarity index 100%
rename from .npmrc
rename to apps/web/.npmrc
diff --git a/.prettierignore b/apps/web/.prettierignore
similarity index 100%
rename from .prettierignore
rename to apps/web/.prettierignore
diff --git a/.prettierrc b/apps/web/.prettierrc
similarity index 100%
rename from .prettierrc
rename to apps/web/.prettierrc
diff --git a/eslint.config.js b/apps/web/eslint.config.js
similarity index 100%
rename from eslint.config.js
rename to apps/web/eslint.config.js
diff --git a/jsconfig.json b/apps/web/jsconfig.json
similarity index 100%
rename from jsconfig.json
rename to apps/web/jsconfig.json
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..7f7c2f5
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@opensource-startup-crm/web",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "format": "prettier --write .",
+ "lint": "prettier --check . && eslint ."
+ },
+ "devDependencies": {
+ "@better-auth/cli": "^1.3.4",
+ "@sveltejs/adapter-cloudflare": "^7.1.3",
+ "@sveltejs/kit": "^2.25.2",
+ "@sveltejs/vite-plugin-svelte": "^6.1.0",
+ "@tailwindcss/typography": "^0.5.16",
+ "@tailwindcss/vite": "^4.1.11",
+ "drizzle-kit": "^0.31.4",
+ "drizzle-orm": "0.44.3",
+ "eslint": "^9.31.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-svelte": "^3.11.0",
+ "prettier": "^3.6.2",
+ "prettier-plugin-svelte": "^3.4.0",
+ "prettier-plugin-tailwindcss": "^0.6.14",
+ "svelte": "^5.36.14",
+ "svelte-check": "^4.3.0",
+ "svelte-dnd-action": "^0.9.64",
+ "tailwindcss": "^4.1.11",
+ "typescript": "^5.8.3",
+ "vite": "^7.0.5"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "esbuild"
+ ]
+ },
+ "dependencies": {
+ "@opensource-startup-crm/constants": "workspace:*",
+ "@lucide/svelte": "^0.525.0",
+ "@opensource-startup-crm/database": "workspace:*",
+ "better-auth": "^1.3.4",
+ "date-fns": "^4.1.0",
+ "dotenv": "^17.2.1",
+ "libphonenumber-js": "^1.12.10",
+ "marked": "^16.1.1",
+ "optimistikit": "^1.0.2",
+ "postgres": "^3.4.7",
+ "svelte-highlight": "^7.8.3",
+ "svelte-meta-tags": "^4.4.0",
+ "uuid": "^11.1.0",
+ "zod": "^4.0.8"
+ }
+}
diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml
new file mode 100644
index 0000000..2711880
--- /dev/null
+++ b/apps/web/pnpm-lock.yaml
@@ -0,0 +1,6771 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@lucide/svelte':
+ specifier: ^0.525.0
+ version: 0.525.0(svelte@5.36.14)
+ '@prisma/client':
+ specifier: 6.12.0
+ version: 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3)
+ axios:
+ specifier: ^1.11.0
+ version: 1.11.0
+ bcryptjs:
+ specifier: ^3.0.2
+ version: 3.0.2
+ better-auth:
+ specifier: ^1.3.4
+ version: 1.3.4
+ cors:
+ specifier: ^2.8.5
+ version: 2.8.5
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
+ dotenv:
+ specifier: ^17.2.1
+ version: 17.2.1
+ express:
+ specifier: ^5.1.0
+ version: 5.1.0
+ express-rate-limit:
+ specifier: ^8.0.1
+ version: 8.0.1(express@5.1.0)
+ google-auth-library:
+ specifier: ^10.2.0
+ version: 10.2.0
+ helmet:
+ specifier: ^8.1.0
+ version: 8.1.0
+ jsonwebtoken:
+ specifier: ^9.0.2
+ version: 9.0.2
+ libphonenumber-js:
+ specifier: ^1.12.10
+ version: 1.12.10
+ marked:
+ specifier: ^16.1.1
+ version: 16.1.1
+ morgan:
+ specifier: ^1.10.1
+ version: 1.10.1
+ postgres:
+ specifier: ^3.4.7
+ version: 3.4.7
+ svelte-highlight:
+ specifier: ^7.8.3
+ version: 7.8.3
+ svelte-meta-tags:
+ specifier: ^4.4.0
+ version: 4.4.0(svelte@5.36.14)
+ swagger-jsdoc:
+ specifier: ^6.2.8
+ version: 6.2.8(openapi-types@12.1.3)
+ swagger-ui-express:
+ specifier: ^5.0.1
+ version: 5.0.1(express@5.1.0)
+ uuid:
+ specifier: ^11.1.0
+ version: 11.1.0
+ winston:
+ specifier: ^3.17.0
+ version: 3.17.0
+ zod:
+ specifier: ^4.0.8
+ version: 4.0.8
+ devDependencies:
+ '@better-auth/cli':
+ specifier: ^1.3.4
+ version: 1.3.4(kysely@0.28.4)(postgres@3.4.7)
+ '@eslint/compat':
+ specifier: ^1.3.1
+ version: 1.3.1(eslint@9.31.0(jiti@2.4.2))
+ '@eslint/js':
+ specifier: ^9.31.0
+ version: 9.31.0
+ '@sveltejs/adapter-node':
+ specifier: ^5.2.13
+ version: 5.2.13(@sveltejs/kit@2.25.2(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))
+ '@sveltejs/kit':
+ specifier: ^2.25.2
+ version: 2.25.2(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))
+ '@sveltejs/vite-plugin-svelte':
+ specifier: ^6.1.0
+ version: 6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))
+ '@tailwindcss/typography':
+ specifier: ^0.5.16
+ version: 0.5.16(tailwindcss@4.1.11)
+ '@tailwindcss/vite':
+ specifier: ^4.1.11
+ version: 4.1.11(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))
+ '@types/bcryptjs':
+ specifier: ^3.0.0
+ version: 3.0.0
+ '@types/cors':
+ specifier: ^2.8.19
+ version: 2.8.19
+ '@types/express':
+ specifier: ^5.0.3
+ version: 5.0.3
+ '@types/jsonwebtoken':
+ specifier: ^9.0.10
+ version: 9.0.10
+ '@types/morgan':
+ specifier: ^1.9.10
+ version: 1.9.10
+ '@types/swagger-jsdoc':
+ specifier: ^6.0.4
+ version: 6.0.4
+ '@types/swagger-ui-express':
+ specifier: ^4.1.8
+ version: 4.1.8
+ drizzle-kit:
+ specifier: ^0.31.4
+ version: 0.31.4
+ drizzle-orm:
+ specifier: ^0.44.4
+ version: 0.44.4(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(gel@2.1.1)(kysely@0.28.4)(postgres@3.4.7)(prisma@6.12.0(typescript@5.8.3))
+ eslint:
+ specifier: ^9.31.0
+ version: 9.31.0(jiti@2.4.2)
+ eslint-config-prettier:
+ specifier: ^10.1.8
+ version: 10.1.8(eslint@9.31.0(jiti@2.4.2))
+ eslint-plugin-svelte:
+ specifier: ^3.11.0
+ version: 3.11.0(eslint@9.31.0(jiti@2.4.2))(svelte@5.36.14)
+ globals:
+ specifier: ^16.3.0
+ version: 16.3.0
+ nodemon:
+ specifier: ^3.1.10
+ version: 3.1.10
+ prettier:
+ specifier: ^3.6.2
+ version: 3.6.2
+ prettier-plugin-svelte:
+ specifier: ^3.4.0
+ version: 3.4.0(prettier@3.6.2)(svelte@5.36.14)
+ prettier-plugin-tailwindcss:
+ specifier: ^0.6.14
+ version: 0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.36.14))(prettier@3.6.2)
+ prisma:
+ specifier: 6.12.0
+ version: 6.12.0(typescript@5.8.3)
+ svelte:
+ specifier: ^5.36.14
+ version: 5.36.14
+ svelte-check:
+ specifier: ^4.3.0
+ version: 4.3.0(picomatch@4.0.2)(svelte@5.36.14)(typescript@5.8.3)
+ svelte-dnd-action:
+ specifier: ^0.9.64
+ version: 0.9.64(svelte@5.36.14)
+ tailwindcss:
+ specifier: ^4.1.11
+ version: 4.1.11
+ typescript:
+ specifier: ^5.8.3
+ version: 5.8.3
+ vite:
+ specifier: ^7.0.5
+ version: 7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)
+
+packages:
+
+ '@ampproject/remapping@2.3.0':
+ resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
+ engines: {node: '>=6.0.0'}
+
+ '@apidevtools/json-schema-ref-parser@9.1.2':
+ resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==}
+
+ '@apidevtools/openapi-schemas@2.1.0':
+ resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==}
+ engines: {node: '>=10'}
+
+ '@apidevtools/swagger-methods@3.0.2':
+ resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==}
+
+ '@apidevtools/swagger-parser@10.0.3':
+ resolution: {integrity: sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==}
+ peerDependencies:
+ openapi-types: '>=7'
+
+ '@babel/code-frame@7.27.1':
+ resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.28.0':
+ resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.28.0':
+ resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.28.0':
+ resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-annotate-as-pure@7.27.3':
+ resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.27.2':
+ resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-create-class-features-plugin@7.27.1':
+ resolution: {integrity: sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-member-expression-to-functions@7.27.1':
+ resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.27.1':
+ resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.27.3':
+ resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-optimise-call-expression@7.27.1':
+ resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-plugin-utils@7.27.1':
+ resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-replace-supers@7.27.1':
+ resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-skip-transparent-expression-wrappers@7.27.1':
+ resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.27.1':
+ resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.28.2':
+ resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.0':
+ resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-syntax-jsx@7.27.1':
+ resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-syntax-typescript@7.27.1':
+ resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-modules-commonjs@7.27.1':
+ resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-display-name@7.28.0':
+ resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-development@7.27.1':
+ resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx@7.27.1':
+ resolution: {integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-pure-annotations@7.27.1':
+ resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-typescript@7.28.0':
+ resolution: {integrity: sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/preset-react@7.27.1':
+ resolution: {integrity: sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/preset-typescript@7.27.1':
+ resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.27.2':
+ resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.28.0':
+ resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.28.2':
+ resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@better-auth/cli@1.3.4':
+ resolution: {integrity: sha512-nEVd/j2d2CQd+62+AxuD2v/NxFwaEYr0tkM0A8pVMv/b2TWHL09P7NzRVWCn7QlU1grAlPudMNolAzZ4IyLmNQ==}
+ hasBin: true
+
+ '@better-auth/utils@0.2.5':
+ resolution: {integrity: sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ==}
+
+ '@better-fetch/fetch@1.1.18':
+ resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==}
+
+ '@chevrotain/cst-dts-gen@10.5.0':
+ resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==}
+
+ '@chevrotain/gast@10.5.0':
+ resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==}
+
+ '@chevrotain/types@10.5.0':
+ resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==}
+
+ '@chevrotain/utils@10.5.0':
+ resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==}
+
+ '@clack/core@0.4.2':
+ resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==}
+
+ '@clack/prompts@0.10.1':
+ resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==}
+
+ '@colors/colors@1.6.0':
+ resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
+ engines: {node: '>=0.1.90'}
+
+ '@dabh/diagnostics@2.0.3':
+ resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
+
+ '@drizzle-team/brocli@0.10.2':
+ resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
+
+ '@esbuild-kit/core-utils@3.3.2':
+ resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
+ deprecated: 'Merged into tsx: https://tsx.is'
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
+ deprecated: 'Merged into tsx: https://tsx.is'
+
+ '@esbuild/aix-ppc64@0.25.2':
+ resolution: {integrity: sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/aix-ppc64@0.25.8':
+ resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.18.20':
+ resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm64@0.25.2':
+ resolution: {integrity: sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm64@0.25.8':
+ resolution: {integrity: sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.18.20':
+ resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.2':
+ resolution: {integrity: sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.8':
+ resolution: {integrity: sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.18.20':
+ resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.2':
+ resolution: {integrity: sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.8':
+ resolution: {integrity: sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.18.20':
+ resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-arm64@0.25.2':
+ resolution: {integrity: sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-arm64@0.25.8':
+ resolution: {integrity: sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.18.20':
+ resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.2':
+ resolution: {integrity: sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.8':
+ resolution: {integrity: sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-arm64@0.25.2':
+ resolution: {integrity: sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-arm64@0.25.8':
+ resolution: {integrity: sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.18.20':
+ resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.2':
+ resolution: {integrity: sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.8':
+ resolution: {integrity: sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.18.20':
+ resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm64@0.25.2':
+ resolution: {integrity: sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm64@0.25.8':
+ resolution: {integrity: sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.18.20':
+ resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.2':
+ resolution: {integrity: sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.8':
+ resolution: {integrity: sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.18.20':
+ resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.2':
+ resolution: {integrity: sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.8':
+ resolution: {integrity: sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.18.20':
+ resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.2':
+ resolution: {integrity: sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.8':
+ resolution: {integrity: sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.18.20':
+ resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.2':
+ resolution: {integrity: sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.8':
+ resolution: {integrity: sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.18.20':
+ resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.2':
+ resolution: {integrity: sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.8':
+ resolution: {integrity: sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.18.20':
+ resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.2':
+ resolution: {integrity: sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.8':
+ resolution: {integrity: sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.18.20':
+ resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.2':
+ resolution: {integrity: sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.8':
+ resolution: {integrity: sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.18.20':
+ resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.2':
+ resolution: {integrity: sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.8':
+ resolution: {integrity: sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.2':
+ resolution: {integrity: sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-arm64@0.25.8':
+ resolution: {integrity: sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.18.20':
+ resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.2':
+ resolution: {integrity: sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.8':
+ resolution: {integrity: sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.2':
+ resolution: {integrity: sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-arm64@0.25.8':
+ resolution: {integrity: sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.18.20':
+ resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.2':
+ resolution: {integrity: sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.8':
+ resolution: {integrity: sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.8':
+ resolution: {integrity: sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.18.20':
+ resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/sunos-x64@0.25.2':
+ resolution: {integrity: sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/sunos-x64@0.25.8':
+ resolution: {integrity: sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.18.20':
+ resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-arm64@0.25.2':
+ resolution: {integrity: sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-arm64@0.25.8':
+ resolution: {integrity: sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.18.20':
+ resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.2':
+ resolution: {integrity: sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.8':
+ resolution: {integrity: sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.18.20':
+ resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.2':
+ resolution: {integrity: sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.8':
+ resolution: {integrity: sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.7.0':
+ resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.1':
+ resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/compat@1.3.1':
+ resolution: {integrity: sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.40 || 9
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+
+ '@eslint/config-array@0.21.0':
+ resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.3.0':
+ resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.14.0':
+ resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.15.1':
+ resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.1':
+ resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.31.0':
+ resolution: {integrity: sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.6':
+ resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.3.1':
+ resolution: {integrity: sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@hexagon/base64@1.1.28':
+ resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
+
+ '@humanfs/core@0.19.1':
+ resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.6':
+ resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.3.1':
+ resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==}
+ engines: {node: '>=18.18'}
+
+ '@humanwhocodes/retry@0.4.2':
+ resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==}
+ engines: {node: '>=18.18'}
+
+ '@isaacs/fs-minipass@4.0.1':
+ resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+ engines: {node: '>=18.0.0'}
+
+ '@jridgewell/gen-mapping@0.3.12':
+ resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
+
+ '@jridgewell/gen-mapping@0.3.8':
+ resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/set-array@1.2.1':
+ resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.0':
+ resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
+
+ '@jridgewell/trace-mapping@0.3.25':
+ resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+
+ '@jridgewell/trace-mapping@0.3.29':
+ resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
+
+ '@jsdevtools/ono@7.1.3':
+ resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
+
+ '@levischuck/tiny-cbor@0.2.11':
+ resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
+
+ '@lucide/svelte@0.525.0':
+ resolution: {integrity: sha512-dyUxkXzepagLUzL8jHQNdeH286nC66ClLACsg+Neu/bjkRJWPWMzkT+H0DKlE70QdkicGCfs1ZGmXCc351hmZA==}
+ peerDependencies:
+ svelte: ^5
+
+ '@mrleebo/prisma-ast@0.12.1':
+ resolution: {integrity: sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==}
+ engines: {node: '>=16'}
+
+ '@noble/ciphers@0.6.0':
+ resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==}
+
+ '@noble/hashes@1.8.0':
+ resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
+ engines: {node: ^14.21.3 || >=16}
+
+ '@peculiar/asn1-android@2.4.0':
+ resolution: {integrity: sha512-YFueREq97CLslZZBI8dKzis7jMfEHSLxM+nr0Zdx1POiXFLjqqwoY5s0F1UimdBiEw/iKlHey2m56MRDv7Jtyg==}
+
+ '@peculiar/asn1-ecc@2.4.0':
+ resolution: {integrity: sha512-fJiYUBCJBDkjh347zZe5H81BdJ0+OGIg0X9z06v8xXUoql3MFeENUX0JsjCaVaU9A0L85PefLPGYkIoGpTnXLQ==}
+
+ '@peculiar/asn1-rsa@2.4.0':
+ resolution: {integrity: sha512-6PP75voaEnOSlWR9sD25iCQyLgFZHXbmxvUfnnDcfL6Zh5h2iHW38+bve4LfH7a60x7fkhZZNmiYqAlAff9Img==}
+
+ '@peculiar/asn1-schema@2.4.0':
+ resolution: {integrity: sha512-umbembjIWOrPSOzEGG5vxFLkeM8kzIhLkgigtsOrfLKnuzxWxejAcUX+q/SoZCdemlODOcr5WiYa7+dIEzBXZQ==}
+
+ '@peculiar/asn1-x509@2.4.0':
+ resolution: {integrity: sha512-F7mIZY2Eao2TaoVqigGMLv+NDdpwuBKU1fucHPONfzaBS4JXXCNCmfO0Z3dsy7JzKGqtDcYC1mr9JjaZQZNiuw==}
+
+ '@petamoriken/float16@3.9.2':
+ resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==}
+
+ '@polka/url@1.0.0-next.29':
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
+ '@prisma/client@5.22.0':
+ resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
+ engines: {node: '>=16.13'}
+ peerDependencies:
+ prisma: '*'
+ peerDependenciesMeta:
+ prisma:
+ optional: true
+
+ '@prisma/client@6.12.0':
+ resolution: {integrity: sha512-wn98bJ3Cj6edlF4jjpgXwbnQIo/fQLqqQHPk2POrZPxTlhY3+n90SSIF3LMRVa8VzRFC/Gec3YKJRxRu+AIGVA==}
+ engines: {node: '>=18.18'}
+ peerDependencies:
+ prisma: '*'
+ typescript: '>=5.1.0'
+ peerDependenciesMeta:
+ prisma:
+ optional: true
+ typescript:
+ optional: true
+
+ '@prisma/config@6.12.0':
+ resolution: {integrity: sha512-HovZWzhWEMedHxmjefQBRZa40P81N7/+74khKFz9e1AFjakcIQdXgMWKgt20HaACzY+d1LRBC+L4tiz71t9fkg==}
+
+ '@prisma/debug@5.22.0':
+ resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==}
+
+ '@prisma/debug@6.12.0':
+ resolution: {integrity: sha512-plbz6z72orcqr0eeio7zgUrZj5EudZUpAeWkFTA/DDdXEj28YHDXuiakvR6S7sD6tZi+jiwQEJAPeV6J6m/tEQ==}
+
+ '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2':
+ resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==}
+
+ '@prisma/engines-version@6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc':
+ resolution: {integrity: sha512-70vhecxBJlRr06VfahDzk9ow4k1HIaSfVUT3X0/kZoHCMl9zbabut4gEXAyzJZxaCGi5igAA7SyyfBI//mmkbQ==}
+
+ '@prisma/engines@5.22.0':
+ resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==}
+
+ '@prisma/engines@6.12.0':
+ resolution: {integrity: sha512-4BRZZUaAuB4p0XhTauxelvFs7IllhPmNLvmla0bO1nkECs8n/o1pUvAVbQ/VOrZR5DnF4HED0PrGai+rIOVePA==}
+
+ '@prisma/fetch-engine@5.22.0':
+ resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==}
+
+ '@prisma/fetch-engine@6.12.0':
+ resolution: {integrity: sha512-EamoiwrK46rpWaEbLX9aqKDPOd8IyLnZAkiYXFNuq0YsU0Z8K09/rH8S7feOWAVJ3xzeSgcEJtBlVDrajM9Sag==}
+
+ '@prisma/get-platform@5.22.0':
+ resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
+
+ '@prisma/get-platform@6.12.0':
+ resolution: {integrity: sha512-nRerTGhTlgyvcBlyWgt8OLNIV7QgJS2XYXMJD1hysorMCuLAjuDDuoxmVt7C2nLxbuxbWPp7OuFRHC23HqD9dA==}
+
+ '@rollup/plugin-commonjs@28.0.3':
+ resolution: {integrity: sha512-pyltgilam1QPdn+Zd9gaCfOLcnjMEJ9gV+bTw6/r73INdvzf1ah9zLIJBm+kW7R6IUFIQ1YO+VqZtYxZNWFPEQ==}
+ engines: {node: '>=16.0.0 || 14 >= 14.17'}
+ peerDependencies:
+ rollup: ^2.68.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/plugin-json@6.1.0':
+ resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/plugin-node-resolve@16.0.1':
+ resolution: {integrity: sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^2.78.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/pluginutils@5.1.4':
+ resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.39.0':
+ resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm-eabi@4.45.1':
+ resolution: {integrity: sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.39.0':
+ resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.45.1':
+ resolution: {integrity: sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.39.0':
+ resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-arm64@4.45.1':
+ resolution: {integrity: sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.39.0':
+ resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.45.1':
+ resolution: {integrity: sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.39.0':
+ resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-arm64@4.45.1':
+ resolution: {integrity: sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.39.0':
+ resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.45.1':
+ resolution: {integrity: sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.39.0':
+ resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.45.1':
+ resolution: {integrity: sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.39.0':
+ resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.45.1':
+ resolution: {integrity: sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.39.0':
+ resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.45.1':
+ resolution: {integrity: sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.39.0':
+ resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.45.1':
+ resolution: {integrity: sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loongarch64-gnu@4.39.0':
+ resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loongarch64-gnu@4.45.1':
+ resolution: {integrity: sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
+ resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-powerpc64le-gnu@4.45.1':
+ resolution: {integrity: sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.39.0':
+ resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.45.1':
+ resolution: {integrity: sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.39.0':
+ resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.45.1':
+ resolution: {integrity: sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.39.0':
+ resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.45.1':
+ resolution: {integrity: sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.39.0':
+ resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.45.1':
+ resolution: {integrity: sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.39.0':
+ resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.45.1':
+ resolution: {integrity: sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-win32-arm64-msvc@4.39.0':
+ resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-arm64-msvc@4.45.1':
+ resolution: {integrity: sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.39.0':
+ resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.45.1':
+ resolution: {integrity: sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.39.0':
+ resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.45.1':
+ resolution: {integrity: sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@scarf/scarf@1.4.0':
+ resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
+
+ '@simplewebauthn/browser@13.1.2':
+ resolution: {integrity: sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw==}
+
+ '@simplewebauthn/server@13.1.2':
+ resolution: {integrity: sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g==}
+ engines: {node: '>=20.0.0'}
+
+ '@sveltejs/acorn-typescript@1.0.5':
+ resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==}
+ peerDependencies:
+ acorn: ^8.9.0
+
+ '@sveltejs/adapter-node@5.2.13':
+ resolution: {integrity: sha512-yS2TVFmIrxjGhYaV5/iIUrJ3mJl6zjaYn0lBD70vTLnYvJeqf3cjvLXeXCUCuYinhSBoyF4DpfGla49BnIy7sQ==}
+ peerDependencies:
+ '@sveltejs/kit': ^2.4.0
+
+ '@sveltejs/kit@2.25.2':
+ resolution: {integrity: sha512-aKfj82vqEINedoH9Pw4Ip16jj3w8soNq9F3nJqc56kxXW74TcEu/gdTAuLUI+gsl8i+KXfetRqg1F+gG/AZRVQ==}
+ engines: {node: '>=18.13'}
+ hasBin: true
+ peerDependencies:
+ '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0
+ svelte: ^4.0.0 || ^5.0.0-next.0
+ vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0
+
+ '@sveltejs/vite-plugin-svelte-inspector@5.0.0':
+ resolution: {integrity: sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==}
+ engines: {node: ^20.19 || ^22.12 || >=24}
+ peerDependencies:
+ '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0
+ svelte: ^5.0.0
+ vite: ^6.3.0 || ^7.0.0
+
+ '@sveltejs/vite-plugin-svelte@6.1.0':
+ resolution: {integrity: sha512-+U6lz1wvGEG/BvQyL4z/flyNdQ9xDNv5vrh+vWBWTHaebqT0c9RNggpZTo/XSPoHsSCWBlYaTlRX8pZ9GATXCw==}
+ engines: {node: ^20.19 || ^22.12 || >=24}
+ peerDependencies:
+ svelte: ^5.0.0
+ vite: ^6.3.0 || ^7.0.0
+
+ '@tailwindcss/node@4.1.11':
+ resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==}
+
+ '@tailwindcss/oxide-android-arm64@4.1.11':
+ resolution: {integrity: sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.11':
+ resolution: {integrity: sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.1.11':
+ resolution: {integrity: sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.11':
+ resolution: {integrity: sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11':
+ resolution: {integrity: sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.11':
+ resolution: {integrity: sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.11':
+ resolution: {integrity: sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.11':
+ resolution: {integrity: sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.11':
+ resolution: {integrity: sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.11':
+ resolution: {integrity: sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.11':
+ resolution: {integrity: sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.11':
+ resolution: {integrity: sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.1.11':
+ resolution: {integrity: sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/typography@0.5.16':
+ resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
+
+ '@tailwindcss/vite@4.1.11':
+ resolution: {integrity: sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7
+
+ '@types/bcryptjs@3.0.0':
+ resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
+ deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
+
+ '@types/better-sqlite3@7.6.13':
+ resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
+
+ '@types/body-parser@1.19.6':
+ resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
+ '@types/connect@3.4.38':
+ resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
+ '@types/cookie@0.6.0':
+ resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
+
+ '@types/cors@2.8.19':
+ resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+
+ '@types/estree@1.0.7':
+ resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/express-serve-static-core@5.0.7':
+ resolution: {integrity: sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==}
+
+ '@types/express@5.0.3':
+ resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==}
+
+ '@types/http-errors@2.0.5':
+ resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/jsonwebtoken@9.0.10':
+ resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+
+ '@types/mime@1.3.5':
+ resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+
+ '@types/morgan@1.9.10':
+ resolution: {integrity: sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
+ '@types/node@24.1.0':
+ resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==}
+
+ '@types/prompts@2.4.9':
+ resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==}
+
+ '@types/qs@6.14.0':
+ resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
+
+ '@types/range-parser@1.2.7':
+ resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
+ '@types/resolve@1.20.2':
+ resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+
+ '@types/send@0.17.5':
+ resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
+
+ '@types/serve-static@1.15.8':
+ resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==}
+
+ '@types/swagger-jsdoc@6.0.4':
+ resolution: {integrity: sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==}
+
+ '@types/swagger-ui-express@4.1.8':
+ resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==}
+
+ '@types/triple-beam@1.3.5':
+ resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
+
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.14.1:
+ resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ acorn@8.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ agent-base@7.1.4:
+ resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+ engines: {node: '>= 14'}
+
+ ajv@6.12.6:
+ resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ asn1js@3.0.6:
+ resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==}
+ engines: {node: '>=12.0.0'}
+
+ async@3.2.6:
+ resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
+
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+ axios@1.11.0:
+ resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+ basic-auth@2.0.1:
+ resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
+ engines: {node: '>= 0.8'}
+
+ bcryptjs@3.0.2:
+ resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==}
+ hasBin: true
+
+ better-auth@1.3.4:
+ resolution: {integrity: sha512-JbZYam6Cs3Eu5CSoMK120zSshfaKvrCftSo/+v7524H1RvhryQ7UtMbzagBcXj0Digjj8hZtVkkR4tTZD/wK2g==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
+ better-call@1.0.13:
+ resolution: {integrity: sha512-auqdP9lnNOli9tKpZIiv0nEIwmmyaD/RotM3Mucql+Ef88etoZi/t7Ph5LjlmZt/hiSahhNTt6YVnx6++rziXA==}
+
+ better-sqlite3@11.10.0:
+ resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
+
+ bignumber.js@9.3.1:
+ resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
+
+ binary-extensions@2.3.0:
+ resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+ engines: {node: '>=8'}
+
+ bindings@1.5.0:
+ resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
+
+ bl@4.1.0:
+ resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+
+ body-parser@2.2.0:
+ resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
+ engines: {node: '>=18'}
+
+ brace-expansion@1.1.11:
+ resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ browserslist@4.25.1:
+ resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
+ buffer-from@1.1.2:
+ resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
+ buffer@5.7.1:
+ resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+
+ bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+
+ c12@2.0.4:
+ resolution: {integrity: sha512-3DbbhnFt0fKJHxU4tEUPmD1ahWE4PWPMomqfYsTJdrhpmEnRKJi3qSC4rO5U6E6zN1+pjBY7+z8fUmNRMaVKLw==}
+ peerDependencies:
+ magicast: ^0.3.5
+ peerDependenciesMeta:
+ magicast:
+ optional: true
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ call-me-maybe@1.0.2:
+ resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ caniuse-lite@1.0.30001731:
+ resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ chalk@5.5.0:
+ resolution: {integrity: sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
+ chevrotain@10.5.0:
+ resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==}
+
+ chokidar@3.6.0:
+ resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+ engines: {node: '>= 8.10.0'}
+
+ chokidar@4.0.3:
+ resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+ engines: {node: '>= 14.16.0'}
+
+ chownr@1.1.4:
+ resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
+
+ chownr@2.0.0:
+ resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
+ engines: {node: '>=10'}
+
+ chownr@3.0.0:
+ resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+ engines: {node: '>=18'}
+
+ citty@0.1.6:
+ resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ color-convert@1.9.3:
+ resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.3:
+ resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ color-string@1.9.1:
+ resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
+
+ color@3.2.1:
+ resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
+
+ colorspace@1.1.4:
+ resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==}
+
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
+ commander@12.1.0:
+ resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
+ engines: {node: '>=18'}
+
+ commander@6.2.0:
+ resolution: {integrity: sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==}
+ engines: {node: '>= 6'}
+
+ commander@9.5.0:
+ resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
+ engines: {node: ^12.20.0 || >=14}
+
+ commondir@1.0.1:
+ resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ confbox@0.1.8:
+ resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
+
+ consola@3.4.2:
+ resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
+ engines: {node: ^14.18.0 || >=16.10.0}
+
+ content-disposition@1.0.0:
+ resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
+ engines: {node: '>= 0.6'}
+
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.6.0:
+ resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
+ engines: {node: '>= 0.6'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
+ cors@2.8.5:
+ resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
+ engines: {node: '>= 0.10'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ data-uri-to-buffer@4.0.1:
+ resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
+ engines: {node: '>= 12'}
+
+ date-fns@4.1.0:
+ resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+
+ debug@2.6.9:
+ resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ debug@4.4.1:
+ resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decompress-response@6.0.0:
+ resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+ engines: {node: '>=10'}
+
+ deep-extend@0.6.0:
+ resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
+ engines: {node: '>=4.0.0'}
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ deepmerge@4.3.1:
+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+ engines: {node: '>=0.10.0'}
+
+ defu@6.1.4:
+ resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
+
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
+ depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+
+ destr@2.0.5:
+ resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
+
+ detect-libc@2.0.4:
+ resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
+ engines: {node: '>=8'}
+
+ devalue@5.1.1:
+ resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==}
+
+ doctrine@3.0.0:
+ resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
+ engines: {node: '>=6.0.0'}
+
+ dotenv@16.6.1:
+ resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
+ engines: {node: '>=12'}
+
+ dotenv@17.2.1:
+ resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
+ engines: {node: '>=12'}
+
+ drizzle-kit@0.31.4:
+ resolution: {integrity: sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==}
+ hasBin: true
+
+ drizzle-orm@0.33.0:
+ resolution: {integrity: sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA==}
+ peerDependencies:
+ '@aws-sdk/client-rds-data': '>=3'
+ '@cloudflare/workers-types': '>=3'
+ '@electric-sql/pglite': '>=0.1.1'
+ '@libsql/client': '*'
+ '@neondatabase/serverless': '>=0.1'
+ '@op-engineering/op-sqlite': '>=2'
+ '@opentelemetry/api': ^1.4.1
+ '@planetscale/database': '>=1'
+ '@prisma/client': '*'
+ '@tidbcloud/serverless': '*'
+ '@types/better-sqlite3': '*'
+ '@types/pg': '*'
+ '@types/react': '>=18'
+ '@types/sql.js': '*'
+ '@vercel/postgres': '>=0.8.0'
+ '@xata.io/client': '*'
+ better-sqlite3: '>=7'
+ bun-types: '*'
+ expo-sqlite: '>=13.2.0'
+ knex: '*'
+ kysely: '*'
+ mysql2: '>=2'
+ pg: '>=8'
+ postgres: '>=3'
+ prisma: '*'
+ react: '>=18'
+ sql.js: '>=1'
+ sqlite3: '>=5'
+ peerDependenciesMeta:
+ '@aws-sdk/client-rds-data':
+ optional: true
+ '@cloudflare/workers-types':
+ optional: true
+ '@electric-sql/pglite':
+ optional: true
+ '@libsql/client':
+ optional: true
+ '@neondatabase/serverless':
+ optional: true
+ '@op-engineering/op-sqlite':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@planetscale/database':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@tidbcloud/serverless':
+ optional: true
+ '@types/better-sqlite3':
+ optional: true
+ '@types/pg':
+ optional: true
+ '@types/react':
+ optional: true
+ '@types/sql.js':
+ optional: true
+ '@vercel/postgres':
+ optional: true
+ '@xata.io/client':
+ optional: true
+ better-sqlite3:
+ optional: true
+ bun-types:
+ optional: true
+ expo-sqlite:
+ optional: true
+ knex:
+ optional: true
+ kysely:
+ optional: true
+ mysql2:
+ optional: true
+ pg:
+ optional: true
+ postgres:
+ optional: true
+ prisma:
+ optional: true
+ react:
+ optional: true
+ sql.js:
+ optional: true
+ sqlite3:
+ optional: true
+
+ drizzle-orm@0.44.4:
+ resolution: {integrity: sha512-ZyzKFpTC/Ut3fIqc2c0dPZ6nhchQXriTsqTNs4ayRgl6sZcFlMs9QZKPSHXK4bdOf41GHGWf+FrpcDDYwW+W6Q==}
+ peerDependencies:
+ '@aws-sdk/client-rds-data': '>=3'
+ '@cloudflare/workers-types': '>=4'
+ '@electric-sql/pglite': '>=0.2.0'
+ '@libsql/client': '>=0.10.0'
+ '@libsql/client-wasm': '>=0.10.0'
+ '@neondatabase/serverless': '>=0.10.0'
+ '@op-engineering/op-sqlite': '>=2'
+ '@opentelemetry/api': ^1.4.1
+ '@planetscale/database': '>=1.13'
+ '@prisma/client': '*'
+ '@tidbcloud/serverless': '*'
+ '@types/better-sqlite3': '*'
+ '@types/pg': '*'
+ '@types/sql.js': '*'
+ '@upstash/redis': '>=1.34.7'
+ '@vercel/postgres': '>=0.8.0'
+ '@xata.io/client': '*'
+ better-sqlite3: '>=7'
+ bun-types: '*'
+ expo-sqlite: '>=14.0.0'
+ gel: '>=2'
+ knex: '*'
+ kysely: '*'
+ mysql2: '>=2'
+ pg: '>=8'
+ postgres: '>=3'
+ prisma: '*'
+ sql.js: '>=1'
+ sqlite3: '>=5'
+ peerDependenciesMeta:
+ '@aws-sdk/client-rds-data':
+ optional: true
+ '@cloudflare/workers-types':
+ optional: true
+ '@electric-sql/pglite':
+ optional: true
+ '@libsql/client':
+ optional: true
+ '@libsql/client-wasm':
+ optional: true
+ '@neondatabase/serverless':
+ optional: true
+ '@op-engineering/op-sqlite':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@planetscale/database':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@tidbcloud/serverless':
+ optional: true
+ '@types/better-sqlite3':
+ optional: true
+ '@types/pg':
+ optional: true
+ '@types/sql.js':
+ optional: true
+ '@upstash/redis':
+ optional: true
+ '@vercel/postgres':
+ optional: true
+ '@xata.io/client':
+ optional: true
+ better-sqlite3:
+ optional: true
+ bun-types:
+ optional: true
+ expo-sqlite:
+ optional: true
+ gel:
+ optional: true
+ knex:
+ optional: true
+ kysely:
+ optional: true
+ mysql2:
+ optional: true
+ pg:
+ optional: true
+ postgres:
+ optional: true
+ prisma:
+ optional: true
+ sql.js:
+ optional: true
+ sqlite3:
+ optional: true
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+ electron-to-chromium@1.5.198:
+ resolution: {integrity: sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==}
+
+ enabled@2.0.0:
+ resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
+
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
+ enhanced-resolve@5.18.1:
+ resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
+ engines: {node: '>=10.13.0'}
+
+ env-paths@3.0.0:
+ resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.1:
+ resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ esbuild-register@3.6.0:
+ resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
+ peerDependencies:
+ esbuild: '>=0.12 <1'
+
+ esbuild@0.18.20:
+ resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ esbuild@0.25.2:
+ resolution: {integrity: sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ esbuild@0.25.8:
+ resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-config-prettier@10.1.8:
+ resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
+ hasBin: true
+ peerDependencies:
+ eslint: '>=7.0.0'
+
+ eslint-plugin-svelte@3.11.0:
+ resolution: {integrity: sha512-KliWlkieHyEa65aQIkRwUFfHzT5Cn4u3BQQsu3KlkJOs7c1u7ryn84EWaOjEzilbKgttT4OfBURA8Uc4JBSQIw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.1 || ^9.0.0
+ svelte: ^3.37.0 || ^4.0.0 || ^5.0.0
+ peerDependenciesMeta:
+ svelte:
+ optional: true
+
+ eslint-scope@8.3.0:
+ resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.0:
+ resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint@9.31.0:
+ resolution: {integrity: sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ esm-env@1.2.2:
+ resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
+
+ espree@10.3.0:
+ resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esquery@1.6.0:
+ resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
+ engines: {node: '>=0.10'}
+
+ esrap@2.1.0:
+ resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+
+ expand-template@2.0.3:
+ resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
+ engines: {node: '>=6'}
+
+ express-rate-limit@8.0.1:
+ resolution: {integrity: sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.1.0:
+ resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
+ engines: {node: '>= 18'}
+
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fdir@6.4.6:
+ resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fecha@4.2.3:
+ resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
+
+ fetch-blob@3.2.0:
+ resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
+ engines: {node: ^12.20 || >= 14.13}
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ file-uri-to-path@1.0.0:
+ resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ finalhandler@2.1.0:
+ resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==}
+ engines: {node: '>= 0.8'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.3.3:
+ resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+
+ fn.name@1.1.0:
+ resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
+
+ follow-redirects@1.15.9:
+ resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
+ form-data@4.0.4:
+ resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
+ engines: {node: '>= 6'}
+
+ formdata-polyfill@4.0.10:
+ resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
+ engines: {node: '>=12.20.0'}
+
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
+ fs-constants@1.0.0:
+ resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+
+ fs-extra@11.3.1:
+ resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
+ engines: {node: '>=14.14'}
+
+ fs-minipass@2.1.0:
+ resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
+ engines: {node: '>= 8'}
+
+ fs.realpath@1.0.0:
+ resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ gaxios@7.1.1:
+ resolution: {integrity: sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==}
+ engines: {node: '>=18'}
+
+ gcp-metadata@7.0.1:
+ resolution: {integrity: sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==}
+ engines: {node: '>=18'}
+
+ gel@2.1.1:
+ resolution: {integrity: sha512-Newg9X7mRYskoBjSw70l1YnJ/ZGbq64VPyR821H5WVkTGpHG2O0mQILxCeUhxdYERLFY9B4tUyKLyf3uMTjtKw==}
+ engines: {node: '>= 18.0.0'}
+ hasBin: true
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-tsconfig@4.10.1:
+ resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
+
+ giget@1.2.5:
+ resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==}
+ hasBin: true
+
+ github-from-package@0.0.0:
+ resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
+
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ glob@7.1.6:
+ resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==}
+ deprecated: Glob versions prior to v9 are no longer supported
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@16.3.0:
+ resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==}
+ engines: {node: '>=18'}
+
+ google-auth-library@10.2.0:
+ resolution: {integrity: sha512-gy/0hRx8+Ye0HlUm3GrfpR4lbmJQ6bJ7F44DmN7GtMxxzWSojLzx0Bhv/hj7Wlj7a2On0FcT8jrz8Y1c1nxCyg==}
+ engines: {node: '>=18'}
+
+ google-logging-utils@1.1.1:
+ resolution: {integrity: sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==}
+ engines: {node: '>=14'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ gtoken@8.0.0:
+ resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
+ engines: {node: '>=18'}
+
+ has-flag@3.0.0:
+ resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
+ engines: {node: '>=4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.2:
+ resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+ engines: {node: '>= 0.4'}
+
+ helmet@8.1.0:
+ resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
+ engines: {node: '>=18.0.0'}
+
+ highlight.js@11.11.1:
+ resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
+ engines: {node: '>=12.0.0'}
+
+ http-errors@2.0.0:
+ resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
+ engines: {node: '>= 0.8'}
+
+ https-proxy-agent@7.0.6:
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+ engines: {node: '>= 14'}
+
+ iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+ engines: {node: '>=0.10.0'}
+
+ ieee754@1.2.1:
+ resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+
+ ignore-by-default@1.0.1:
+ resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ inflight@1.0.6:
+ resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+ deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ ini@1.3.8:
+ resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+
+ ip-address@10.0.1:
+ resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
+ is-arrayish@0.3.2:
+ resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
+
+ is-binary-path@2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+
+ is-core-module@2.16.1:
+ resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-module@1.0.0:
+ resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
+ is-reference@1.2.1:
+ resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
+
+ is-reference@3.0.3:
+ resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
+
+ is-stream@2.0.1:
+ resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+ engines: {node: '>=8'}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ isexe@3.1.1:
+ resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
+ engines: {node: '>=16'}
+
+ jiti@2.4.2:
+ resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
+ hasBin: true
+
+ jose@5.10.0:
+ resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.1.0:
+ resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+ hasBin: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-bigint@1.0.0:
+ resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jsonfile@6.1.0:
+ resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
+
+ jsonwebtoken@9.0.2:
+ resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
+ engines: {node: '>=12', npm: '>=6'}
+
+ jwa@1.4.2:
+ resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
+
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+ jws@3.2.2:
+ resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
+
+ jws@4.0.0:
+ resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ kleur@3.0.3:
+ resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
+ engines: {node: '>=6'}
+
+ kleur@4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
+ engines: {node: '>=6'}
+
+ known-css-properties@0.37.0:
+ resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==}
+
+ kuler@2.0.0:
+ resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
+
+ kysely@0.28.4:
+ resolution: {integrity: sha512-pfQj8/Bo3KSzC1HIZB5MeeYRWcDmx1ZZv8H25LsyeygqXE+gfsbUAgPT1GSYZFctB1cdOVlv+OifuCls2mQSnw==}
+ engines: {node: '>=20.0.0'}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ libphonenumber-js@1.12.10:
+ resolution: {integrity: sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==}
+
+ lightningcss-darwin-arm64@1.30.1:
+ resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.30.1:
+ resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.30.1:
+ resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.30.1:
+ resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.30.1:
+ resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.30.1:
+ resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.30.1:
+ resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.30.1:
+ resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.30.1:
+ resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.30.1:
+ resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.30.1:
+ resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
+ engines: {node: '>= 12.0.0'}
+
+ lilconfig@2.1.0:
+ resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
+ engines: {node: '>=10'}
+
+ locate-character@3.0.0:
+ resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.castarray@4.4.0:
+ resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
+
+ lodash.get@4.4.2:
+ resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
+ deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
+
+ lodash.includes@4.3.0:
+ resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+ lodash.isboolean@3.0.3:
+ resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+ lodash.isequal@4.5.0:
+ resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
+ deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
+
+ lodash.isinteger@4.0.4:
+ resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+ lodash.isnumber@3.0.3:
+ resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+ lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+ lodash.isstring@4.0.1:
+ resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ lodash.mergewith@4.6.2:
+ resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
+
+ lodash.once@4.1.1:
+ resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
+ lodash@4.17.21:
+ resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+
+ logform@2.7.0:
+ resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
+ engines: {node: '>= 12.0.0'}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ magic-string@0.30.17:
+ resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
+
+ marked@16.1.1:
+ resolution: {integrity: sha512-ij/2lXfCRT71L6u0M29tJPhP0bM5shLL3u5BePhFwPELj2blMJ6GDtD7PfJhRLhJ/c2UwrK17ySVcDzy2YHjHQ==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ media-typer@1.1.0:
+ resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@3.0.1:
+ resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
+ engines: {node: '>= 0.6'}
+
+ mimic-response@3.1.0:
+ resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+ engines: {node: '>=10'}
+
+ minimatch@3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ minipass@3.3.6:
+ resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
+ engines: {node: '>=8'}
+
+ minipass@5.0.0:
+ resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
+ engines: {node: '>=8'}
+
+ minipass@7.1.2:
+ resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minizlib@2.1.2:
+ resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
+ engines: {node: '>= 8'}
+
+ minizlib@3.0.2:
+ resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
+ engines: {node: '>= 18'}
+
+ mkdirp-classic@0.5.3:
+ resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
+
+ mkdirp@1.0.4:
+ resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ mkdirp@3.0.1:
+ resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ mlly@1.7.4:
+ resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
+
+ morgan@1.10.1:
+ resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==}
+ engines: {node: '>= 0.8.0'}
+
+ mri@1.2.0:
+ resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
+ engines: {node: '>=4'}
+
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
+ ms@2.0.0:
+ resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ nanostores@0.11.4:
+ resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+
+ napi-build-utils@2.0.0:
+ resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ negotiator@1.0.0:
+ resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+ engines: {node: '>= 0.6'}
+
+ node-abi@3.75.0:
+ resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==}
+ engines: {node: '>=10'}
+
+ node-domexception@1.0.0:
+ resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
+ engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
+
+ node-fetch-native@1.6.7:
+ resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+
+ node-fetch@3.3.2:
+ resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
+ node-releases@2.0.19:
+ resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
+
+ nodemon@3.1.10:
+ resolution: {integrity: sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
+ nypm@0.5.4:
+ resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==}
+ engines: {node: ^14.16.0 || >=16.10.0}
+ hasBin: true
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ ohash@2.0.11:
+ resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
+
+ on-finished@2.3.0:
+ resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
+ engines: {node: '>= 0.8'}
+
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
+ on-headers@1.1.0:
+ resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
+ engines: {node: '>= 0.8'}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ one-time@1.0.0:
+ resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
+
+ openapi-types@12.1.3:
+ resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-is-absolute@1.0.1:
+ resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+ engines: {node: '>=0.10.0'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ path-to-regexp@8.2.0:
+ resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==}
+ engines: {node: '>=16'}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ perfect-debounce@1.0.0:
+ resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+
+ picomatch@4.0.2:
+ resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
+ engines: {node: '>=12'}
+
+ pkg-types@1.3.1:
+ resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
+
+ postcss-load-config@3.1.4:
+ resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
+ engines: {node: '>= 10'}
+ peerDependencies:
+ postcss: '>=8.0.9'
+ ts-node: '>=9.0.0'
+ peerDependenciesMeta:
+ postcss:
+ optional: true
+ ts-node:
+ optional: true
+
+ postcss-safe-parser@7.0.1:
+ resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==}
+ engines: {node: '>=18.0'}
+ peerDependencies:
+ postcss: ^8.4.31
+
+ postcss-scss@4.0.9:
+ resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.4.29
+
+ postcss-selector-parser@6.0.10:
+ resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
+ engines: {node: '>=4'}
+
+ postcss-selector-parser@7.1.0:
+ resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==}
+ engines: {node: '>=4'}
+
+ postcss@8.5.3:
+ resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postgres@3.4.7:
+ resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
+ engines: {node: '>=12'}
+
+ prebuild-install@7.1.3:
+ resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prettier-plugin-svelte@3.4.0:
+ resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==}
+ peerDependencies:
+ prettier: ^3.0.0
+ svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
+
+ prettier-plugin-tailwindcss@0.6.14:
+ resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==}
+ engines: {node: '>=14.21.3'}
+ peerDependencies:
+ '@ianvs/prettier-plugin-sort-imports': '*'
+ '@prettier/plugin-hermes': '*'
+ '@prettier/plugin-oxc': '*'
+ '@prettier/plugin-pug': '*'
+ '@shopify/prettier-plugin-liquid': '*'
+ '@trivago/prettier-plugin-sort-imports': '*'
+ '@zackad/prettier-plugin-twig': '*'
+ prettier: ^3.0
+ prettier-plugin-astro: '*'
+ prettier-plugin-css-order: '*'
+ prettier-plugin-import-sort: '*'
+ prettier-plugin-jsdoc: '*'
+ prettier-plugin-marko: '*'
+ prettier-plugin-multiline-arrays: '*'
+ prettier-plugin-organize-attributes: '*'
+ prettier-plugin-organize-imports: '*'
+ prettier-plugin-sort-imports: '*'
+ prettier-plugin-style-order: '*'
+ prettier-plugin-svelte: '*'
+ peerDependenciesMeta:
+ '@ianvs/prettier-plugin-sort-imports':
+ optional: true
+ '@prettier/plugin-hermes':
+ optional: true
+ '@prettier/plugin-oxc':
+ optional: true
+ '@prettier/plugin-pug':
+ optional: true
+ '@shopify/prettier-plugin-liquid':
+ optional: true
+ '@trivago/prettier-plugin-sort-imports':
+ optional: true
+ '@zackad/prettier-plugin-twig':
+ optional: true
+ prettier-plugin-astro:
+ optional: true
+ prettier-plugin-css-order:
+ optional: true
+ prettier-plugin-import-sort:
+ optional: true
+ prettier-plugin-jsdoc:
+ optional: true
+ prettier-plugin-marko:
+ optional: true
+ prettier-plugin-multiline-arrays:
+ optional: true
+ prettier-plugin-organize-attributes:
+ optional: true
+ prettier-plugin-organize-imports:
+ optional: true
+ prettier-plugin-sort-imports:
+ optional: true
+ prettier-plugin-style-order:
+ optional: true
+ prettier-plugin-svelte:
+ optional: true
+
+ prettier@3.6.2:
+ resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
+ engines: {node: '>=14'}
+ hasBin: true
+
+ prisma@5.22.0:
+ resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
+ engines: {node: '>=16.13'}
+ hasBin: true
+
+ prisma@6.12.0:
+ resolution: {integrity: sha512-pmV7NEqQej9WjizN6RSNIwf7Y+jeh9mY1JEX2WjGxJi4YZWexClhde1yz/FuvAM+cTwzchcMytu2m4I6wPkIzg==}
+ engines: {node: '>=18.18'}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.1.0'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ prompts@2.4.2:
+ resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
+ engines: {node: '>= 6'}
+
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
+ pstree.remy@1.1.8:
+ resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
+
+ pump@3.0.3:
+ resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ pvtsutils@1.3.6:
+ resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
+
+ pvutils@1.1.3:
+ resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
+ engines: {node: '>=6.0.0'}
+
+ qs@6.14.0:
+ resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
+ engines: {node: '>=0.6'}
+
+ range-parser@1.2.1:
+ resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+ engines: {node: '>= 0.6'}
+
+ raw-body@3.0.0:
+ resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
+ engines: {node: '>= 0.8'}
+
+ rc9@2.1.2:
+ resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
+
+ rc@1.2.8:
+ resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
+ hasBin: true
+
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
+ readdirp@3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+
+ readdirp@4.1.2:
+ resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+ engines: {node: '>= 14.18.0'}
+
+ regexp-to-ast@0.5.0:
+ resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+ resolve@1.22.10:
+ resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ rollup@4.39.0:
+ resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ rollup@4.45.1:
+ resolution: {integrity: sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ rou3@0.5.1:
+ resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==}
+
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
+ sade@1.8.1:
+ resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
+ engines: {node: '>=6'}
+
+ safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+ safe-stable-stringify@2.5.0:
+ resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
+ engines: {node: '>=10'}
+
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+ schema-dts@1.1.5:
+ resolution: {integrity: sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.7.1:
+ resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ send@1.2.0:
+ resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
+ engines: {node: '>= 18'}
+
+ serve-static@2.2.0:
+ resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
+ engines: {node: '>= 18'}
+
+ set-cookie-parser@2.7.1:
+ resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
+
+ setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ shell-quote@1.8.3:
+ resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-list@1.0.0:
+ resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.0:
+ resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+ engines: {node: '>= 0.4'}
+
+ simple-concat@1.0.1:
+ resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+
+ simple-get@4.0.1:
+ resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+
+ simple-swizzle@0.2.2:
+ resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
+
+ simple-update-notifier@2.0.0:
+ resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
+ engines: {node: '>=10'}
+
+ sirv@3.0.1:
+ resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
+ engines: {node: '>=18'}
+
+ sisteransi@1.0.5:
+ resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ source-map-support@0.5.21:
+ resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+
+ source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+
+ stack-trace@0.0.10:
+ resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
+
+ statuses@2.0.1:
+ resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
+ engines: {node: '>= 0.8'}
+
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+ strip-json-comments@2.0.1:
+ resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
+ engines: {node: '>=0.10.0'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ supports-color@5.5.0:
+ resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
+ engines: {node: '>=4'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ svelte-check@4.3.0:
+ resolution: {integrity: sha512-Iz8dFXzBNAM7XlEIsUjUGQhbEE+Pvv9odb9+0+ITTgFWZBGeJRRYqHUUglwe2EkLD5LIsQaAc4IUJyvtKuOO5w==}
+ engines: {node: '>= 18.0.0'}
+ hasBin: true
+ peerDependencies:
+ svelte: ^4.0.0 || ^5.0.0-next.0
+ typescript: '>=5.0.0'
+
+ svelte-dnd-action@0.9.64:
+ resolution: {integrity: sha512-kbbnOTuVc+VINheraVyEQ7K11jXdQii6JNTGpsyIuwUqmda030eT3rPpqckD8UVh1DuyYH3xqyJDTWb8S610Jg==}
+ peerDependencies:
+ svelte: '>=3.23.0 || ^5.0.0-next.0'
+
+ svelte-eslint-parser@1.3.0:
+ resolution: {integrity: sha512-VCgMHKV7UtOGcGLGNFSbmdm6kEKjtzo5nnpGU/mnx4OsFY6bZ7QwRF5DUx+Hokw5Lvdyo8dpk8B1m8mliomrNg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ svelte: ^3.37.0 || ^4.0.0 || ^5.0.0
+ peerDependenciesMeta:
+ svelte:
+ optional: true
+
+ svelte-highlight@7.8.3:
+ resolution: {integrity: sha512-i4CE/6yda1fCh0ovUVATk1S1feu1y3+CV+l1brgtMPPRO9VTGq+hPpUjVEJWQkE7hPAgwgVpHccoa5M2gpKxYQ==}
+
+ svelte-meta-tags@4.4.0:
+ resolution: {integrity: sha512-0g7sksBXdCGYcNM44uipqhVwDrtImB73iZdcpWHE0q0+k96Zg0WS6ySPAV+gX34DSqrkrvcqkG/tI2lwN1KbbA==}
+ peerDependencies:
+ svelte: ^5.0.0
+
+ svelte@5.36.14:
+ resolution: {integrity: sha512-okgNwfVa4FfDGOgd0ndooKjQz1LknUFDGfEJp6QNjYP6B4hDG0KktOP+Pta3ZtE8s+JELsYP+7nqMrJzQLkf5A==}
+ engines: {node: '>=18'}
+
+ swagger-jsdoc@6.2.8:
+ resolution: {integrity: sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+
+ swagger-parser@10.0.3:
+ resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==}
+ engines: {node: '>=10'}
+
+ swagger-ui-dist@5.27.0:
+ resolution: {integrity: sha512-tS6LRyBhY6yAqxrfsA9IYpGWPUJOri6sclySa7TdC7XQfGLvTwDY531KLgfQwHEtQsn+sT4JlUspbeQDBVGWig==}
+
+ swagger-ui-express@5.0.1:
+ resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
+ engines: {node: '>= v0.10.32'}
+ peerDependencies:
+ express: '>=4.0.0 || >=5.0.0-beta'
+
+ tailwindcss@4.1.11:
+ resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==}
+
+ tapable@2.2.1:
+ resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
+ engines: {node: '>=6'}
+
+ tar-fs@2.1.3:
+ resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==}
+
+ tar-stream@2.2.0:
+ resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
+ engines: {node: '>=6'}
+
+ tar@6.2.1:
+ resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
+ engines: {node: '>=10'}
+
+ tar@7.4.3:
+ resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
+ engines: {node: '>=18'}
+
+ text-hex@1.0.0:
+ resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
+
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
+ tinyglobby@0.2.14:
+ resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
+ engines: {node: '>=12.0.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+
+ totalist@3.0.1:
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+ engines: {node: '>=6'}
+
+ touch@3.1.1:
+ resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
+ hasBin: true
+
+ triple-beam@1.4.1:
+ resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
+ engines: {node: '>= 14.0.0'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ tunnel-agent@0.6.0:
+ resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ type-is@2.0.1:
+ resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
+ engines: {node: '>= 0.6'}
+
+ typescript@5.8.3:
+ resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ ufo@1.6.1:
+ resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
+
+ uncrypto@0.1.3:
+ resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+
+ undefsafe@2.0.5:
+ resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
+
+ undici-types@7.8.0:
+ resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==}
+
+ universalify@2.0.1:
+ resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
+ engines: {node: '>= 10.0.0'}
+
+ unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+
+ update-browserslist-db@1.1.3:
+ resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ uuid@11.1.0:
+ resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
+ hasBin: true
+
+ validator@13.15.15:
+ resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==}
+ engines: {node: '>= 0.10'}
+
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
+ vite@7.0.5:
+ resolution: {integrity: sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitefu@1.1.1:
+ resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==}
+ peerDependencies:
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0
+ peerDependenciesMeta:
+ vite:
+ optional: true
+
+ web-streams-polyfill@3.3.3:
+ resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
+ engines: {node: '>= 8'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ which@4.0.0:
+ resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
+ engines: {node: ^16.13.0 || >=18.0.0}
+ hasBin: true
+
+ winston-transport@4.9.0:
+ resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==}
+ engines: {node: '>= 12.0.0'}
+
+ winston@3.17.0:
+ resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==}
+ engines: {node: '>= 12.0.0'}
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yallist@4.0.0:
+ resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+
+ yallist@5.0.0:
+ resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+ engines: {node: '>=18'}
+
+ yaml@1.10.2:
+ resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
+ engines: {node: '>= 6'}
+
+ yaml@2.0.0-1:
+ resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==}
+ engines: {node: '>= 6'}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ yocto-spinner@0.1.2:
+ resolution: {integrity: sha512-VfmLIh/ZSZOJnVRQZc/dvpPP90lWL4G0bmxQMP0+U/2vKBA8GSpcBuWv17y7F+CZItRuO97HN1wdbb4p10uhOg==}
+ engines: {node: '>=18.19'}
+
+ yoctocolors@2.1.1:
+ resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
+ engines: {node: '>=18'}
+
+ z-schema@5.0.5:
+ resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==}
+ engines: {node: '>=8.0.0'}
+ hasBin: true
+
+ zimmerframe@1.1.2:
+ resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
+
+ zod@4.0.8:
+ resolution: {integrity: sha512-+MSh9cZU9r3QKlHqrgHMTSr3QwMGv4PLfR0M4N/sYWV5/x67HgXEhIGObdBkpnX8G78pTgWnIrBL2lZcNJOtfg==}
+
+snapshots:
+
+ '@ampproject/remapping@2.3.0':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.8
+ '@jridgewell/trace-mapping': 0.3.25
+
+ '@apidevtools/json-schema-ref-parser@9.1.2':
+ dependencies:
+ '@jsdevtools/ono': 7.1.3
+ '@types/json-schema': 7.0.15
+ call-me-maybe: 1.0.2
+ js-yaml: 4.1.0
+
+ '@apidevtools/openapi-schemas@2.1.0': {}
+
+ '@apidevtools/swagger-methods@3.0.2': {}
+
+ '@apidevtools/swagger-parser@10.0.3(openapi-types@12.1.3)':
+ dependencies:
+ '@apidevtools/json-schema-ref-parser': 9.1.2
+ '@apidevtools/openapi-schemas': 2.1.0
+ '@apidevtools/swagger-methods': 3.0.2
+ '@jsdevtools/ono': 7.1.3
+ call-me-maybe: 1.0.2
+ openapi-types: 12.1.3
+ z-schema: 5.0.5
+
+ '@babel/code-frame@7.27.1':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.27.1
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.28.0': {}
+
+ '@babel/core@7.28.0':
+ dependencies:
+ '@ampproject/remapping': 2.3.0
+ '@babel/code-frame': 7.27.1
+ '@babel/generator': 7.28.0
+ '@babel/helper-compilation-targets': 7.27.2
+ '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0)
+ '@babel/helpers': 7.28.2
+ '@babel/parser': 7.28.0
+ '@babel/template': 7.27.2
+ '@babel/traverse': 7.28.0
+ '@babel/types': 7.28.2
+ convert-source-map: 2.0.0
+ debug: 4.4.1(supports-color@5.5.0)
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.28.0':
+ dependencies:
+ '@babel/parser': 7.28.0
+ '@babel/types': 7.28.2
+ '@jridgewell/gen-mapping': 0.3.12
+ '@jridgewell/trace-mapping': 0.3.29
+ jsesc: 3.1.0
+
+ '@babel/helper-annotate-as-pure@7.27.3':
+ dependencies:
+ '@babel/types': 7.28.2
+
+ '@babel/helper-compilation-targets@7.27.2':
+ dependencies:
+ '@babel/compat-data': 7.28.0
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.25.1
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-create-class-features-plugin@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-annotate-as-pure': 7.27.3
+ '@babel/helper-member-expression-to-functions': 7.27.1
+ '@babel/helper-optimise-call-expression': 7.27.1
+ '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.0)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
+ '@babel/traverse': 7.28.0
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-member-expression-to-functions@7.27.1':
+ dependencies:
+ '@babel/traverse': 7.28.0
+ '@babel/types': 7.28.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-imports@7.27.1':
+ dependencies:
+ '@babel/traverse': 7.28.0
+ '@babel/types': 7.28.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-module-imports': 7.27.1
+ '@babel/helper-validator-identifier': 7.27.1
+ '@babel/traverse': 7.28.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-optimise-call-expression@7.27.1':
+ dependencies:
+ '@babel/types': 7.28.2
+
+ '@babel/helper-plugin-utils@7.27.1': {}
+
+ '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-member-expression-to-functions': 7.27.1
+ '@babel/helper-optimise-call-expression': 7.27.1
+ '@babel/traverse': 7.28.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-skip-transparent-expression-wrappers@7.27.1':
+ dependencies:
+ '@babel/traverse': 7.28.0
+ '@babel/types': 7.28.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.27.1': {}
+
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.28.2':
+ dependencies:
+ '@babel/template': 7.27.2
+ '@babel/types': 7.28.2
+
+ '@babel/parser@7.28.0':
+ dependencies:
+ '@babel/types': 7.28.2
+
+ '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-plugin-utils': 7.27.1
+
+ '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-plugin-utils': 7.27.1
+
+ '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0)
+ '@babel/helper-plugin-utils': 7.27.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-plugin-utils': 7.27.1
+
+ '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-annotate-as-pure': 7.27.3
+ '@babel/helper-module-imports': 7.27.1
+ '@babel/helper-plugin-utils': 7.27.1
+ '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0)
+ '@babel/types': 7.28.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-annotate-as-pure': 7.27.3
+ '@babel/helper-plugin-utils': 7.27.1
+
+ '@babel/plugin-transform-typescript@7.28.0(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-annotate-as-pure': 7.27.3
+ '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.28.0)
+ '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
+ '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/preset-react@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-validator-option': 7.27.1
+ '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.28.0)
+ '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.0)
+ '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.0)
+ '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.28.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/preset-typescript@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-validator-option': 7.27.1
+ '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0)
+ '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.0)
+ '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/template@7.27.2':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/parser': 7.28.0
+ '@babel/types': 7.28.2
+
+ '@babel/traverse@7.28.0':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/generator': 7.28.0
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.28.0
+ '@babel/template': 7.27.2
+ '@babel/types': 7.28.2
+ debug: 4.4.1(supports-color@5.5.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.28.2':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.27.1
+
+ '@better-auth/cli@1.3.4(kysely@0.28.4)(postgres@3.4.7)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/preset-react': 7.27.1(@babel/core@7.28.0)
+ '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0)
+ '@clack/prompts': 0.10.1
+ '@mrleebo/prisma-ast': 0.12.1
+ '@prisma/client': 5.22.0(prisma@5.22.0)
+ '@types/better-sqlite3': 7.6.13
+ '@types/prompts': 2.4.9
+ better-auth: 1.3.4
+ better-sqlite3: 11.10.0
+ c12: 2.0.4
+ chalk: 5.5.0
+ commander: 12.1.0
+ dotenv: 16.6.1
+ drizzle-orm: 0.33.0(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.28.4)(postgres@3.4.7)(prisma@5.22.0)
+ fs-extra: 11.3.1
+ get-tsconfig: 4.10.1
+ prettier: 3.6.2
+ prisma: 5.22.0
+ prompts: 2.4.2
+ semver: 7.7.1
+ tinyexec: 0.3.2
+ yocto-spinner: 0.1.2
+ zod: 4.0.8
+ transitivePeerDependencies:
+ - '@aws-sdk/client-rds-data'
+ - '@cloudflare/workers-types'
+ - '@electric-sql/pglite'
+ - '@libsql/client'
+ - '@neondatabase/serverless'
+ - '@op-engineering/op-sqlite'
+ - '@opentelemetry/api'
+ - '@planetscale/database'
+ - '@tidbcloud/serverless'
+ - '@types/pg'
+ - '@types/react'
+ - '@types/sql.js'
+ - '@vercel/postgres'
+ - '@xata.io/client'
+ - bun-types
+ - expo-sqlite
+ - knex
+ - kysely
+ - magicast
+ - mysql2
+ - pg
+ - postgres
+ - react
+ - react-dom
+ - sql.js
+ - sqlite3
+ - supports-color
+
+ '@better-auth/utils@0.2.5':
+ dependencies:
+ typescript: 5.8.3
+ uncrypto: 0.1.3
+
+ '@better-fetch/fetch@1.1.18': {}
+
+ '@chevrotain/cst-dts-gen@10.5.0':
+ dependencies:
+ '@chevrotain/gast': 10.5.0
+ '@chevrotain/types': 10.5.0
+ lodash: 4.17.21
+
+ '@chevrotain/gast@10.5.0':
+ dependencies:
+ '@chevrotain/types': 10.5.0
+ lodash: 4.17.21
+
+ '@chevrotain/types@10.5.0': {}
+
+ '@chevrotain/utils@10.5.0': {}
+
+ '@clack/core@0.4.2':
+ dependencies:
+ picocolors: 1.1.1
+ sisteransi: 1.0.5
+
+ '@clack/prompts@0.10.1':
+ dependencies:
+ '@clack/core': 0.4.2
+ picocolors: 1.1.1
+ sisteransi: 1.0.5
+
+ '@colors/colors@1.6.0': {}
+
+ '@dabh/diagnostics@2.0.3':
+ dependencies:
+ colorspace: 1.1.4
+ enabled: 2.0.0
+ kuler: 2.0.0
+
+ '@drizzle-team/brocli@0.10.2': {}
+
+ '@esbuild-kit/core-utils@3.3.2':
+ dependencies:
+ esbuild: 0.18.20
+ source-map-support: 0.5.21
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ dependencies:
+ '@esbuild-kit/core-utils': 3.3.2
+ get-tsconfig: 4.10.1
+
+ '@esbuild/aix-ppc64@0.25.2':
+ optional: true
+
+ '@esbuild/aix-ppc64@0.25.8':
+ optional: true
+
+ '@esbuild/android-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.8':
+ optional: true
+
+ '@esbuild/android-arm@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm@0.25.2':
+ optional: true
+
+ '@esbuild/android-arm@0.25.8':
+ optional: true
+
+ '@esbuild/android-x64@0.18.20':
+ optional: true
+
+ '@esbuild/android-x64@0.25.2':
+ optional: true
+
+ '@esbuild/android-x64@0.25.8':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.8':
+ optional: true
+
+ '@esbuild/darwin-x64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.8':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.8':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.8':
+ optional: true
+
+ '@esbuild/linux-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.8':
+ optional: true
+
+ '@esbuild/linux-arm@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.8':
+ optional: true
+
+ '@esbuild/linux-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.8':
+ optional: true
+
+ '@esbuild/linux-loong64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.8':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.18.20':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.8':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.8':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.8':
+ optional: true
+
+ '@esbuild/linux-s390x@0.18.20':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.8':
+ optional: true
+
+ '@esbuild/linux-x64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.8':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.8':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.8':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.8':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.8':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.8':
+ optional: true
+
+ '@esbuild/sunos-x64@0.18.20':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.8':
+ optional: true
+
+ '@esbuild/win32-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.8':
+ optional: true
+
+ '@esbuild/win32-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.8':
+ optional: true
+
+ '@esbuild/win32-x64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.8':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0(jiti@2.4.2))':
+ dependencies:
+ eslint: 9.31.0(jiti@2.4.2)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.1': {}
+
+ '@eslint/compat@1.3.1(eslint@9.31.0(jiti@2.4.2))':
+ optionalDependencies:
+ eslint: 9.31.0(jiti@2.4.2)
+
+ '@eslint/config-array@0.21.0':
+ dependencies:
+ '@eslint/object-schema': 2.1.6
+ debug: 4.4.1(supports-color@5.5.0)
+ minimatch: 3.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.3.0': {}
+
+ '@eslint/core@0.14.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/core@0.15.1':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.1':
+ dependencies:
+ ajv: 6.12.6
+ debug: 4.4.1(supports-color@5.5.0)
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.1.0
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.31.0': {}
+
+ '@eslint/object-schema@2.1.6': {}
+
+ '@eslint/plugin-kit@0.3.1':
+ dependencies:
+ '@eslint/core': 0.14.0
+ levn: 0.4.1
+
+ '@hexagon/base64@1.1.28': {}
+
+ '@humanfs/core@0.19.1': {}
+
+ '@humanfs/node@0.16.6':
+ dependencies:
+ '@humanfs/core': 0.19.1
+ '@humanwhocodes/retry': 0.3.1
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.3.1': {}
+
+ '@humanwhocodes/retry@0.4.2': {}
+
+ '@isaacs/fs-minipass@4.0.1':
+ dependencies:
+ minipass: 7.1.2
+
+ '@jridgewell/gen-mapping@0.3.12':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/trace-mapping': 0.3.29
+
+ '@jridgewell/gen-mapping@0.3.8':
+ dependencies:
+ '@jridgewell/set-array': 1.2.1
+ '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/trace-mapping': 0.3.25
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/set-array@1.2.1': {}
+
+ '@jridgewell/sourcemap-codec@1.5.0': {}
+
+ '@jridgewell/trace-mapping@0.3.25':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.0
+
+ '@jridgewell/trace-mapping@0.3.29':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.0
+
+ '@jsdevtools/ono@7.1.3': {}
+
+ '@levischuck/tiny-cbor@0.2.11': {}
+
+ '@lucide/svelte@0.525.0(svelte@5.36.14)':
+ dependencies:
+ svelte: 5.36.14
+
+ '@mrleebo/prisma-ast@0.12.1':
+ dependencies:
+ chevrotain: 10.5.0
+ lilconfig: 2.1.0
+
+ '@noble/ciphers@0.6.0': {}
+
+ '@noble/hashes@1.8.0': {}
+
+ '@peculiar/asn1-android@2.4.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.4.0
+ asn1js: 3.0.6
+ tslib: 2.8.1
+
+ '@peculiar/asn1-ecc@2.4.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.4.0
+ '@peculiar/asn1-x509': 2.4.0
+ asn1js: 3.0.6
+ tslib: 2.8.1
+
+ '@peculiar/asn1-rsa@2.4.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.4.0
+ '@peculiar/asn1-x509': 2.4.0
+ asn1js: 3.0.6
+ tslib: 2.8.1
+
+ '@peculiar/asn1-schema@2.4.0':
+ dependencies:
+ asn1js: 3.0.6
+ pvtsutils: 1.3.6
+ tslib: 2.8.1
+
+ '@peculiar/asn1-x509@2.4.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.4.0
+ asn1js: 3.0.6
+ pvtsutils: 1.3.6
+ tslib: 2.8.1
+
+ '@petamoriken/float16@3.9.2':
+ optional: true
+
+ '@polka/url@1.0.0-next.29': {}
+
+ '@prisma/client@5.22.0(prisma@5.22.0)':
+ optionalDependencies:
+ prisma: 5.22.0
+
+ '@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3)':
+ optionalDependencies:
+ prisma: 6.12.0(typescript@5.8.3)
+ typescript: 5.8.3
+
+ '@prisma/config@6.12.0':
+ dependencies:
+ jiti: 2.4.2
+
+ '@prisma/debug@5.22.0': {}
+
+ '@prisma/debug@6.12.0': {}
+
+ '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {}
+
+ '@prisma/engines-version@6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc': {}
+
+ '@prisma/engines@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
+ '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
+ '@prisma/fetch-engine': 5.22.0
+ '@prisma/get-platform': 5.22.0
+
+ '@prisma/engines@6.12.0':
+ dependencies:
+ '@prisma/debug': 6.12.0
+ '@prisma/engines-version': 6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc
+ '@prisma/fetch-engine': 6.12.0
+ '@prisma/get-platform': 6.12.0
+
+ '@prisma/fetch-engine@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
+ '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
+ '@prisma/get-platform': 5.22.0
+
+ '@prisma/fetch-engine@6.12.0':
+ dependencies:
+ '@prisma/debug': 6.12.0
+ '@prisma/engines-version': 6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc
+ '@prisma/get-platform': 6.12.0
+
+ '@prisma/get-platform@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
+
+ '@prisma/get-platform@6.12.0':
+ dependencies:
+ '@prisma/debug': 6.12.0
+
+ '@rollup/plugin-commonjs@28.0.3(rollup@4.39.0)':
+ dependencies:
+ '@rollup/pluginutils': 5.1.4(rollup@4.39.0)
+ commondir: 1.0.1
+ estree-walker: 2.0.2
+ fdir: 6.4.6(picomatch@4.0.2)
+ is-reference: 1.2.1
+ magic-string: 0.30.17
+ picomatch: 4.0.2
+ optionalDependencies:
+ rollup: 4.39.0
+
+ '@rollup/plugin-json@6.1.0(rollup@4.39.0)':
+ dependencies:
+ '@rollup/pluginutils': 5.1.4(rollup@4.39.0)
+ optionalDependencies:
+ rollup: 4.39.0
+
+ '@rollup/plugin-node-resolve@16.0.1(rollup@4.39.0)':
+ dependencies:
+ '@rollup/pluginutils': 5.1.4(rollup@4.39.0)
+ '@types/resolve': 1.20.2
+ deepmerge: 4.3.1
+ is-module: 1.0.0
+ resolve: 1.22.10
+ optionalDependencies:
+ rollup: 4.39.0
+
+ '@rollup/pluginutils@5.1.4(rollup@4.39.0)':
+ dependencies:
+ '@types/estree': 1.0.7
+ estree-walker: 2.0.2
+ picomatch: 4.0.2
+ optionalDependencies:
+ rollup: 4.39.0
+
+ '@rollup/rollup-android-arm-eabi@4.39.0':
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.45.1':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.39.0':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.45.1':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.39.0':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.45.1':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.39.0':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.45.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.39.0':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.45.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.39.0':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-loongarch64-gnu@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-loongarch64-gnu@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-powerpc64le-gnu@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.45.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.39.0':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.45.1':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.39.0':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.45.1':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.39.0':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.45.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.39.0':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.45.1':
+ optional: true
+
+ '@scarf/scarf@1.4.0': {}
+
+ '@simplewebauthn/browser@13.1.2': {}
+
+ '@simplewebauthn/server@13.1.2':
+ dependencies:
+ '@hexagon/base64': 1.1.28
+ '@levischuck/tiny-cbor': 0.2.11
+ '@peculiar/asn1-android': 2.4.0
+ '@peculiar/asn1-ecc': 2.4.0
+ '@peculiar/asn1-rsa': 2.4.0
+ '@peculiar/asn1-schema': 2.4.0
+ '@peculiar/asn1-x509': 2.4.0
+
+ '@sveltejs/acorn-typescript@1.0.5(acorn@8.14.1)':
+ dependencies:
+ acorn: 8.14.1
+
+ '@sveltejs/adapter-node@5.2.13(@sveltejs/kit@2.25.2(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))':
+ dependencies:
+ '@rollup/plugin-commonjs': 28.0.3(rollup@4.39.0)
+ '@rollup/plugin-json': 6.1.0(rollup@4.39.0)
+ '@rollup/plugin-node-resolve': 16.0.1(rollup@4.39.0)
+ '@sveltejs/kit': 2.25.2(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))
+ rollup: 4.39.0
+
+ '@sveltejs/kit@2.25.2(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))':
+ dependencies:
+ '@sveltejs/acorn-typescript': 1.0.5(acorn@8.14.1)
+ '@sveltejs/vite-plugin-svelte': 6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))
+ '@types/cookie': 0.6.0
+ acorn: 8.14.1
+ cookie: 0.6.0
+ devalue: 5.1.1
+ esm-env: 1.2.2
+ kleur: 4.1.5
+ magic-string: 0.30.17
+ mrmime: 2.0.1
+ sade: 1.8.1
+ set-cookie-parser: 2.7.1
+ sirv: 3.0.1
+ svelte: 5.36.14
+ vite: 7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)
+
+ '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))':
+ dependencies:
+ '@sveltejs/vite-plugin-svelte': 6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))
+ debug: 4.4.1(supports-color@5.5.0)
+ svelte: 5.36.14
+ vite: 7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))':
+ dependencies:
+ '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.14)(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))
+ debug: 4.4.1(supports-color@5.5.0)
+ deepmerge: 4.3.1
+ kleur: 4.1.5
+ magic-string: 0.30.17
+ svelte: 5.36.14
+ vite: 7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)
+ vitefu: 1.1.1(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))
+ transitivePeerDependencies:
+ - supports-color
+
+ '@tailwindcss/node@4.1.11':
+ dependencies:
+ '@ampproject/remapping': 2.3.0
+ enhanced-resolve: 5.18.1
+ jiti: 2.4.2
+ lightningcss: 1.30.1
+ magic-string: 0.30.17
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.11
+
+ '@tailwindcss/oxide-android-arm64@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.11':
+ optional: true
+
+ '@tailwindcss/oxide@4.1.11':
+ dependencies:
+ detect-libc: 2.0.4
+ tar: 7.4.3
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.11
+ '@tailwindcss/oxide-darwin-arm64': 4.1.11
+ '@tailwindcss/oxide-darwin-x64': 4.1.11
+ '@tailwindcss/oxide-freebsd-x64': 4.1.11
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.11
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.11
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.11
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.11
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.11
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.11
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.11
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.11
+
+ '@tailwindcss/typography@0.5.16(tailwindcss@4.1.11)':
+ dependencies:
+ lodash.castarray: 4.4.0
+ lodash.isplainobject: 4.0.6
+ lodash.merge: 4.6.2
+ postcss-selector-parser: 6.0.10
+ tailwindcss: 4.1.11
+
+ '@tailwindcss/vite@4.1.11(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1))':
+ dependencies:
+ '@tailwindcss/node': 4.1.11
+ '@tailwindcss/oxide': 4.1.11
+ tailwindcss: 4.1.11
+ vite: 7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)
+
+ '@types/bcryptjs@3.0.0':
+ dependencies:
+ bcryptjs: 3.0.2
+
+ '@types/better-sqlite3@7.6.13':
+ dependencies:
+ '@types/node': 24.1.0
+
+ '@types/body-parser@1.19.6':
+ dependencies:
+ '@types/connect': 3.4.38
+ '@types/node': 24.1.0
+
+ '@types/connect@3.4.38':
+ dependencies:
+ '@types/node': 24.1.0
+
+ '@types/cookie@0.6.0': {}
+
+ '@types/cors@2.8.19':
+ dependencies:
+ '@types/node': 24.1.0
+
+ '@types/estree@1.0.7': {}
+
+ '@types/estree@1.0.8': {}
+
+ '@types/express-serve-static-core@5.0.7':
+ dependencies:
+ '@types/node': 24.1.0
+ '@types/qs': 6.14.0
+ '@types/range-parser': 1.2.7
+ '@types/send': 0.17.5
+
+ '@types/express@5.0.3':
+ dependencies:
+ '@types/body-parser': 1.19.6
+ '@types/express-serve-static-core': 5.0.7
+ '@types/serve-static': 1.15.8
+
+ '@types/http-errors@2.0.5': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/jsonwebtoken@9.0.10':
+ dependencies:
+ '@types/ms': 2.1.0
+ '@types/node': 24.1.0
+
+ '@types/mime@1.3.5': {}
+
+ '@types/morgan@1.9.10':
+ dependencies:
+ '@types/node': 24.1.0
+
+ '@types/ms@2.1.0': {}
+
+ '@types/node@24.1.0':
+ dependencies:
+ undici-types: 7.8.0
+
+ '@types/prompts@2.4.9':
+ dependencies:
+ '@types/node': 24.1.0
+ kleur: 3.0.3
+
+ '@types/qs@6.14.0': {}
+
+ '@types/range-parser@1.2.7': {}
+
+ '@types/resolve@1.20.2': {}
+
+ '@types/send@0.17.5':
+ dependencies:
+ '@types/mime': 1.3.5
+ '@types/node': 24.1.0
+
+ '@types/serve-static@1.15.8':
+ dependencies:
+ '@types/http-errors': 2.0.5
+ '@types/node': 24.1.0
+ '@types/send': 0.17.5
+
+ '@types/swagger-jsdoc@6.0.4': {}
+
+ '@types/swagger-ui-express@4.1.8':
+ dependencies:
+ '@types/express': 5.0.3
+ '@types/serve-static': 1.15.8
+
+ '@types/triple-beam@1.3.5': {}
+
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.1
+ negotiator: 1.0.0
+
+ acorn-jsx@5.3.2(acorn@8.14.1):
+ dependencies:
+ acorn: 8.14.1
+
+ acorn-jsx@5.3.2(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+
+ acorn@8.14.1: {}
+
+ acorn@8.15.0: {}
+
+ agent-base@7.1.4: {}
+
+ ajv@6.12.6:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.1
+
+ argparse@2.0.1: {}
+
+ aria-query@5.3.2: {}
+
+ asn1js@3.0.6:
+ dependencies:
+ pvtsutils: 1.3.6
+ pvutils: 1.1.3
+ tslib: 2.8.1
+
+ async@3.2.6: {}
+
+ asynckit@0.4.0: {}
+
+ axios@1.11.0:
+ dependencies:
+ follow-redirects: 1.15.9
+ form-data: 4.0.4
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
+ axobject-query@4.1.0: {}
+
+ balanced-match@1.0.2: {}
+
+ base64-js@1.5.1: {}
+
+ basic-auth@2.0.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
+ bcryptjs@3.0.2: {}
+
+ better-auth@1.3.4:
+ dependencies:
+ '@better-auth/utils': 0.2.5
+ '@better-fetch/fetch': 1.1.18
+ '@noble/ciphers': 0.6.0
+ '@noble/hashes': 1.8.0
+ '@simplewebauthn/browser': 13.1.2
+ '@simplewebauthn/server': 13.1.2
+ better-call: 1.0.13
+ defu: 6.1.4
+ jose: 5.10.0
+ kysely: 0.28.4
+ nanostores: 0.11.4
+ zod: 4.0.8
+
+ better-call@1.0.13:
+ dependencies:
+ '@better-fetch/fetch': 1.1.18
+ rou3: 0.5.1
+ set-cookie-parser: 2.7.1
+ uncrypto: 0.1.3
+
+ better-sqlite3@11.10.0:
+ dependencies:
+ bindings: 1.5.0
+ prebuild-install: 7.1.3
+
+ bignumber.js@9.3.1: {}
+
+ binary-extensions@2.3.0: {}
+
+ bindings@1.5.0:
+ dependencies:
+ file-uri-to-path: 1.0.0
+
+ bl@4.1.0:
+ dependencies:
+ buffer: 5.7.1
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+
+ body-parser@2.2.0:
+ dependencies:
+ bytes: 3.1.2
+ content-type: 1.0.5
+ debug: 4.4.1(supports-color@5.5.0)
+ http-errors: 2.0.0
+ iconv-lite: 0.6.3
+ on-finished: 2.4.1
+ qs: 6.14.0
+ raw-body: 3.0.0
+ type-is: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ brace-expansion@1.1.11:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ browserslist@4.25.1:
+ dependencies:
+ caniuse-lite: 1.0.30001731
+ electron-to-chromium: 1.5.198
+ node-releases: 2.0.19
+ update-browserslist-db: 1.1.3(browserslist@4.25.1)
+
+ buffer-equal-constant-time@1.0.1: {}
+
+ buffer-from@1.1.2: {}
+
+ buffer@5.7.1:
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+
+ bytes@3.1.2: {}
+
+ c12@2.0.4:
+ dependencies:
+ chokidar: 4.0.3
+ confbox: 0.1.8
+ defu: 6.1.4
+ dotenv: 16.6.1
+ giget: 1.2.5
+ jiti: 2.4.2
+ mlly: 1.7.4
+ ohash: 2.0.11
+ pathe: 2.0.3
+ perfect-debounce: 1.0.0
+ pkg-types: 1.3.1
+ rc9: 2.1.2
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ call-me-maybe@1.0.2: {}
+
+ callsites@3.1.0: {}
+
+ caniuse-lite@1.0.30001731: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ chalk@5.5.0: {}
+
+ chevrotain@10.5.0:
+ dependencies:
+ '@chevrotain/cst-dts-gen': 10.5.0
+ '@chevrotain/gast': 10.5.0
+ '@chevrotain/types': 10.5.0
+ '@chevrotain/utils': 10.5.0
+ lodash: 4.17.21
+ regexp-to-ast: 0.5.0
+
+ chokidar@3.6.0:
+ dependencies:
+ anymatch: 3.1.3
+ braces: 3.0.3
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ chokidar@4.0.3:
+ dependencies:
+ readdirp: 4.1.2
+
+ chownr@1.1.4: {}
+
+ chownr@2.0.0: {}
+
+ chownr@3.0.0: {}
+
+ citty@0.1.6:
+ dependencies:
+ consola: 3.4.2
+
+ clsx@2.1.1: {}
+
+ color-convert@1.9.3:
+ dependencies:
+ color-name: 1.1.3
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.3: {}
+
+ color-name@1.1.4: {}
+
+ color-string@1.9.1:
+ dependencies:
+ color-name: 1.1.4
+ simple-swizzle: 0.2.2
+
+ color@3.2.1:
+ dependencies:
+ color-convert: 1.9.3
+ color-string: 1.9.1
+
+ colorspace@1.1.4:
+ dependencies:
+ color: 3.2.1
+ text-hex: 1.0.0
+
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
+ commander@12.1.0: {}
+
+ commander@6.2.0: {}
+
+ commander@9.5.0:
+ optional: true
+
+ commondir@1.0.1: {}
+
+ concat-map@0.0.1: {}
+
+ confbox@0.1.8: {}
+
+ consola@3.4.2: {}
+
+ content-disposition@1.0.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ content-type@1.0.5: {}
+
+ convert-source-map@2.0.0: {}
+
+ cookie-signature@1.2.2: {}
+
+ cookie@0.6.0: {}
+
+ cookie@0.7.2: {}
+
+ cors@2.8.5:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ cssesc@3.0.0: {}
+
+ data-uri-to-buffer@4.0.1: {}
+
+ date-fns@4.1.0: {}
+
+ debug@2.6.9:
+ dependencies:
+ ms: 2.0.0
+
+ debug@4.4.1(supports-color@5.5.0):
+ dependencies:
+ ms: 2.1.3
+ optionalDependencies:
+ supports-color: 5.5.0
+
+ decompress-response@6.0.0:
+ dependencies:
+ mimic-response: 3.1.0
+
+ deep-extend@0.6.0: {}
+
+ deep-is@0.1.4: {}
+
+ deepmerge@4.3.1: {}
+
+ defu@6.1.4: {}
+
+ delayed-stream@1.0.0: {}
+
+ depd@2.0.0: {}
+
+ destr@2.0.5: {}
+
+ detect-libc@2.0.4: {}
+
+ devalue@5.1.1: {}
+
+ doctrine@3.0.0:
+ dependencies:
+ esutils: 2.0.3
+
+ dotenv@16.6.1: {}
+
+ dotenv@17.2.1: {}
+
+ drizzle-kit@0.31.4:
+ dependencies:
+ '@drizzle-team/brocli': 0.10.2
+ '@esbuild-kit/esm-loader': 2.6.5
+ esbuild: 0.25.8
+ esbuild-register: 3.6.0(esbuild@0.25.8)
+ transitivePeerDependencies:
+ - supports-color
+
+ drizzle-orm@0.33.0(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.28.4)(postgres@3.4.7)(prisma@5.22.0):
+ optionalDependencies:
+ '@prisma/client': 5.22.0(prisma@5.22.0)
+ '@types/better-sqlite3': 7.6.13
+ better-sqlite3: 11.10.0
+ kysely: 0.28.4
+ postgres: 3.4.7
+ prisma: 5.22.0
+
+ drizzle-orm@0.44.4(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(gel@2.1.1)(kysely@0.28.4)(postgres@3.4.7)(prisma@6.12.0(typescript@5.8.3)):
+ optionalDependencies:
+ '@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3)
+ '@types/better-sqlite3': 7.6.13
+ better-sqlite3: 11.10.0
+ gel: 2.1.1
+ kysely: 0.28.4
+ postgres: 3.4.7
+ prisma: 6.12.0(typescript@5.8.3)
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ ee-first@1.1.1: {}
+
+ electron-to-chromium@1.5.198: {}
+
+ enabled@2.0.0: {}
+
+ encodeurl@2.0.0: {}
+
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
+ enhanced-resolve@5.18.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.2.1
+
+ env-paths@3.0.0:
+ optional: true
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-object-atoms@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.2
+
+ esbuild-register@3.6.0(esbuild@0.25.8):
+ dependencies:
+ debug: 4.4.1(supports-color@5.5.0)
+ esbuild: 0.25.8
+ transitivePeerDependencies:
+ - supports-color
+
+ esbuild@0.18.20:
+ optionalDependencies:
+ '@esbuild/android-arm': 0.18.20
+ '@esbuild/android-arm64': 0.18.20
+ '@esbuild/android-x64': 0.18.20
+ '@esbuild/darwin-arm64': 0.18.20
+ '@esbuild/darwin-x64': 0.18.20
+ '@esbuild/freebsd-arm64': 0.18.20
+ '@esbuild/freebsd-x64': 0.18.20
+ '@esbuild/linux-arm': 0.18.20
+ '@esbuild/linux-arm64': 0.18.20
+ '@esbuild/linux-ia32': 0.18.20
+ '@esbuild/linux-loong64': 0.18.20
+ '@esbuild/linux-mips64el': 0.18.20
+ '@esbuild/linux-ppc64': 0.18.20
+ '@esbuild/linux-riscv64': 0.18.20
+ '@esbuild/linux-s390x': 0.18.20
+ '@esbuild/linux-x64': 0.18.20
+ '@esbuild/netbsd-x64': 0.18.20
+ '@esbuild/openbsd-x64': 0.18.20
+ '@esbuild/sunos-x64': 0.18.20
+ '@esbuild/win32-arm64': 0.18.20
+ '@esbuild/win32-ia32': 0.18.20
+ '@esbuild/win32-x64': 0.18.20
+
+ esbuild@0.25.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.2
+ '@esbuild/android-arm': 0.25.2
+ '@esbuild/android-arm64': 0.25.2
+ '@esbuild/android-x64': 0.25.2
+ '@esbuild/darwin-arm64': 0.25.2
+ '@esbuild/darwin-x64': 0.25.2
+ '@esbuild/freebsd-arm64': 0.25.2
+ '@esbuild/freebsd-x64': 0.25.2
+ '@esbuild/linux-arm': 0.25.2
+ '@esbuild/linux-arm64': 0.25.2
+ '@esbuild/linux-ia32': 0.25.2
+ '@esbuild/linux-loong64': 0.25.2
+ '@esbuild/linux-mips64el': 0.25.2
+ '@esbuild/linux-ppc64': 0.25.2
+ '@esbuild/linux-riscv64': 0.25.2
+ '@esbuild/linux-s390x': 0.25.2
+ '@esbuild/linux-x64': 0.25.2
+ '@esbuild/netbsd-arm64': 0.25.2
+ '@esbuild/netbsd-x64': 0.25.2
+ '@esbuild/openbsd-arm64': 0.25.2
+ '@esbuild/openbsd-x64': 0.25.2
+ '@esbuild/sunos-x64': 0.25.2
+ '@esbuild/win32-arm64': 0.25.2
+ '@esbuild/win32-ia32': 0.25.2
+ '@esbuild/win32-x64': 0.25.2
+
+ esbuild@0.25.8:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.8
+ '@esbuild/android-arm': 0.25.8
+ '@esbuild/android-arm64': 0.25.8
+ '@esbuild/android-x64': 0.25.8
+ '@esbuild/darwin-arm64': 0.25.8
+ '@esbuild/darwin-x64': 0.25.8
+ '@esbuild/freebsd-arm64': 0.25.8
+ '@esbuild/freebsd-x64': 0.25.8
+ '@esbuild/linux-arm': 0.25.8
+ '@esbuild/linux-arm64': 0.25.8
+ '@esbuild/linux-ia32': 0.25.8
+ '@esbuild/linux-loong64': 0.25.8
+ '@esbuild/linux-mips64el': 0.25.8
+ '@esbuild/linux-ppc64': 0.25.8
+ '@esbuild/linux-riscv64': 0.25.8
+ '@esbuild/linux-s390x': 0.25.8
+ '@esbuild/linux-x64': 0.25.8
+ '@esbuild/netbsd-arm64': 0.25.8
+ '@esbuild/netbsd-x64': 0.25.8
+ '@esbuild/openbsd-arm64': 0.25.8
+ '@esbuild/openbsd-x64': 0.25.8
+ '@esbuild/openharmony-arm64': 0.25.8
+ '@esbuild/sunos-x64': 0.25.8
+ '@esbuild/win32-arm64': 0.25.8
+ '@esbuild/win32-ia32': 0.25.8
+ '@esbuild/win32-x64': 0.25.8
+
+ escalade@3.2.0: {}
+
+ escape-html@1.0.3: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-config-prettier@10.1.8(eslint@9.31.0(jiti@2.4.2)):
+ dependencies:
+ eslint: 9.31.0(jiti@2.4.2)
+
+ eslint-plugin-svelte@3.11.0(eslint@9.31.0(jiti@2.4.2))(svelte@5.36.14):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.4.2))
+ '@jridgewell/sourcemap-codec': 1.5.0
+ eslint: 9.31.0(jiti@2.4.2)
+ esutils: 2.0.3
+ globals: 16.3.0
+ known-css-properties: 0.37.0
+ postcss: 8.5.3
+ postcss-load-config: 3.1.4(postcss@8.5.3)
+ postcss-safe-parser: 7.0.1(postcss@8.5.3)
+ semver: 7.7.1
+ svelte-eslint-parser: 1.3.0(svelte@5.36.14)
+ optionalDependencies:
+ svelte: 5.36.14
+ transitivePeerDependencies:
+ - ts-node
+
+ eslint-scope@8.3.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.0: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint@9.31.0(jiti@2.4.2):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.4.2))
+ '@eslint-community/regexpp': 4.12.1
+ '@eslint/config-array': 0.21.0
+ '@eslint/config-helpers': 0.3.0
+ '@eslint/core': 0.15.1
+ '@eslint/eslintrc': 3.3.1
+ '@eslint/js': 9.31.0
+ '@eslint/plugin-kit': 0.3.1
+ '@humanfs/node': 0.16.6
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.2
+ '@types/estree': 1.0.7
+ '@types/json-schema': 7.0.15
+ ajv: 6.12.6
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.1(supports-color@5.5.0)
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.6.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.4.2
+ transitivePeerDependencies:
+ - supports-color
+
+ esm-env@1.2.2: {}
+
+ espree@10.3.0:
+ dependencies:
+ acorn: 8.14.1
+ acorn-jsx: 5.3.2(acorn@8.14.1)
+ eslint-visitor-keys: 4.2.0
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ eslint-visitor-keys: 4.2.1
+
+ esquery@1.6.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrap@2.1.0:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ estree-walker@2.0.2: {}
+
+ esutils@2.0.3: {}
+
+ etag@1.8.1: {}
+
+ expand-template@2.0.3: {}
+
+ express-rate-limit@8.0.1(express@5.1.0):
+ dependencies:
+ express: 5.1.0
+ ip-address: 10.0.1
+
+ express@5.1.0:
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.2.0
+ content-disposition: 1.0.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.1(supports-color@5.5.0)
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.0
+ fresh: 2.0.0
+ http-errors: 2.0.0
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.1
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.14.0
+ range-parser: 1.2.1
+ router: 2.2.0
+ send: 1.2.0
+ serve-static: 2.2.0
+ statuses: 2.0.2
+ type-is: 2.0.1
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ extend@3.0.2: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fdir@6.4.6(picomatch@4.0.2):
+ optionalDependencies:
+ picomatch: 4.0.2
+
+ fecha@4.2.3: {}
+
+ fetch-blob@3.2.0:
+ dependencies:
+ node-domexception: 1.0.0
+ web-streams-polyfill: 3.3.3
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ file-uri-to-path@1.0.0: {}
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ finalhandler@2.1.0:
+ dependencies:
+ debug: 4.4.1(supports-color@5.5.0)
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.3.3
+ keyv: 4.5.4
+
+ flatted@3.3.3: {}
+
+ fn.name@1.1.0: {}
+
+ follow-redirects@1.15.9: {}
+
+ form-data@4.0.4:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.2
+ mime-types: 2.1.35
+
+ formdata-polyfill@4.0.10:
+ dependencies:
+ fetch-blob: 3.2.0
+
+ forwarded@0.2.0: {}
+
+ fresh@2.0.0: {}
+
+ fs-constants@1.0.0: {}
+
+ fs-extra@11.3.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 6.1.0
+ universalify: 2.0.1
+
+ fs-minipass@2.1.0:
+ dependencies:
+ minipass: 3.3.6
+
+ fs.realpath@1.0.0: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ gaxios@7.1.1:
+ dependencies:
+ extend: 3.0.2
+ https-proxy-agent: 7.0.6
+ node-fetch: 3.3.2
+ transitivePeerDependencies:
+ - supports-color
+
+ gcp-metadata@7.0.1:
+ dependencies:
+ gaxios: 7.1.1
+ google-logging-utils: 1.1.1
+ json-bigint: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ gel@2.1.1:
+ dependencies:
+ '@petamoriken/float16': 3.9.2
+ debug: 4.4.1(supports-color@5.5.0)
+ env-paths: 3.0.0
+ semver: 7.7.1
+ shell-quote: 1.8.3
+ which: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.2
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.1
+
+ get-tsconfig@4.10.1:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ giget@1.2.5:
+ dependencies:
+ citty: 0.1.6
+ consola: 3.4.2
+ defu: 6.1.4
+ node-fetch-native: 1.6.7
+ nypm: 0.5.4
+ pathe: 2.0.3
+ tar: 6.2.1
+
+ github-from-package@0.0.0: {}
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob@7.1.6:
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+
+ globals@14.0.0: {}
+
+ globals@16.3.0: {}
+
+ google-auth-library@10.2.0:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 7.1.1
+ gcp-metadata: 7.0.1
+ google-logging-utils: 1.1.1
+ gtoken: 8.0.0
+ jws: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ google-logging-utils@1.1.1: {}
+
+ gopd@1.2.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ gtoken@8.0.0:
+ dependencies:
+ gaxios: 7.1.1
+ jws: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ has-flag@3.0.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.2:
+ dependencies:
+ function-bind: 1.1.2
+
+ helmet@8.1.0: {}
+
+ highlight.js@11.11.1: {}
+
+ http-errors@2.0.0:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.1
+ toidentifier: 1.0.1
+
+ https-proxy-agent@7.0.6:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.1(supports-color@5.5.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ iconv-lite@0.6.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
+ ieee754@1.2.1: {}
+
+ ignore-by-default@1.0.1: {}
+
+ ignore@5.3.2: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ inflight@1.0.6:
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+
+ inherits@2.0.4: {}
+
+ ini@1.3.8: {}
+
+ ip-address@10.0.1: {}
+
+ ipaddr.js@1.9.1: {}
+
+ is-arrayish@0.3.2: {}
+
+ is-binary-path@2.1.0:
+ dependencies:
+ binary-extensions: 2.3.0
+
+ is-core-module@2.16.1:
+ dependencies:
+ hasown: 2.0.2
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-module@1.0.0: {}
+
+ is-number@7.0.0: {}
+
+ is-promise@4.0.0: {}
+
+ is-reference@1.2.1:
+ dependencies:
+ '@types/estree': 1.0.7
+
+ is-reference@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.7
+
+ is-stream@2.0.1: {}
+
+ isexe@2.0.0: {}
+
+ isexe@3.1.1:
+ optional: true
+
+ jiti@2.4.2: {}
+
+ jose@5.10.0: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.1.0:
+ dependencies:
+ argparse: 2.0.1
+
+ jsesc@3.1.0: {}
+
+ json-bigint@1.0.0:
+ dependencies:
+ bignumber.js: 9.3.1
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
+ jsonfile@6.1.0:
+ dependencies:
+ universalify: 2.0.1
+ optionalDependencies:
+ graceful-fs: 4.2.11
+
+ jsonwebtoken@9.0.2:
+ dependencies:
+ jws: 3.2.2
+ lodash.includes: 4.3.0
+ lodash.isboolean: 3.0.3
+ lodash.isinteger: 4.0.4
+ lodash.isnumber: 3.0.3
+ lodash.isplainobject: 4.0.6
+ lodash.isstring: 4.0.1
+ lodash.once: 4.1.1
+ ms: 2.1.3
+ semver: 7.7.1
+
+ jwa@1.4.2:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@3.2.2:
+ dependencies:
+ jwa: 1.4.2
+ safe-buffer: 5.2.1
+
+ jws@4.0.0:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ kleur@3.0.3: {}
+
+ kleur@4.1.5: {}
+
+ known-css-properties@0.37.0: {}
+
+ kuler@2.0.0: {}
+
+ kysely@0.28.4: {}
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ libphonenumber-js@1.12.10: {}
+
+ lightningcss-darwin-arm64@1.30.1:
+ optional: true
+
+ lightningcss-darwin-x64@1.30.1:
+ optional: true
+
+ lightningcss-freebsd-x64@1.30.1:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.30.1:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.30.1:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.30.1:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.30.1:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.30.1:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.30.1:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.30.1:
+ optional: true
+
+ lightningcss@1.30.1:
+ dependencies:
+ detect-libc: 2.0.4
+ optionalDependencies:
+ lightningcss-darwin-arm64: 1.30.1
+ lightningcss-darwin-x64: 1.30.1
+ lightningcss-freebsd-x64: 1.30.1
+ lightningcss-linux-arm-gnueabihf: 1.30.1
+ lightningcss-linux-arm64-gnu: 1.30.1
+ lightningcss-linux-arm64-musl: 1.30.1
+ lightningcss-linux-x64-gnu: 1.30.1
+ lightningcss-linux-x64-musl: 1.30.1
+ lightningcss-win32-arm64-msvc: 1.30.1
+ lightningcss-win32-x64-msvc: 1.30.1
+
+ lilconfig@2.1.0: {}
+
+ locate-character@3.0.0: {}
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.castarray@4.4.0: {}
+
+ lodash.get@4.4.2: {}
+
+ lodash.includes@4.3.0: {}
+
+ lodash.isboolean@3.0.3: {}
+
+ lodash.isequal@4.5.0: {}
+
+ lodash.isinteger@4.0.4: {}
+
+ lodash.isnumber@3.0.3: {}
+
+ lodash.isplainobject@4.0.6: {}
+
+ lodash.isstring@4.0.1: {}
+
+ lodash.merge@4.6.2: {}
+
+ lodash.mergewith@4.6.2: {}
+
+ lodash.once@4.1.1: {}
+
+ lodash@4.17.21: {}
+
+ logform@2.7.0:
+ dependencies:
+ '@colors/colors': 1.6.0
+ '@types/triple-beam': 1.3.5
+ fecha: 4.2.3
+ ms: 2.1.3
+ safe-stable-stringify: 2.5.0
+ triple-beam: 1.4.1
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ magic-string@0.30.17:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.0
+
+ marked@16.1.1: {}
+
+ math-intrinsics@1.1.0: {}
+
+ media-typer@1.1.0: {}
+
+ merge-descriptors@2.0.0: {}
+
+ mime-db@1.52.0: {}
+
+ mime-db@1.54.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
+ mime-types@3.0.1:
+ dependencies:
+ mime-db: 1.54.0
+
+ mimic-response@3.1.0: {}
+
+ minimatch@3.1.2:
+ dependencies:
+ brace-expansion: 1.1.11
+
+ minimist@1.2.8: {}
+
+ minipass@3.3.6:
+ dependencies:
+ yallist: 4.0.0
+
+ minipass@5.0.0: {}
+
+ minipass@7.1.2: {}
+
+ minizlib@2.1.2:
+ dependencies:
+ minipass: 3.3.6
+ yallist: 4.0.0
+
+ minizlib@3.0.2:
+ dependencies:
+ minipass: 7.1.2
+
+ mkdirp-classic@0.5.3: {}
+
+ mkdirp@1.0.4: {}
+
+ mkdirp@3.0.1: {}
+
+ mlly@1.7.4:
+ dependencies:
+ acorn: 8.15.0
+ pathe: 2.0.3
+ pkg-types: 1.3.1
+ ufo: 1.6.1
+
+ morgan@1.10.1:
+ dependencies:
+ basic-auth: 2.0.1
+ debug: 2.6.9
+ depd: 2.0.0
+ on-finished: 2.3.0
+ on-headers: 1.1.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mri@1.2.0: {}
+
+ mrmime@2.0.1: {}
+
+ ms@2.0.0: {}
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.11: {}
+
+ nanostores@0.11.4: {}
+
+ napi-build-utils@2.0.0: {}
+
+ natural-compare@1.4.0: {}
+
+ negotiator@1.0.0: {}
+
+ node-abi@3.75.0:
+ dependencies:
+ semver: 7.7.1
+
+ node-domexception@1.0.0: {}
+
+ node-fetch-native@1.6.7: {}
+
+ node-fetch@3.3.2:
+ dependencies:
+ data-uri-to-buffer: 4.0.1
+ fetch-blob: 3.2.0
+ formdata-polyfill: 4.0.10
+
+ node-releases@2.0.19: {}
+
+ nodemon@3.1.10:
+ dependencies:
+ chokidar: 3.6.0
+ debug: 4.4.1(supports-color@5.5.0)
+ ignore-by-default: 1.0.1
+ minimatch: 3.1.2
+ pstree.remy: 1.1.8
+ semver: 7.7.1
+ simple-update-notifier: 2.0.0
+ supports-color: 5.5.0
+ touch: 3.1.1
+ undefsafe: 2.0.5
+
+ normalize-path@3.0.0: {}
+
+ nypm@0.5.4:
+ dependencies:
+ citty: 0.1.6
+ consola: 3.4.2
+ pathe: 2.0.3
+ pkg-types: 1.3.1
+ tinyexec: 0.3.2
+ ufo: 1.6.1
+
+ object-assign@4.1.1: {}
+
+ object-inspect@1.13.4: {}
+
+ ohash@2.0.11: {}
+
+ on-finished@2.3.0:
+ dependencies:
+ ee-first: 1.1.1
+
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
+ on-headers@1.1.0: {}
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ one-time@1.0.0:
+ dependencies:
+ fn.name: 1.1.0
+
+ openapi-types@12.1.3: {}
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ parseurl@1.3.3: {}
+
+ path-exists@4.0.0: {}
+
+ path-is-absolute@1.0.1: {}
+
+ path-key@3.1.1: {}
+
+ path-parse@1.0.7: {}
+
+ path-to-regexp@8.2.0: {}
+
+ pathe@2.0.3: {}
+
+ perfect-debounce@1.0.0: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.1: {}
+
+ picomatch@4.0.2: {}
+
+ pkg-types@1.3.1:
+ dependencies:
+ confbox: 0.1.8
+ mlly: 1.7.4
+ pathe: 2.0.3
+
+ postcss-load-config@3.1.4(postcss@8.5.3):
+ dependencies:
+ lilconfig: 2.1.0
+ yaml: 1.10.2
+ optionalDependencies:
+ postcss: 8.5.3
+
+ postcss-safe-parser@7.0.1(postcss@8.5.3):
+ dependencies:
+ postcss: 8.5.3
+
+ postcss-scss@4.0.9(postcss@8.5.3):
+ dependencies:
+ postcss: 8.5.3
+
+ postcss-selector-parser@6.0.10:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-selector-parser@7.1.0:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss@8.5.3:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postgres@3.4.7: {}
+
+ prebuild-install@7.1.3:
+ dependencies:
+ detect-libc: 2.0.4
+ expand-template: 2.0.3
+ github-from-package: 0.0.0
+ minimist: 1.2.8
+ mkdirp-classic: 0.5.3
+ napi-build-utils: 2.0.0
+ node-abi: 3.75.0
+ pump: 3.0.3
+ rc: 1.2.8
+ simple-get: 4.0.1
+ tar-fs: 2.1.3
+ tunnel-agent: 0.6.0
+
+ prelude-ls@1.2.1: {}
+
+ prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.36.14):
+ dependencies:
+ prettier: 3.6.2
+ svelte: 5.36.14
+
+ prettier-plugin-tailwindcss@0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.36.14))(prettier@3.6.2):
+ dependencies:
+ prettier: 3.6.2
+ optionalDependencies:
+ prettier-plugin-svelte: 3.4.0(prettier@3.6.2)(svelte@5.36.14)
+
+ prettier@3.6.2: {}
+
+ prisma@5.22.0:
+ dependencies:
+ '@prisma/engines': 5.22.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ prisma@6.12.0(typescript@5.8.3):
+ dependencies:
+ '@prisma/config': 6.12.0
+ '@prisma/engines': 6.12.0
+ optionalDependencies:
+ typescript: 5.8.3
+
+ prompts@2.4.2:
+ dependencies:
+ kleur: 3.0.3
+ sisteransi: 1.0.5
+
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
+ proxy-from-env@1.1.0: {}
+
+ pstree.remy@1.1.8: {}
+
+ pump@3.0.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
+ punycode@2.3.1: {}
+
+ pvtsutils@1.3.6:
+ dependencies:
+ tslib: 2.8.1
+
+ pvutils@1.1.3: {}
+
+ qs@6.14.0:
+ dependencies:
+ side-channel: 1.1.0
+
+ range-parser@1.2.1: {}
+
+ raw-body@3.0.0:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.0
+ iconv-lite: 0.6.3
+ unpipe: 1.0.0
+
+ rc9@2.1.2:
+ dependencies:
+ defu: 6.1.4
+ destr: 2.0.5
+
+ rc@1.2.8:
+ dependencies:
+ deep-extend: 0.6.0
+ ini: 1.3.8
+ minimist: 1.2.8
+ strip-json-comments: 2.0.1
+
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
+ readdirp@3.6.0:
+ dependencies:
+ picomatch: 2.3.1
+
+ readdirp@4.1.2: {}
+
+ regexp-to-ast@0.5.0: {}
+
+ resolve-from@4.0.0: {}
+
+ resolve-pkg-maps@1.0.0: {}
+
+ resolve@1.22.10:
+ dependencies:
+ is-core-module: 2.16.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ rollup@4.39.0:
+ dependencies:
+ '@types/estree': 1.0.7
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.39.0
+ '@rollup/rollup-android-arm64': 4.39.0
+ '@rollup/rollup-darwin-arm64': 4.39.0
+ '@rollup/rollup-darwin-x64': 4.39.0
+ '@rollup/rollup-freebsd-arm64': 4.39.0
+ '@rollup/rollup-freebsd-x64': 4.39.0
+ '@rollup/rollup-linux-arm-gnueabihf': 4.39.0
+ '@rollup/rollup-linux-arm-musleabihf': 4.39.0
+ '@rollup/rollup-linux-arm64-gnu': 4.39.0
+ '@rollup/rollup-linux-arm64-musl': 4.39.0
+ '@rollup/rollup-linux-loongarch64-gnu': 4.39.0
+ '@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
+ '@rollup/rollup-linux-riscv64-gnu': 4.39.0
+ '@rollup/rollup-linux-riscv64-musl': 4.39.0
+ '@rollup/rollup-linux-s390x-gnu': 4.39.0
+ '@rollup/rollup-linux-x64-gnu': 4.39.0
+ '@rollup/rollup-linux-x64-musl': 4.39.0
+ '@rollup/rollup-win32-arm64-msvc': 4.39.0
+ '@rollup/rollup-win32-ia32-msvc': 4.39.0
+ '@rollup/rollup-win32-x64-msvc': 4.39.0
+ fsevents: 2.3.3
+
+ rollup@4.45.1:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.45.1
+ '@rollup/rollup-android-arm64': 4.45.1
+ '@rollup/rollup-darwin-arm64': 4.45.1
+ '@rollup/rollup-darwin-x64': 4.45.1
+ '@rollup/rollup-freebsd-arm64': 4.45.1
+ '@rollup/rollup-freebsd-x64': 4.45.1
+ '@rollup/rollup-linux-arm-gnueabihf': 4.45.1
+ '@rollup/rollup-linux-arm-musleabihf': 4.45.1
+ '@rollup/rollup-linux-arm64-gnu': 4.45.1
+ '@rollup/rollup-linux-arm64-musl': 4.45.1
+ '@rollup/rollup-linux-loongarch64-gnu': 4.45.1
+ '@rollup/rollup-linux-powerpc64le-gnu': 4.45.1
+ '@rollup/rollup-linux-riscv64-gnu': 4.45.1
+ '@rollup/rollup-linux-riscv64-musl': 4.45.1
+ '@rollup/rollup-linux-s390x-gnu': 4.45.1
+ '@rollup/rollup-linux-x64-gnu': 4.45.1
+ '@rollup/rollup-linux-x64-musl': 4.45.1
+ '@rollup/rollup-win32-arm64-msvc': 4.45.1
+ '@rollup/rollup-win32-ia32-msvc': 4.45.1
+ '@rollup/rollup-win32-x64-msvc': 4.45.1
+ fsevents: 2.3.3
+
+ rou3@0.5.1: {}
+
+ router@2.2.0:
+ dependencies:
+ debug: 4.4.1(supports-color@5.5.0)
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ sade@1.8.1:
+ dependencies:
+ mri: 1.2.0
+
+ safe-buffer@5.1.2: {}
+
+ safe-buffer@5.2.1: {}
+
+ safe-stable-stringify@2.5.0: {}
+
+ safer-buffer@2.1.2: {}
+
+ schema-dts@1.1.5: {}
+
+ semver@6.3.1: {}
+
+ semver@7.7.1: {}
+
+ send@1.2.0:
+ dependencies:
+ debug: 4.4.1(supports-color@5.5.0)
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.0
+ mime-types: 3.0.1
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.2.1
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ serve-static@2.2.0:
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ set-cookie-parser@2.7.1: {}
+
+ setprototypeof@1.2.0: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ shell-quote@1.8.3:
+ optional: true
+
+ side-channel-list@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.0
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ simple-concat@1.0.1: {}
+
+ simple-get@4.0.1:
+ dependencies:
+ decompress-response: 6.0.0
+ once: 1.4.0
+ simple-concat: 1.0.1
+
+ simple-swizzle@0.2.2:
+ dependencies:
+ is-arrayish: 0.3.2
+
+ simple-update-notifier@2.0.0:
+ dependencies:
+ semver: 7.7.1
+
+ sirv@3.0.1:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
+ sisteransi@1.0.5: {}
+
+ source-map-js@1.2.1: {}
+
+ source-map-support@0.5.21:
+ dependencies:
+ buffer-from: 1.1.2
+ source-map: 0.6.1
+
+ source-map@0.6.1: {}
+
+ stack-trace@0.0.10: {}
+
+ statuses@2.0.1: {}
+
+ statuses@2.0.2: {}
+
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ strip-json-comments@2.0.1: {}
+
+ strip-json-comments@3.1.1: {}
+
+ supports-color@5.5.0:
+ dependencies:
+ has-flag: 3.0.0
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ svelte-check@4.3.0(picomatch@4.0.2)(svelte@5.36.14)(typescript@5.8.3):
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.25
+ chokidar: 4.0.3
+ fdir: 6.4.6(picomatch@4.0.2)
+ picocolors: 1.1.1
+ sade: 1.8.1
+ svelte: 5.36.14
+ typescript: 5.8.3
+ transitivePeerDependencies:
+ - picomatch
+
+ svelte-dnd-action@0.9.64(svelte@5.36.14):
+ dependencies:
+ svelte: 5.36.14
+
+ svelte-eslint-parser@1.3.0(svelte@5.36.14):
+ dependencies:
+ eslint-scope: 8.3.0
+ eslint-visitor-keys: 4.2.0
+ espree: 10.3.0
+ postcss: 8.5.3
+ postcss-scss: 4.0.9(postcss@8.5.3)
+ postcss-selector-parser: 7.1.0
+ optionalDependencies:
+ svelte: 5.36.14
+
+ svelte-highlight@7.8.3:
+ dependencies:
+ highlight.js: 11.11.1
+
+ svelte-meta-tags@4.4.0(svelte@5.36.14):
+ dependencies:
+ schema-dts: 1.1.5
+ svelte: 5.36.14
+
+ svelte@5.36.14:
+ dependencies:
+ '@ampproject/remapping': 2.3.0
+ '@jridgewell/sourcemap-codec': 1.5.0
+ '@sveltejs/acorn-typescript': 1.0.5(acorn@8.14.1)
+ '@types/estree': 1.0.7
+ acorn: 8.14.1
+ aria-query: 5.3.2
+ axobject-query: 4.1.0
+ clsx: 2.1.1
+ esm-env: 1.2.2
+ esrap: 2.1.0
+ is-reference: 3.0.3
+ locate-character: 3.0.0
+ magic-string: 0.30.17
+ zimmerframe: 1.1.2
+
+ swagger-jsdoc@6.2.8(openapi-types@12.1.3):
+ dependencies:
+ commander: 6.2.0
+ doctrine: 3.0.0
+ glob: 7.1.6
+ lodash.mergewith: 4.6.2
+ swagger-parser: 10.0.3(openapi-types@12.1.3)
+ yaml: 2.0.0-1
+ transitivePeerDependencies:
+ - openapi-types
+
+ swagger-parser@10.0.3(openapi-types@12.1.3):
+ dependencies:
+ '@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.3)
+ transitivePeerDependencies:
+ - openapi-types
+
+ swagger-ui-dist@5.27.0:
+ dependencies:
+ '@scarf/scarf': 1.4.0
+
+ swagger-ui-express@5.0.1(express@5.1.0):
+ dependencies:
+ express: 5.1.0
+ swagger-ui-dist: 5.27.0
+
+ tailwindcss@4.1.11: {}
+
+ tapable@2.2.1: {}
+
+ tar-fs@2.1.3:
+ dependencies:
+ chownr: 1.1.4
+ mkdirp-classic: 0.5.3
+ pump: 3.0.3
+ tar-stream: 2.2.0
+
+ tar-stream@2.2.0:
+ dependencies:
+ bl: 4.1.0
+ end-of-stream: 1.4.5
+ fs-constants: 1.0.0
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+
+ tar@6.2.1:
+ dependencies:
+ chownr: 2.0.0
+ fs-minipass: 2.1.0
+ minipass: 5.0.0
+ minizlib: 2.1.2
+ mkdirp: 1.0.4
+ yallist: 4.0.0
+
+ tar@7.4.3:
+ dependencies:
+ '@isaacs/fs-minipass': 4.0.1
+ chownr: 3.0.0
+ minipass: 7.1.2
+ minizlib: 3.0.2
+ mkdirp: 3.0.1
+ yallist: 5.0.0
+
+ text-hex@1.0.0: {}
+
+ tinyexec@0.3.2: {}
+
+ tinyglobby@0.2.14:
+ dependencies:
+ fdir: 6.4.6(picomatch@4.0.2)
+ picomatch: 4.0.2
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ toidentifier@1.0.1: {}
+
+ totalist@3.0.1: {}
+
+ touch@3.1.1: {}
+
+ triple-beam@1.4.1: {}
+
+ tslib@2.8.1: {}
+
+ tunnel-agent@0.6.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ type-is@2.0.1:
+ dependencies:
+ content-type: 1.0.5
+ media-typer: 1.1.0
+ mime-types: 3.0.1
+
+ typescript@5.8.3: {}
+
+ ufo@1.6.1: {}
+
+ uncrypto@0.1.3: {}
+
+ undefsafe@2.0.5: {}
+
+ undici-types@7.8.0: {}
+
+ universalify@2.0.1: {}
+
+ unpipe@1.0.0: {}
+
+ update-browserslist-db@1.1.3(browserslist@4.25.1):
+ dependencies:
+ browserslist: 4.25.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ util-deprecate@1.0.2: {}
+
+ uuid@11.1.0: {}
+
+ validator@13.15.15: {}
+
+ vary@1.1.2: {}
+
+ vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1):
+ dependencies:
+ esbuild: 0.25.2
+ fdir: 6.4.6(picomatch@4.0.2)
+ picomatch: 4.0.2
+ postcss: 8.5.6
+ rollup: 4.45.1
+ tinyglobby: 0.2.14
+ optionalDependencies:
+ '@types/node': 24.1.0
+ fsevents: 2.3.3
+ jiti: 2.4.2
+ lightningcss: 1.30.1
+
+ vitefu@1.1.1(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)):
+ optionalDependencies:
+ vite: 7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)
+
+ web-streams-polyfill@3.3.3: {}
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ which@4.0.0:
+ dependencies:
+ isexe: 3.1.1
+ optional: true
+
+ winston-transport@4.9.0:
+ dependencies:
+ logform: 2.7.0
+ readable-stream: 3.6.2
+ triple-beam: 1.4.1
+
+ winston@3.17.0:
+ dependencies:
+ '@colors/colors': 1.6.0
+ '@dabh/diagnostics': 2.0.3
+ async: 3.2.6
+ is-stream: 2.0.1
+ logform: 2.7.0
+ one-time: 1.0.0
+ readable-stream: 3.6.2
+ safe-stable-stringify: 2.5.0
+ stack-trace: 0.0.10
+ triple-beam: 1.4.1
+ winston-transport: 4.9.0
+
+ word-wrap@1.2.5: {}
+
+ wrappy@1.0.2: {}
+
+ yallist@3.1.1: {}
+
+ yallist@4.0.0: {}
+
+ yallist@5.0.0: {}
+
+ yaml@1.10.2: {}
+
+ yaml@2.0.0-1: {}
+
+ yocto-queue@0.1.0: {}
+
+ yocto-spinner@0.1.2:
+ dependencies:
+ yoctocolors: 2.1.1
+
+ yoctocolors@2.1.1: {}
+
+ z-schema@5.0.5:
+ dependencies:
+ lodash.get: 4.4.2
+ lodash.isequal: 4.5.0
+ validator: 13.15.15
+ optionalDependencies:
+ commander: 9.5.0
+
+ zimmerframe@1.1.2: {}
+
+ zod@4.0.8: {}
diff --git a/apps/web/server.js b/apps/web/server.js
new file mode 100644
index 0000000..ba39648
--- /dev/null
+++ b/apps/web/server.js
@@ -0,0 +1,102 @@
+import express from 'express';
+import cors from 'cors';
+import helmet from 'helmet';
+import rateLimit from 'express-rate-limit';
+import swaggerJsdoc from 'swagger-jsdoc';
+import swaggerUi from 'swagger-ui-express';
+import dotenv from 'dotenv';
+import { createLogger } from '../api/config/logger.js';
+import { requestLogger } from '../api/middleware/requestLogger.js';
+import { errorHandler } from '../api/middleware/errorHandler.js';
+import authRoutes from '../api/routes/auth.js';
+import dashboardRoutes from '../api/routes/dashboard.js';
+import leadRoutes from '../api/routes/leads.js';
+import accountRoutes from '../api/routes/accounts.js';
+import contactRoutes from '../api/routes/contacts.js';
+import opportunityRoutes from '../api/routes/opportunities.js';
+import taskRoutes from '../api/routes/tasks.js';
+import organizationRoutes from '../api/routes/organizations.js';
+
+dotenv.config();
+
+const app = express();
+const logger = createLogger();
+const PORT = process.env.PORT || 3001;
+
+// Trust proxy setting for rate limiting
+app.set('trust proxy', 1);
+
+const rateLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 100,
+ message: 'Too many requests from this IP, please try again later.',
+});
+
+const swaggerOptions = {
+ definition: {
+ openapi: '3.0.0',
+ info: {
+ title: 'BottleCRM API',
+ version: '1.0.0',
+ description: 'Multi-tenant CRM API with JWT authentication',
+ },
+ servers: [
+ {
+ url: `http://localhost:${PORT}`,
+ description: 'Development server',
+ },
+ ],
+ components: {
+ securitySchemes: {
+ bearerAuth: {
+ type: 'http',
+ scheme: 'bearer',
+ bearerFormat: 'JWT',
+ },
+ },
+ },
+ security: [
+ {
+ bearerAuth: [],
+ },
+ ],
+ },
+ apis: ['./api/routes/*.js'],
+};
+
+const specs = swaggerJsdoc(swaggerOptions);
+
+app.use(helmet());
+app.use(cors({
+ origin: process.env.FRONTEND_URL || 'http://localhost:5173',
+ credentials: true,
+}));
+app.use(rateLimiter);
+app.use(express.json({ limit: '10mb' }));
+app.use(express.urlencoded({ extended: true }));
+
+app.use(requestLogger);
+
+app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
+
+app.use('/auth', authRoutes);
+app.use('/dashboard', dashboardRoutes);
+app.use('/leads', leadRoutes);
+app.use('/accounts', accountRoutes);
+app.use('/contacts', contactRoutes);
+app.use('/opportunities', opportunityRoutes);
+app.use('/tasks', taskRoutes);
+app.use('/organizations', organizationRoutes);
+
+app.get('/health', (req, res) => {
+ res.json({ status: 'OK', timestamp: new Date().toISOString() });
+});
+
+app.use(errorHandler);
+
+app.listen(PORT, () => {
+ logger.info(`BottleCRM API server running on port ${PORT}`);
+ logger.info(`Swagger documentation available at http://localhost:${PORT}/api-docs`);
+});
+
+export default app;
\ No newline at end of file
diff --git a/apps/web/src/app.css b/apps/web/src/app.css
new file mode 100644
index 0000000..a596c08
--- /dev/null
+++ b/apps/web/src/app.css
@@ -0,0 +1,3 @@
+@import 'tailwindcss';
+@plugin '@tailwindcss/typography';
+
diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts
new file mode 100644
index 0000000..7d8cb20
--- /dev/null
+++ b/apps/web/src/app.d.ts
@@ -0,0 +1,24 @@
+import type { AuthType } from './lib/auth';
+import type { DrizzleClient } from '@opensource-startup-crm/database';
+
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ interface Locals {
+ db: DrizzleClient;
+ auth: AuthType;
+ user: AuthType["$Infer"]["Session"]["user"] | null;
+ org?: { id: string; name: string } | null;
+ session: AuthType["$Infer"]["Session"]["session"] | null;
+ }
+ // interface PageData {}
+ // interface PageState {}
+ interface Platform {
+ env: Env;
+ }
+ }
+}
+
+export { };
diff --git a/apps/web/src/app.html b/apps/web/src/app.html
new file mode 100644
index 0000000..c09a9ae
--- /dev/null
+++ b/apps/web/src/app.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts
new file mode 100644
index 0000000..2376683
--- /dev/null
+++ b/apps/web/src/hooks.server.ts
@@ -0,0 +1,55 @@
+import { error, redirect, type Handle } from '@sveltejs/kit';
+import { building } from '$app/environment';
+import { svelteKitHandler } from 'better-auth/svelte-kit';
+import { createAuth } from '$lib/auth';
+import { getDb, schema } from '@opensource-startup-crm/database';
+import { eq } from 'drizzle-orm';
+import { sequence } from '@sveltejs/kit/hooks';
+
+const handleAuth: Handle = async ({ event, resolve }) => {
+ const env = event.platform?.env;
+ if (!env) throw error(500, 'Platform env is not defined');
+ const db = getDb(env);
+ event.locals.db = db;
+ event.locals.auth = createAuth(env, db);
+
+ const auth = event.locals.auth as ReturnType;
+ const sessionData: { user: App.Locals['user']; session: App.Locals['session'] } | null =
+ await auth.api.getSession({ headers: event.request.headers });
+ event.locals.user = sessionData?.user ?? null;
+ event.locals.session = sessionData?.session ?? null;
+ return resolve(event);
+};
+
+// Resolve org from activeOrganizationId and apply route guards
+const handleOrgAndGuards: Handle = async ({ event, resolve }) => {
+ const db = event.locals.db as ReturnType;
+ if (event.locals.user && event.locals.session?.activeOrganizationId) {
+ const activeOrgId = event.locals.session.activeOrganizationId as string;
+ const [org] = await db
+ .select({ id: schema.organization.id, name: schema.organization.name })
+ .from(schema.organization)
+ .where(eq(schema.organization.id, activeOrgId));
+ event.locals.org = org ?? null;
+ } else {
+ event.locals.org = null;
+ }
+ if (event.url.pathname.startsWith('/app')) {
+ if (!event.locals.user) throw redirect(307, '/login');
+ if (!event.locals.org) throw redirect(307, '/org');
+ } else if (event.url.pathname.startsWith('/admin')) {
+ if (!event.locals.user) throw redirect(307, '/login');
+ if (!event.locals.user?.email) {
+ throw redirect(307, '/app');
+ }
+ } else if (event.url.pathname.startsWith('/org')) {
+ if (!event.locals.user) throw redirect(307, '/login');
+ }
+ return resolve(event);
+};
+
+export const handle = sequence(
+ handleAuth,
+ ({ event, resolve }) => svelteKitHandler({ event, resolve, auth: event.locals.auth, building }),
+ handleOrgAndGuards
+);
diff --git a/apps/web/src/lib/assets/images/banner.png b/apps/web/src/lib/assets/images/banner.png
new file mode 100644
index 0000000..641cc18
Binary files /dev/null and b/apps/web/src/lib/assets/images/banner.png differ
diff --git a/src/lib/assets/images/google.svg b/apps/web/src/lib/assets/images/google.svg
similarity index 100%
rename from src/lib/assets/images/google.svg
rename to apps/web/src/lib/assets/images/google.svg
diff --git a/src/lib/assets/images/img_login.png b/apps/web/src/lib/assets/images/img_login.png
similarity index 100%
rename from src/lib/assets/images/img_login.png
rename to apps/web/src/lib/assets/images/img_login.png
diff --git a/apps/web/src/lib/assets/images/logo.png b/apps/web/src/lib/assets/images/logo.png
new file mode 100644
index 0000000..1433123
Binary files /dev/null and b/apps/web/src/lib/assets/images/logo.png differ
diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts
new file mode 100644
index 0000000..41e4c30
--- /dev/null
+++ b/apps/web/src/lib/auth-client.ts
@@ -0,0 +1,14 @@
+import { createAuthClient } from 'better-auth/client';
+import { organizationClient, inferOrgAdditionalFields } from 'better-auth/client/plugins';
+import type { AuthType } from '$lib/auth';
+
+export const authClient = createAuthClient({
+ baseURL: typeof window !== 'undefined' ? window.location.origin : process.env.PUBLIC_APP_URL || 'http://localhost:5173',
+ plugins: [
+ organizationClient({
+ schema: inferOrgAdditionalFields()
+ })
+ ]
+});
+
+
diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts
new file mode 100644
index 0000000..9299e45
--- /dev/null
+++ b/apps/web/src/lib/auth.ts
@@ -0,0 +1,49 @@
+import { betterAuth } from 'better-auth';
+import { organization } from 'better-auth/plugins';
+import { jwt } from 'better-auth/plugins';
+import { drizzleAdapter } from 'better-auth/adapters/drizzle';
+import type { DrizzleClient } from '@opensource-startup-crm/database';
+
+
+export function createAuth(env: Env, db: DrizzleClient) {
+ return betterAuth({
+ baseURL: env.BASE_URL || 'http://localhost:5173',
+ emailAndPassword: { enabled: true },
+ database: drizzleAdapter(db, {
+ provider: 'pg',
+ }),
+ socialProviders: {
+ google: {
+ clientId: env?.GOOGLE_CLIENT_ID || '',
+ clientSecret: env?.GOOGLE_CLIENT_SECRET || '',
+ enabled: !!env?.GOOGLE_CLIENT_ID && !!env?.GOOGLE_CLIENT_SECRET
+ }
+ },
+ plugins: [
+ organization({
+ schema: {
+ organization: {
+ additionalFields: {
+ domain: { type: 'string', input: true, required: false },
+ website: { type: 'string', input: true, required: false },
+ industry: { type: 'string', input: true, required: false },
+ description: { type: 'string', input: true, required: false },
+ isActive: { type: 'boolean', input: true, required: false }
+ }
+ },
+ member: {
+ additionalFields: {}
+ },
+ invitation: {
+ additionalFields: {}
+ }
+ }
+ }),
+ jwt()
+ ]
+ });
+}
+
+export type AuthType = ReturnType;
+
+
diff --git a/apps/web/src/lib/data/enum-helpers.ts b/apps/web/src/lib/data/enum-helpers.ts
new file mode 100644
index 0000000..e6455e3
--- /dev/null
+++ b/apps/web/src/lib/data/enum-helpers.ts
@@ -0,0 +1,33 @@
+function normalizeInput(input: unknown): string {
+ return typeof input === 'string' ? input.trim() : '';
+}
+
+export function validateEnumOrDefault(
+ input: unknown,
+ allowed: T,
+ fallback: T[number]
+): T[number] {
+ const value = normalizeInput(input);
+ return (allowed as readonly string[]).includes(value) ? (value as T[number]) : fallback;
+}
+
+export function validateEnumOrNull(
+ input: unknown,
+ allowed: T
+): T[number] | null {
+ const value = normalizeInput(input);
+ return (allowed as readonly string[]).includes(value) ? (value as T[number]) : null;
+}
+
+export type ValueLabel = { value: string; label: string };
+
+export function toLabel(
+ value: string | null | undefined,
+ options: readonly ValueLabel[],
+ fallback = 'N/A'
+): string {
+ if (!value) return fallback;
+ const match = options.find((o) => o.value === value);
+ return match?.label ?? fallback;
+}
+
diff --git a/apps/web/src/lib/data/index.ts b/apps/web/src/lib/data/index.ts
new file mode 100644
index 0000000..1c9aa5d
--- /dev/null
+++ b/apps/web/src/lib/data/index.ts
@@ -0,0 +1,190 @@
+// Consolidated simple lookup tuples. All are readonly tuple arrays.
+
+import {
+ // Value/Label options
+ INDUSTRY_OPTIONS,
+ ACCOUNT_TYPE_OPTIONS,
+ ACCOUNT_OWNERSHIP_OPTIONS,
+ RATING_OPTIONS,
+ LEAD_SOURCE_OPTIONS,
+ LEAD_STATUS_OPTIONS,
+ OPPORTUNITY_TYPE_OPTIONS,
+ FORECAST_CATEGORY_OPTIONS,
+ CASE_STATUS_OPTIONS,
+ OPPORTUNITY_STAGE_OPTIONS,
+ TASK_STATUS_OPTIONS,
+ TASK_PRIORITY_OPTIONS,
+ // Types
+ type ValueLabel,
+ type TASK_PRIORITIES,
+ CASE_STATUSES,
+ QUOTE_STATUSES,
+ TASK_STATUSES,
+ LEAD_STATUSES,
+ OPPORTUNITY_STAGES
+} from '@opensource-startup-crm/constants';
+
+// Tuples removed; rely on constants options/values
+// industries tuples not needed in app layer
+
+// accountTypes tuples not needed in app layer
+
+// accountOwnership tuples not needed in app layer
+
+// ratings tuples not needed in app layer
+
+export const countries = [
+ ['US', 'United States'],
+ ['UK', 'United Kingdom'],
+ ['CA', 'Canada'],
+ ['AU', 'Australia'],
+ ['IN', 'India'],
+ ['DE', 'Germany'],
+ ['FR', 'France'],
+ ['JP', 'Japan'],
+ ['CN', 'China'],
+ ['BR', 'Brazil'],
+ ['MX', 'Mexico'],
+ ['IT', 'Italy'],
+ ['ES', 'Spain'],
+ ['NL', 'Netherlands'],
+ ['SE', 'Sweden'],
+ ['NO', 'Norway'],
+ ['DK', 'Denmark'],
+ ['FI', 'Finland'],
+ ['CH', 'Switzerland'],
+ ['AT', 'Austria'],
+ ['BE', 'Belgium'],
+ ['IE', 'Ireland'],
+ ['PL', 'Poland'],
+ ['RU', 'Russia'],
+ ['KR', 'South Korea'],
+ ['SG', 'Singapore'],
+ ['TH', 'Thailand'],
+ ['MY', 'Malaysia'],
+ ['ID', 'Indonesia'],
+ ['PH', 'Philippines'],
+ ['VN', 'Vietnam'],
+ ['NZ', 'New Zealand'],
+ ['ZA', 'South Africa'],
+ ['EG', 'Egypt'],
+ ['NG', 'Nigeria'],
+ ['KE', 'Kenya'],
+ ['AR', 'Argentina'],
+ ['CL', 'Chile'],
+ ['CO', 'Colombia'],
+ ['PE', 'Peru'],
+ ['OTHER', 'Other']
+] as const;
+
+// Opportunity stages with display colors (labels sourced from constants)
+const stageColorMap: Record = {
+ PROSPECTING: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
+ QUALIFICATION: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
+ PROPOSAL: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
+ NEGOTIATION: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
+ CLOSED_WON: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
+ CLOSED_LOST: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
+};
+export const opportunityStages: ReadonlyArray<{ value: string; label: string; color: string }> =
+ OPPORTUNITY_STAGE_OPTIONS.map(({ value, label }) => ({ value, label, color: stageColorMap[value as keyof typeof stageColorMap] }));
+
+export type Option = ValueLabel;
+
+export const sourceOptions: Option[] = [{ value: '', label: 'Select Source' }, ...LEAD_SOURCE_OPTIONS];
+export const leadStatusOptions: Option[] = LEAD_STATUS_OPTIONS;
+export const ratingOptions: Option[] = [{ value: '', label: 'Select Rating' }, ...RATING_OPTIONS];
+export const opportunityTypeOptions: Option[] = [{ value: '', label: 'Select Type' }, ...OPPORTUNITY_TYPE_OPTIONS];
+export const forecastCategoryOptions: Option[] = [{ value: '', label: 'Select Category' }, ...FORECAST_CATEGORY_OPTIONS];
+export const accountTypeOptions: Option[] = [{ value: '', label: 'Select Type' }, ...ACCOUNT_TYPE_OPTIONS];
+export const accountOwnershipOptions: Option[] = [{ value: '', label: 'Select Ownership' }, ...ACCOUNT_OWNERSHIP_OPTIONS];
+export const industryOptions: Option[] = [{ value: '', label: 'Select Industry' }, ...INDUSTRY_OPTIONS];
+export const countryOptions: Option[] = [{ value: '', label: 'Select Country' }, ...countries.map(([value, label]) => ({ value, label }))];
+
+export const caseStatusOptions: Option[] = [{ value: '', label: 'Select Status' }, ...CASE_STATUS_OPTIONS];
+
+
+// Lead visuals and filters
+import { Star, TrendingUp, CheckCircle2 as LeadCheckCircle2, Clock as LeadClock, XCircle as LeadXCircle, AlertCircle as LeadAlertCircle } from '@lucide/svelte';
+type LeadIconComponent = typeof Star;
+export const leadStatusVisuals: Record = {
+ NEW: { icon: Star, color: 'border-blue-200 bg-blue-100 text-blue-800' },
+ PENDING: { icon: LeadClock, color: 'border-yellow-200 bg-yellow-100 text-yellow-800' },
+ CONTACTED: { icon: LeadCheckCircle2, color: 'border-green-200 bg-green-100 text-green-800' },
+ QUALIFIED: { icon: TrendingUp, color: 'border-indigo-200 bg-indigo-100 text-indigo-800' },
+ UNQUALIFIED: { icon: LeadXCircle, color: 'border-red-200 bg-red-100 text-red-800' },
+ CONVERTED: { icon: LeadCheckCircle2, color: 'border-gray-200 bg-gray-100 text-gray-800' }
+};
+export const leadStatusOptionsWithColor: Array = leadStatusOptions.map((o) => ({
+ ...o,
+ ...leadStatusVisuals[o.value as keyof typeof leadStatusVisuals]
+}));
+
+export const ratingVisuals: Record = {
+ HOT: { color: 'text-red-600', dots: 3 },
+ WARM: { color: 'text-orange-500', dots: 2 },
+ COLD: { color: 'text-blue-500', dots: 1 }
+};
+
+// Lead list filters (consolidated)
+export type LeadSortField = 'createdAt' | 'firstName' | 'lastName' | 'company' | 'rating';
+export const leadStatusFilterOptions: Option[] = [
+ { value: 'ALL', label: 'All Statuses' },
+ ...leadStatusOptions
+];
+export const leadSourceFilterOptions: Option[] = [{ value: 'ALL', label: 'All Sources' }, ...sourceOptions];
+export const leadRatingFilterOptions: Option[] = [{ value: 'ALL', label: 'All Ratings' }, ...ratingOptions];
+export const leadSortOptions: Option[] = [
+ { value: 'createdAt', label: 'Created Date' },
+ { value: 'firstName', label: 'First Name' },
+ { value: 'lastName', label: 'Last Name' },
+ { value: 'company', label: 'Company' },
+ { value: 'rating', label: 'Rating' }
+];
+
+// Task visuals
+import { CheckCircle2, PlayCircle, Pause, Clock, XCircle, AlertCircle } from '@lucide/svelte';
+type IconComponent = typeof CheckCircle2;
+export const taskStatusOptions: Option[] = TASK_STATUS_OPTIONS;
+export const taskPriorityOptions: Option[] = TASK_PRIORITY_OPTIONS;
+const taskStatusVisuals: Record = {
+ COMPLETED: { icon: CheckCircle2, iconColor: 'text-green-500 dark:text-green-400', badgeColor: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' },
+ IN_PROGRESS: { icon: PlayCircle, iconColor: 'text-yellow-500 dark:text-yellow-400', badgeColor: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300' },
+ NOT_STARTED: { icon: Pause, iconColor: 'text-gray-400 dark:text-gray-500', badgeColor: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300' },
+ WAITING_ON_SOMEONE_ELSE: { icon: Clock, iconColor: 'text-purple-500 dark:text-purple-400', badgeColor: 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300' },
+ DEFERRED: { icon: XCircle, iconColor: 'text-pink-500 dark:text-pink-400', badgeColor: 'bg-pink-100 dark:bg-pink-900/30 text-pink-800 dark:text-pink-300' }
+};
+const taskPriorityVisuals: Record = {
+ HIGH: { icon: AlertCircle, iconColor: 'text-red-500 dark:text-red-400', badgeColor: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300' },
+ NORMAL: { icon: Clock, iconColor: 'text-blue-500 dark:text-blue-400', badgeColor: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300' },
+ LOW: { icon: Clock, iconColor: 'text-gray-400 dark:text-gray-500', badgeColor: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300' }
+};
+export const taskStatusVisualMap: (Option & { icon: IconComponent; iconColor: string; badgeColor: string })[] =
+ taskStatusOptions.map((o) => ({ ...o, ...taskStatusVisuals[o.value as keyof typeof taskStatusVisuals] }));
+export const taskPriorityVisualMap: (Option & { icon: IconComponent; iconColor: string; badgeColor: string })[] =
+ taskPriorityOptions.map((o) => ({ ...o, ...taskPriorityVisuals[o.value as keyof typeof taskPriorityVisuals] }));
+
+
+// Case visuals
+export const caseStatusVisuals: Record = {
+ OPEN: { badgeColor: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800' },
+ IN_PROGRESS: { badgeColor: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' },
+ CLOSED: { badgeColor: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800' }
+};
+export const casePriorityVisuals: Record = {
+ HIGH: { badgeColor: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800' },
+ NORMAL: { badgeColor: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' },
+ LOW: { badgeColor: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600' }
+};
+
+
+// Quote/Invoice visuals
+export const quoteStatusVisuals: Record = {
+ DRAFT: { badgeColor: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300' },
+ NEEDS_REVIEW: { badgeColor: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300' },
+ IN_REVIEW: { badgeColor: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300' },
+ APPROVED: { badgeColor: 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300' },
+ REJECTED: { badgeColor: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300' },
+ PRESENTED: { badgeColor: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300' },
+ ACCEPTED: { badgeColor: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' }
+};
diff --git a/apps/web/src/lib/newsletter.ts b/apps/web/src/lib/newsletter.ts
new file mode 100644
index 0000000..c0d86b5
--- /dev/null
+++ b/apps/web/src/lib/newsletter.ts
@@ -0,0 +1,35 @@
+export function generateUnsubscribeLink(token: string, baseUrl = 'https://bottlecrm.io') {
+ return `${baseUrl}/unsubscribe?token=${token}`;
+}
+
+export function isValidEmail(email: string) {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+}
+
+export function generateWelcomeEmail(email: string, unsubscribeLink: string) {
+ return {
+ subject: 'Welcome to BottleCRM Newsletter!',
+ html: `Welcome ${email}! Unsubscribe `,
+ text: `Welcome ${email}! Unsubscribe: ${unsubscribeLink}`
+ };
+}
+
+export function generateNewsletterTemplate(
+ content: { subject: string; headline?: string; articles?: { title: string; excerpt: string; link: string }[]; ctaText?: string; ctaLink?: string },
+ unsubscribeLink: string
+) {
+ const subject = content.subject;
+ const articles = content.articles ?? [];
+ const ctaText = content.ctaText ?? 'Learn More';
+ const ctaLink = content.ctaLink ?? 'https://bottlecrm.io';
+ const articlesHtml = articles
+ .map((article) => ``)
+ .join('');
+ return {
+ subject,
+ html: `${content.headline ?? subject} ${articlesHtml}${ctaText} `
+ };
+}
+
+
diff --git a/apps/web/src/lib/stores/auth.ts b/apps/web/src/lib/stores/auth.ts
new file mode 100644
index 0000000..46e5903
--- /dev/null
+++ b/apps/web/src/lib/stores/auth.ts
@@ -0,0 +1,18 @@
+// src/lib/stores/auth.js
+import { writable } from 'svelte/store';
+
+// Create a reactive state object for authentication
+export const auth = writable({
+ isAuthenticated: false,
+ user: null,
+});
+
+// Helper to get the current session user from event.locals (SvelteKit convention)
+/**
+ * @param {any} event
+ */
+export function getSessionUser(event) {
+ // If you use event.locals.user for authentication, return it
+ // You can adjust this logic if your user is stored differently
+ return event.locals?.user || null;
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/utils/date.ts b/apps/web/src/lib/utils/date.ts
new file mode 100644
index 0000000..59d4840
--- /dev/null
+++ b/apps/web/src/lib/utils/date.ts
@@ -0,0 +1,24 @@
+export function formatDate(
+ date: string | Date | null | undefined,
+ locale: string = 'en-US',
+ options?: Intl.DateTimeFormatOptions,
+ fallback: string = '-'
+): string {
+ if (!date) return fallback;
+ const formatOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric', ...(options ?? {}) };
+ try {
+ return new Date(date).toLocaleDateString(locale, formatOptions);
+ } catch {
+ return fallback;
+ }
+}
+
+export function formatCurrency(value: number | null | undefined, fallback: string = '-') {
+ if (!value) return fallback;
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 0
+ }).format(value);
+}
+
diff --git a/apps/web/src/lib/utils/phone.ts b/apps/web/src/lib/utils/phone.ts
new file mode 100644
index 0000000..a6f3bf7
--- /dev/null
+++ b/apps/web/src/lib/utils/phone.ts
@@ -0,0 +1,33 @@
+import { parsePhoneNumber, parsePhoneNumberWithError, type CountryCode } from 'libphonenumber-js';
+
+export function validatePhoneNumber(phoneNumber: string, defaultCountry: CountryCode = 'US') {
+ if (!phoneNumber || phoneNumber.trim() === '') return { isValid: true } as const;
+ try {
+ const parsed = parsePhoneNumber(phoneNumber, defaultCountry);
+ if (parsed && parsed.isValid()) {
+ return { isValid: true, formatted: parsed.formatInternational() } as const;
+ }
+ return { isValid: false, error: 'Please enter a valid phone number' } as const;
+ } catch {
+ return { isValid: false, error: 'Please enter a valid phone number' } as const;
+ }
+}
+
+export function formatPhoneNumber(phoneNumber: string, defaultCountry: CountryCode = 'US') {
+ if (!phoneNumber) return '';
+ const parsed = parsePhoneNumberWithError(phoneNumber, defaultCountry);
+ return parsed.formatInternational();
+}
+
+export function formatPhoneForStorage(phoneNumber: string, defaultCountry: CountryCode = 'US') {
+ if (!phoneNumber) return '';
+ try {
+ const parsed = parsePhoneNumberWithError(phoneNumber, defaultCountry);
+ return parsed.format('E.164');
+ } catch (e) {
+ console.error(e);
+ return phoneNumber;
+ }
+}
+
+
diff --git a/apps/web/src/routes/(admin)/+layout.svelte b/apps/web/src/routes/(admin)/+layout.svelte
new file mode 100644
index 0000000..b75c16d
--- /dev/null
+++ b/apps/web/src/routes/(admin)/+layout.svelte
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Logout
+
+
+
+
(mobileMenuOpen = !mobileMenuOpen)}
+ >
+ {#if mobileMenuOpen}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+ {#if mobileMenuOpen}
+
+ {/if}
+
+
+
+
+ {@render children()}
+
+
diff --git a/apps/web/src/routes/(admin)/admin/+page.server.ts b/apps/web/src/routes/(admin)/admin/+page.server.ts
new file mode 100644
index 0000000..3d123b2
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/+page.server.ts
@@ -0,0 +1,100 @@
+import type { PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, gte, inArray, eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ try {
+ const db = locals.db;
+ // Get basic counts
+ const [
+ totalUsers,
+ totalOrganizations,
+ totalAccounts,
+ totalContacts,
+ totalLeads,
+ totalOpportunities,
+ totalTasks,
+ totalCases
+ ] = await Promise.all([
+ db.$count(schema.user, eq(schema.user.banned, false) as any),
+ db.$count(schema.organization),
+ db.$count(schema.crmAccount, and(eq(schema.crmAccount.isActive, true), eq(schema.crmAccount.isDeleted, false))),
+ db.$count(schema.contact),
+ db.$count(schema.lead),
+ db.$count(schema.opportunity),
+ db.$count(schema.task),
+ db.$count(schema.caseTable)
+ ]);
+
+ // Get opportunity metrics
+ const [wonOpportunities, openOpportunities] = await Promise.all([
+ db.$count(schema.opportunity, eq(schema.opportunity.stage, 'CLOSED_WON' as any)),
+ db.$count(
+ schema.opportunity,
+ inArray(schema.opportunity.stage as any, ['PROSPECTING', 'QUALIFICATION', 'PROPOSAL', 'NEGOTIATION'])
+ )
+ ]);
+
+ // Get recent activity (last 30 days)
+ const thirtyDaysAgo = new Date();
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+
+ const [
+ newAccountsThisMonth,
+ newLeadsThisMonth,
+ newOpportunitiesThisMonth,
+ tasksCompletedThisMonth
+ ] = await Promise.all([
+ db.$count(
+ schema.crmAccount,
+ and(
+ gte(schema.crmAccount.createdAt, thirtyDaysAgo as any),
+ eq(schema.crmAccount.isActive, true),
+ eq(schema.crmAccount.isDeleted, false)
+ )
+ ),
+ db.$count(schema.lead, gte(schema.lead.createdAt, thirtyDaysAgo as any)),
+ db.$count(schema.opportunity, gte(schema.opportunity.createdAt, thirtyDaysAgo as any)),
+ db.$count(schema.task, and(gte(schema.task.updatedAt, thirtyDaysAgo as any), eq(schema.task.status, 'COMPLETED' as any)))
+ ]);
+
+ return {
+ metrics: {
+ totalUsers,
+ totalOrganizations,
+ totalAccounts,
+ totalContacts,
+ totalLeads,
+ totalOpportunities,
+ totalTasks,
+ totalCases,
+ wonOpportunities,
+ openOpportunities,
+ newAccountsThisMonth,
+ newLeadsThisMonth,
+ newOpportunitiesThisMonth,
+ tasksCompletedThisMonth
+ }
+ };
+ } catch (error) {
+ console.error('Error loading analytics:', error);
+ return {
+ metrics: {
+ totalUsers: 0,
+ totalOrganizations: 0,
+ totalAccounts: 0,
+ totalContacts: 0,
+ totalLeads: 0,
+ totalOpportunities: 0,
+ totalTasks: 0,
+ totalCases: 0,
+ wonOpportunities: 0,
+ openOpportunities: 0,
+ newAccountsThisMonth: 0,
+ newLeadsThisMonth: 0,
+ newOpportunitiesThisMonth: 0,
+ tasksCompletedThisMonth: 0
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/apps/web/src/routes/(admin)/admin/+page.svelte b/apps/web/src/routes/(admin)/admin/+page.svelte
new file mode 100644
index 0000000..50e3195
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/+page.svelte
@@ -0,0 +1,183 @@
+
+
+
+ Analytics - BottleCRM
+
+
+
+
+
+
Analytics Dashboard
+
Overview of your CRM performance and key metrics
+
+
+
+
+
+
+
+
+
Total Users
+
{formatNumber(metrics.totalUsers)}
+
+
+
+
+
+
Active users in the system
+
+
+
+
+
+
+
Organizations
+
{formatNumber(metrics.totalOrganizations)}
+
+
+
+
+
+
Active organizations
+
+
+
+
+
+
+
Accounts
+
{formatNumber(metrics.totalAccounts)}
+
+
+
+
+
+
+ +{formatNumber(metrics.newAccountsThisMonth)} this month
+
+
+
+
+
+
+
+
+
+
+
Contacts
+
{formatNumber(metrics.totalContacts)}
+
+
+
+
+
+
+
+
+
+
+
+
Leads
+
{formatNumber(metrics.totalLeads)}
+
+
+
+
+
+
+ +{formatNumber(metrics.newLeadsThisMonth)} this month
+
+
+
+
+
+
+
+
Opportunities
+
{formatNumber(metrics.totalOpportunities)}
+
+
+
+
+
+
+ {formatNumber(metrics.openOpportunities)} active
+
+
+
+
+
+
+
+
+
+
Tasks
+
+
+
+
+ Total Tasks
+ {formatNumber(metrics.totalTasks)}
+
+
+ Completed This Month
+ {formatNumber(metrics.tasksCompletedThisMonth)}
+
+
+
+
+
+
+
+
+
+ Total Cases
+ {formatNumber(metrics.totalCases)}
+
+
+
+
+
+
+
+
This Month
+
+
+
+
+ New Opportunities
+ {formatNumber(metrics.newOpportunitiesThisMonth)}
+
+
+
+
+
diff --git a/apps/web/src/routes/(admin)/admin/blogs/+page.server.ts b/apps/web/src/routes/(admin)/admin/blogs/+page.server.ts
new file mode 100644
index 0000000..5c7265a
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/blogs/+page.server.ts
@@ -0,0 +1,18 @@
+import type { PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { desc } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const db = locals.db;
+ const blogs = await db
+ .select({
+ id: schema.blogPost.id,
+ title: schema.blogPost.title,
+ createdAt: schema.blogPost.createdAt,
+ updatedAt: schema.blogPost.updatedAt,
+ draft: schema.blogPost.draft
+ })
+ .from(schema.blogPost)
+ .orderBy(desc(schema.blogPost.updatedAt));
+ return { blogs };
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(admin)/admin/blogs/+page.svelte b/apps/web/src/routes/(admin)/admin/blogs/+page.svelte
new file mode 100644
index 0000000..b349cae
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/blogs/+page.svelte
@@ -0,0 +1,53 @@
+
+
+
+
Blogs
+
+
+
+
+ Title
+ Category
+ Draft
+ Created At
+ Updated At
+
+
+
+ {#each data.blogs as blog}
+
+ {blog.title} -
+ Edit
+ N/A
+
+ {#if blog.draft}
+ Draft
+ {:else}
+ Published
+ {/if}
+
+ {new Date(blog.createdAt).toLocaleString()}
+ {new Date(blog.updatedAt).toLocaleString()}
+
+ {/each}
+
+
+
diff --git a/apps/web/src/routes/(admin)/admin/blogs/[id]/+page.server.ts b/apps/web/src/routes/(admin)/admin/blogs/[id]/+page.server.ts
new file mode 100644
index 0000000..0598b5c
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/blogs/[id]/+page.server.ts
@@ -0,0 +1,16 @@
+import type { PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { eq, asc } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const db = locals.db
+ const [blog] = await db.select().from(schema.blogPost).where(eq(schema.blogPost.id, params.id));
+ const contentBlocks = blog
+ ? await db
+ .select()
+ .from(schema.blogContentBlock)
+ .where(eq(schema.blogContentBlock.blogId, params.id))
+ .orderBy(asc(schema.blogContentBlock.displayOrder))
+ : [];
+ return { blog: blog ? { ...blog, contentBlocks } : null };
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(admin)/admin/blogs/[id]/+page.svelte b/apps/web/src/routes/(admin)/admin/blogs/[id]/+page.svelte
new file mode 100644
index 0000000..8cfadf4
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/blogs/[id]/+page.svelte
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Blog Post
+
+
+
+ {data.blog?.title || 'Untitled'}
+
+
+
+
+
+
+
+
+ {#each data.blog?.contentBlocks || [] as block}
+
+ {#if block.type == 'MARKDOWN'}
+
+ {@html marked(block.content)}
+
+ {/if}
+
+ {#if block.type == 'CODE'}
+
+
+
+ {/if}
+
+ {/each}
+
+
+
+
+
+
+
+
+ Share this post
+
+
+
+
+
+
+
+
+
+
+
+
Blog Details
+
+
+ Type:
+ Blog Post
+
+
+ Status:
+ Published
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(admin)/admin/blogs/[id]/edit/+page.server.ts b/apps/web/src/routes/(admin)/admin/blogs/[id]/edit/+page.server.ts
new file mode 100644
index 0000000..28606d7
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/blogs/[id]/edit/+page.server.ts
@@ -0,0 +1,150 @@
+import { schema } from '@opensource-startup-crm/database';
+import { asc, eq } from 'drizzle-orm';
+import type { Actions, PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const db = locals.db
+ const rows = await db
+ .select({
+ id: schema.blogPost.id,
+ title: schema.blogPost.title,
+ slug: schema.blogPost.slug,
+ excerpt: schema.blogPost.excerpt,
+ draft: schema.blogPost.draft,
+ seoDescription: schema.blogPost.seoDescription,
+ seoTitle: schema.blogPost.seoTitle,
+ createdAt: schema.blogPost.createdAt,
+ updatedAt: schema.blogPost.updatedAt,
+ blockId: schema.blogContentBlock.id,
+ blockType: schema.blogContentBlock.type,
+ blockContent: schema.blogContentBlock.content,
+ blockDisplayOrder: schema.blogContentBlock.displayOrder,
+ blockDraft: schema.blogContentBlock.draft
+ })
+ .from(schema.blogPost)
+ .leftJoin(
+ schema.blogContentBlock,
+ eq(schema.blogContentBlock.blogId, schema.blogPost.id)
+ )
+ .where(eq(schema.blogPost.id, params.id))
+ .orderBy(asc(schema.blogContentBlock.displayOrder));
+
+ if (rows.length === 0) {
+ return { blog: null };
+ }
+
+ const base = rows[0];
+ const blog = {
+ id: base.id,
+ title: base.title,
+ slug: base.slug,
+ excerpt: base.excerpt,
+ draft: base.draft,
+ seoDescription: base.seoDescription,
+ seoTitle: base.seoTitle,
+ createdAt: base.createdAt,
+ updatedAt: base.updatedAt,
+ contentBlocks: rows
+ .filter((r) => r.blockId !== null)
+ .map((r) => ({
+ id: r.blockId!,
+ type: r.blockType,
+ content: r.blockContent,
+ displayOrder: r.blockDisplayOrder,
+ draft: r.blockDraft
+ }))
+ };
+
+ return { blog };
+};
+
+export const actions: Actions = {
+
+ 'add-block': async ({ request, params, locals }) => {
+ const form = await request.formData();
+ const type = form.get('type')?.toString() as typeof schema.blogContentBlock.$inferInsert['type'];
+ const content = form.get('content')?.toString();
+ const displayOrder = form.get('displayOrder')?.toString();
+
+ if (!type || !content || !displayOrder) {
+ return { success: false, error: 'Missing required fields' };
+ }
+
+ const db = locals.db
+
+ await db.insert(schema.blogContentBlock).values({
+ blogId: params.id,
+ type,
+ content,
+ displayOrder: Number(displayOrder),
+ draft: form.get('draft') === 'on'
+ });
+ return { success: true };
+ },
+ 'edit-block': async ({ request, locals }) => {
+ const form = await request.formData();
+ const id = form.get('id')?.toString();
+ const type = form.get('type')?.toString() as typeof schema.blogContentBlock.$inferInsert['type'];
+ const content = form.get('content')?.toString();
+
+ if (!id || !type || !content) {
+ return { success: false, error: 'Missing required fields' };
+ }
+
+ const db = locals.db
+
+ await db
+ .update(schema.blogContentBlock)
+ .set({ type, content, draft: form.get('draft') === 'on' })
+ .where(eq(schema.blogContentBlock.id, id));
+ return { success: true };
+ },
+ 'delete-block': async ({ request, locals }) => {
+ const form = await request.formData();
+ const id = form.get('id')?.toString();
+
+ if (!id) {
+ return { success: false, error: 'Missing block ID' };
+ }
+ const db = locals.db
+
+ await db.delete(schema.blogContentBlock).where(eq(schema.blogContentBlock.id, id));
+ return { success: true };
+ },
+ 'update-blog': async ({ request, params, locals }) => {
+ const db = locals.db
+ const form = await request.formData();
+ const data = {
+ title: form.get('title')?.toString() || '',
+ seoTitle: form.get('seoTitle')?.toString() || '',
+ seoDescription: form.get('seoDescription')?.toString() || '',
+ excerpt: form.get('excerpt')?.toString() || '',
+ slug: form.get('slug')?.toString() || '',
+ draft: form.get('draft') === 'on'
+ };
+ await db
+ .update(schema.blogPost)
+ .set(data)
+ .where(eq(schema.blogPost.id, params.id));
+ return { success: true };
+ }
+ ,
+ 'reorder-blocks': async ({ request, params, locals }) => {
+ const db = locals.db
+ const form = await request.formData();
+ const orderStr = form.get('order')?.toString();
+
+ if (!orderStr) {
+ return { success: false, error: 'Missing order data' };
+ }
+
+ const order = JSON.parse(orderStr);
+ for (const { id, displayOrder } of order as Array<{ id: string; displayOrder: number }>) {
+ await db
+ .update(schema.blogContentBlock)
+ .set({ displayOrder })
+ .where(eq(schema.blogContentBlock.id, id));
+ }
+ return { success: true };
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(admin)/admin/blogs/[id]/edit/+page.svelte b/apps/web/src/routes/(admin)/admin/blogs/[id]/edit/+page.svelte
new file mode 100644
index 0000000..2dc8306
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/blogs/[id]/edit/+page.svelte
@@ -0,0 +1,313 @@
+
+
+
+
Edit Blog
+
+
+
Content Blocks
+
+ {#each contentBlocks as block (block.id)}
+
+ {#if editingBlockId === block.id}
+
+
+
+ Type:
+
+ Markdown
+ Code
+
+
+
+ Content:
+
+
+
+ Draft
+
+
+
+
+ Save
+ cancelEditBlock(block)}
+ class="rounded bg-gray-300 px-3 py-1 hover:bg-gray-400">Cancel
+
+
+ {:else}
+
+
+ {block.type}
+ Order: {block.displayOrder}
+ {#if block.draft}
+ Draft
+ {/if}
+
+
+ startEditBlock(block)}
+ class="rounded bg-blue-500 px-3 py-1 text-white hover:bg-blue-600">Edit
+
+
+ {
+ e.preventDefault();
+ if (confirm('Delete this block?')) {
+ const target = e.target as HTMLElement;
+ const form = target.closest('form');
+ if (form) form.requestSubmit();
+ }
+ }}>Delete
+
+
+
+ {block.content}
+ {/if}
+
+ {/each}
+
+
+
Add Content Block
+
+
+ Type:
+
+ Markdown
+ Code
+
+
+
+ Content:
+
+
+
+ Display Order:
+
+
+
+ Draft
+
+
+
+ Add Block
+
+
+ {#if message}
+
{message}
+ {/if}
+
diff --git a/apps/web/src/routes/(admin)/admin/blogs/new/+page.server.ts b/apps/web/src/routes/(admin)/admin/blogs/new/+page.server.ts
new file mode 100644
index 0000000..c47353d
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/blogs/new/+page.server.ts
@@ -0,0 +1,33 @@
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+
+export const load: PageServerLoad = async () => {
+ return {};
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ const db = locals.db;
+ const data = await request.formData();
+ const title = data.get('title')?.toString();
+ const excerpt = data.get('excerpt')?.toString();
+ const slug = data.get('slug')?.toString();
+
+ if (!title || !excerpt || !slug) {
+ return { error: 'All fields are required' } as any;
+ }
+ try {
+ await db.insert(schema.blogPost).values({
+ title,
+ excerpt,
+ slug,
+ seoTitle: '',
+ seoDescription: '',
+ draft: true
+ });
+ return { success: true } as any;
+ } catch (e: any) {
+ return { error: e?.message || 'Error creating blog' } as any;
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(admin)/admin/blogs/new/+page.svelte b/apps/web/src/routes/(admin)/admin/blogs/new/+page.svelte
new file mode 100644
index 0000000..882bc73
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/blogs/new/+page.svelte
@@ -0,0 +1,72 @@
+
+
+
+
Create New Blog
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ Blog post created successfully!
+
+ {/if}
+
+
+
+ Title
+
+
+
+ Excerpt
+
+
+
+ Slug
+
+
+
+ Create Blog
+
+
diff --git a/apps/web/src/routes/(admin)/admin/contacts/+page.server.ts b/apps/web/src/routes/(admin)/admin/contacts/+page.server.ts
new file mode 100644
index 0000000..6cd6e18
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/contacts/+page.server.ts
@@ -0,0 +1,8 @@
+import type { PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const db = locals.db
+ const contacts = await db.select().from(schema.contactSubmission);
+ return { contacts };
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(admin)/admin/contacts/+page.svelte b/apps/web/src/routes/(admin)/admin/contacts/+page.svelte
new file mode 100644
index 0000000..f8269f1
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/contacts/+page.svelte
@@ -0,0 +1,112 @@
+
+
+
+
Contact Submissions
+
+
+{#if data.contacts && data.contacts.length > 0}
+
+
+
+
+
+ Contact Info
+
+
+ Reason
+
+
+ Message
+
+
+ Submitted
+
+
+ Tracking
+
+
+
+
+ {#each data.contacts as contact}
+
+
+
+
{contact.name}
+
{contact.email}
+
+
+
+
+ {contact.reason}
+
+
+
+
+ {contact.message}
+
+
+
+ {formatDate(contact.createdAt)}
+
+
+
+ {#if contact.ipAddress}
+
IP: {contact.ipAddress}
+ {/if}
+ {#if contact.referrer}
+
+ Ref: {contact.referrer}
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+
+
+ Total submissions: {data.contacts.length}
+
+{:else}
+
+
+
No contact submissions
+
No contact form requests have been submitted yet.
+
+{/if}
diff --git a/apps/web/src/routes/(admin)/admin/newsletter/+page.server.ts b/apps/web/src/routes/(admin)/admin/newsletter/+page.server.ts
new file mode 100644
index 0000000..023d9af
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/newsletter/+page.server.ts
@@ -0,0 +1,45 @@
+import { schema } from '@opensource-startup-crm/database';
+import { eq, desc, count } from 'drizzle-orm';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ try {
+ const db = locals.db
+ const subscribers = await db
+ .select({
+ id: schema.newsletterSubscriber.id,
+ email: schema.newsletterSubscriber.email,
+ isActive: schema.newsletterSubscriber.isActive,
+ isConfirmed: schema.newsletterSubscriber.isConfirmed,
+ subscribedAt: schema.newsletterSubscriber.subscribedAt,
+ unsubscribedAt: schema.newsletterSubscriber.unsubscribedAt,
+ confirmedAt: schema.newsletterSubscriber.confirmedAt,
+ ipAddress: schema.newsletterSubscriber.ipAddress
+ })
+ .from(schema.newsletterSubscriber)
+ .orderBy(desc(schema.newsletterSubscriber.subscribedAt));
+
+ const [{ count: activeCount }] = await db
+ .select({ count: count() })
+ .from(schema.newsletterSubscriber)
+ .where(eq(schema.newsletterSubscriber.isActive, true));
+
+ const [{ count: totalSubscribers }] = await db
+ .select({ count: count() })
+ .from(schema.newsletterSubscriber);
+
+ return {
+ subscribers,
+ activeCount: Number(activeCount ?? 0),
+ totalCount: totalSubscribers
+ };
+ } catch (error) {
+ console.error('Failed to load newsletter subscribers:', error);
+ return {
+ subscribers: [],
+ activeCount: 0,
+ totalCount: 0,
+ error: 'Failed to load subscribers'
+ };
+ }
+}
diff --git a/apps/web/src/routes/(admin)/admin/newsletter/+page.svelte b/apps/web/src/routes/(admin)/admin/newsletter/+page.svelte
new file mode 100644
index 0000000..8df5e35
--- /dev/null
+++ b/apps/web/src/routes/(admin)/admin/newsletter/+page.svelte
@@ -0,0 +1,171 @@
+
+
+
+ Newsletter Subscribers - Admin
+
+
+
+
+
+
+
Newsletter Management
+
Manage and view newsletter subscribers
+
+
+
+
+
+
+
+
+
+
+
Total Subscribers
+
{data.totalCount}
+
+
+
+
+
+
+
+
+
+
+
Active Subscribers
+
{data.activeCount}
+
+
+
+
+
+
+
+
+
+
+
Active Rate
+
+ {data.totalCount > 0 ? Math.round((data.activeCount / data.totalCount) * 100) : 0}%
+
+
+
+
+
+
+ {#if data.error}
+
+ {/if}
+
+
+
+
+
+
+ Newsletter Subscribers
+
+
+
+ {#if data.subscribers.length === 0}
+
+
+
No subscribers yet
+
Get started by promoting your newsletter.
+
+ {:else}
+
+
+
+
+
+ Email
+
+
+ Status
+
+
+ Subscribed
+
+
+ Confirmed
+
+
+ IP Address
+
+
+
+
+ {#each data.subscribers as subscriber}
+
+
+ {subscriber.email}
+
+
+
+ {getStatusText(subscriber.isActive, subscriber.isConfirmed)}
+
+
+
+
+
+ {formatDate(subscriber.subscribedAt, 'en-US', { hour: '2-digit', minute: '2-digit' }, '-')}
+
+
+
+ {#if subscriber.confirmedAt}
+
+
+ {formatDate(subscriber.confirmedAt, 'en-US', { hour: '2-digit', minute: '2-digit' }, '-')}
+
+ {:else}
+ Not confirmed
+ {/if}
+
+
+ {subscriber.ipAddress || 'N/A'}
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
diff --git a/apps/web/src/routes/(app)/+layout.server.ts b/apps/web/src/routes/(app)/+layout.server.ts
new file mode 100644
index 0000000..e085fb6
--- /dev/null
+++ b/apps/web/src/routes/(app)/+layout.server.ts
@@ -0,0 +1,11 @@
+
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ locals }) => {
+ return {
+ user: locals.user,
+ org_name: locals.org?.name || 'BottleCRM'
+ };
+};
+
+export const ssr = false;
diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte
new file mode 100644
index 0000000..d9a9785
--- /dev/null
+++ b/apps/web/src/routes/(app)/+layout.svelte
@@ -0,0 +1,33 @@
+
+
+
+ (drawerHidden = !drawerHidden)}
+ class="fixed left-4 top-4 z-50 inline-flex items-center rounded-lg border border-gray-200 bg-white p-2 text-gray-500 shadow-md transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 lg:hidden dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:focus:ring-gray-600 {!drawerHidden
+ ? ''
+ : 'hidden'}"
+ aria-label="Open sidebar menu"
+>
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
diff --git a/apps/web/src/routes/(app)/Sidebar.svelte b/apps/web/src/routes/(app)/Sidebar.svelte
new file mode 100644
index 0000000..4be1fbc
--- /dev/null
+++ b/apps/web/src/routes/(app)/Sidebar.svelte
@@ -0,0 +1,338 @@
+
+
+
+
+
+
+
+
+
+
+ {#each navigationItems as item}
+ {#if item.type === 'link'}
+
+
+ {item.label}
+
+ {:else if item.type === 'dropdown'}
+
+
toggleDropdown(item.key!)}
+ >
+
+
+ {item.label}
+
+
+
+
+ {#if item.key && openDropdowns[item.key] && item.children}
+
+ {/if}
+
+ {/if}
+ {/each}
+
+
+
+
+
+
+
+
+
{user.name}
+
{user.email}
+
+
+
+
+
+
+ {#if isDark}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+ {#if userDropdownOpen}
+
{
+ if (e.key === 'Enter' || e.key === ' ') handleDropdownClick(e);
+ }}
+ tabindex="0"
+ role="menu"
+ >
+ handleSettingsLinkClick(e, '/app/profile')}
+ class="flex w-full items-center gap-3 rounded px-3 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-white dark:text-gray-300 dark:hover:bg-gray-700"
+ >
+
+ Profile
+
+ handleSettingsLinkClick(e, '/app/users')}
+ class="flex w-full items-center gap-3 rounded px-3 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-white dark:text-gray-300 dark:hover:bg-gray-700"
+ >
+
+ Users
+
+ handleSettingsLinkClick(e, '/org')}
+ class="flex w-full items-center gap-3 rounded px-3 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-white dark:text-gray-300 dark:hover:bg-gray-700"
+ >
+
+ Organizations
+
+
+ handleSettingsLinkClick(e, '/logout')}
+ class="flex w-full items-center gap-3 rounded px-3 py-2 text-left text-sm text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
+ >
+
+ Sign out
+
+
+ {/if}
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/+page.server.ts b/apps/web/src/routes/(app)/app/+page.server.ts
new file mode 100644
index 0000000..cceaafe
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/+page.server.ts
@@ -0,0 +1,123 @@
+import type { PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, count, desc, eq, gte, not } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const userId = locals.user?.id;
+ const organizationId = locals.org?.id;
+ const db = locals.db
+
+ if (!userId || !organizationId) {
+ return {
+ error: 'User not authenticated',
+ metrics: {
+ totalLeads: 0,
+ totalOpportunities: 0,
+ totalAccounts: 0,
+ totalContacts: 0,
+ pendingTasks: 0,
+ opportunityRevenue: 0
+ },
+ recentData: {
+ leads: [],
+ opportunities: [],
+ tasks: [],
+ activities: []
+ }
+ }
+ }
+
+ try {
+
+ const counts = await db.select({
+ totalLeads: count(),
+ totalOpportunities: db.$count(schema.opportunity, and(eq(schema.opportunity.organizationId, organizationId), not(eq(schema.opportunity.stage, 'CLOSED_WON')))),
+ totalAccounts: db.$count(schema.crmAccount, and(eq(schema.crmAccount.organizationId, organizationId), eq(schema.crmAccount.isActive, true))),
+ totalContacts: db.$count(schema.contact, eq(schema.contact.organizationId, organizationId)),
+ pendingTasks: db.$count(schema.task, and(eq(schema.task.organizationId, organizationId), not(eq(schema.task.status, 'COMPLETED')), eq(schema.task.ownerId, userId)))
+ }).from(schema.lead).where(and(eq(schema.lead.organizationId, organizationId), eq(schema.lead.isConverted, false))).then(res => res[0])
+
+ const [totalLeads, totalOpportunities, totalAccounts, totalContacts, pendingTasks] = [counts.totalLeads, counts.totalOpportunities, counts.totalAccounts, counts.totalContacts, counts.pendingTasks];
+
+ const recentLeads = await db
+ .select({ id: schema.lead.id, firstName: schema.lead.firstName, lastName: schema.lead.lastName, company: schema.lead.company, status: schema.lead.status, createdAt: schema.lead.createdAt })
+ .from(schema.lead)
+ .where(eq(schema.lead.organizationId, organizationId))
+ .leftJoin(schema.user, eq(schema.lead.ownerId, schema.user.id))
+ .orderBy(desc(schema.lead.createdAt))
+ .limit(5);
+
+ const recentOpportunities = await db
+ .select({
+ id: schema.opportunity.id,
+ name: schema.opportunity.name,
+ createdAt: schema.opportunity.createdAt,
+ accountName: schema.crmAccount.name,
+ amount: schema.opportunity.amount
+ })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .where(eq(schema.opportunity.organizationId, organizationId))
+ .orderBy(desc(schema.opportunity.createdAt))
+ .limit(5);
+
+ const now = new Date();
+ const upcomingTasks = await db
+ .select({ id: schema.task.id, subject: schema.task.subject, status: schema.task.status, priority: schema.task.priority, dueDate: schema.task.dueDate })
+ .from(schema.task)
+ .where(and(eq(schema.task.organizationId, organizationId), eq(schema.task.ownerId, userId), not(eq(schema.task.status, 'COMPLETED')), gte(schema.task.dueDate, now)))
+ .orderBy(schema.task.dueDate)
+ .limit(5);
+
+ const recentActivities = await db
+ .select({ id: schema.auditLog.id, timestamp: schema.auditLog.timestamp, action: schema.auditLog.action, description: schema.auditLog.description, userName: schema.user.name, entityType: schema.auditLog.entityType })
+ .from(schema.auditLog)
+ .leftJoin(schema.user, eq(schema.user.id, schema.auditLog.userId))
+ .where(eq(schema.auditLog.organizationId, organizationId))
+ .orderBy(desc(schema.auditLog.timestamp))
+ .limit(10);
+
+ // Compute opportunity revenue
+ const revenueRows = await db
+ .select({ amount: schema.opportunity.amount })
+ .from(schema.opportunity)
+ .where(eq(schema.opportunity.organizationId, organizationId));
+ const opportunityRevenue = revenueRows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0);
+
+ return {
+ metrics: {
+ totalLeads,
+ totalOpportunities,
+ totalAccounts,
+ totalContacts,
+ pendingTasks,
+ opportunityRevenue
+ },
+ recentData: {
+ leads: recentLeads,
+ opportunities: recentOpportunities,
+ tasks: upcomingTasks,
+ activities: recentActivities
+ }
+ };
+ } catch (error) {
+ console.error('Dashboard load error:', error);
+ return {
+ error: 'Failed to load dashboard data',
+ metrics: {
+ totalLeads: 0,
+ totalOpportunities: 0,
+ totalAccounts: 0,
+ totalContacts: 0,
+ pendingTasks: 0,
+ opportunityRevenue: 0
+ },
+ recentData: {
+ leads: [],
+ opportunities: [],
+ tasks: [],
+ activities: []
+ }
+ }
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/+page.svelte b/apps/web/src/routes/(app)/app/+page.svelte
new file mode 100644
index 0000000..111635f
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/+page.svelte
@@ -0,0 +1,330 @@
+
+
+
+ Dashboard - BottleCRM
+
+
+
+
+
+
+
Dashboard
+
+ Welcome back! Here's what's happening with your CRM.
+
+
+
+
+ {#if data?.error}
+
+ {:else}
+
+
+
+
+
+
Active Leads
+
{metrics.totalLeads}
+
+
+
+
+
+
+
+
+
+
+
Opportunities
+
+ {metrics.totalOpportunities}
+
+
+
+
+
+
+
+
+
+
+
+
Accounts
+
{metrics.totalAccounts}
+
+
+
+
+
+
+
+
+
+
+
Contacts
+
{metrics.totalContacts}
+
+
+
+
+
+
+
+
+
Pending Tasks
+
{metrics.pendingTasks}
+
+
+
+
+
+
+
+
+
+
+
Pipeline Value
+
+ {formatCurrency(metrics.opportunityRevenue)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if recentData.leads?.length > 0}
+
+ {#each recentData.leads as lead}
+
+
+
+ {lead.firstName}
+ {lead.lastName}
+
+
+ {lead.company || 'No company'}
+
+
+
+
+ {toLabel(lead.status, LEAD_STATUS_OPTIONS, '-')}
+
+
+ {formatDate(lead.createdAt, 'en-US', undefined, '-')}
+
+
+
+ {/each}
+
+ {:else}
+
No recent leads
+ {/if}
+
+
+
+
+
+
+
+
+ Recent Opportunities
+
+
+
+
+
+ {#if recentData.opportunities?.length > 0}
+
+ {#each recentData.opportunities as opportunity}
+
+
+
{opportunity.name}
+
+ {opportunity.accountName || 'No account'}
+
+
+
+ {#if opportunity.amount}
+
+ {formatCurrency(opportunity.amount)}
+
+ {/if}
+
+ {formatDate(opportunity.createdAt, 'en-US', undefined, '-')}
+
+
+
+ {/each}
+
+ {:else}
+
No recent opportunities
+ {/if}
+
+
+
+
+
+
+
+ {#if recentData.tasks?.length > 0}
+
+ {#each recentData.tasks as task}
+ {@const priorityItem = taskPriorityVisualMap.find((p) => p.value === task.priority)}
+
+
+
{task.subject}
+
+ {toLabel(task.status, TASK_STATUS_OPTIONS, 'N/A')}
+
+
+
+
+ {toLabel(task.priority, TASK_PRIORITY_OPTIONS, 'Normal')}
+
+ {#if task.dueDate}
+
+ {formatDate(task.dueDate, 'en-US', undefined, '-')}
+
+ {/if}
+
+
+ {/each}
+
+ {:else}
+
No upcoming tasks
+ {/if}
+
+
+
+
+
+
+
+
+ {#if recentData.activities?.length > 0}
+
+ {#each recentData.activities as activity}
+
+
+
+
+ {activity.userName || 'Someone'}
+ {activity.description ||
+ `performed ${activity.action.toLowerCase()} on ${activity.entityType}`}
+
+
+ {formatDate(activity.timestamp, 'en-US', undefined, '-')}
+
+
+
+ {/each}
+
+ {:else}
+
No recent activities
+ {/if}
+
+
+ {/if}
+
diff --git a/apps/web/src/routes/(app)/app/accounts/+page.server.ts b/apps/web/src/routes/(app)/app/accounts/+page.server.ts
new file mode 100644
index 0000000..037538e
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/accounts/+page.server.ts
@@ -0,0 +1,111 @@
+import { error } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { and, asc, count, desc, eq, inArray } from 'drizzle-orm';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const org = locals.org!;
+ const db = locals.db;
+
+ const page = parseInt(url.searchParams.get('page') || '1');
+ const limit = parseInt(url.searchParams.get('limit') || '10');
+ const sort = url.searchParams.get('sort') || 'name';
+ const order = url.searchParams.get('order') || 'asc';
+ const skip = (page - 1) * limit;
+
+ try {
+ const status = url.searchParams.get('status');
+
+ const filters = [eq(schema.crmAccount.organizationId, org.id)];
+ if (status === 'open') filters.push(eq(schema.crmAccount.isActive, true));
+ if (status === 'closed') filters.push(eq(schema.crmAccount.isActive, false));
+
+ const accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name, isActive: schema.crmAccount.isActive, ownerId: schema.crmAccount.ownerId, closedAt: schema.crmAccount.closedAt, industry: schema.crmAccount.industry, type: schema.crmAccount.type, website: schema.crmAccount.website, phone: schema.crmAccount.phone, street: schema.crmAccount.street, city: schema.crmAccount.city, state: schema.crmAccount.state, postalCode: schema.crmAccount.postalCode, country: schema.crmAccount.country, createdAt: schema.crmAccount.createdAt, annualRevenue: schema.crmAccount.annualRevenue })
+ .from(schema.crmAccount)
+ .where(and(...filters))
+ .orderBy((order === 'asc' ? asc : desc)(schema.crmAccount.name))
+ .limit(limit)
+ .offset(skip);
+
+ const accountIds = accounts.map((a) => a.id);
+
+ const [owners, opportunities, relatedContacts, tasks] = await Promise.all([
+ db
+ .select({ id: schema.user.id, name: schema.user.name, email: schema.user.email, image: schema.user.image, accountId: schema.crmAccount.id })
+ .from(schema.crmAccount)
+ .leftJoin(schema.user, eq(schema.user.id, schema.crmAccount.ownerId))
+ .where(inArray(schema.crmAccount.id, accountIds)),
+ db
+ .select({ id: schema.opportunity.id, stage: schema.opportunity.stage, amount: schema.opportunity.amount, accountId: schema.opportunity.accountId })
+ .from(schema.opportunity)
+ .where(inArray(schema.opportunity.accountId, accountIds)),
+ db
+ .select({ accountId: schema.accountContactRelationship.accountId, contactId: schema.contact.id, firstName: schema.contact.firstName, lastName: schema.contact.lastName, isPrimary: schema.accountContactRelationship.isPrimary, role: schema.accountContactRelationship.role })
+ .from(schema.accountContactRelationship)
+ .innerJoin(schema.contact, eq(schema.contact.id, schema.accountContactRelationship.contactId))
+ .where(inArray(schema.accountContactRelationship.accountId, accountIds)),
+ db
+ .select({ id: schema.task.id, status: schema.task.status, accountId: schema.task.accountId })
+ .from(schema.task)
+ .where(inArray(schema.task.accountId, accountIds))
+ ]);
+
+ const ownerByAccount = new Map();
+ for (const row of owners) {
+ ownerByAccount.set(row.accountId, { id: row.id ?? null, name: row.name ?? null, email: row.email ?? null, image: row.image ?? null });
+ }
+
+ const oppByAccount = new Map();
+ for (const opp of opportunities) {
+ const list = oppByAccount.get(opp.accountId) || [];
+ list.push(opp);
+ oppByAccount.set(opp.accountId, list);
+ }
+
+ const contactsByAccount = new Map();
+ for (const rc of relatedContacts) {
+ const list = contactsByAccount.get(rc.accountId) || [];
+ list.push({ id: rc.contactId, firstName: rc.firstName, lastName: rc.lastName });
+ contactsByAccount.set(rc.accountId, list);
+ }
+
+ const tasksByAccount = new Map();
+ for (const t of tasks) {
+ if (!t.accountId) continue;
+ const list = tasksByAccount.get(t.accountId) || [];
+ list.push({ id: t.id, status: t.status });
+ tasksByAccount.set(t.accountId, list);
+ }
+
+ const [{ value: total = 0 } = { value: 0 }] = await db
+ .select({ value: count() })
+ .from(schema.crmAccount)
+ .where(and(...filters));
+
+ const mapped = accounts.map((account) => {
+ const accountOpps = oppByAccount.get(account.id) || [];
+ const accountContacts = contactsByAccount.get(account.id) || [];
+ const accountTasks = tasksByAccount.get(account.id) || [];
+ const openOppCount = accountOpps.filter((o) => !['CLOSED_WON', 'CLOSED_LOST'].includes(o.stage)).length;
+ const totalOppValue = accountOpps.reduce((sum, o) => sum + (Number(o.amount) || 0), 0);
+ const topContacts = accountContacts.slice(0, 3).map((c) => ({ id: c.id, name: `${c.firstName ?? ''} ${c.lastName ?? ''}`.trim() }));
+
+ return {
+ ...account,
+ owner: ownerByAccount.get(account.id) || null,
+ opportunityCount: accountOpps.length,
+ contactCount: accountContacts.length,
+ taskCount: accountTasks.length,
+ openOpportunities: openOppCount,
+ totalOpportunityValue: totalOppValue,
+ topContacts
+ };
+ });
+
+ return { accounts: mapped, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } };
+ } catch (err) {
+ console.error('Error fetching accounts:', err);
+ throw error(500, 'Failed to fetch accounts');
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/accounts/+page.svelte b/apps/web/src/routes/(app)/app/accounts/+page.svelte
new file mode 100644
index 0000000..f1ae63e
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/accounts/+page.svelte
@@ -0,0 +1,520 @@
+
+
+
+
+
+
+
+
Accounts
+
+ Manage all your customer accounts and business relationships
+
+
+
+
+
+
+
+ Search accounts
+
+ debounceSearch((e.target as HTMLInputElement).value)}
+ />
+
+
+
+
+ Filter accounts by status
+
+
+ All Status
+ Open
+ Closed
+
+
+
+
+
+
+ New Account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Total Accounts
+
{pagination.total}
+
+
+
+
+
+
+
+
+
+
+
Active
+
+ {accounts.filter((a) => a.isActive).length}
+
+
+
+
+
+
+
+
+
+
+
+
Total Contacts
+
+ {accounts.reduce((sum, a) => sum + (a.contactCount || 0), 0)}
+
+
+
+
+
+
+
+
+
+
+
+
Opportunities
+
+ {accounts.reduce((sum, a) => sum + (a.opportunityCount || 0), 0)}
+
+
+
+
+
+
+
+
+
+
+
+
+ toggleSort('name')}
+ >
+
+
+ Account Name
+ {#if sortField === 'name'}
+ {#if sortOrder === 'asc'}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+
+ Industry
+ Type
+ Contact Info
+ Revenue
+ Relations
+ Created
+ Actions
+
+
+
+ {#if isLoading}
+
+
+
+
+
Loading accounts...
+
+
+
+ {:else if accounts.length === 0}
+
+
+
+
+
+
+ No accounts found
+
+
+ Get started by creating your first account
+
+
+
+
+ Create Account
+
+
+
+
+ {:else}
+ {#each accounts as account (account.id)}
+
+
+
+
+
+ {account.name?.[0]?.toUpperCase() || 'A'}
+
+
+
+
+
+
+ {toLabel(account.industry, INDUSTRY_OPTIONS, '-')}
+
+
+
+ {toLabel(account.type, ACCOUNT_TYPE_OPTIONS, 'Customer')}
+
+
+
+
+ {#if account.website}
+
+ {/if}
+ {#if account.phone}
+
+ {/if}
+ {#if account.city || account.state}
+
+
+ {[account.city, account.state].filter(Boolean).join(', ')}
+
+ {/if}
+
+
+
+
+ {#if account.annualRevenue}
+
{formatCurrency(account.annualRevenue, '-')}
+
Annual Revenue
+ {:else}
+
-
+ {/if}
+
+
+
+
+
+
+ {account.contactCount || 0}
+
+
+
+ {account.opportunityCount || 0}
+
+
+
+
+
+
+ {formatDate(account.createdAt)}
+
+
+
+
+
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+ {#if pagination.totalPages > 1}
+
+
+ Showing {(pagination.page - 1) * pagination.limit + 1} to
+ {Math.min(pagination.page * pagination.limit, pagination.total)}
+ of
+ {pagination.total} accounts
+
+
+ changePage(1)}
+ class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ disabled={pagination.page === 1}
+ >
+ First
+
+ changePage(pagination.page - 1)}
+ class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ disabled={pagination.page === 1}
+ >
+ Previous
+
+
+ {pagination.page} of {pagination.totalPages}
+
+ changePage(pagination.page + 1)}
+ class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ disabled={pagination.page === pagination.totalPages}
+ >
+ Next
+
+ changePage(pagination.totalPages)}
+ class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ disabled={pagination.page === pagination.totalPages}
+ >
+ Last
+
+
+
+ {/if}
+
diff --git a/apps/web/src/routes/(app)/app/accounts/[accountId]/+page.server.ts b/apps/web/src/routes/(app)/app/accounts/[accountId]/+page.server.ts
new file mode 100644
index 0000000..925929b
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/accounts/[accountId]/+page.server.ts
@@ -0,0 +1,210 @@
+import { error, fail } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { and, desc, eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ params, url, locals }) => {
+ const org = locals.org!;
+ const db = locals.db;
+ try {
+ const accountId = params.accountId;
+
+ const [account] = await db
+ .select()
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), eq(schema.crmAccount.organizationId, org.id)));
+ if (!account) throw error(404, 'Account not found');
+
+ const contactRelationships = await db
+ .select({
+ isPrimary: schema.accountContactRelationship.isPrimary,
+ role: schema.accountContactRelationship.role,
+ contact: schema.contact
+ })
+ .from(schema.accountContactRelationship)
+ .innerJoin(schema.contact, eq(schema.contact.id, schema.accountContactRelationship.contactId))
+ .where(eq(schema.accountContactRelationship.accountId, accountId));
+
+ const contacts = contactRelationships.map((rel) => ({ ...rel.contact, isPrimary: rel.isPrimary, role: rel.role }));
+
+ const opportunities = await db
+ .select()
+ .from(schema.opportunity)
+ .where(eq(schema.opportunity.accountId, accountId));
+
+ const comments = await db
+ .select({
+ id: schema.comment.id,
+ body: schema.comment.body,
+ createdAt: schema.comment.createdAt,
+ authorId: schema.user.id,
+ authorName: schema.user.name,
+ isPrivate: schema.comment.isPrivate
+ })
+ .from(schema.comment)
+ .leftJoin(schema.user, eq(schema.user.id, schema.comment.authorId))
+ .where(eq(schema.comment.accountId, accountId))
+ .orderBy(desc(schema.comment.createdAt));
+
+ if (url.searchParams.get('commentsOnly') === '1') {
+ return new Response(JSON.stringify({ comments }), { headers: { 'Content-Type': 'application/json' } });
+ }
+
+ const quotes = await db.select().from(schema.quote).where(eq(schema.quote.accountId, accountId));
+
+ const tasks = await db
+ .select({ id: schema.task.id, status: schema.task.status, ownerId: schema.user.id, ownerName: schema.user.name, subject: schema.task.subject, priority: schema.task.priority, dueDate: schema.task.dueDate })
+ .from(schema.task)
+ .leftJoin(schema.user, eq(schema.user.id, schema.task.ownerId))
+ .where(eq(schema.task.accountId, accountId));
+
+ const cases = await db.select().from(schema.caseTable).where(eq(schema.caseTable.accountId, accountId));
+
+ const users = await db
+ .select({ id: schema.user.id, name: schema.user.name, email: schema.user.email })
+ .from(schema.member)
+ .innerJoin(schema.user, eq(schema.member.userId, schema.user.id))
+ .where(eq(schema.member.organizationId, account.organizationId));
+
+ return {
+ account,
+ contacts,
+ opportunities,
+ comments,
+ quotes,
+ tasks,
+ cases,
+ users,
+ meta: { title: account.name, description: `Account details for ${account.name}` }
+ };
+ } catch (err) {
+ console.error('Error loading account data:', err);
+ const errorMessage = err instanceof Error ? err.message : 'Error loading account data';
+ throw error(500, errorMessage);
+ }
+};
+
+export const actions: Actions = {
+ closeAccount: async ({ request, locals, params }) => {
+ try {
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db;
+ const { accountId } = params;
+ const formData = await request.formData();
+ const closureReason = formData.get('closureReason')?.toString();
+ if (!closureReason) return fail(400, { success: false, message: 'Please provide a reason for closing this account' });
+
+ const [account] = await db
+ .select({ id: schema.crmAccount.id, closedAt: schema.crmAccount.closedAt, organizationId: schema.crmAccount.organizationId, ownerId: schema.crmAccount.ownerId })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), eq(schema.crmAccount.organizationId, org.id)));
+ if (!account) return fail(404, { success: false, message: 'Account not found' });
+ if (account.closedAt) return fail(400, { success: false, message: 'Account is already closed' });
+
+ // Permission: allow org updaters or the account owner
+ let isAuthorized = user.id === account.ownerId;
+ if (!isAuthorized) {
+ const { success } = await locals.auth.api.hasPermission({
+ body: { organizationId: org.id, permission: { organization: ['update'] } },
+ headers: request.headers
+ });
+ isAuthorized = success;
+ }
+ if (!isAuthorized) {
+ return fail(403, { success: false, message: 'Permission denied. Only account owners or admins can close accounts.' });
+ }
+
+ const [updated] = await db
+ .update(schema.crmAccount)
+ .set({ closedAt: new Date(), isActive: false, closureReason })
+ .where(eq(schema.crmAccount.id, accountId))
+ .returning();
+
+ await db.insert(schema.auditLog).values({
+ action: 'UPDATE',
+ entityType: 'Account',
+ entityId: accountId,
+ description: `Account closed: ${closureReason}`,
+ oldValues: { closedAt: null, closureReason: null } as any,
+ newValues: { closedAt: updated.closedAt, closureReason } as any,
+ userId: user.id,
+ organizationId: account.organizationId,
+ ipAddress: request.headers.get('x-forwarded-for') || 'unknown'
+ });
+ return { success: true };
+ } catch (error) {
+ console.error('Error closing account:', error);
+ return fail(500, { success: false, message: 'An unexpected error occurred' });
+ }
+ },
+ reopenAccount: async ({ request, locals, params }) => {
+ try {
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db;
+ const { accountId } = params;
+
+ const { success: hasPermission } =
+ await locals.auth.api.hasPermission({
+ body: {
+ organizationId: org.id,
+ permission: {
+ organization: ['update'],
+ }
+ },
+ headers: request.headers
+ })
+
+ if (!hasPermission) {
+ return fail(403, { success: false, message: 'Permission denied. Only account owners, sales managers, or admins can reopen accounts.' });
+ }
+
+ const [account] = await db
+ .select({ id: schema.crmAccount.id, closedAt: schema.crmAccount.closedAt, closureReason: schema.crmAccount.closureReason, organizationId: schema.crmAccount.organizationId, ownerId: schema.crmAccount.ownerId })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), eq(schema.crmAccount.organizationId, org.id)));
+ if (!account) return fail(404, { success: false, message: 'Account not found' });
+ if (!account.closedAt) return fail(400, { success: false, message: 'Account is not closed' });
+
+ const oldValues = { closedAt: account.closedAt, closureReason: account.closureReason } as any;
+ await db
+ .update(schema.crmAccount)
+ .set({ closedAt: null, isActive: true, closureReason: null })
+ .where(eq(schema.crmAccount.id, accountId));
+
+ await db.insert(schema.auditLog).values({
+ action: 'UPDATE',
+ entityType: 'Account',
+ entityId: accountId,
+ description: `Account reopened`,
+ oldValues,
+ newValues: { closedAt: null, closureReason: null } as any,
+ userId: user.id,
+ organizationId: account.organizationId,
+ ipAddress: request.headers.get('x-forwarded-for') || 'unknown'
+ });
+ return { success: true };
+ } catch (error) {
+ console.error('Error reopening account:', error);
+ return fail(500, { success: false, message: 'An unexpected error occurred' });
+ }
+ },
+ comment: async ({ request, locals, params }) => {
+ const org = locals.org!;
+ const db = locals.db;
+ const [account] = await db
+ .select({ organizationId: schema.crmAccount.organizationId, ownerId: schema.crmAccount.ownerId })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, params.accountId), eq(schema.crmAccount.organizationId, org.id)));
+ if (!account) return fail(404, { error: 'Account not found.' });
+
+ const authorId = account.ownerId;
+ const organizationId = account.organizationId;
+ const form = await request.formData();
+ const body = form.get('body')?.toString().trim();
+ if (!body) return fail(400, { error: 'Comment cannot be empty.' });
+ await db.insert(schema.comment).values({ body, authorId, organizationId, accountId: params.accountId });
+ return { success: true };
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/accounts/[accountId]/+page.svelte b/apps/web/src/routes/(app)/app/accounts/[accountId]/+page.svelte
new file mode 100644
index 0000000..fa7778a
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/accounts/[accountId]/+page.svelte
@@ -0,0 +1,1075 @@
+
+
+{#if account}
+
+
+
+
+
+
+
+
+ Back to Accounts
+
+
+
{account?.name}
+
+
+ {account?.isActive ? 'Active' : 'Inactive'}
+
+ {#if account?.type}
+
+ {toLabel(account.type, ACCOUNT_TYPE_OPTIONS, '')}
+
+ {/if}
+
+
+
+
+
+ {#if account?.closedAt}
+
+
+
+ Reopen Account
+
+
+ {:else}
+
+
+ Edit
+
+
(showCloseModal = true)}
+ class="inline-flex items-center rounded-lg bg-yellow-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-yellow-700"
+ >
+
+ Close Account
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account Information
+
+
+
+
+
+
+
Name
+
+ {account.name || 'N/A'}
+
+
+
+
Industry
+
+ {toLabel(account.industry, INDUSTRY_OPTIONS, 'N/A')}
+
+
+
+
+
+
+
+
+
+
Annual Revenue
+
+ {account.annualRevenue ? formatCurrency(account.annualRevenue) : 'N/A'}
+
+
+
+
Employees
+
+ {account.numberOfEmployees
+ ? account.numberOfEmployees.toLocaleString()
+ : 'N/A'}
+
+
+
+
Ownership
+
+ {toLabel(account.accountOwnership, ACCOUNT_OWNERSHIP_OPTIONS, 'N/A')}
+
+
+
+
Rating
+
+ {toLabel(account.rating, RATING_OPTIONS, 'N/A')}
+
+
+
+
SIC Code
+
+ {account.sicCode || 'N/A'}
+
+
+
+
+
+ {#if account.street || account.city || account.state || account.country}
+
+
Address
+
+
+
+ {account.street || ''}
+ {account.city || ''}{account.city && account.state
+ ? ', '
+ : ''}{account.state || ''}
+ {account.postalCode || ''}
+ {account.country || ''}
+
+
+
+ {/if}
+
+ {#if account.description}
+
+
Description
+
+ {account.description}
+
+
+ {/if}
+
+ {#if account.closedAt}
+
+
+
+
+
+
+ This account was closed on {formatDate(account.closedAt)}.
+
+
+ Reason: {account.closureReason || 'No reason provided'}
+
+
+
+
+
+ {/if}
+
+
+
+
Created
+
{formatDate(account.createdAt)}
+
+
+
Last Updated
+
{formatDate(account.updatedAt)}
+
+
+
+
+
+
+
+
+
+
+ (activeTab = 'contacts')}
+ class={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium ${
+ activeTab === 'contacts'
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
+ }`}
+ >
+ Contacts ({contacts.length})
+
+ (activeTab = 'opportunities')}
+ class={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium ${
+ activeTab === 'opportunities'
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
+ }`}
+ >
+ Opportunities ({opportunities.length})
+
+ (activeTab = 'tasks')}
+ class={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium ${
+ activeTab === 'tasks'
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
+ }`}
+ >
+ Tasks ({tasks.length})
+
+ (activeTab = 'cases')}
+ class={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium ${
+ activeTab === 'cases'
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
+ }`}
+ >
+ Cases ({cases.length})
+
+ (activeTab = 'notes')}
+ class={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium ${
+ activeTab === 'notes'
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
+ }`}
+ >
+ Notes ({comments.length})
+
+
+
+
+
+
+ {#if activeTab === 'contacts'}
+ {#if contacts.length === 0}
+
+
+
+ No contacts found for this account
+
+
+
+ Add Contact
+
+
+ {:else}
+
+ {/if}
+ {/if}
+
+ {#if activeTab === 'opportunities'}
+ {#if opportunities.length === 0}
+
+
+
+ No opportunities found for this account
+
+
+
+ Add Opportunity
+
+
+ {:else}
+
+
+
+
+ Name
+ Value
+ Stage
+ Close Date
+ Probability
+ Actions
+
+
+
+ {#each opportunities as opportunity (opportunity.id)}
+
+
+
+ {opportunity.name}
+
+
+ {formatCurrency(opportunity.amount)}
+
+ {#if opportunity.stage}
+ {#each opportunityStages as s}
+ {#if s.value === opportunity.stage}
+
+ {s.label}
+
+ {/if}
+ {/each}
+ {:else}
+
+ Unknown
+
+ {/if}
+
+ {formatDate(opportunity.closeDate)}
+ {opportunity.probability ? `${opportunity.probability}%` : 'N/A'}
+
+ View
+
+
+ {/each}
+
+
+
+ {/if}
+ {/if}
+
+ {#if activeTab === 'tasks'}
+ {#if tasks.length === 0}
+
+
+
+ No tasks found for this account
+
+
+
+ Add Task
+
+
+ {:else}
+
+
+
+
+ Subject
+ Status
+ Priority
+ Due Date
+ Assigned To
+ Actions
+
+
+
+ {#each tasks as task (task.id)}
+
+
+
+ {task.subject}
+
+
+
+
+ {toLabel(task.status, TASK_STATUS_OPTIONS, 'N/A')}
+
+
+
+
+ {toLabel(task.priority, TASK_PRIORITY_OPTIONS, 'Normal')}
+
+
+ {formatDate(task.dueDate)}
+ {task.ownerName || 'Unassigned'}
+
+ View
+
+
+ {/each}
+
+
+
+ {/if}
+ {/if}
+
+ {#if activeTab === 'cases'}
+ {#if cases.length === 0}
+
+
+
+ No cases found for this account
+
+
+
+ Open Case
+
+
+ {:else}
+
+
+
+
+ Case Number
+ Subject
+ Status
+ Priority
+ Created Date
+ Actions
+
+
+
+ {#each cases as caseItem (caseItem.id)}
+
+
+
+ {caseItem.caseNumber}
+
+
+ {caseItem.subject}
+
+
+ {toLabel(caseItem.status, CASE_STATUS_OPTIONS, 'Unknown')}
+
+
+
+
+ {caseItem.priority}
+
+
+ {formatDate(caseItem.createdAt)}
+
+ View
+
+
+ {/each}
+
+
+
+ {/if}
+ {/if}
+
+ {#if activeTab === 'notes'}
+
+
+
+
Add a note
+
+ {#if commentError}
+
{commentError}
+ {/if}
+
+
+
+ {#if isSubmittingComment}Adding...{:else}Add Note{/if}
+
+
+
+
+
+ {#if comments.length === 0}
+
+
+
+ No notes found for this account
+
+
+ {:else}
+
+ {#each comments as comment (comment.id)}
+
+
+
+ {comment.authorName || 'Unknown'}
+
+ {formatDate(comment.createdAt)}
+
+ {#if comment.isPrivate}
+
+ Private
+
+ {/if}
+
+
+
+ {comment.body}
+
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Overview
+
+
+
+
+
Contacts
+
{contacts.length}
+
+
+
+
+
+
+
Opportunities
+
+ {opportunities.length}
+
+
+
+
+
+
+
+
Pipeline Value
+
+ {formatCurrency(
+ opportunities.reduce((sum: number, opp: any) => sum + (opp.amount || 0), 0)
+ )}
+
+
+
+
+
+
+
+
Open Cases
+
+ {cases.filter((c: any) => c.status !== 'CLOSED').length}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if showCloseModal}
+
+
+
(showCloseModal = false)}
+ onkeydown={(e) => e.key === 'Escape' && (showCloseModal = false)}
+ >
+
+
+
+
+
+
+
+
+
+
+ Close Account
+
+
+
+ You are about to close the account "{account.name}". This action will mark
+ the account as closed but will retain all account data for historical
+ purposes.
+
+
+
+
+ Reason for Closing *
+
+
+ {#if closeError}
+
{closeError}
+ {/if}
+
+
+
+
+
+
+
+ Close Account
+
+ (showCloseModal = false)}
+ class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto dark:bg-gray-600 dark:text-white dark:ring-gray-500 dark:hover:bg-gray-500"
+ >
+ Cancel
+
+
+
+
+
+
+ {/if}
+
+{/if}
diff --git a/apps/web/src/routes/(app)/app/accounts/[accountId]/edit/+page.server.ts b/apps/web/src/routes/(app)/app/accounts/[accountId]/edit/+page.server.ts
new file mode 100644
index 0000000..68bfb21
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/accounts/[accountId]/edit/+page.server.ts
@@ -0,0 +1,38 @@
+import { fail, redirect, error } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const db = locals.db
+ const [account] = await db
+ .select()
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, params.accountId), eq(schema.crmAccount.organizationId, locals.org!.id)) as any);
+ if (!account) throw error(404, 'Account not found');
+ return { account };
+};
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ const org = locals.org!;
+ const form = await request.formData();
+ const name = form.get('name')?.toString();
+ const industry = form.get('industry')?.toString() || null;
+ const type = form.get('type')?.toString() || null;
+ const website = form.get('website')?.toString() || null;
+ const phone = form.get('phone')?.toString() || null;
+
+ if (!name) {
+ return fail(400, { name, missing: true });
+ }
+
+ const db = locals.db
+
+ await db
+ .update(schema.crmAccount)
+ .set({ name, industry, type, website, phone })
+ .where(and(eq(schema.crmAccount.id, params.accountId), eq(schema.crmAccount.organizationId, org.id)) as any);
+ throw redirect(303, `/app/accounts/${params.accountId}`);
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/accounts/[accountId]/edit/+page.svelte b/apps/web/src/routes/(app)/app/accounts/[accountId]/edit/+page.svelte
new file mode 100644
index 0000000..7ce8496
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/accounts/[accountId]/edit/+page.svelte
@@ -0,0 +1,112 @@
+
+
+
diff --git a/apps/web/src/routes/(app)/app/accounts/new/+page.server.ts b/apps/web/src/routes/(app)/app/accounts/new/+page.server.ts
new file mode 100644
index 0000000..d706642
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/accounts/new/+page.server.ts
@@ -0,0 +1,84 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone';
+import { validateEnumOrNull } from '$lib/data/enum-helpers';
+import { ACCOUNT_TYPES, INDUSTRIES, ACCOUNT_OWNERSHIP, RATINGS } from '@opensource-startup-crm/constants';
+
+export const load: PageServerLoad = async () => ({});
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ // Get user and org from locals
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db
+
+ // Get the submitted form data
+ const formData = await request.formData();
+
+ // Extract and validate required fields
+ const name = formData.get('name')?.toString().trim();
+
+ if (!name) {
+ return fail(400, { error: 'Account name is required' });
+ }
+
+ // Validate phone number if provided
+ let formattedPhone = null;
+ const phone = formData.get('phone')?.toString();
+ if (phone && phone.trim().length > 0) {
+ const phoneValidation = validatePhoneNumber(phone.trim());
+ if (!phoneValidation.isValid) {
+ return fail(400, { error: phoneValidation.error || 'Please enter a valid phone number' });
+ }
+ formattedPhone = formatPhoneForStorage(phone.trim());
+ }
+
+ // Extract all form fields
+ const accountData = {
+ name,
+ type: validateEnumOrNull(formData.get('type'), ACCOUNT_TYPES),
+ industry: validateEnumOrNull(formData.get('industry'), INDUSTRIES),
+ website: formData.get('website')?.toString() || null,
+ phone: formattedPhone,
+ street: formData.get('street')?.toString() || null,
+ city: formData.get('city')?.toString() || null,
+ state: formData.get('state')?.toString() || null,
+ postalCode: formData.get('postalCode')?.toString() || null,
+ country: formData.get('country')?.toString() || null,
+ description: formData.get('description')?.toString() || null,
+ numberOfEmployees: formData.get('numberOfEmployees') ?
+ parseInt(formData.get('numberOfEmployees')?.toString() || '0') : null,
+ annualRevenue: formData.get('annualRevenue') ?
+ parseFloat(formData.get('annualRevenue')?.toString() || '0') : null,
+ accountOwnership: validateEnumOrNull(formData.get('accountOwnership'), ACCOUNT_OWNERSHIP),
+ tickerSymbol: formData.get('tickerSymbol')?.toString() || null,
+ rating: validateEnumOrNull(formData.get('rating'), RATINGS),
+ sicCode: formData.get('sicCode')?.toString() || null
+ };
+
+ try {
+ const [account] = await db
+ .insert(schema.crmAccount)
+ .values({
+ ...accountData,
+ ownerId: user.id,
+ organizationId: org.id
+ })
+ .returning();
+
+ return {
+ status: 'success',
+ message: 'Account created successfully',
+ account
+ };
+ } catch (err) {
+ console.error('Error creating account:', err);
+ return fail(500, {
+ error: 'Failed to create account: ' + (err instanceof Error ? err.message : 'Unknown error'),
+ values: accountData // Return entered values so the form can be repopulated
+ });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/accounts/new/+page.svelte b/apps/web/src/routes/(app)/app/accounts/new/+page.svelte
new file mode 100644
index 0000000..0d781c7
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/accounts/new/+page.svelte
@@ -0,0 +1,732 @@
+
+
+
+
+
+ {#if showToast}
+
+
+
+ {#if toastType === 'success'}
+
+ {:else}
+
+ {/if}
+
+
+
(showToast = false)}
+ class="-mx-1.5 -my-1.5 ml-auto rounded-lg p-1.5 {toastType === 'success'
+ ? 'text-green-500 hover:bg-green-100 dark:text-green-400 dark:hover:bg-green-800/30'
+ : 'text-red-500 hover:bg-red-100 dark:text-red-400 dark:hover:bg-red-800/30'}"
+ >
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Create New Account
+
+
+ Add a new company or organization to your CRM
+
+
+
+
+
+
+
+ {#if form?.error}
+
+
+
+
Error:
+
{form.error}
+
+
+ {/if}
+
+
+
{
+ if (!validateForm()) {
+ cancel();
+ return;
+ }
+
+ isSubmitting = true;
+
+ return async ({ result }) => {
+ isSubmitting = false;
+
+ if (result.type === 'success') {
+ showNotification('Account created successfully!', 'success');
+ resetForm();
+ setTimeout(() => goto('/app/accounts'), 1500);
+ } else if (result.type === 'failure') {
+ const errorMessage =
+ result.data &&
+ typeof result.data === 'object' &&
+ 'error' in result.data &&
+ typeof result.data.error === 'string'
+ ? result.data.error
+ : 'Failed to create account';
+ showNotification(errorMessage, 'error');
+ }
+ };
+ }}
+ class="space-y-6"
+ >
+
+
+
+
+
+ Basic Information
+
+
+
+
+
+
+
+ Account Name *
+
+
+ {#if errors.name}
+
{errors.name}
+ {/if}
+
+
+
+
+
+ Account Type
+
+
+ {#each accountTypeOptions as opt (opt.value)}
+ {opt.label}
+ {/each}
+
+
+
+
+
+
+ Industry
+
+
+ {#each industryOptions as opt (opt.value)}
+ {opt.label}
+ {/each}
+
+
+
+
+
+
+ Rating
+
+
+ {#each ratingOptions as opt (opt.value)}
+ {opt.label}
+ {/each}
+
+
+
+
+
+
+ Ownership
+
+
+ {#each accountOwnershipOptions as opt (opt.value)}
+ {opt.label}
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+ Contact Information
+
+
+
+
+
+
+
+
+ Website
+
+
+ {#if errors.website}
+
{errors.website}
+ {/if}
+
+
+
+
+
+
+ Phone
+
+
+ {#if errors.phone}
+
{errors.phone}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Address Information
+
+
+
+
+
+
+
+
+
+
+ Company Details
+
+
+
+
+
+
+
+
+ Number of Employees
+
+
+ {#if errors.numberOfEmployees}
+
+ {errors.numberOfEmployees}
+
+ {/if}
+
+
+
+
+
+
+ Annual Revenue
+
+
+ {#if errors.annualRevenue}
+
{errors.annualRevenue}
+ {/if}
+
+
+
+
+
+
+ Ticker Symbol
+
+
+
+
+
+
+
+ SIC Code
+
+
+
+
+
+
+
+
+
+
+
Additional Details
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+
goto('/app/accounts')}
+ disabled={isSubmitting}
+ class="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-6 py-2 text-gray-700 transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-blue-400 dark:focus:ring-offset-gray-800"
+ >
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Creating...
+ {:else}
+
+ Create Account
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/cases/+page.server.ts b/apps/web/src/routes/(app)/app/cases/+page.server.ts
new file mode 100644
index 0000000..25ef5aa
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/cases/+page.server.ts
@@ -0,0 +1,135 @@
+import { fail, redirect, error } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, desc, eq } from 'drizzle-orm';
+import { randomUUID } from 'crypto';
+import { caseStatusOptions as statusOptions } from '$lib/data';
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ const org = locals.org!;
+ const user = locals.user;
+ const db = locals.db
+ // Filters from query params
+ const status = url.searchParams.get('status') || undefined;
+ const assigned = url.searchParams.get('assigned') || undefined;
+ const account = url.searchParams.get('account') || undefined;
+
+ // Build filters for drizzle .where(and(...filters))
+ const filters = [eq(schema.caseTable.organizationId, org.id) as any];
+ if (status) filters.push(eq(schema.caseTable.status, status as any) as any);
+ if (assigned) filters.push(eq(schema.user.name, assigned) as any);
+ if (account) filters.push(eq(schema.crmAccount.name, account) as any);
+
+ // Fetch all possible filter options
+ const [allUsers, allAccounts] = await Promise.all([
+ db.select({ id: schema.user.id, name: schema.user.name }).from(schema.user),
+ db.select({ id: schema.crmAccount.id, name: schema.crmAccount.name }).from(schema.crmAccount)
+ ]);
+
+ // Optionally, define all possible statuses
+
+ const cases = await db
+ .select({
+ id: schema.caseTable.id,
+ subject: schema.caseTable.subject,
+ status: schema.caseTable.status,
+ description: schema.caseTable.description,
+ dueDate: schema.caseTable.dueDate,
+ priority: schema.caseTable.priority,
+ owner: { id: schema.user.id, name: schema.user.name },
+ account: { id: schema.crmAccount.id, name: schema.crmAccount.name }
+ })
+ .from(schema.caseTable)
+ .leftJoin(schema.user, eq(schema.user.id, schema.caseTable.ownerId))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.caseTable.accountId))
+ .where(and(...filters))
+ .orderBy(desc(schema.caseTable.createdAt));
+
+ return { cases, allUsers, allAccounts, statusOptions };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ const db = locals.db
+ const form = await request.formData();
+ const subject = form.get('title')?.toString().trim();
+ const description = form.get('description')?.toString().trim();
+ const accountId = form.get('accountId')?.toString();
+ const dueDateValue = form.get('dueDate');
+ const dueDate = dueDateValue ? new Date(dueDateValue.toString()) : null;
+ const priority = form.get('priority')?.toString() || 'Medium';
+ const ownerId = form.get('assignedId')?.toString();
+ if (!subject || !accountId || !ownerId) {
+ return fail(400, { error: 'Missing required fields.' });
+ }
+ const [created] = await db
+ .insert(schema.caseTable)
+ .values({
+ id: randomUUID(),
+ caseNumber: randomUUID(),
+ subject,
+ description,
+ accountId: accountId as string,
+ dueDate: dueDate,
+ priority,
+ ownerId: ownerId as string,
+ organizationId: locals.org!.id
+ })
+ .returning();
+ throw redirect(303, `/app/cases/${created.id}`);
+ },
+ update: async ({ request, params, locals }) => {
+ const db = locals.db
+ const form = await request.formData();
+ const subject = form.get('title')?.toString().trim();
+ const description = form.get('description')?.toString().trim();
+ const accountId = form.get('accountId')?.toString();
+ const dueDateValue = form.get('dueDate');
+ const dueDate = dueDateValue ? new Date(dueDateValue.toString()) : null;
+ const priority = form.get('priority')?.toString() || 'Medium';
+ const ownerId = form.get('assignedId')?.toString();
+ const caseId = form.get('caseId')?.toString();
+ if (!subject || !accountId || !ownerId || !caseId) {
+ return fail(400, { error: 'Missing required fields.' });
+ }
+ await db
+ .update(schema.caseTable)
+ .set({
+ subject,
+ description,
+ accountId: accountId as string,
+ dueDate: dueDate,
+ priority,
+ ownerId: ownerId as string
+ })
+ .where(and(eq(schema.caseTable.id, caseId), eq(schema.caseTable.organizationId, locals.org!.id)));
+ throw redirect(303, `/app/cases/${caseId}`);
+ },
+ delete: async ({ request, locals }) => {
+ const db = locals.db
+ const form = await request.formData();
+ const caseId = form.get('caseId')?.toString();
+ if (!caseId) {
+ return fail(400, { error: 'Case ID is required.' });
+ }
+ await db
+ .delete(schema.caseTable)
+ .where(and(eq(schema.caseTable.id, caseId), eq(schema.caseTable.organizationId, locals.org!.id)));
+ throw redirect(303, '/app/cases');
+ },
+ comment: async ({ request, locals }) => {
+ const db = locals.db
+ const form = await request.formData();
+ const body = form.get('body')?.toString().trim();
+ const caseId = form.get('caseId')?.toString();
+ if (!body || !caseId) return fail(400, { error: 'Comment and case ID are required.' });
+ await db.insert(schema.comment).values({
+ id: randomUUID(),
+ body,
+ authorId: locals.user!.id,
+ organizationId: locals.org!.id,
+ caseId
+ });
+ return { success: true };
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/cases/+page.svelte b/apps/web/src/routes/(app)/app/cases/+page.svelte
new file mode 100644
index 0000000..b4c9891
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/cases/+page.svelte
@@ -0,0 +1,375 @@
+
+
+
+
+
+
+
+
+
+
+
+
Cases
+
+ Manage customer support cases and issues
+
+
+
+
+
+ New Case
+
+
+
+
+
+
+
+
Filters
+ {#if hasActiveFilters}
+
+ {[statusFilter, assignedFilter, accountFilter].filter(Boolean).length} active
+
+ {/if}
+
+
+
+
+ Status
+
+ All Statuses
+ {#each statusOptions as s}
+ {s.label}
+ {/each}
+
+
+
+
+ Assigned To
+
+ All Users
+ {#each assignedOptions as a}
+ {a}
+ {/each}
+
+
+
+
+ Account
+
+ All Accounts
+ {#each accountOptions as acc}
+ {acc}
+ {/each}
+
+
+
+ {#if hasActiveFilters}
+
+
+
+ Clear
+
+
+ {/if}
+
+
+
+
+
+ {#if filteredCases.length}
+
+
+
+
+
+ Case
+ Account
+ Assigned
+ Due Date
+ Priority
+ Status
+ Actions
+
+
+
+ {#each filteredCases as c}
+
+
+
+
+
+ {c.account?.name || '-'}
+
+
+
+
+
+ {c.owner?.name?.[0] || '?'}
+
+
+
{c.owner?.name || 'Unassigned'}
+
+
+
+
+ {formatDate(c.dueDate)}
+
+
+
+
+ {toLabel(c.priority, TASK_PRIORITY_OPTIONS)}
+
+
+
+
+ {toLabel(c.status, CASE_STATUS_OPTIONS)}
+
+
+
+
+ View
+
+
+
+ {/each}
+
+
+
+
+
+
+ {#each filteredCases as c}
+
+
+
+ {#if c.description}
+
+ {c.description}
+
+ {/if}
+
+
+
+ Account:
+ {c.account?.name || '-'}
+
+
+ Priority:
+
+ {toLabel(c.priority, TASK_PRIORITY_OPTIONS)}
+
+
+
+ Assigned:
+ {c.owner?.name || 'Unassigned'}
+
+
+ Due:
+
+ {formatDate(c.dueDate)}
+
+
+
+
+
+
+ {/each}
+
+ {:else}
+
+
+
+
+
No cases found
+
+ {hasActiveFilters
+ ? 'No cases match your current filters.'
+ : 'Get started by creating your first case.'}
+
+ {#if hasActiveFilters}
+
+
+ Clear Filters
+
+ {:else}
+
+
+ Create Case
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/apps/web/src/routes/(app)/app/cases/[caseId]/+page.server.ts b/apps/web/src/routes/(app)/app/cases/[caseId]/+page.server.ts
new file mode 100644
index 0000000..2d4e90f
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/cases/[caseId]/+page.server.ts
@@ -0,0 +1,84 @@
+import { error, fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, desc, eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+ const caseId = params.caseId;
+ const [base] = await db
+ .select({
+ id: schema.caseTable.id,
+ subject: schema.caseTable.subject,
+ description: schema.caseTable.description,
+ status: schema.caseTable.status,
+ dueDate: schema.caseTable.dueDate,
+ priority: schema.caseTable.priority,
+ ownerId: schema.user.id,
+ ownerName: schema.user.name,
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name
+ })
+ .from(schema.caseTable)
+ .leftJoin(schema.user, eq(schema.user.id, schema.caseTable.ownerId))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.caseTable.accountId))
+ .where(and(eq(schema.caseTable.id, caseId), eq(schema.caseTable.organizationId, org.id)) as any);
+
+ if (!base) throw error(404, 'Case not found');
+
+ const comments = await db
+ .select({
+ id: schema.comment.id,
+ body: schema.comment.body,
+ createdAt: schema.comment.createdAt,
+ authorId: schema.user.id,
+ authorName: schema.user.name
+ })
+ .from(schema.comment)
+ .leftJoin(schema.user, eq(schema.user.id, schema.comment.authorId))
+ .where(eq(schema.comment.caseId, caseId))
+ .orderBy(desc(schema.comment.createdAt));
+
+ const caseItem = {
+ id: base.id,
+ subject: base.subject,
+ description: base.description,
+ status: base.status,
+ dueDate: base.dueDate,
+ priority: base.priority,
+ owner: { id: base.ownerId, name: base.ownerName },
+ account: { id: base.accountId, name: base.accountName },
+ comments
+ } as any;
+ if (!caseItem) throw error(404, 'Case not found');
+ return { caseItem };
+};
+
+export const actions: Actions = {
+ comment: async ({ request, params, locals }) => {
+ const org = locals.org!;
+ const user = locals.user!;
+ const db = locals.db
+
+ // check if the case is related to the organization
+ const [caseExists] = await db
+ .select({ id: schema.caseTable.id })
+ .from(schema.caseTable)
+ .where(and(eq(schema.caseTable.id, params.caseId), eq(schema.caseTable.organizationId, org.id)) as any);
+ if (!caseExists) {
+ return fail(404, { error: 'Case not found or does not belong to this organization.' });
+ }
+ const form = await request.formData();
+ const body = form.get('body')?.toString().trim();
+ if (!body) return fail(400, { error: 'Comment cannot be empty.' });
+ await db.insert(schema.comment).values({
+ id: crypto.randomUUID(),
+ body,
+ authorId: user.id,
+ organizationId: org.id,
+ caseId: params.caseId
+ } as any);
+ return { success: true };
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/cases/[caseId]/+page.svelte b/apps/web/src/routes/(app)/app/cases/[caseId]/+page.svelte
new file mode 100644
index 0000000..953d00d
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/cases/[caseId]/+page.svelte
@@ -0,0 +1,300 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {data.caseItem.subject}
+
+
Case #{data.caseItem.caseNumber}
+
+
+
+
+
+
+
+
+
+
+
+
Case Information
+
+ {#if data.caseItem.description}
+
+
Description
+
+ {data.caseItem.description}
+
+
+ {/if}
+
+
+
+
+
+
+
Account
+
+ {data.caseItem.account?.name || 'Not assigned'}
+
+
+
+
+
+
+
+
Assigned to
+
+ {data.caseItem.owner?.name || 'Unassigned'}
+
+
+
+
+
+
+ {#if data.caseItem.dueDate}
+
+
+
+
Due Date
+
{formatDate(data.caseItem.dueDate)}
+
+
+ {/if}
+
+
+
+
+
Created
+
{formatDate(data.caseItem.createdAt)}
+
+
+
+
+
+
+
+
+
+
+
Comments
+ ({data.caseItem.comments?.length || 0})
+
+
+
+
+ {#if data.caseItem.comments && data.caseItem.comments.length}
+ {#each data.caseItem.comments as c}
+
+
+
+
+ {c.author?.name?.[0]?.toUpperCase() || 'U'}
+
+
+
+
+
+ {c.author?.name || 'Unknown User'}
+
+
•
+
+ {formatDate(c.createdAt)}
+
+
+
{c.body}
+
+
+
+ {/each}
+ {:else}
+
+
+
No comments yet. Be the first to add one!
+
+ {/if}
+
+
+
+
+
+
+
+
+ Post
+
+
+ {#if errorMsg}
+ {errorMsg}
+ {/if}
+
+
+
+
+
+
+
+
+
+ Status & Priority
+
+
+
+
+
Status
+
+
+
+ {toLabel(data.caseItem.status, CASE_STATUS_OPTIONS, '-')}
+
+
+
+
+
+
Priority
+
+ {toLabel(data.caseItem.priority, TASK_PRIORITY_OPTIONS, 'Normal')}
+
+
+
+
+
+
+
+
+ Activity Timeline
+
+
+
+
+
+
+
Case Created
+
+ {formatDate(data.caseItem.createdAt)}
+
+
+
+
+ {#if data.caseItem.updatedAt && data.caseItem.updatedAt !== data.caseItem.createdAt}
+
+
+
+
Last Updated
+
+ {formatDate(data.caseItem.updatedAt)}
+
+
+
+ {/if}
+
+ {#if data.caseItem.closedAt}
+
+
+
+
Case Closed
+
+ {formatDate(data.caseItem.closedAt)}
+
+
+
+ {/if}
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/cases/[caseId]/edit/+page.server.ts b/apps/web/src/routes/(app)/app/cases/[caseId]/edit/+page.server.ts
new file mode 100644
index 0000000..6fd4f64
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/cases/[caseId]/edit/+page.server.ts
@@ -0,0 +1,134 @@
+import { fail, redirect, error } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const org = locals.org!;
+ const caseId = params.caseId;
+ const db = locals.db
+
+ const [caseItem] = await db
+ .select({
+ id: schema.caseTable.id,
+ subject: schema.caseTable.subject,
+ description: schema.caseTable.description,
+ status: schema.caseTable.status,
+ dueDate: schema.caseTable.dueDate,
+ priority: schema.caseTable.priority,
+ owner: { id: schema.user.id, name: schema.user.name },
+ account: { id: schema.crmAccount.id, name: schema.crmAccount.name }
+ })
+ .from(schema.caseTable)
+ .leftJoin(schema.user, eq(schema.user.id, schema.caseTable.ownerId))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.caseTable.accountId))
+ .where(and(eq(schema.caseTable.id, caseId), eq(schema.caseTable.organizationId, org.id)));
+ if (!caseItem) throw error(404, 'Case not found');
+ // Fetch all users and accounts for dropdowns
+ const [users, accounts] = await Promise.all([
+ db
+ .select({ id: schema.user.id, name: schema.user.name })
+ .from(schema.member)
+ .innerJoin(schema.user, eq(schema.user.id, schema.member.userId))
+ .where(eq(schema.member.organizationId, org.id)),
+ db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name })
+ .from(schema.crmAccount)
+ .where(eq(schema.crmAccount.organizationId, org.id))
+ ]);
+ return { caseItem, users, accounts };
+};
+
+export const actions: Actions = {
+ update: async ({ request, params, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+ const form = await request.formData();
+ const subject = form.get('title')?.toString().trim();
+ const description = form.get('description')?.toString().trim();
+ const accountId = form.get('accountId')?.toString();
+ const dueDateRaw = form.get('dueDate');
+ const dueDate = dueDateRaw ? new Date(dueDateRaw.toString()) : null;
+ const priority = form.get('priority')?.toString() || 'Medium';
+ const ownerId = form.get('assignedId')?.toString();
+
+ if (!subject || !accountId || !ownerId) {
+ return fail(400, { error: 'Missing required fields.' });
+ }
+
+ // Validate case is part of the organization
+ const [caseExists] = await db
+ .select({ id: schema.caseTable.id })
+ .from(schema.caseTable)
+ .where(and(eq(schema.caseTable.id, params.caseId), eq(schema.caseTable.organizationId, org.id)));
+ if (!caseExists) {
+ return fail(404, { error: 'Case not found or does not belong to this organization.' });
+ }
+
+ try {
+ // Validate account belongs to the active organization
+ const [accountBelongs] = await db
+ .select({ id: schema.crmAccount.id })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId as string), eq(schema.crmAccount.organizationId, org.id)));
+ if (!accountBelongs) {
+ return fail(403, { error: 'Selected account does not belong to your organization.' });
+ }
+
+ // Validate owner is a member of the active organization
+ const [ownerIsMember] = await db
+ .select({ id: schema.member.userId })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, ownerId as string), eq(schema.member.organizationId, org.id)));
+ if (!ownerIsMember) {
+ return fail(403, { error: 'Selected assignee is not part of your organization.' });
+ }
+
+ await db
+ .update(schema.caseTable)
+ .set({ subject, description, accountId: accountId as string, dueDate: dueDate, priority, ownerId: ownerId as string })
+ .where(and(eq(schema.caseTable.id, params.caseId), eq(schema.caseTable.organizationId, org.id)));
+ return { success: true };
+ } catch (error) {
+ return fail(500, { error: 'Failed to update case.' });
+ }
+ },
+ close: async ({ params, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+
+ // Validate case is part of the organization
+ const [caseExists] = await db
+ .select({ id: schema.caseTable.id })
+ .from(schema.caseTable)
+ .where(and(eq(schema.caseTable.id, params.caseId), eq(schema.caseTable.organizationId, org.id)));
+ if (!caseExists) {
+ return fail(404, { error: 'Case not found or does not belong to this organization.' });
+ }
+
+ await db
+ .update(schema.caseTable)
+ .set({ status: 'CLOSED', closedAt: new Date() })
+ .where(and(eq(schema.caseTable.id, params.caseId), eq(schema.caseTable.organizationId, org.id)));
+ throw redirect(303, `/app/cases/${params.caseId}`);
+ },
+ reopen: async ({ params, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+
+ // Validate case is part of the organization
+ const [caseExists] = await db
+ .select({ id: schema.caseTable.id })
+ .from(schema.caseTable)
+ .where(and(eq(schema.caseTable.id, params.caseId), eq(schema.caseTable.organizationId, org.id)));
+ if (!caseExists) {
+ return fail(404, { error: 'Case not found or does not belong to this organization.' });
+ }
+
+ await db
+ .update(schema.caseTable)
+ .set({ status: 'OPEN', closedAt: null })
+ .where(and(eq(schema.caseTable.id, params.caseId), eq(schema.caseTable.organizationId, org.id)));
+ throw redirect(303, `/app/cases/${params.caseId}`);
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/cases/[caseId]/edit/+page.svelte b/apps/web/src/routes/(app)/app/cases/[caseId]/edit/+page.svelte
new file mode 100644
index 0000000..a7708ce
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/cases/[caseId]/edit/+page.svelte
@@ -0,0 +1,406 @@
+
+
+
+
+
+
+
+
+
Edit Case
+
+ Update case details and assignment
+
+
+
+ goto(`/app/cases/${data.caseItem.id}`)}
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:ring-offset-gray-900"
+ >
+
+ Cancel
+
+
+
+
+
+
+
+
{
+ loading = true;
+ errorMsg = '';
+ successMsg = '';
+ return async ({ result, update }) => {
+ loading = false;
+ if (result.type === 'failure') {
+ errorMsg = (result.data as any)?.error || 'An error occurred';
+ } else if (result.type === 'success') {
+ successMsg = 'Case updated successfully!';
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+
+ Case Title
+ *
+
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ Account
+ *
+
+
+ Select an account...
+ {#each data.accounts as acc}
+ {acc.name}
+ {/each}
+
+
+
+
+
+
+
+
+ Due Date
+
+
+
+
+
+
+
+ Assign To
+ *
+
+
+ Select a user...
+ {#each data.users as u (u.id)}
+ {u.name}
+ {/each}
+
+
+
+
+
+
+
+
+ Priority
+
+
+ High
+ Medium
+ Low
+
+
+
+
+ {#if errorMsg}
+
+ {/if}
+
+ {#if successMsg}
+
+ {/if}
+
+
+
+
+
+
+ {loading
+ ? 'Saving...'
+ : data.caseItem.status === 'CLOSED'
+ ? 'Case is Closed'
+ : 'Save Changes'}
+
+
+ {#if data.caseItem.status === 'CLOSED'}
+
+
+ Reopen Case
+
+ {:else}
+
+
+ Close Case
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+ {#if showCloseConfirmation}
+
+
+
+
+
+
+
+
+
Close Case
+
+ Are you sure you want to close this case?
+
+
+
+
+ This action will mark the case as closed. You can still view the case details, but it
+ will no longer be active.
+
+
+
+ Close Case
+
+
+ Cancel
+
+
+
+
+
+ {/if}
+
+
+ {#if showReopenConfirmation}
+
+
+
+
+
+
+
+
+
Reopen Case
+
+ Are you sure you want to reopen this case?
+
+
+
+
+ This action will mark the case as active again and allow you to continue working on
+ it.
+
+
+
+ Reopen Case
+
+
+ Cancel
+
+
+
+
+
+ {/if}
+
+
diff --git a/apps/web/src/routes/(app)/app/cases/new/+page.server.ts b/apps/web/src/routes/(app)/app/cases/new/+page.server.ts
new file mode 100644
index 0000000..7c8f35b
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/cases/new/+page.server.ts
@@ -0,0 +1,71 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, asc, eq } from 'drizzle-orm';
+import { randomUUID } from 'crypto';
+import { validateEnumOrNull } from '$lib/data/enum-helpers';
+import { TASK_PRIORITIES, TASK_PRIORITY_OPTIONS } from '@opensource-startup-crm/constants';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const org = locals.org!;
+ const db = locals.db
+
+ const preSelectedAccountId = url.searchParams.get('accountId');
+ const accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name })
+ .from(schema.crmAccount)
+ .where(eq(schema.crmAccount.organizationId, org.id))
+ .orderBy(asc(schema.crmAccount.name));
+
+ const users = await db
+ .select({ id: schema.user.id, name: schema.user.name })
+ .from(schema.member)
+ .innerJoin(schema.user, eq(schema.user.id, schema.member.userId))
+ .where(eq(schema.member.organizationId, org.id))
+ .orderBy(asc(schema.user.name));
+
+ return { accounts, users, preSelectedAccountId };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+ const form = await request.formData();
+ const subject = form.get('title')?.toString().trim();
+ const description = form.get('description')?.toString().trim();
+ const accountId = form.get('accountId')?.toString();
+ const dueDateValue = form.get('dueDate');
+ const dueDate = dueDateValue ? new Date(dueDateValue.toString()) : null;
+ const priority = validateEnumOrNull(form.get('priority'), TASK_PRIORITIES) || 'NORMAL';
+ const ownerId = form.get('assignedId')?.toString();
+ if (!subject || !accountId || !ownerId) {
+ return fail(400, { error: 'Missing required fields.' });
+ }
+
+ // check if the ownerId is valid and related to the organization
+ const [isValidOwner] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, ownerId), eq(schema.member.organizationId, org.id)));
+ if (!isValidOwner) {
+ return fail(400, { error: 'Invalid owner ID.' });
+ }
+
+ const [newCase] = await db
+ .insert(schema.caseTable)
+ .values({
+ id: randomUUID(),
+ caseNumber: randomUUID(),
+ subject,
+ description,
+ accountId,
+ dueDate,
+ priority,
+ ownerId,
+ organizationId: org.id
+ }).returning();
+
+ throw redirect(303, `/app/cases/${newCase.id}`);
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/cases/new/+page.svelte b/apps/web/src/routes/(app)/app/cases/new/+page.svelte
new file mode 100644
index 0000000..7853128
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/cases/new/+page.svelte
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
+
+
Create New Case
+
+
Create and assign a new support case to track customer issues and requests.
+
+
+
+
+
+
+
+
+
+
Case Details
+
Basic information about the case
+
+
+
+
+
+ Case Title *
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+ Account *
+
+
+ Select an account...
+ {#each data.accounts as acc}
+ {acc.name}
+ {/each}
+
+
+
+
+
+
+
+
Assignment & Priority
+
Set ownership and urgency level
+
+
+
+
+
+ Due Date
+
+
+
+
+
+ Priority
+
+ 🔴 High
+ 🟡 Medium
+ 🟢 Low
+
+
+
+
+
+
+
+ Assign To *
+
+
+ Select a team member...
+ {#each data.users as u (u.id)}
+ {u.name}
+ {/each}
+
+
+
+
+
+ {#if errorMsg}
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/contacts/+page.server.ts b/apps/web/src/routes/(app)/app/contacts/+page.server.ts
new file mode 100644
index 0000000..068f4b1
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/contacts/+page.server.ts
@@ -0,0 +1,187 @@
+import { error, fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, asc, count, desc, eq, ilike, inArray, or } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ const db = locals.db
+ try {
+ const page = parseInt(url.searchParams.get('page') || '1');
+ const limit = parseInt(url.searchParams.get('limit') || '20');
+ const search = url.searchParams.get('search') || '';
+ const ownerId = url.searchParams.get('owner') || '';
+
+ const skip = (page - 1) * limit;
+
+ const filters = [eq(schema.contact.organizationId, locals.org!.id)];
+ if (ownerId) filters.push(eq(schema.contact.ownerId, ownerId));
+ if (search) {
+ const like = `%${search}%`;
+ filters.push(
+ or(
+ ilike(schema.contact.firstName, like),
+ ilike(schema.contact.lastName, like),
+ ilike(schema.contact.email, like),
+ ilike(schema.contact.phone, like),
+ ilike(schema.contact.title, like),
+ ilike(schema.contact.department, like)
+ )!
+ );
+ }
+
+ const rows = await db
+ .select({
+ id: schema.contact.id,
+ firstName: schema.contact.firstName,
+ lastName: schema.contact.lastName,
+ email: schema.contact.email,
+ phone: schema.contact.phone,
+ title: schema.contact.title,
+ department: schema.contact.department,
+ createdAt: schema.contact.createdAt,
+ ownerId: schema.contact.ownerId,
+ ownerUserId: schema.user.id,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email
+ })
+ .from(schema.contact)
+ .leftJoin(schema.user, eq(schema.user.id, schema.contact.ownerId))
+ .where(and(...filters))
+ .orderBy(desc(schema.contact.createdAt))
+ .limit(limit)
+ .offset(skip);
+
+ const contactIds = rows.map((r) => r.id);
+
+ const relatedAccounts = contactIds.length
+ ? await db
+ .select({
+ contactId: schema.accountContactRelationship.contactId,
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name
+ })
+ .from(schema.accountContactRelationship)
+ .innerJoin(
+ schema.crmAccount,
+ eq(schema.crmAccount.id, schema.accountContactRelationship.accountId)
+ )
+ .where(and(
+ inArray(schema.accountContactRelationship.contactId, contactIds),
+ eq(schema.crmAccount.organizationId, locals.org!.id)
+ ))
+ : [];
+
+ const tasksByContact = new Map();
+ const eventsByContact = new Map();
+ const oppsByContact = new Map();
+ const casesByContact = new Map();
+
+ if (contactIds.length) {
+ const tasks = await db
+ .select({ contactId: schema.task.contactId })
+ .from(schema.task)
+ .where(and(inArray(schema.task.contactId, contactIds), eq(schema.task.organizationId, locals.org!.id)));
+ for (const t of tasks) if (t.contactId) tasksByContact.set(t.contactId, (tasksByContact.get(t.contactId) || 0) + 1);
+
+ const events = await db
+ .select({ contactId: schema.event.contactId })
+ .from(schema.event)
+ .where(and(inArray(schema.event.contactId, contactIds), eq(schema.event.organizationId, locals.org!.id)));
+ for (const e of events) if (e.contactId) eventsByContact.set(e.contactId, (eventsByContact.get(e.contactId) || 0) + 1);
+
+ const oppLinks = await db
+ .select({ contactId: schema.contactToOpportunity.contactId })
+ .from(schema.contactToOpportunity)
+ .innerJoin(
+ schema.opportunity,
+ eq(schema.opportunity.id, schema.contactToOpportunity.opportunityId)
+ )
+ .where(and(
+ inArray(schema.contactToOpportunity.contactId, contactIds),
+ eq(schema.opportunity.organizationId, locals.org!.id)
+ ));
+ for (const o of oppLinks) oppsByContact.set(o.contactId, (oppsByContact.get(o.contactId) || 0) + 1);
+
+ const caseRows = await db
+ .select({ contactId: schema.caseTable.contactId })
+ .from(schema.caseTable)
+ .where(and(inArray(schema.caseTable.contactId, contactIds), eq(schema.caseTable.organizationId, locals.org!.id)));
+ for (const c of caseRows) if (c.contactId) casesByContact.set(c.contactId, (casesByContact.get(c.contactId) || 0) + 1);
+ }
+
+ const owners = await db
+ .select({ id: schema.user.id, name: schema.user.name, email: schema.user.email })
+ .from(schema.member)
+ .innerJoin(schema.user, eq(schema.user.id, schema.member.userId))
+ .where(eq(schema.member.organizationId, locals.org!.id))
+ .orderBy(asc(schema.user.name));
+
+ const [{ value: totalCount = 0 } = { value: 0 }] = await db
+ .select({ value: count() })
+ .from(schema.contact)
+ .where(and(...filters));
+
+ const accountsByContact = new Map();
+ for (const rel of relatedAccounts) {
+ const list = accountsByContact.get(rel.contactId) || [];
+ list.push({ id: rel.accountId, name: rel.accountName });
+ accountsByContact.set(rel.contactId, list);
+ }
+
+ const contacts = rows.map((r) => ({
+ id: r.id,
+ firstName: r.firstName,
+ lastName: r.lastName,
+ email: r.email,
+ phone: r.phone,
+ title: r.title,
+ department: r.department,
+ createdAt: r.createdAt,
+ owner: r.ownerUserId ? { id: r.ownerUserId, name: r.ownerName, email: r.ownerEmail } : null,
+ relatedAccounts: (accountsByContact.get(r.id) || []).map((a) => ({ account: a })),
+ _count: {
+ tasks: tasksByContact.get(r.id) || 0,
+ events: eventsByContact.get(r.id) || 0,
+ opportunities: oppsByContact.get(r.id) || 0,
+ cases: casesByContact.get(r.id) || 0
+ }
+ }));
+
+ return {
+ contacts,
+ totalCount,
+ currentPage: page,
+ totalPages: Math.ceil(Number(totalCount) / limit),
+ limit,
+ search,
+ ownerId,
+ owners
+ };
+ } catch (err) {
+ console.error('Error loading contacts:', err);
+ throw error(500, 'Failed to load contacts');
+ }
+};
+
+export const actions: Actions = {
+ delete: async ({ request, locals }) => {
+ const db = locals.db
+ const org = locals.org!
+ try {
+ const data = await request.formData();
+ const contactId = data.get('contactId')?.toString();
+ if (!contactId) {
+ return fail(400, { error: 'Contact ID is required' });
+ }
+
+ await db
+ .delete(schema.contact)
+ .where(and(eq(schema.contact.id, contactId), eq(schema.contact.organizationId, org.id)));
+
+ return { success: true };
+ } catch (err) {
+ console.error('Error deleting contact:', err);
+ throw error(500, 'Failed to delete contact');
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/contacts/+page.svelte b/apps/web/src/routes/(app)/app/contacts/+page.svelte
new file mode 100644
index 0000000..793cd0a
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/contacts/+page.svelte
@@ -0,0 +1,454 @@
+
+
+
+ Contacts - BottleCRM
+
+
+
+
+
+
+
+
+
+
+
+
+
Contacts
+
+ {data.totalCount} total contacts
+
+
+
+
+
showFilters = !showFilters}
+ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
+ >
+
+ Filters
+
+
+
+ Add Contact
+
+
+
+
+
+
+
+
+ e.key === 'Enter' && handleSearch()}
+ class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+ {#if searchQuery}
+ { searchQuery = ''; handleSearch(); }}
+ class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
+ >
+ ×
+
+ {/if}
+
+
+
+
+ {#if showFilters}
+
+
+
+
+ Owner
+
+
+ All owners
+ {#each data.owners as owner}
+ {owner.name || owner.email}
+ {/each}
+
+
+
+
+
+ Apply Filters
+
+
+ Clear
+
+
+
+ {/if}
+
+
+
+
+
+ {#if data.contacts.length === 0}
+
+
+
No contacts found
+
+ {data.search ? 'Try adjusting your search criteria.' : 'Get started by creating your first contact.'}
+
+ {#if !data.search}
+
+
+ Add Contact
+
+ {/if}
+
+ {:else}
+
+
+
+
+
+
+ Contact
+
+
+ Title & Department
+
+
+ Contact Info
+
+
+ Owner
+
+
+ Activity
+
+
+ Created
+
+
+ Actions
+
+
+
+
+ {#each data.contacts as contact}
+
+
+
+
+
+
+ {#if contact.relatedAccounts.length > 0}
+
+
+ {contact.relatedAccounts[0].account.name}
+
+ {/if}
+
+
+
+
+ {contact.title || '—'}
+ {contact.department || '—'}
+
+
+ {#if contact.email}
+
+
+ {contact.email}
+
+ {/if}
+ {#if contact.phone}
+
+
+ {formatPhone(contact.phone)}
+
+ {/if}
+
+
+
+ {contact.owner?.name || contact.owner?.email}
+
+
+
+
+ {#if contact._count.tasks > 0}
+
+ {contact._count.tasks} tasks
+
+ {/if}
+ {#if contact._count.opportunities > 0}
+
+ {contact._count.opportunities} opps
+
+ {/if}
+
+
+
+
+
+ {formatDate(contact.createdAt)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ if (!confirm('Are you sure you want to delete this contact?')) {
+ e.preventDefault();
+ }
+ }}
+ >
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+
+ {#each data.contacts as contact}
+
+
+
+
+ {#if contact.email}
+
+
+ {contact.email}
+
+ {/if}
+ {#if contact.phone}
+
+
+ {formatPhone(contact.phone)}
+
+ {/if}
+ {#if contact.relatedAccounts.length > 0}
+
+
+ {contact.relatedAccounts[0].account.name}
+
+ {/if}
+
+
+
+
+ {#if contact._count.tasks > 0}
+
+ {contact._count.tasks} tasks
+
+ {/if}
+ {#if contact._count.opportunities > 0}
+
+ {contact._count.opportunities} opps
+
+ {/if}
+
+
+
+
+ {/each}
+
+
+
+ {#if data.totalPages > 1}
+
+
+ Showing {((data.currentPage - 1) * data.limit) + 1} to {Math.min(data.currentPage * data.limit, data.totalCount)} of {data.totalCount} contacts
+
+
+
+
+
+
+ {#each Array.from({length: Math.min(5, data.totalPages)}, (_, i) => {
+ const start = Math.max(1, data.currentPage - 2);
+ return start + i;
+ }) as pageNum}
+ {#if pageNum <= data.totalPages}
+
+ {pageNum}
+
+ {/if}
+ {/each}
+
+
+
+
+
+
+ {/if}
+ {/if}
+
+
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/contacts/[contactId]/+page.server.ts b/apps/web/src/routes/(app)/app/contacts/[contactId]/+page.server.ts
new file mode 100644
index 0000000..677f4b4
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/contacts/[contactId]/+page.server.ts
@@ -0,0 +1,112 @@
+import { schema } from '@opensource-startup-crm/database';
+import { and, desc, eq } from 'drizzle-orm';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+ const contactId = params.contactId;
+
+ const [contactRow] = await db
+ .select({
+ id: schema.contact.id,
+ firstName: schema.contact.firstName,
+ lastName: schema.contact.lastName,
+ email: schema.contact.email,
+ phone: schema.contact.phone,
+ title: schema.contact.title,
+ department: schema.contact.department,
+ street: schema.contact.street,
+ city: schema.contact.city,
+ state: schema.contact.state,
+ postalCode: schema.contact.postalCode,
+ country: schema.contact.country,
+ createdAt: schema.contact.createdAt,
+ description: schema.contact.description,
+ owner: { id: schema.user.id, name: schema.user.name, email: schema.user.email }
+ })
+ .from(schema.contact)
+ .leftJoin(schema.user, eq(schema.user.id, schema.contact.ownerId))
+ .where(and(eq(schema.contact.id, contactId), eq(schema.contact.organizationId, org.id)));
+
+ if (!contactRow) {
+ return {
+ status: 404,
+ error: new Error('Contact not found')
+ };
+ }
+
+ const accountRels = await db
+ .select({
+ id: schema.accountContactRelationship.id,
+ role: schema.accountContactRelationship.role,
+ isPrimary: schema.accountContactRelationship.isPrimary,
+ description: schema.accountContactRelationship.description,
+ startDate: schema.accountContactRelationship.startDate,
+ endDate: schema.accountContactRelationship.endDate,
+ account: schema.crmAccount
+ })
+ .from(schema.accountContactRelationship)
+ .innerJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.accountContactRelationship.accountId))
+ .where(eq(schema.accountContactRelationship.contactId, contactId))
+ .orderBy(desc(schema.accountContactRelationship.isPrimary), desc(schema.accountContactRelationship.startDate));
+
+ const opportunities = await db
+ .select({
+ id: schema.opportunity.id,
+ name: schema.opportunity.name,
+ amount: schema.opportunity.amount,
+ stage: schema.opportunity.stage,
+ createdAt: schema.opportunity.createdAt,
+ account: { id: schema.crmAccount.id, name: schema.crmAccount.name }
+ })
+ .from(schema.contactToOpportunity)
+ .innerJoin(schema.opportunity, eq(schema.opportunity.id, schema.contactToOpportunity.opportunityId))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .where(and(eq(schema.contactToOpportunity.contactId, contactId), eq(schema.opportunity.organizationId, org.id)))
+ .orderBy(desc(schema.opportunity.createdAt))
+ .limit(5);
+
+ const tasks = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ createdAt: schema.task.createdAt,
+ dueDate: schema.task.dueDate,
+ owner: { id: schema.user.id, name: schema.user.name },
+ createdBy: { id: schema.user.id, name: schema.user.name }
+ })
+ .from(schema.task)
+ .leftJoin(schema.user, eq(schema.user.id, schema.task.ownerId))
+ .where(and(eq(schema.task.contactId, contactId), eq(schema.task.organizationId, org.id)))
+ .orderBy(desc(schema.task.createdAt))
+ .limit(5);
+
+ const events = await db
+ .select({
+ id: schema.event.id,
+ subject: schema.event.subject,
+ location: schema.event.location,
+ startDate: schema.event.startDate,
+ endDate: schema.event.endDate,
+ owner: { id: schema.user.id, name: schema.user.name },
+ createdBy: { id: schema.user.id, name: schema.user.name }
+ })
+ .from(schema.event)
+ .leftJoin(schema.user, eq(schema.user.id, schema.event.ownerId))
+ .where(and(eq(schema.event.contactId, contactId), eq(schema.event.organizationId, org.id)))
+ .orderBy(desc(schema.event.startDate))
+ .limit(5);
+
+ return {
+ contact: {
+ ...contactRow,
+ accountRelationships: accountRels,
+ opportunities,
+ tasks,
+ events
+ }
+ };
+}
diff --git a/apps/web/src/routes/(app)/app/contacts/[contactId]/+page.svelte b/apps/web/src/routes/(app)/app/contacts/[contactId]/+page.svelte
new file mode 100644
index 0000000..c5b6697
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/contacts/[contactId]/+page.svelte
@@ -0,0 +1,427 @@
+
+
+
+
+
+
+
+
+ {#if primaryAccountRel}
+
+
+ Back to {primaryAccountRel.account.name}
+
+ {:else}
+
+
+ Back to Contacts
+
+ {/if}
+
+
+ {contact.firstName?.[0]}{contact.lastName?.[0]}
+
+
+
+ {contact.firstName}
+ {contact.lastName}
+
+
{contact.title || 'Contact'}
+
+ {#if primaryAccountRel?.isPrimary}
+
+
+ Primary
+
+ {/if}
+ {#if hasMultipleAccounts}
+
+
+ {contact.accountRelationships.length} Accounts
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Contact Information
+
+
+
+
+
+
+
Department
+
{contact.department || 'N/A'}
+
+
+
+
+
Title
+
{contact.title || 'N/A'}
+
+
+
Owner
+
{contact.owner?.name || 'N/A'}
+
+
+
Created
+
+
+ {formatDate(contact.createdAt, 'en-US', undefined, '-')}
+
+
+
+
+ {#if contact.description}
+
+
Description
+
{contact.description}
+
+ {/if}
+
+
+
+ {#if contact.accountRelationships && contact.accountRelationships.length > 0}
+
+
+
+ Account Relationships
+ ({contact.accountRelationships.length})
+
+
+ {#each contact.accountRelationships as relationship}
+
+
+
+
+ {#if relationship.role}
+
+
+ {relationship.role}
+
+ {/if}
+
+
+ Since {formatDate(relationship.startDate, 'en-US', undefined, '-')}
+
+
+ {#if relationship.description}
+
+ {relationship.description}
+
+ {/if}
+
+
+
+ {relationship.account.type || 'Account'}
+
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if contact.street || contact.city || contact.state || contact.country}
+
+
+
+ Address
+
+
+ {#if contact.street}
{contact.street}
{/if}
+
+ {contact.city || ''}{contact.city && contact.state ? ', ' : ''}{contact.state || ''}
+ {contact.postalCode || ''}
+
+ {#if contact.country}
{contact.country}
{/if}
+
+
+ {/if}
+
+
+ {#if contact.opportunities && contact.opportunities.length > 0}
+
+
+
+ {#each contact.opportunities as opp}
+
+
+
+
+
+ {formatCurrency(opp.amount || 0)}
+
+
+ {opp.stage.replace('_', ' ')}
+
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+ {#if contact.tasks && contact.tasks.length > 0}
+
+
+
+ {#each contact.tasks as task}
+
+ {#if task.status === 'COMPLETED'}
+
+ {:else}
+
+ {/if}
+
+
+ {task.subject}
+
+
+
+ {toLabel(task.priority, TASK_PRIORITY_OPTIONS, 'Normal')}
+
+ {#if task.dueDate}
+
+
+ {formatDate(task.dueDate, 'en-US', undefined, '-')}
+
+ {/if}
+
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if contact.events && contact.events.length > 0}
+
+
+
+ {#each contact.events as event}
+
+
{event.subject}
+
+
+ {formatDate(
+ event.startDate,
+ 'en-US',
+ { hour: '2-digit', minute: '2-digit' },
+ '-'
+ )}
+
+ {#if event.location}
+
+
+ {event.location}
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.ts b/apps/web/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.ts
new file mode 100644
index 0000000..e1367d5
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.ts
@@ -0,0 +1,99 @@
+import { schema } from '@opensource-startup-crm/database';
+import { fail } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { and, eq } from 'drizzle-orm';
+import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+ const [row] = await db
+ .select({
+ contact: schema.contact,
+ isPrimary: schema.accountContactRelationship.isPrimary,
+ role: schema.accountContactRelationship.role,
+ account: schema.crmAccount
+ })
+ .from(schema.contact)
+ .leftJoin(
+ schema.accountContactRelationship,
+ eq(schema.accountContactRelationship.contactId, schema.contact.id)
+ )
+ .leftJoin(
+ schema.crmAccount,
+ eq(schema.crmAccount.id, schema.accountContactRelationship.accountId)
+ )
+ .where(and(eq(schema.contact.id, params.contactId), eq(schema.contact.organizationId, org.id)));
+
+ if (!row?.contact) return fail(404, { message: 'Contact not found' });
+
+ return {
+ contact: row.contact,
+ account: row.account || null,
+ isPrimary: row.isPrimary || false,
+ role: row.role || ''
+ };
+}
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+
+ const formData = await request.formData();
+ const firstName = formData.get('firstName')?.toString().trim();
+ const lastName = formData.get('lastName')?.toString().trim();
+ const email = formData.get('email')?.toString().trim() || null;
+ const phone = formData.get('phone')?.toString().trim() || null;
+ const title = formData.get('title')?.toString().trim() || null;
+ const department = formData.get('department')?.toString().trim() || null;
+ const street = formData.get('street')?.toString().trim() || null;
+ const city = formData.get('city')?.toString().trim() || null;
+ const state = formData.get('state')?.toString().trim() || null;
+ const postalCode = formData.get('postalCode')?.toString().trim() || null;
+ const country = formData.get('country')?.toString().trim() || null;
+ const description = formData.get('description')?.toString().trim() || null;
+
+ if (!firstName || !lastName) {
+ return fail(400, { message: 'First and last name are required.' });
+ }
+
+ // Validate phone number if provided
+ let formattedPhone = null;
+ if (phone && phone.length > 0) {
+ const phoneValidation = validatePhoneNumber(phone);
+ if (!phoneValidation.isValid) {
+ return fail(400, { message: phoneValidation.error || 'Please enter a valid phone number' });
+ }
+ formattedPhone = formatPhoneForStorage(phone);
+ }
+
+ const [contact] = await db
+ .select({ id: schema.contact.id })
+ .from(schema.contact)
+ .where(and(eq(schema.contact.id, params.contactId), eq(schema.contact.organizationId, org.id)));
+ if (!contact) {
+ return fail(404, { message: 'Contact not found' });
+ }
+
+ await db
+ .update(schema.contact)
+ .set({
+ firstName,
+ lastName,
+ email,
+ phone: formattedPhone,
+ title,
+ department,
+ street,
+ city,
+ state,
+ postalCode,
+ country,
+ description
+ })
+ .where(eq(schema.contact.id, params.contactId));
+
+ return { success: true };
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte b/apps/web/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte
new file mode 100644
index 0000000..a20fe62
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte
@@ -0,0 +1,468 @@
+
+
+
+
+
+
+
+
goto(`/app/contacts/${contact?.id}`)}
+ class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
+ >
+
+
+
+
Edit Contact
+
+ Update contact information and details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Basic Information
+
+
+
+
+
+
+
+
+
+
+
+
+
Contact Information
+
+
+
+
+
+
+
+ Phone Number
+
+
+ {#if phoneError}
+
{phoneError}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
Address Information
+
+
+
+
+
+
+ Street Address
+
+
+
+
+
+
+ Country
+
+
+
+
+
+
+
+
+ {#if account}
+
+
+
+
+
+
+
+ Account Relationship
+
+
+
+
+
+
+
Account
+
{account.name}
+
+ {#if role}
+
+ {/if}
+ {#if isPrimary}
+
+
+
+ Primary Contact
+
+
+ {/if}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Additional Information
+
+
+
+
+
+
+ Notes & Description
+
+
+
+
+
+
+
+ {#if errorMsg}
+
+ {/if}
+
+
+
+
goto(`/app/contacts/${contact?.id}`)}
+ class="rounded-lg border border-gray-300 bg-white px-6 py-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-gray-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:ring-gray-400"
+ >
+ Cancel
+
+
+ {#if submitting}
+
+ Saving...
+ {:else}
+
+ Save Changes
+ {/if}
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/contacts/new/+page.server.ts b/apps/web/src/routes/(app)/app/contacts/new/+page.server.ts
new file mode 100644
index 0000000..bd8a87f
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/contacts/new/+page.server.ts
@@ -0,0 +1,235 @@
+import { redirect, fail } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js';
+import { and, eq, inArray, asc } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const db = locals.db
+ const user = locals.user!
+
+ // Get user's organizations for the dropdown
+ const userOrganizations = await db
+ .select({ id: schema.organization.id, name: schema.organization.name })
+ .from(schema.member)
+ .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId))
+ .where(eq(schema.member.userId, user.id));
+
+ // Get accounts for the account dropdown (if no specific accountId is provided)
+ const accountId = url.searchParams.get('accountId');
+ let accounts: Array<{ id: string; name: string; organizationId: string }> = [];
+
+ if (!accountId) {
+ // Load accounts from user's organizations
+ const organizationIds = userOrganizations.map((uo) => uo.id);
+ accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name, organizationId: schema.crmAccount.organizationId })
+ .from(schema.crmAccount)
+ .where(and(inArray(schema.crmAccount.organizationId, organizationIds), eq(schema.crmAccount.isDeleted, false)))
+ .orderBy(asc(schema.crmAccount.name));
+ } else {
+ // Load the specific account to validate access and show in UI
+ const [account] = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name, organizationId: schema.crmAccount.organizationId })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), inArray(schema.crmAccount.organizationId, userOrganizations.map((uo) => uo.id)), eq(schema.crmAccount.isDeleted, false)));
+
+ if (account) {
+ accounts = [account];
+ }
+ }
+
+ return { organizations: userOrganizations.map((o) => ({ id: o.id, name: o.name })), accounts };
+}
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ const db = locals.db
+ const user = locals.user!
+
+ const data = await request.formData();
+ const firstName = data.get('firstName')?.toString().trim() || '';
+ const lastName = data.get('lastName')?.toString().trim() || '';
+ const email = data.get('email')?.toString().trim();
+ const phone = data.get('phone')?.toString().trim();
+ const title = data.get('title')?.toString().trim();
+ const department = data.get('department')?.toString().trim();
+ const street = data.get('street')?.toString().trim();
+ const city = data.get('city')?.toString().trim();
+ const state = data.get('state')?.toString().trim();
+ const postalCode = data.get('postalCode')?.toString().trim();
+ const country = data.get('country')?.toString().trim();
+ const description = data.get('description')?.toString().trim();
+ const organizationId = data.get('organizationId')?.toString();
+ const accountId = data.get('accountId')?.toString();
+ const role = data.get('role')?.toString().trim();
+ const isPrimary = data.get('isPrimary') === 'on';
+
+ // Validation
+ const errors: Record = {};
+
+ if (!firstName) errors.firstName = 'First name is required';
+ if (!lastName) errors.lastName = 'Last name is required';
+
+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ errors.email = 'Please enter a valid email address';
+ }
+
+ // Validate phone number if provided
+ let formattedPhone = null;
+ if (phone && phone.length > 0) {
+ const phoneValidation = validatePhoneNumber(phone);
+ if (!phoneValidation.isValid) {
+ errors.phone = phoneValidation.error || 'Please enter a valid phone number';
+ } else {
+ formattedPhone = formatPhoneForStorage(phone);
+ }
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return fail(400, {
+ errors,
+ values: {
+ firstName, lastName, email, phone, title, department,
+ street, city, state, postalCode, country, description,
+ organizationId, accountId, role, isPrimary
+ }
+ });
+ }
+
+ try {
+ let validatedOrganizationId = organizationId || locals.org?.id;
+
+ // If accountId is provided, validate it and get its organizationId
+ if (accountId) {
+ const [account] = await db
+ .select({ id: schema.crmAccount.id, organizationId: schema.crmAccount.organizationId })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId), eq(schema.crmAccount.isDeleted, false)));
+
+ if (!account) {
+ return fail(404, {
+ errors: { accountId: 'Account not found' },
+ values: {
+ firstName, lastName, email, phone, title, department,
+ street, city, state, postalCode, country, description,
+ organizationId, accountId, role, isPrimary
+ }
+ });
+ }
+
+ const [membership] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, user.id), eq(schema.member.organizationId, account.organizationId)));
+ if (!membership) {
+ return fail(403, {
+ errors: { accountId: 'You do not have access to this account' },
+ values: {
+ firstName, lastName, email, phone, title, department,
+ street, city, state, postalCode, country, description,
+ organizationId, accountId, role, isPrimary
+ }
+ });
+ }
+
+ validatedOrganizationId = account.organizationId;
+ }
+
+ // Verify user has access to the organization
+ if (validatedOrganizationId) {
+ const [userOrg] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, user.id), eq(schema.member.organizationId, validatedOrganizationId)));
+
+ if (!userOrg) {
+ return fail(403, {
+ errors: { organizationId: 'You do not have access to this organization' },
+ values: {
+ firstName, lastName, email, phone, title, department,
+ street, city, state, postalCode, country, description,
+ organizationId, accountId, role, isPrimary
+ }
+ });
+ }
+
+ // Check for duplicate email within the organization
+ if (email) {
+ const [existingContact] = await db
+ .select({ id: schema.contact.id })
+ .from(schema.contact)
+ .where(and(eq(schema.contact.email, email), eq(schema.contact.organizationId, validatedOrganizationId)));
+
+ if (existingContact) {
+ return fail(400, {
+ errors: { email: 'A contact with this email already exists in this organization' },
+ values: {
+ firstName, lastName, email, phone, title, department,
+ street, city, state, postalCode, country, description,
+ organizationId, accountId, role, isPrimary
+ }
+ });
+ }
+ }
+ }
+
+ // Create the contact
+ const [contact] = await db
+ .insert(schema.contact)
+ .values({
+ firstName,
+ lastName,
+ email: email || null,
+ phone: formattedPhone,
+ title: title || null,
+ department: department || null,
+ street: street || null,
+ city: city || null,
+ state: state || null,
+ postalCode: postalCode || null,
+ country: country || null,
+ description: description || null,
+ ownerId: user.id,
+ organizationId: validatedOrganizationId!
+ })
+ .returning();
+
+ // Create account-contact relationship if accountId is provided
+ if (accountId) {
+ await db.insert(schema.accountContactRelationship).values({
+ accountId: accountId,
+ contactId: contact.id,
+ role: role || null,
+ isPrimary: isPrimary
+ });
+ }
+
+ // Create audit log
+ await db.insert(schema.auditLog).values({
+ action: 'CREATE',
+ entityType: 'Contact',
+ entityId: contact.id,
+ description: `Created contact: ${firstName} ${lastName}${accountId ? ` and linked to account` : ''}`,
+ newValues: { contact, accountRelationship: accountId ? { accountId, role, isPrimary } : null } as any,
+ userId: user.id,
+ organizationId: locals.org!.id
+ });
+
+ } catch (error) {
+ console.error('Error creating contact:', error);
+ return fail(500, {
+ errors: { general: 'An error occurred while creating the contact. Please try again.' },
+ values: {
+ firstName, lastName, email, phone, title, department,
+ street, city, state, postalCode, country, description,
+ organizationId, accountId, role, isPrimary
+ }
+ });
+ }
+
+ // Redirect back to account if accountId was provided, otherwise to contacts list
+ const redirectUrl = accountId ? `/app/accounts/${accountId}` : '/app/contacts';
+ throw redirect(302, redirectUrl);
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/contacts/new/+page.svelte b/apps/web/src/routes/(app)/app/contacts/new/+page.svelte
new file mode 100644
index 0000000..43372e6
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/contacts/new/+page.svelte
@@ -0,0 +1,549 @@
+
+
+
+ New Contact - BottleCRM
+
+
+
+
+
+
+
+
+
+ {#if errors.general}
+
+ {/if}
+
+
+ {#if selectedAccount}
+
+
+
+
+ This contact will be added to {selectedAccount.name}
+
+
+
+ {/if}
+
+
+
+ {#if accountId}
+
+ {/if}
+
+
+
+
+
+
+
Basic Information
+
+
+
+
+ {#if !accountId}
+
+
+ Organization
+
+
+ Select an organization
+ {#each data.organizations as org}
+ {org.name}
+ {/each}
+
+
+ {/if}
+
+
+ {#if !accountId && data.accounts?.length > 0}
+
+
+ Account (Optional)
+
+
+ Select an account (optional)
+ {#each data.accounts as account}
+ {account.name}
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+ First Name *
+
+
+ {#if errors.firstName}
+
{errors.firstName}
+ {/if}
+
+
+
+ Last Name *
+
+
+ {#if errors.lastName}
+
{errors.lastName}
+ {/if}
+
+
+
+
+
+
+
+
+ Email
+
+
+ {#if errors.email}
+
{errors.email}
+ {/if}
+
+
+
+
+ Phone
+
+
+ {#if errors.phone}
+
{errors.phone}
+ {/if}
+ {#if phoneError}
+
{phoneError}
+ {/if}
+
+
+
+
+
+
+ {#if selectedAccount || formValues.accountId}
+
+
+
+
+
+ Account Relationship
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+ Professional Information
+
+
+
+
+
+
+
+
+
+
+
+
Address Information
+
+
+
+
+
+ Street Address
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Additional Information
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Creating...
+ {:else}
+
+ Create Contact
+ {/if}
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/invoices/+page.server.ts b/apps/web/src/routes/(app)/app/invoices/+page.server.ts
new file mode 100644
index 0000000..7c9903f
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/invoices/+page.server.ts
@@ -0,0 +1,66 @@
+import { redirect } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { desc, eq, asc } from 'drizzle-orm';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const db = locals.db
+ // One query with joins: quote <- crmAccount, quoteLineItem <- product
+ const rows = await db
+ .select({
+ id: schema.quote.id,
+ quoteNumber: schema.quote.quoteNumber,
+ name: schema.quote.name,
+ grandTotal: schema.quote.grandTotal,
+ createdAt: schema.quote.createdAt,
+ status: schema.quote.status,
+ expirationDate: schema.quote.expirationDate,
+ accountId: schema.quote.accountId,
+ accountName: schema.crmAccount.name,
+ liId: schema.quoteLineItem.id,
+ liDescription: schema.quoteLineItem.description,
+ liTotalPrice: schema.quoteLineItem.totalPrice,
+ productName: schema.product.name
+ })
+ .from(schema.quote)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.quote.accountId))
+ .leftJoin(schema.quoteLineItem, eq(schema.quoteLineItem.quoteId, schema.quote.id))
+ .leftJoin(schema.product, eq(schema.product.id, schema.quoteLineItem.productId))
+ .where(eq(schema.quote.organizationId, locals.org!.id))
+ .orderBy(desc(schema.quote.createdAt), asc(schema.quoteLineItem.id));
+
+ if (rows.length === 0) {
+ return { invoices: [] };
+ }
+
+ // Group rows by invoice id and compose
+ const byId = new Map>();
+ function compose(r: typeof rows[number]) {
+ return {
+ id: r.id,
+ quoteNumber: r.quoteNumber,
+ name: r.name,
+ grandTotal: r.grandTotal,
+ createdAt: r.createdAt,
+ status: r.status,
+ expirationDate: r.expirationDate,
+ accountId: r.accountId,
+ account: { id: r.accountId, name: r.accountName },
+ lineItems: [] as Array<{ id: string; description: string | null; totalPrice: any; product?: { name: string } }>
+ };
+ }
+
+ for (const r of rows) {
+ if (!byId.has(r.id)) byId.set(r.id, compose(r));
+ if (r.liId) {
+ byId.get(r.id)!.lineItems.push({
+ id: r.liId,
+ description: r.liDescription,
+ totalPrice: r.liTotalPrice,
+ product: r.productName ? { name: r.productName } : undefined
+ });
+ }
+ }
+
+ return { invoices: Array.from(byId.values()) };
+};
diff --git a/apps/web/src/routes/(app)/app/invoices/+page.svelte b/apps/web/src/routes/(app)/app/invoices/+page.svelte
new file mode 100644
index 0000000..0371a71
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/invoices/+page.svelte
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Search invoices
+
+
+
+
+
+
+
+
+
+
Filter by status
+
+ All Statuses
+ Paid
+ Unpaid
+ Overdue
+
+
+
+
+
+
+
+
+
+
Date range filter
+
+
+
+
+
+
+ {#each data.invoices as invoice}
+
+
+
+ {invoice.status.toLowerCase()}
+ Due: {invoice.expirationDate
+ ? new Date(invoice.expirationDate).toLocaleDateString()
+ : 'N/A'}
+
+
+
{invoice.quoteNumber}
+
{invoice.account.name}
+
+ {#each invoice.lineItems as item}
+
+ {item.description || item.product?.name}
+ {formatCurrency(Number(item.totalPrice))}
+
+ {/each}
+
+
+
+
+
+
+
+ {/each}
+
+
+ {#if data.invoices.length === 0}
+
+
📄
+
No invoices yet
+
Create your first invoice to get started
+
+ Create Invoice
+
+
+ {/if}
+
+
+
diff --git a/apps/web/src/routes/(app)/app/invoices/[invoiceId]/+page.server.ts b/apps/web/src/routes/(app)/app/invoices/[invoiceId]/+page.server.ts
new file mode 100644
index 0000000..ca27a0a
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/invoices/[invoiceId]/+page.server.ts
@@ -0,0 +1,115 @@
+import { error, redirect } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq, asc } from 'drizzle-orm';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const db = locals.db
+
+ // Join across quote -> account/contact/preparedBy/lineItems/product in one query
+ const rows = await db
+ .select({
+ // quote
+ id: schema.quote.id,
+ quoteNumber: schema.quote.quoteNumber,
+ name: schema.quote.name,
+ status: schema.quote.status,
+ description: schema.quote.description,
+ expirationDate: schema.quote.expirationDate,
+ subtotal: schema.quote.subtotal,
+ discountAmount: schema.quote.discountAmount,
+ taxAmount: schema.quote.taxAmount,
+ grandTotal: schema.quote.grandTotal,
+ createdAt: schema.quote.createdAt,
+ updatedAt: schema.quote.updatedAt,
+ // account
+ accountId: schema.crmAccount.id,
+ accountName: schema.crmAccount.name,
+ accountStreet: schema.crmAccount.street,
+ accountCity: schema.crmAccount.city,
+ accountState: schema.crmAccount.state,
+ accountPostalCode: schema.crmAccount.postalCode,
+ accountCountry: schema.crmAccount.country,
+ // contact
+ contactId: schema.contact.id,
+ contactFirstName: schema.contact.firstName,
+ contactLastName: schema.contact.lastName,
+ contactEmail: schema.contact.email,
+ // prepared by
+ preparedByName: schema.user.name,
+ preparedByEmail: schema.user.email,
+ // line items
+ liId: schema.quoteLineItem.id,
+ liDescription: schema.quoteLineItem.description,
+ liQuantity: schema.quoteLineItem.quantity,
+ liListPrice: schema.quoteLineItem.listPrice,
+ liUnitPrice: schema.quoteLineItem.unitPrice,
+ liDiscount: schema.quoteLineItem.discount,
+ liTotalPrice: schema.quoteLineItem.totalPrice,
+ productName: schema.product.name,
+ productCode: schema.product.code
+ })
+ .from(schema.quote)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.quote.accountId))
+ .leftJoin(schema.contact, eq(schema.contact.id, schema.quote.contactId))
+ .leftJoin(schema.user, eq(schema.user.id, schema.quote.preparedById))
+ .leftJoin(schema.quoteLineItem, eq(schema.quoteLineItem.quoteId, schema.quote.id))
+ .leftJoin(schema.product, eq(schema.product.id, schema.quoteLineItem.productId))
+ .where(and(eq(schema.quote.id, params.invoiceId as string), eq(schema.quote.organizationId, locals.org!.id)))
+ .orderBy(asc(schema.quoteLineItem.id));
+
+ if (!rows.length) {
+ throw error(404, 'Invoice not found');
+ }
+
+ const base = rows[0];
+ const invoice = {
+ id: base.id,
+ quoteNumber: base.quoteNumber,
+ name: base.name,
+ status: base.status,
+ description: base.description,
+ expirationDate: base.expirationDate,
+ subtotal: base.subtotal,
+ discountAmount: base.discountAmount,
+ taxAmount: base.taxAmount,
+ grandTotal: base.grandTotal,
+ createdAt: base.createdAt,
+ updatedAt: base.updatedAt,
+ accountId: base.accountId,
+ account: base.accountId
+ ? {
+ id: base.accountId,
+ name: base.accountName,
+ street: base.accountStreet,
+ city: base.accountCity,
+ state: base.accountState,
+ postalCode: base.accountPostalCode,
+ country: base.accountCountry
+ }
+ : null,
+ contact: base.contactId
+ ? {
+ firstName: base.contactFirstName,
+ lastName: base.contactLastName,
+ email: base.contactEmail
+ }
+ : null,
+ preparedBy: base.preparedByName ? { name: base.preparedByName, email: base.preparedByEmail } : null,
+ lineItems: rows
+ .filter((r) => r.liId !== null)
+ .map((r) => ({
+ id: r.liId!,
+ description: r.liDescription,
+ quantity: r.liQuantity,
+ listPrice: r.liListPrice,
+ unitPrice: r.liUnitPrice,
+ discount: r.liDiscount,
+ totalPrice: r.liTotalPrice,
+ productName: r.productName,
+ productCode: r.productCode
+ }))
+ };
+
+ return { invoice };
+};
diff --git a/apps/web/src/routes/(app)/app/invoices/[invoiceId]/+page.svelte b/apps/web/src/routes/(app)/app/invoices/[invoiceId]/+page.svelte
new file mode 100644
index 0000000..8342076
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/invoices/[invoiceId]/+page.svelte
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
{invoice.quoteNumber}
+
+
+
Prepared by:
+
{invoice.preparedBy?.name}
+
{invoice.preparedBy?.email}
+
+
+
+
+
+
From:
+
{invoice.account?.name}
+
+ {#if invoice.account?.street}
+ {invoice.account.street}
+ {/if}
+ {#if invoice.account?.city}
+ {invoice.account.city}{#if invoice.account.state}, {invoice.account.state}{/if}
+ {invoice.account.postalCode}
+ {/if}
+ {#if invoice.account?.country}
+ {invoice.account.country}
+ {/if}
+
+
+
+
To:
+
+ {#if invoice.contact}
+ {invoice.contact.firstName} {invoice.contact.lastName}
+ {:else}
+ {invoice.account?.name}
+ {/if}
+
+
+ {#if invoice.contact && invoice.contact.email}
+ {invoice.contact.email}
+ {/if}
+
+
+
+
+ Status:
+
+ {toLabel(invoice.status, QUOTE_STATUS_OPTIONS, 'Draft')}
+
+
+
+ Created: {formatDate(invoice.createdAt)}
+
+
+ Due Date: {formatDate(invoice.expirationDate)}
+
+
+
+
+
+
+
+
+ Description
+ Quantity
+ Rate
+ Total
+
+
+
+ {#each invoice.lineItems as item}
+
+ {item.description || item.productName || 'N/A'}
+ {item.quantity}
+ {formatCurrency(Number(item.unitPrice))}
+ {formatCurrency(Number(item.totalPrice))}
+
+ {/each}
+
+
+
+ Subtotal:
+ {formatCurrency(Number(invoice.subtotal))}
+
+
+
+
+
+
+
+
+
+ {#if invoice.description}
+
+
Notes:
+
+ {invoice.description}
+
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.server.ts b/apps/web/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.server.ts
new file mode 100644
index 0000000..183a652
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.server.ts
@@ -0,0 +1,140 @@
+import { error, fail, redirect, type Actions } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, asc, eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const db = locals.db
+ // Load invoice with account and line items (with product) via joins
+ const rows = await db
+ .select({
+ id: schema.quote.id,
+ accountId: schema.quote.accountId,
+ name: schema.quote.name,
+ quoteNumber: schema.quote.quoteNumber,
+ status: schema.quote.status,
+ description: schema.quote.description,
+ expirationDate: schema.quote.expirationDate,
+ accountName: schema.crmAccount.name,
+ liId: schema.quoteLineItem.id,
+ liQuantity: schema.quoteLineItem.quantity,
+ liListPrice: schema.quoteLineItem.listPrice,
+ liUnitPrice: schema.quoteLineItem.unitPrice,
+ liDiscount: schema.quoteLineItem.discount,
+ liTotalPrice: schema.quoteLineItem.totalPrice,
+ productId: schema.product.id,
+ productName: schema.product.name,
+ productCode: schema.product.code
+ })
+ .from(schema.quote)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.quote.accountId))
+ .leftJoin(schema.quoteLineItem, eq(schema.quoteLineItem.quoteId, schema.quote.id))
+ .leftJoin(schema.product, eq(schema.product.id, schema.quoteLineItem.productId))
+ .where(
+ and(
+ eq(schema.quote.id, params.invoiceId as string),
+ eq(schema.quote.organizationId, (locals.org as any).id as string)
+ )
+ )
+ .orderBy(asc(schema.quoteLineItem.id));
+
+ if (!rows.length) {
+ throw error(404, 'Invoice not found');
+ }
+
+ const base = rows[0];
+ const invoice = {
+ id: base.id,
+ accountId: base.accountId,
+ name: base.name,
+ quoteNumber: base.quoteNumber,
+ status: base.status,
+ description: base.description,
+ expirationDate: base.expirationDate,
+ account: base.accountId ? { id: base.accountId, name: base.accountName } : null,
+ lineItems: rows
+ .filter((r) => r.liId !== null)
+ .map((r) => ({
+ id: r.liId!,
+ quantity: r.liQuantity,
+ listPrice: r.liListPrice,
+ unitPrice: r.liUnitPrice,
+ discount: r.liDiscount,
+ totalPrice: r.liTotalPrice,
+ product: r.productId ? { id: r.productId, name: r.productName, code: r.productCode } : null
+ }))
+ };
+
+ // Get accounts for the dropdown
+ const accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.organizationId, locals.org!.id), eq(schema.crmAccount.isActive, true), eq(schema.crmAccount.isDeleted, false)))
+ .orderBy(asc(schema.crmAccount.name));
+
+ return {
+ invoice,
+ accounts
+ };
+}
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ const db = locals.db
+ const formData = await request.formData();
+
+ const accountId = String(formData.get('account_id') || '');
+ const invoiceDate = String(formData.get('invoice_date') || '');
+ const dueDate = String(formData.get('due_date') || '');
+ const status = String(formData.get('status') || 'DRAFT');
+ const notes = String(formData.get('notes') || '');
+
+ // Validation
+ if (!accountId || !invoiceDate || !dueDate) {
+ return fail(400, {
+ error: 'Account, invoice date, and due date are required'
+ });
+ }
+
+ try {
+ const [invoice] = await db
+ .select({ id: schema.quote.id })
+ .from(schema.quote)
+ .where(
+ and(
+ eq(schema.quote.id, params.invoiceId as string),
+ eq(schema.quote.organizationId, (locals.org as any).id as string)
+ )
+ );
+
+ if (!invoice) {
+ return fail(404, { error: 'Invoice not found' });
+ }
+
+ // Convert status for Quote model
+ const quoteStatus = status === 'DRAFT' ? 'DRAFT' :
+ status === 'SENT' ? 'PRESENTED' :
+ status === 'PAID' ? 'ACCEPTED' : 'DRAFT';
+
+ await db
+ .update(schema.quote)
+ .set({
+ accountId,
+ status: quoteStatus as any,
+ description: notes,
+ expirationDate: new Date(dueDate),
+ updatedAt: new Date()
+ })
+ .where(eq(schema.quote.id, params.invoiceId as string));
+
+ throw redirect(303, `/app/invoices/${params.invoiceId}`);
+ } catch (err) {
+ if (err instanceof Response) throw err; // Re-throw redirects
+
+ console.error('Error updating invoice:', err);
+ return fail(500, {
+ error: 'Failed to update invoice. Please try again.'
+ });
+ }
+ }
+};
diff --git a/src/routes/(app)/app/+page.svelte b/apps/web/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.svelte
similarity index 100%
rename from src/routes/(app)/app/+page.svelte
rename to apps/web/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.svelte
diff --git a/apps/web/src/routes/(app)/app/invoices/new/+page.server.ts b/apps/web/src/routes/(app)/app/invoices/new/+page.server.ts
new file mode 100644
index 0000000..963ccff
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/invoices/new/+page.server.ts
@@ -0,0 +1,72 @@
+import { fail, redirect } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { asc, eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+import { validateEnumOrDefault } from '$lib/data/enum-helpers';
+import { QUOTE_STATUSES } from '@opensource-startup-crm/constants';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const db = locals.db
+
+ // Get accounts for the dropdown
+ const accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name })
+ .from(schema.crmAccount)
+ .where(eq(schema.crmAccount.organizationId, locals.org!.id))
+ .orderBy(asc(schema.crmAccount.name));
+
+ return {
+ accounts
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ const db = locals.db
+ const formData = await request.formData();
+
+ const invoiceNumber = String(formData.get('invoice_number') || '');
+ const accountId = String(formData.get('account_id') || '');
+ const invoiceDate = String(formData.get('invoice_date') || '');
+ const dueDate = String(formData.get('due_date') || '');
+ // Validate invoice status (DRAFT | SENT | PAID), then map to quote status enum
+ const invoiceStatus = validateEnumOrDefault(formData.get('status'), ['DRAFT', 'SENT', 'PAID'] as const, 'DRAFT');
+ const mappedQuoteStatus =
+ invoiceStatus === 'DRAFT' ? 'DRAFT' : invoiceStatus === 'SENT' ? 'PRESENTED' : 'ACCEPTED';
+ const status = validateEnumOrDefault(mappedQuoteStatus, QUOTE_STATUSES, 'DRAFT');
+ const notes = String(formData.get('notes') || '');
+
+ // Validation
+ if (!invoiceNumber || !accountId || !invoiceDate || !dueDate) {
+ return fail(400, {
+ error: 'Invoice number, account, invoice date, and due date are required'
+ });
+ }
+
+ try {
+ const quoteNumber = `INV-${Date.now()}`;
+ const [quote] = await db
+ .insert(schema.quote)
+ .values({
+ quoteNumber,
+ name: `Invoice ${invoiceNumber}`,
+ status,
+ description: notes,
+ expirationDate: new Date(dueDate),
+ subtotal: '0.00',
+ grandTotal: '0.00',
+ preparedById: locals.user!.id,
+ accountId: accountId,
+ organizationId: locals.org!.id
+ })
+ .returning();
+
+ throw redirect(303, `/app/invoices/${quote.id}`);
+ } catch (error) {
+ console.error('Error creating invoice:', error);
+ return fail(500, {
+ error: 'Failed to create invoice. Please try again.'
+ });
+ }
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/invoices/new/+page.svelte b/apps/web/src/routes/(app)/app/invoices/new/+page.svelte
new file mode 100644
index 0000000..9291815
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/invoices/new/+page.svelte
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
+
+
Create a professional invoice for your client
+
+
+ DRAFT
+
+
+
+
+
+
+
+ Invoice Number
+
+
+
+ Account *
+
+ Select Account
+ {#each data.accounts as account}
+ {account.name}
+ {/each}
+
+
+
+ Invoice Date *
+
+
+
+ Due Date *
+
+
+
+ Status
+
+ Draft
+ Sent
+ Paid
+
+
+
+
+
+
+
+
Line Items
+
+
+ Add Item
+
+
+
+
+
+
+
+
+ Notes
+
+
+
+
+
+ goto('/app/invoices')}
+ class="flex items-center gap-2 rounded-lg bg-blue-100 px-6 py-3 font-semibold text-blue-700 shadow transition hover:bg-blue-200"
+ >
+
+ Cancel
+
+
+
+ Save Invoice
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/leads/+page.server.ts b/apps/web/src/routes/(app)/app/leads/+page.server.ts
new file mode 100644
index 0000000..4e539d5
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/leads/+page.server.ts
@@ -0,0 +1,39 @@
+import type { PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, desc, eq, inArray } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+
+ const rows = await db
+ .select({
+ id: schema.lead.id,
+ firstName: schema.lead.firstName,
+ lastName: schema.lead.lastName,
+ email: schema.lead.email,
+ company: schema.lead.company,
+ phone: schema.lead.phone,
+ status: schema.lead.status,
+ leadSource: schema.lead.leadSource,
+ rating: schema.lead.rating,
+ title: schema.lead.title,
+ createdAt: schema.lead.createdAt,
+ isConverted: schema.lead.isConverted,
+ updatedAt: schema.lead.updatedAt,
+ ownerName: schema.user.name,
+ ownerEmail: schema.user.email
+ })
+ .from(schema.lead)
+ .leftJoin(schema.user, eq(schema.user.id, schema.lead.ownerId))
+ .where(
+ and(
+ eq(schema.lead.organizationId, org.id),
+ inArray(schema.lead.status, ['NEW', 'PENDING', 'CONTACTED', 'QUALIFIED']),
+ eq(schema.lead.isConverted, false)
+ )
+ )
+ .orderBy(desc(schema.lead.updatedAt));
+
+ return { leads: rows };
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/leads/+page.svelte b/apps/web/src/routes/(app)/app/leads/+page.svelte
new file mode 100644
index 0000000..f13f1a8
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/leads/+page.svelte
@@ -0,0 +1,617 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Open Leads
+
+
+ {filteredLeads.length} of {leads.length} leads
+
+
+
+
+
+ New Lead
+
+
+
+
+
+
+
+
+
+
+
+ Search leads
+
+
+
+
(showFilters = !showFilters)}
+ class="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-gray-900 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-700"
+ >
+
+ Filters
+ {#if showFilters}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#if showFilters}
+
+
+ Status
+
+ {#each leadStatusFilterOptions as status}
+ {status.label}
+ {/each}
+
+
+
+ Source
+
+ {#each leadSourceFilterOptions as source}
+ {source.label}
+ {/each}
+
+
+
+ Rating
+
+ {#each leadRatingFilterOptions as rating}
+ {rating.label}
+ {/each}
+
+
+
+
+ Clear Filters
+
+
+
+ {/if}
+
+
+
+
+
+ {#if isLoading}
+
+ {:else if filteredLeads.length === 0}
+
+
📭
+
No leads found
+
+ Try adjusting your search criteria or create a new lead.
+
+
+
+ Create New Lead
+
+
+ {:else}
+
+
+
+
+
+
+
+ Lead
+ toggleSort('company')}
+ >
+
+ Company
+ {#if sortBy === 'company'}
+ {#if sortOrder === 'asc'}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+
+ Contact
+ Source
+ Rating
+ Status
+ toggleSort('createdAt')}
+ >
+
+ Created
+ {#if sortBy === 'createdAt'}
+ {#if sortOrder === 'asc'}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+
+ Owner
+ Actions
+
+
+
+ {#each filteredLeads as lead, i}
+ {@const statusItem = leadStatusVisuals[lead.status]}
+ {@const StatusIcon = statusItem?.icon || AlertCircle}
+ {@const ratingItem = lead.rating ? ratingVisuals[lead.rating] : undefined}
+
+
+
+
+ {lead.firstName.charAt(0)}{lead.lastName.charAt(0)}
+
+
+
+
+
+ {#if lead.company}
+
+
+ {lead.company}
+
+ {:else}
+ -
+ {/if}
+
+
+
+
+
+ {#if lead.leadSource}
+
+ {toLabel(lead.leadSource, LEAD_SOURCE_OPTIONS, '-')}
+
+ {:else}
+ -
+ {/if}
+
+
+ {#if lead.rating}
+
+ {#each Array(ratingItem?.dots || 0) as _, i}
+
+ {/each}
+
{toLabel(lead.rating, leadRatingFilterOptions.slice(1), '-')}
+
+ {:else}
+ -
+ {/if}
+
+
+
+
+
+ {toLabel(lead.status, LEAD_STATUS_OPTIONS, '-')}
+
+
+
+
+
+
+ {formatDate(lead.createdAt)}
+
+
+
+ {#if lead.ownerName}
+
+
+ {lead.ownerName.charAt(0)}
+
+
{lead.ownerName}
+
+ {:else}
+ -
+ {/if}
+
+
+
+
+ View
+
+
+
+ {/each}
+
+
+
+
+
+
+
+ {#each filteredLeads as lead, i}
+ {@const statusItem = leadStatusVisuals[lead.status]}
+ {@const ratingItem = lead.rating ? ratingVisuals[lead.rating] : undefined}
+
+
+
+
+
+ {lead.firstName.charAt(0)}{lead.lastName.charAt(0)}
+
+
+
+
+ {#each [lead.status] as _}
+ {@const statusItem = leadStatusVisuals[lead.status]}
+ {@const StatusIcon = statusItem?.icon || AlertCircle}
+
+
+ {lead.status}
+
+ {/each}
+
+
+
+
+
+ {#if lead.company}
+
+
+ {lead.company}
+
+ {/if}
+
+ {#if lead.email}
+
+
+ {lead.email}
+
+ {/if}
+
+ {#if lead.phone}
+
+
+ {lead.phone}
+
+ {/if}
+
+
+
+
+ {formatDate(lead.createdAt)}
+
+
+ {#if lead.rating}
+ {@const ratingItem = lead.rating ? ratingVisuals[lead.rating] : undefined}
+
+ {#each Array(ratingItem?.dots || 0) as _, i}
+
+ {/each}
+
{lead.rating}
+
+ {/if}
+
+
+ {#if lead.ownerName}
+
+
+ Owned by {lead.ownerName}
+
+ {/if}
+
+
+
+
+
+ {/each}
+
+
+ {/if}
+
+
diff --git a/apps/web/src/routes/(app)/app/leads/[lead_id]/+page.server.ts b/apps/web/src/routes/(app)/app/leads/[lead_id]/+page.server.ts
new file mode 100644
index 0000000..f50b2c3
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/leads/[lead_id]/+page.server.ts
@@ -0,0 +1,268 @@
+import { error, fail } from '@sveltejs/kit';
+import { z } from 'zod';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq, sql, asc, desc } from 'drizzle-orm';
+import { alias } from 'drizzle-orm/pg-core';
+import type { PageServerLoad, Actions } from './$types';
+
+const commentSchema = z.object({
+ comment: z.string().min(1, 'Comment cannot be empty').max(1000, 'Comment too long').trim()
+});
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const leadId = params.lead_id;
+ const org = locals.org!;
+ const db = locals.db
+
+ const taskOwner = alias(schema.user, 'task_owner');
+ const eventOwner = alias(schema.user, 'event_owner');
+ const commentAuthor = alias(schema.user, 'comment_author');
+
+ const rows = await db
+ .select({
+ lead: schema.lead,
+ owner: schema.user,
+ contact: schema.contact,
+ // task
+ task: {
+ id: schema.task.id,
+ subject: schema.task.subject,
+ createdAt: schema.task.createdAt
+ },
+ taskOwnerName: taskOwner.name,
+ // event
+ event: {
+ id: schema.event.id,
+ subject: schema.event.subject,
+ startDate: schema.event.startDate
+ },
+ eventOwnerName: eventOwner.name,
+ // comment
+ comment: {
+ id: schema.comment.id,
+ body: schema.comment.body,
+ createdAt: schema.comment.createdAt,
+ authorId: schema.comment.authorId
+ },
+ commentAuthorName: commentAuthor.name
+ })
+ .from(schema.lead)
+ .leftJoin(schema.user, eq(schema.user.id, schema.lead.ownerId))
+ .leftJoin(schema.contact, eq(schema.contact.id, schema.lead.contactId))
+ .leftJoin(schema.task, eq(schema.task.leadId, schema.lead.id))
+ .leftJoin(taskOwner, eq(taskOwner.id, schema.task.ownerId))
+ .leftJoin(schema.event, eq(schema.event.leadId, schema.lead.id))
+ .leftJoin(eventOwner, eq(eventOwner.id, schema.event.ownerId))
+ .leftJoin(schema.comment, eq(schema.comment.leadId, schema.lead.id))
+ .leftJoin(commentAuthor, eq(commentAuthor.id, schema.comment.authorId))
+ .where(and(eq(schema.lead.id, leadId), eq(schema.lead.organizationId, org.id)))
+ .orderBy(
+ // Ensure stable grouping and ordering across joined rows
+ sql`CASE WHEN ${schema.task.id} IS NOT NULL THEN 1 WHEN ${schema.event.id} IS NOT NULL THEN 2 WHEN ${schema.comment.id} IS NOT NULL THEN 3 ELSE 4 END`,
+ desc(schema.task.createdAt),
+ asc(schema.event.startDate),
+ desc(schema.comment.createdAt)
+ );
+
+ if (!rows || rows.length === 0) {
+ throw error(404, 'Lead not found');
+ }
+
+ const head = rows[0];
+ const tasks: { id: string; subject: string | null; createdAt: Date | null; ownerName: string | null }[] = [];
+ const events: { id: string; subject: string | null; startDate: Date | null; ownerName: string | null }[] = [];
+ const comments: { id: string; body: string; createdAt: Date; authorId: string; authorName: string | null }[] = [];
+ const seenTask = new Set();
+ const seenEvent = new Set();
+ const seenComment = new Set();
+
+ for (const r of rows) {
+ if (r.task && r.task.id && !seenTask.has(r.task.id)) {
+ seenTask.add(r.task.id);
+ tasks.push({
+ id: r.task.id,
+ subject: r.task.subject,
+ createdAt: r.task.createdAt,
+ ownerName: (r).taskOwnerName ?? null
+ });
+ }
+ if (r.event && r.event.id && !seenEvent.has(r.event.id)) {
+ seenEvent.add(r.event.id);
+ events.push({
+ id: r.event.id,
+ subject: r.event.subject,
+ startDate: r.event.startDate,
+ ownerName: (r).eventOwnerName ?? null
+ });
+ }
+ if (r.comment && r.comment.id && !seenComment.has(r.comment.id)) {
+ seenComment.add(r.comment.id);
+ comments.push({
+ id: r.comment.id,
+ body: r.comment.body,
+ createdAt: r.comment.createdAt,
+ authorId: r.comment.authorId,
+ authorName: (r).commentAuthorName ?? null
+ });
+ }
+ }
+
+ return {
+ lead: {
+ ...head.lead,
+ owner: head.owner,
+ contact: head.contact,
+ tasks,
+ events,
+ comments
+ }
+ };
+};
+
+export const actions: Actions = {
+ convert: async ({ params, locals }) => {
+ const leadId = params.lead_id;
+ const org = locals.org!;
+ const db = locals.db
+
+ try {
+ const [lead] = await db
+ .select()
+ .from(schema.lead)
+ .where(and(eq(schema.lead.id, leadId), eq(schema.lead.organizationId, org.id)));
+
+ if (!lead) {
+ return fail(404, { status: 'error', message: 'Lead not found' });
+ }
+ if (lead.status === 'CONVERTED') {
+ return { status: 'success', message: 'Lead already converted' };
+ }
+
+ const [contact] = await db
+ .insert(schema.contact)
+ .values({
+ firstName: lead.firstName,
+ lastName: lead.lastName,
+ email: lead.email,
+ phone: lead.phone,
+ title: lead.title,
+ description: lead.description,
+ ownerId: lead.ownerId,
+ organizationId: lead.organizationId
+ })
+ .returning();
+
+ let accountId: string | null = null;
+ if (lead.company) {
+ const [account] = await db
+ .insert(schema.crmAccount)
+ .values({
+ name: lead.company,
+ industry: lead.industry,
+ ownerId: lead.ownerId,
+ organizationId: lead.organizationId
+ })
+ .returning();
+ accountId = account.id;
+ await db.insert(schema.accountContactRelationship).values({
+ accountId: account.id,
+ contactId: contact.id,
+ isPrimary: true,
+ role: 'Primary Contact'
+ });
+ } else {
+ const [account] = await db
+ .insert(schema.crmAccount)
+ .values({
+ name: `${lead.firstName} ${lead.lastName} Account`,
+ ownerId: lead.ownerId,
+ organizationId: lead.organizationId
+ })
+ .returning();
+ accountId = account.id;
+ await db.insert(schema.accountContactRelationship).values({
+ accountId: account.id,
+ contactId: contact.id,
+ isPrimary: true,
+ role: 'Primary Contact'
+ });
+ }
+
+ const [opportunity] = await db
+ .insert(schema.opportunity)
+ .values({
+ name: `${lead.company || lead.firstName + ' ' + lead.lastName} Opportunity`,
+ stage: 'PROSPECTING',
+ amount: 0,
+ closeDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
+ ownerId: lead.ownerId,
+ organizationId: lead.organizationId,
+ accountId: accountId!
+ })
+ .returning();
+
+ // link contact to opportunity via join table
+ await db.insert(schema.contactToOpportunity).values({ contactId: contact.id, opportunityId: opportunity.id });
+
+ await db
+ .update(schema.lead)
+ .set({
+ status: 'CONVERTED',
+ isConverted: true,
+ convertedAt: new Date(),
+ convertedContactId: contact.id,
+ convertedAccountId: accountId!,
+ convertedOpportunityId: opportunity.id,
+ contactId: contact.id
+ })
+ .where(eq(schema.lead.id, leadId));
+
+ return {
+ status: 'success',
+ message: 'Lead successfully converted',
+ redirectTo: `/app/accounts/${accountId}`,
+ contact,
+ opportunity
+ };
+ } catch (err: any) {
+ console.error('Error converting lead:', err?.message || err);
+ return fail(500, { status: 'error', message: `Error converting lead: ${err?.message || 'unknown'}` });
+ }
+ },
+
+ addComment: async ({ params, request, locals }) => {
+ const leadId = params.lead_id;
+ const org = locals.org!;
+ const user = locals.user!;
+ const db = locals.db
+
+ const data = await request.formData();
+ const comment = data.get('comment');
+ try {
+ const validated = commentSchema.parse({ comment });
+
+ const [lead] = await db
+ .select({ organizationId: schema.lead.organizationId })
+ .from(schema.lead)
+ .where(and(eq(schema.lead.id, leadId), eq(schema.lead.organizationId, org.id)));
+ if (!lead) {
+ return fail(404, { status: 'error', message: 'Lead not found' });
+ }
+
+ const updated = await db.insert(schema.comment).values({
+ body: validated.comment,
+ leadId: leadId,
+ authorId: user.id,
+ organizationId: lead.organizationId
+ }).returning();
+
+ return { status: 'success', message: 'Comment added successfully', commentAdded: true, comment: { ...updated[0], authorName: user.name } };
+ } catch (err: any) {
+ if (err instanceof z.ZodError) {
+ return fail(400, { status: 'error', message: err.issues[0].message });
+ }
+ console.error('Error adding comment:', err?.message || err);
+ return fail(500, { status: 'error', message: 'Failed to add comment' });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/leads/[lead_id]/+page.svelte b/apps/web/src/routes/(app)/app/leads/[lead_id]/+page.svelte
new file mode 100644
index 0000000..d72b1af
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/leads/[lead_id]/+page.svelte
@@ -0,0 +1,825 @@
+
+
+
+{#if showConfirmModal}
+
+
+
+
+
+
+
+
Convert Lead
+
This action cannot be undone
+
+
+
+
+ Are you sure you want to convert {getFullName(lead)} into an account and contact?
+ This will create new records and mark the lead as converted.
+
+
+
+
+ Cancel
+
+
+
+ Yes, Convert Lead
+
+
+
+
+{/if}
+
+
+{#if showToast}
+
+
+
+ {#if toastType === 'success'}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
{toastMessage}
+
+
+
+
+
+{/if}
+
+
+
+
+
+
+
+ Leads
+
+
+
+ {getFullName(lead)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {getInitials(lead)}
+
+
+
+
+ {getFullName(lead)}
+
+
+
+ {toLabel(lead.status, LEAD_STATUS_OPTIONS, '-')}
+
+ {#if lead.company}
+
+
+ {lead.company}
+
+ {/if}
+ {#if lead.title}
+
+
+ {lead.title}
+
+ {/if}
+
+
+
+
+ {#if lead.email}
+
+ {/if}
+
+ {#if lead.phone}
+
+ {/if}
+
+
+
+
+
+
+
+
+ {#if lead.status !== 'CONVERTED'}
+
+
+
+
+ {#if isConverting}
+
+ Converting...
+ {:else}
+
+ Convert Lead
+ {/if}
+
+ {/if}
+
+
+ Edit Lead
+
+
+
+
+
+
+
+
+
+
+
+ Lead Information
+
+
+
+
+ {#if lead.leadSource}
+
+
+
+ Lead Source
+
+
+ {toLabel(lead.leadSource, LEAD_SOURCE_OPTIONS, 'Unknown')}
+
+
+ {/if}
+
+
+ {#if lead.industry}
+
+
+
+ Industry
+
+
+ {lead.industry}
+
+
+ {/if}
+
+
+ {#if lead.rating}
+ {@const ratingItem = lead.rating ? ratingVisuals[lead.rating] : undefined}
+
+
+
+ {#each Array(ratingItem?.dots || 0) as _, i}
+
+ {/each}
+
{toLabel(lead.rating, RATING_OPTIONS, '-')}
+
+
+ {/if}
+
+
+
+
+
+ Lead Owner
+
+
+ {lead.owner?.name || 'Unassigned'}
+
+
+
+
+
+
+
+ Created
+
+
+ {formatDate(
+ lead.createdAt,
+ 'en-US',
+ { hour: '2-digit', minute: '2-digit' },
+ 'N/A'
+ )}
+
+
+
+
+
+ {#if lead.description}
+
+
+
+ Description
+
+
+
+ {@html lead.description}
+
+
+
+ {/if}
+
+
+ {#if lead.isConverted}
+
+
+
+
+
+
+
+ Lead Converted
+
+
+ {#if lead.convertedAt}
+
+ Converted on {formatDate(lead.convertedAt)}
+
+ {/if}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Activity & Notes
+
+
+
+
+
+
+
+
+
+
+ {#if isSubmittingComment}
+
+ Adding...
+ {:else}
+
+ Add Note
+ {/if}
+
+
+
+
+
+
+
+ {#if lead.comments && lead.comments.length > 0}
+ {#each lead.comments as comment, i (comment.id || i)}
+
+
+
+
+
+ {comment.authorName || 'Unknown User'}
+
+
+ {formatDate(comment.createdAt)}
+
+
+
+ {comment.body}
+
+
+
+ {/each}
+ {:else}
+
+
+
+
+
+ No activity yet
+
+
+ Be the first to add a note or log an interaction.
+
+
+ {/if}
+
+
+
+
+
+ {#if lead.convertedContactId && lead.contact}
+
+
+
+
+
+
+ Related Contact
+
+
+
+
+
+
+
+
+
+
+
+ {lead.contact.firstName}
+ {lead.contact.lastName}
+
+
Contact
+
+
+
+ {#if lead.contact.email}
+
+
+ {lead.contact.email}
+
+ {/if}
+
+ {#if lead.contact.phone}
+
+
+
{lead.contact.phone}
+
+ {/if}
+
+
+
+ View Contact
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+ Quick Stats
+
+
+
+
+
+ Comments
+ {lead.comments?.length || 0}
+
+
+ Days Since Created
+
+ {Math.floor(
+ (new Date().getTime() - new Date(lead.createdAt).getTime()) /
+ (1000 * 60 * 60 * 24)
+ )}
+
+
+ {#if lead.convertedAt}
+
+ Days to Convert
+
+ {Math.floor(
+ (new Date(lead.convertedAt).getTime() - new Date(lead.createdAt).getTime()) /
+ (1000 * 60 * 60 * 24)
+ )}
+
+
+ {/if}
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/leads/[lead_id]/edit/+page.server.ts b/apps/web/src/routes/(app)/app/leads/[lead_id]/edit/+page.server.ts
new file mode 100644
index 0000000..d0c44c1
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/leads/[lead_id]/edit/+page.server.ts
@@ -0,0 +1,122 @@
+import { error, fail, redirect } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq } from 'drizzle-orm';
+import { validateEnumOrDefault, validateEnumOrNull } from '$lib/data/enum-helpers';
+import { INDUSTRIES, LEAD_SOURCES, LEAD_STATUSES, RATINGS } from '@opensource-startup-crm/constants';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const { lead_id } = params;
+ const org = locals.org!;
+ const db = locals.db
+
+ const [lead] = await db
+ .select({
+ id: schema.lead.id,
+ firstName: schema.lead.firstName,
+ lastName: schema.lead.lastName,
+ email: schema.lead.email,
+ phone: schema.lead.phone,
+ company: schema.lead.company,
+ title: schema.lead.title,
+ industry: schema.lead.industry,
+ rating: schema.lead.rating,
+ description: schema.lead.description,
+ status: schema.lead.status,
+ leadSource: schema.lead.leadSource,
+ ownerId: schema.lead.ownerId,
+ contactId: schema.lead.contactId
+ })
+ .from(schema.lead)
+ .where(and(eq(schema.lead.id, lead_id), eq(schema.lead.organizationId, org.id)));
+
+ if (!lead) throw error(404, 'Lead not found');
+
+ const users = await db
+ .select({ user: { id: schema.user.id, name: schema.user.name } })
+ .from(schema.member)
+ .innerJoin(schema.user, eq(schema.member.userId, schema.user.id))
+ .where(eq(schema.member.organizationId, org.id));
+
+ return { lead, users };
+};
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ const { lead_id } = params;
+ const formData = await request.formData();
+ const org = locals.org!;
+ const db = locals.db
+
+ const leadEmail = formData.get('email');
+ const ownerId = formData.get('ownerId');
+ const firstName = formData.get('firstName');
+ const lastName = formData.get('lastName');
+
+ if (!firstName || typeof firstName !== 'string' || firstName.trim() === '') {
+ return { success: false, error: 'First name is required.' };
+ }
+ if (!lastName || typeof lastName !== 'string' || lastName.trim() === '') {
+ return { success: false, error: 'Last name is required.' };
+ }
+ if (!ownerId || typeof ownerId !== 'string') {
+ return { success: false, error: 'Owner ID is required.' };
+ }
+
+ const [ownerValidation] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, ownerId), eq(schema.member.organizationId, org.id)));
+ if (!ownerValidation) {
+ return { success: false, error: 'Invalid owner selected. User is not part of this organization.' };
+ }
+
+ if (typeof leadEmail === 'string' && leadEmail.trim() !== '') {
+ const [user] = await db
+ .select({ id: schema.user.id })
+ .from(schema.user)
+ .where(eq(schema.user.email, leadEmail));
+ if (!user) {
+ return { success: false, error: 'User with this email does not exist.' };
+ }
+ const [userOrgMembership] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, user.id), eq(schema.member.organizationId, org.id)));
+ if (!userOrgMembership) {
+ return { success: false, error: 'User is not part of this organization.' };
+ }
+ }
+
+ const status = validateEnumOrDefault(formData.get('status'), LEAD_STATUSES, 'PENDING');
+ const leadSource = validateEnumOrNull(formData.get('leadSource'), LEAD_SOURCES);
+ const industry = validateEnumOrNull(formData.get('industry'), INDUSTRIES);
+ const rating = validateEnumOrNull(formData.get('rating'), RATINGS);
+
+ try {
+ await db
+ .update(schema.lead)
+ .set({
+ firstName: firstName.toString().trim(),
+ lastName: lastName.toString().trim(),
+ email: formData.get('email')?.toString() || null,
+ phone: formData.get('phone')?.toString() || null,
+ company: formData.get('company')?.toString() || null,
+ title: formData.get('title')?.toString() || null,
+ industry,
+ rating,
+ description: formData.get('description')?.toString() || null,
+ ownerId: ownerId.toString(),
+ organizationId: org.id,
+ status,
+ leadSource
+ })
+ .where(eq(schema.lead.id, lead_id));
+
+ return { success: true };
+ } catch (err) {
+ console.error('Error updating lead:', err);
+ return { success: false, error: 'Failed to update lead' };
+ }
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/leads/[lead_id]/edit/+page.svelte b/apps/web/src/routes/(app)/app/leads/[lead_id]/edit/+page.svelte
new file mode 100644
index 0000000..c615208
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/leads/[lead_id]/edit/+page.svelte
@@ -0,0 +1,513 @@
+
+
+
+
+
+
+
+
+
goto(`/app/leads/${lead.id}`)}
+ class="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
+ >
+
+
+
+
Edit Lead
+
+ Editing {lead.firstName}
+ {lead.lastName}
+
+
+
+
+
+
+
+
+
+ {#if formSubmitted && !errorMessage}
+
+
+
+
+
+ Lead updated successfully!
+
+
+
+
+ {/if}
+
+ {#if errorMessage}
+
+ {/if}
+
+
+ {
+ const isValid = validateForm(formData);
+ if (!isValid) return;
+
+ isSubmitting = true;
+ return async ({ result, update }) => {
+ isSubmitting = false;
+ formSubmitted = true;
+
+ if (result.type === 'success') {
+ if (result.data?.success) {
+ await update();
+ setTimeout(() => {
+ goto(`/app/leads/${lead.id}`);
+ }, 1500);
+ } else if (result.data?.error) {
+ errorMessage = result.data.error as string;
+ }
+ } else {
+ errorMessage = 'An unexpected error occurred';
+ }
+ };
+ }}
+ class="space-y-8"
+ >
+
+
+
+
+
+
+ Personal Information
+
+
+
+
+
+
+
+ First Name *
+
+
+ {#if errors.firstName}
+
{errors.firstName}
+ {/if}
+
+
+
+
+ Last Name *
+
+
+ {#if errors.lastName}
+
{errors.lastName}
+ {/if}
+
+
+
+
+
+ Email Address
+
+
+ {#if errors.email}
+
{errors.email}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
Company Information
+
+
+
+
+
+ Company Name
+
+
+
+
+ Job Title
+
+
+
+
+ Industry
+
+ Select Industry
+ {#each industryOptions as option}
+ {option.label}
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+ Status
+
+ {#each statusOptions as option}
+ {option.label}
+ {/each}
+
+
+
+
+ Lead Source
+
+ Select Source
+ {#each sourceOptions as option}
+ {option.label}
+ {/each}
+
+
+
+
+
+
+ Rating
+
+
+ Select Rating
+ {#each ratingOptions as option}
+ {option.label}
+ {/each}
+
+
+
+
+
+
+ Lead Owner
+
+ {#each users as userOrg}
+ {userOrg.user.name}
+ {/each}
+
+
+
+
+
+ Description / Notes
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+
+
+
+ {:else}
+
+ {/if}
+ Save Changes
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/leads/new/+page.server.ts b/apps/web/src/routes/(app)/app/leads/new/+page.server.ts
new file mode 100644
index 0000000..85965e9
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/leads/new/+page.server.ts
@@ -0,0 +1,121 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { eq, and, asc } from 'drizzle-orm';
+import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js';
+import { validateEnumOrDefault, validateEnumOrNull } from '$lib/data/enum-helpers';
+import { LEAD_STATUSES, LEAD_SOURCES, INDUSTRIES } from '@opensource-startup-crm/constants';
+
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db
+
+ // Get the submitted form data
+ const formData = await request.formData();
+
+ // Extract and validate required fields
+ const firstName = formData.get('first_name')?.toString().trim();
+ const lastName = formData.get('last_name')?.toString().trim() || '';
+ const email = formData.get('email')?.toString().trim();
+ const leadTitle = formData.get('lead_title')?.toString().trim();
+
+ if (!firstName) {
+ return fail(400, { error: 'First name is required' });
+ }
+
+ if (!lastName) {
+ return fail(400, { error: 'Last name is required' });
+ }
+
+ if (!leadTitle) {
+ return fail(400, { error: 'Lead title is required' });
+ }
+
+ // Validate phone number if provided
+ let formattedPhone = null;
+ const phone = formData.get('phone')?.toString();
+ if (phone && phone.trim().length > 0) {
+ const phoneValidation = validatePhoneNumber(phone.trim());
+ if (!phoneValidation.isValid) {
+ return fail(400, { error: phoneValidation.error || 'Please enter a valid phone number' });
+ }
+ formattedPhone = formatPhoneForStorage(phone.trim());
+ }
+
+ // Validate enum-backed fields using shared generic helpers
+ const status = validateEnumOrDefault(formData.get('status'), LEAD_STATUSES, 'PENDING');
+ const leadSource = validateEnumOrNull(formData.get('source'), LEAD_SOURCES);
+ const industry = validateEnumOrNull(formData.get('industry'), INDUSTRIES);
+
+ // Extract all form fields
+ const leadData = {
+ firstName,
+ lastName,
+ title: leadTitle,
+ email: email || null,
+ phone: formattedPhone,
+ company: formData.get('company')?.toString() || null,
+ status,
+ leadSource,
+ industry,
+ description: formData.get('description')?.toString() || null,
+
+ // Store opportunity amount in description since it's not in the Lead schema
+ opportunityAmount: formData.get('opportunity_amount') ?
+ parseFloat(formData.get('opportunity_amount')?.toString() || '0') : null,
+
+ // Store probability in description since it's not in the Lead schema
+ probability: formData.get('probability') ?
+ parseFloat(formData.get('probability')?.toString() || '0') : null,
+
+ // Address fields
+ street: formData.get('street')?.toString() || null,
+ city: formData.get('city')?.toString() || null,
+ state: formData.get('state')?.toString() || null,
+ postalCode: formData.get('postcode')?.toString() || null,
+ country: formData.get('country')?.toString() || null,
+
+ // Save these to include in description if not available in the model
+ website: formData.get('website')?.toString() || null,
+ skypeID: formData.get('skype_ID')?.toString() || null,
+ };
+
+ try {
+ const [membership] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, user.id), eq(schema.member.organizationId, org.id)));
+ if (!membership) return fail(403, { error: 'Not a member of this organization' });
+
+ const [lead] = await db
+ .insert(schema.lead)
+ .values({
+ firstName: leadData.firstName!,
+ lastName: leadData.lastName!,
+ email: leadData.email,
+ phone: leadData.phone,
+ company: leadData.company,
+ title: leadData.title!,
+ status: leadData.status,
+ leadSource: leadData.leadSource,
+ industry: leadData.industry,
+ description: leadData.description || '',
+ rating: null,
+ ownerId: user.id,
+ organizationId: org.id
+ })
+ .returning();
+
+ return { status: 'success', message: 'Lead created successfully', lead: { id: lead.id, name: `${lead.firstName} ${lead.lastName}` } };
+ } catch (err) {
+ console.error('Error creating lead:', err);
+ return fail(500, {
+ error: 'Failed to create lead: ' + (err instanceof Error ? err.message : 'Unknown error'),
+ values: leadData // Return entered values so the form can be repopulated
+ });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/leads/new/+page.svelte b/apps/web/src/routes/(app)/app/leads/new/+page.svelte
new file mode 100644
index 0000000..0761bf4
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/leads/new/+page.svelte
@@ -0,0 +1,979 @@
+
+
+
+
+
+ {#if showToast}
+
+
+
+ {#if toastType === 'success'}
+
+ {:else}
+
+ {/if}
+
+
+
(showToast = false)}
+ class="-mx-1.5 -my-1.5 ml-auto rounded-lg p-1.5 {toastType === 'success'
+ ? 'text-green-500 hover:bg-green-100 dark:text-green-400 dark:hover:bg-green-800/30'
+ : 'text-red-500 hover:bg-red-100 dark:text-red-400 dark:hover:bg-red-800/30'}"
+ >
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Create New Lead
+
+
+ Capture lead information and start building relationships
+
+
+
+
+
+
+
+ {#if form?.error}
+
+
+
+
Error:
+
{form.error}
+
+
+ {/if}
+
+
+
{
+ if (!validateForm()) {
+ cancel();
+ return;
+ }
+
+ isSubmitting = true;
+
+ return async ({ result }) => {
+ isSubmitting = false;
+
+ if (result.type === 'success') {
+ showNotification('Lead created successfully!', 'success');
+ resetForm();
+ setTimeout(() => goto('/app/leads'), 1500);
+ } else if (result.type === 'failure') {
+ const errorMessage =
+ result.data && typeof result.data === 'object' && 'error' in result.data
+ ? String(result.data.error)
+ : 'Failed to create lead';
+ showNotification(errorMessage, 'error');
+ }
+ };
+ }}
+ class="space-y-6"
+ >
+
+
+
+
+
+ Lead Information
+
+
+
+
+
+
+
+ Lead Title *
+
+
+ {#if errors.lead_title}
+
{errors.lead_title}
+ {/if}
+
+
+
+
+
+
+ Company
+
+
+
+
+
+
+
+ Lead Source
+
+
+ Select source
+ {#each sourceOptions as { value, label }}
+ {label}
+ {/each}
+
+
+
+
+
+
+ Industry
+
+
+ Select industry
+ {#each industryOptions as { value, label }}
+ {label}
+ {/each}
+
+
+
+
+
+
+ Status
+
+
+ {#each leadStatusOptions as { value, label }}
+ {label}
+ {/each}
+
+
+
+
+
+
+ Rating
+
+
+ {#each ratingOptions as { value, label }}
+ {label}
+ {/each}
+
+
+
+
+
+
+
+ Website
+
+
+ {#if errors.website}
+
{errors.website}
+ {/if}
+
+
+
+
+
+
+ Opportunity Amount
+
+
+
+
+
+
+
+
+ Probability (%)
+
+
+ {#if errors.probability}
+
{errors.probability}
+ {/if}
+
+
+
+
+
+ Budget Range
+
+
+ Select budget range
+ Under $10K
+ $10K - $50K
+ $50K - $100K
+ $100K - $500K
+ $500K+
+
+
+
+
+
+
+
+ Decision Timeframe
+
+
+ Select timeframe
+ Immediate (< 1 month)
+ Short term (1-3 months)
+ Medium term (3-6 months)
+ Long term (6+ months)
+
+
+
+
+
+
+
+
+
+
+
+ Contact Information
+
+
+
+
+
+
+
+ First Name *
+
+
+ {#if errors.first_name}
+
{errors.first_name}
+ {/if}
+
+
+
+
+
+ Last Name *
+
+
+ {#if errors.last_name}
+
{errors.last_name}
+ {/if}
+
+
+
+
+
+ Job Title
+
+
+
+
+
+
+
+
+ Phone
+
+
+ {#if errors.phone}
+
{errors.phone}
+ {/if}
+ {#if phoneError}
+
{phoneError}
+ {/if}
+
+
+
+
+
+
+ Email *
+
+
+ {#if errors.email}
+
{errors.email}
+ {/if}
+
+
+
+
+
+ LinkedIn Profile
+
+
+ {#if errors.linkedin_url}
+
{errors.linkedin_url}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Address Information
+
+
+
+
+
+
+
+
+
Additional Details
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+ Pain Points
+
+
+
+
+
+
+
+
+
+
+
+
+
+
goto('/app/leads/')}
+ disabled={isSubmitting}
+ class="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-6 py-2 text-gray-700 transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-blue-400 dark:focus:ring-offset-gray-800"
+ >
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Creating...
+ {:else}
+
+ Create Lead
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/opportunities/+page.server.ts b/apps/web/src/routes/(app)/app/opportunities/+page.server.ts
new file mode 100644
index 0000000..a69239f
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/+page.server.ts
@@ -0,0 +1,99 @@
+import { schema } from '@opensource-startup-crm/database';
+import { fail } from '@sveltejs/kit';
+import { and, desc, eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const db = locals.db
+ try {
+ // Fetch opportunities with joins
+ const rows = await db
+ .select({
+ id: schema.opportunity.id,
+ name: schema.opportunity.name,
+ amount: schema.opportunity.amount,
+ expectedRevenue: schema.opportunity.expectedRevenue,
+ stage: schema.opportunity.stage,
+ type: schema.opportunity.type,
+ probability: schema.opportunity.probability,
+ closeDate: schema.opportunity.closeDate,
+ createdAt: schema.opportunity.createdAt,
+ account: {
+ id: schema.crmAccount.id,
+ name: schema.crmAccount.name,
+ type: schema.crmAccount.type,
+ },
+ owner: {
+ id: schema.user.id,
+ name: schema.user.name,
+ email: schema.user.email,
+ },
+ _count: {
+ tasks: db.$count(schema.task, eq(schema.task.opportunityId, schema.opportunity.id)),
+ events: db.$count(schema.event, eq(schema.event.opportunityId, schema.opportunity.id))
+ }
+ })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .leftJoin(schema.user, eq(schema.user.id, schema.opportunity.ownerId))
+ .where(eq(schema.opportunity.organizationId, locals.org!.id))
+ .orderBy(desc(schema.opportunity.createdAt));
+
+ // Calculate stats
+ const stats = {
+ total: rows.length,
+ totalValue: rows.reduce((sum, opp) => sum + (Number(opp.amount) || 0), 0),
+ wonValue: rows
+ .filter(opp => opp.stage === 'CLOSED_WON')
+ .reduce((sum, opp) => sum + (Number(opp.amount) || 0), 0),
+ pipeline: rows
+ .filter(opp => !['CLOSED_WON', 'CLOSED_LOST'].includes(opp.stage))
+ .reduce((sum, opp) => sum + (Number(opp.amount) || 0), 0)
+ };
+
+ return {
+ opportunities: rows,
+ stats
+ };
+ } catch (error) {
+ console.error('Error loading opportunities:', error);
+ return {
+ opportunities: [],
+ stats: {
+ total: 0,
+ totalValue: 0,
+ wonValue: 0,
+ pipeline: 0
+ }
+ };
+ }
+};
+
+export const actions: Actions = {
+ delete: async ({ request, locals }) => {
+ const db = locals.db
+ try {
+ const formData = await request.formData();
+ const opportunityId = formData.get('opportunityId')?.toString();
+ if (!opportunityId) {
+ return fail(400, { message: 'Missing required data' });
+ }
+
+ const [opportunity] = await db
+ .select({ id: schema.opportunity.id })
+ .from(schema.opportunity)
+ .where(and(eq(schema.opportunity.id, opportunityId), eq(schema.opportunity.organizationId, locals.org!.id)));
+
+ if (!opportunity) {
+ return fail(404, { message: 'Opportunity not found' });
+ }
+
+ await db.delete(schema.opportunity).where(eq(schema.opportunity.id, opportunityId));
+
+ return { success: true, message: 'Opportunity deleted successfully' };
+ } catch (error) {
+ console.error('Error deleting opportunity:', error);
+ return fail(500, { message: 'Failed to delete opportunity' });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/opportunities/+page.svelte b/apps/web/src/routes/(app)/app/opportunities/+page.svelte
new file mode 100644
index 0000000..bde389a
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/+page.svelte
@@ -0,0 +1,592 @@
+
+
+
+ Opportunities - BottleCRM
+
+
+
+
+ {#if form?.success}
+
+
+ Success!
+ {form.message || 'Opportunity deleted successfully.'}
+
+
+ {/if}
+
+ {#if form?.message && !form?.success}
+
+
+ Error!
+ {form.message}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Opportunities
+
+
+ {data.stats.total}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Value
+
+
+ {formatCurrency(data.stats.totalValue)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pipeline Value
+
+
+ {formatCurrency(data.stats.pipeline)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Won Value
+
+
+ {formatCurrency(data.stats.wonValue)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Search opportunities
+
+
+
+
+
+
+
+
+
+ Filter opportunities by stage
+
+ All Stages
+ {#each opportunityStages as s}
+ {s.label}
+ {/each}
+
+
+
+
+
(showFilters = !showFilters)}
+ class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
+ >
+
+ Filters
+
+
+
+
+
+
+
+
+
+
+
+ toggleSort('name')}
+ >
+
+ Opportunity
+
+
+
+ toggleSort('account.name')}
+ >
+
+ Account
+
+
+
+
+ Stage
+
+ toggleSort('amount')}
+ >
+
+ Amount
+
+
+
+ toggleSort('closeDate')}
+ >
+
+ Close Date
+
+
+
+ toggleSort('owner.name')}
+ >
+
+ Owner
+
+
+
+
+ Activities
+
+
+ Actions
+
+
+
+
+ {#each filteredOpportunities as opportunity (opportunity.id)}
+ {@const s = opportunityStages.find((st) => st.value === opportunity.stage)}
+
+
+
+
+
+ {opportunity.name || 'Unnamed Opportunity'}
+
+ {#if opportunity.type}
+
+ {toLabel(opportunity.type, OPPORTUNITY_TYPE_OPTIONS, '')}
+
+ {/if}
+
+
+
+
+
+
+
+
+ {opportunity.account?.name || 'No Account'}
+
+ {#if opportunity.account?.type}
+
+ {toLabel(opportunity.account.type, ACCOUNT_TYPE_OPTIONS, '')}
+
+ {/if}
+
+
+
+
+
+ {s ? s.label : 'Unknown'}
+
+
+
+ {formatCurrency(opportunity.amount)}
+ {#if opportunity.probability}
+
+ {opportunity.probability}% probability
+
+ {/if}
+
+
+
+
+ {formatDate(opportunity.closeDate)}
+
+
+
+
+
+
+ {opportunity.owner?.name || opportunity.owner?.email || 'No Owner'}
+
+
+
+
+
+ {#if opportunity._count?.tasks > 0}
+
+
+ {opportunity._count.tasks}
+
+ {/if}
+ {#if opportunity._count?.events > 0}
+
+
+ {opportunity._count.events}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
openDeleteModal(opportunity)}
+ class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
+ title="Delete"
+ >
+
+
+
+
+
+ {/each}
+
+
+
+
+ {#if filteredOpportunities.length === 0}
+
+
+
No opportunities
+
+ {searchTerm || selectedStage !== 'all'
+ ? 'No opportunities match your current filters.'
+ : 'Get started by creating a new opportunity.'}
+
+ {#if !searchTerm && selectedStage === 'all'}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+{#if showDeleteModal && opportunityToDelete}
+ e.key === 'Escape' && closeDeleteModal()}
+ >
+
e.key === 'Escape' && closeDeleteModal()}
+ onclick={(e) => e.stopPropagation()}
+ >
+
+
+
+
+
+
+ Delete Opportunity
+
+
+
+
+
+
+
+
+
+
+ Are you sure you want to delete the opportunity "{opportunityToDelete?.name || 'Unknown'}" ? This action cannot be undone and will also delete all associated tasks, events, and
+ comments.
+
+
+
+
+
+ Cancel
+
+
+
{
+ deleteLoading = true;
+
+ return async ({ result }) => {
+ deleteLoading = false;
+
+ if (result.type === 'success') {
+ closeDeleteModal();
+ // Use goto with replaceState and invalidateAll for a clean refresh
+ await goto(page.url.pathname, {
+ replaceState: true,
+ invalidateAll: true
+ });
+ } else if (result.type === 'failure') {
+ console.error('Delete failed:', result.data?.message);
+ alert(
+ 'Failed to delete opportunity: ' + (result.data?.message || 'Unknown error')
+ );
+ } else if (result.type === 'error') {
+ console.error('Delete error:', result.error);
+ alert('An error occurred while deleting the opportunity.');
+ }
+ };
+ }}
+ >
+
+
+ {deleteLoading ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+
+{/if}
diff --git a/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/+page.server.ts b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/+page.server.ts
new file mode 100644
index 0000000..9bf3130
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/+page.server.ts
@@ -0,0 +1,43 @@
+import { error } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { eq } from 'drizzle-orm';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const db = locals.db
+ const [row] = await db
+ .select({
+ id: schema.opportunity.id,
+ name: schema.opportunity.name,
+ amount: schema.opportunity.amount,
+ expectedRevenue: schema.opportunity.expectedRevenue,
+ stage: schema.opportunity.stage,
+ probability: schema.opportunity.probability,
+ closeDate: schema.opportunity.closeDate,
+ nextStep: schema.opportunity.nextStep,
+ leadSource: schema.opportunity.leadSource,
+ forecastCategory: schema.opportunity.forecastCategory,
+ createdAt: schema.opportunity.createdAt,
+ updatedAt: schema.opportunity.updatedAt,
+ description: schema.opportunity.description,
+ account: {
+ id: schema.opportunity.accountId,
+ name: schema.crmAccount.name,
+ type: schema.crmAccount.type
+ },
+ owner: {
+ id: schema.opportunity.ownerId,
+ name: schema.user.name,
+ email: schema.user.email
+ },
+ type: schema.opportunity.type
+ })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .leftJoin(schema.user, eq(schema.user.id, schema.opportunity.ownerId))
+ .where(eq(schema.opportunity.id, params.opportunityId));
+
+ if (!row) throw error(404, 'Opportunity not found');
+
+ return { opportunity: row };
+};
diff --git a/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/+page.svelte b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/+page.svelte
new file mode 100644
index 0000000..87d3caa
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/+page.svelte
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+
{opportunity.name}
+
+ {#if opportunity.stage}
+ {@const stageItem = opportunityStages.find((s) => s.value === opportunity.stage)}
+
+ {stageItem ? stageItem.label : 'Unknown'}
+
+ {:else}
+
Unknown
+ {/if}
+ {#if opportunity.probability}
+
+
+ {opportunity.probability}% probability
+
+ {/if}
+
+
+
+
+ {#if opportunity.stage !== 'CLOSED_LOST'}
+
+
+ Progress
+ {Math.round(getStageProgress(opportunity.stage))}%
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
Financial Details
+
+
+
+
Amount
+
+ {formatCurrency(opportunity.amount)}
+
+
+
+
Expected Revenue
+
+ {formatCurrency(opportunity.expectedRevenue)}
+
+
+
+
+
+
+
+
+
+
+ Opportunity Information
+
+
+
+
+
Type
+
+ {toLabel(opportunity.type, OPPORTUNITY_TYPE_OPTIONS, 'Not specified')}
+
+
+
+
Lead Source
+
+ {toLabel(opportunity.leadSource, LEAD_SOURCE_OPTIONS, 'Not specified')}
+
+
+
+
Forecast Category
+
+ {toLabel(opportunity.forecastCategory, FORECAST_CATEGORY_OPTIONS, 'Not specified')}
+
+
+
+
Close Date
+
+
+ {formatDate(opportunity.closeDate)}
+
+
+
+
+
+
+ {#if opportunity.nextStep}
+
+
+
{opportunity.nextStep}
+
+ {/if}
+
+
+ {#if opportunity.description}
+
+
+
+
Description
+
+
+ {opportunity.description}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
Key Metrics
+
+
+
+ Probability
+
+ {opportunity.probability ? `${opportunity.probability}%` : 'N/A'}
+
+
+
+ Days to Close
+
+ {opportunity.closeDate
+ ? Math.ceil(
+ (new Date(opportunity.closeDate).getTime() - new Date().getTime()) /
+ (1000 * 60 * 60 * 24)
+ )
+ : 'N/A'}
+
+
+
+
+
+
+
+
+
+
Related Records
+
+
+
+
+
Owner
+
+
+ {owner?.name ?? 'Unassigned'}
+
+
+
+
+
+
+
+
+
+
System Information
+
+
+
+
Created
+
+ {formatDate(
+ opportunity.createdAt,
+ 'en-US',
+ { hour: '2-digit', minute: '2-digit' },
+ '-'
+ )}
+
+
+
+
Last Updated
+
+ {formatDate(
+ opportunity.updatedAt,
+ 'en-US',
+ { hour: '2-digit', minute: '2-digit' },
+ '-'
+ )}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.server.ts b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.server.ts
new file mode 100644
index 0000000..2cb6c31
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.server.ts
@@ -0,0 +1,111 @@
+import { error, fail, redirect } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const org = locals.org!
+ if (!org?.id) {
+ throw error(403, 'Organization access required');
+ }
+
+ const db = locals.db
+
+ const [row] = await db
+ .select({
+ id: schema.opportunity.id,
+ name: schema.opportunity.name,
+ stage: schema.opportunity.stage,
+ description: schema.opportunity.description,
+ closeDate: schema.opportunity.closeDate,
+ amount: schema.opportunity.amount,
+ probability: schema.opportunity.probability,
+ account: {
+ id: schema.opportunity.accountId,
+ name: schema.crmAccount.name,
+ type: schema.crmAccount.type
+ }
+ })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .where(and(eq(schema.opportunity.id, params.opportunityId), eq(schema.opportunity.organizationId, org.id)));
+
+ if (!row) {
+ throw error(404, 'Opportunity not found');
+ }
+
+ return {
+ opportunity: row
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ const org = locals.org!
+ if (!org?.id) {
+ throw error(403, 'Organization access required');
+ }
+
+ const db = locals.db
+
+ const formData = await request.formData();
+ const status = formData.get('status')?.toString() as 'CLOSED_WON' | 'CLOSED_LOST';
+ const closeDate = formData.get('closeDate')?.toString();
+ const closeReason = formData.get('closeReason')?.toString();
+
+ if (!status || !closeDate) {
+ return fail(400, { error: 'Status and close date are required' });
+ }
+
+ const validCloseStatuses = ['CLOSED_WON', 'CLOSED_LOST'] as const;
+ if (!validCloseStatuses.includes(status as any)) {
+ return fail(400, { error: 'Invalid status selected' });
+ }
+
+ try {
+ // verify opportunity exists and belongs to org
+ const [opp] = await db
+ .select({ id: schema.opportunity.id, description: schema.opportunity.description, organizationId: schema.opportunity.organizationId })
+ .from(schema.opportunity)
+ .where(and(eq(schema.opportunity.id, params.opportunityId), eq(schema.opportunity.organizationId, org.id)));
+
+ if (!opp) {
+ return fail(404, { error: 'Opportunity not found' });
+ }
+
+ const newDescription = closeReason
+ ? (opp.description ? `${opp.description}\n\nClose Reason: ${closeReason}` : `Close Reason: ${closeReason}`)
+ : opp.description;
+
+ await db
+ .update(schema.opportunity)
+ .set({
+ stage: status,
+ closeDate: closeDate ? new Date(closeDate) : null,
+ description: newDescription || null,
+ updatedAt: new Date()
+ })
+ .where(eq(schema.opportunity.id, params.opportunityId));
+
+ await db.insert(schema.auditLog).values({
+ id: crypto.randomUUID(),
+ action: 'UPDATE',
+ entityType: 'Opportunity',
+ entityId: params.opportunityId,
+ description: `Opportunity closed with status: ${status}`,
+ newValues: {
+ stage: status,
+ closeDate,
+ closeReason
+ },
+ userId: locals.user!.id,
+ organizationId: locals.org!.id
+ });
+
+ throw redirect(303, `/app/opportunities/${params.opportunityId}`);
+ } catch (err) {
+ console.error('Error closing opportunity:', err);
+ return fail(500, { error: 'Failed to close opportunity. Please try again.' });
+ }
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.svelte b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.svelte
new file mode 100644
index 0000000..e20f88d
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.svelte
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+
+
+
+
+
+ Close Opportunity
+
+
+ Update the status and close details for this opportunity
+
+
+
+
+
+
+
+
Opportunity Details
+
+
+
Name
+
{opportunity.name}
+
+
+
Amount
+
+ {opportunity.amount ? `$${opportunity.amount.toLocaleString()}` : 'Not specified'}
+
+
+
+
Current Stage
+
+ {toLabel(opportunity.stage, OPPORTUNITY_STAGE_OPTIONS, 'Unknown')}
+
+
+
+
Probability
+
+ {opportunity.probability ? `${opportunity.probability}%` : 'Not specified'}
+
+
+
+
+
+
+
+
+
+
Close Opportunity
+
+
+
{
+ return async ({ update }) => {
+ isSubmitting = true;
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-6 p-6"
+ >
+ {#if form?.error}
+
+
+
+ Error
+
+
{form.error}
+
+ {/if}
+
+
+
+
+ Closing Status *
+
+
+ {#each statusOptions as option}
+
+
+
+
+ {option.label}
+ {#if selectedStatus === option.value}
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+
+
+
+
+ Close Date *
+
+
+
+
+
+
+
+
+ Reason for Closing
+
+
+
+
+
+
+
+ {#if isSubmitting}
+
+ Closing Opportunity...
+ {:else}
+
+ Close Opportunity
+ {/if}
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.ts b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.ts
new file mode 100644
index 0000000..8fa849c
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.ts
@@ -0,0 +1,75 @@
+import { error, fail } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const userId = locals.user?.id;
+ const organizationId = locals.org?.id;
+ const db = locals.db
+
+ if (!userId || !organizationId) {
+ throw error(401, 'Unauthorized');
+ }
+
+ const [row] = await db
+ .select({
+ id: schema.opportunity.id,
+ name: schema.opportunity.name,
+ amount: schema.opportunity.amount,
+ stage: schema.opportunity.stage,
+ account: {
+ id: schema.opportunity.accountId,
+ name: schema.crmAccount.name,
+ type: schema.crmAccount.type
+ },
+ owner: {
+ id: schema.opportunity.ownerId,
+ name: schema.user.name,
+ email: schema.user.email
+ }
+ })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .leftJoin(schema.user, eq(schema.user.id, schema.opportunity.ownerId))
+ .where(and(eq(schema.opportunity.id, params.opportunityId), eq(schema.opportunity.organizationId, organizationId!)));
+
+ if (!row) {
+ throw error(404, 'Opportunity not found');
+ }
+
+ return { opportunity: row };
+}
+
+export const actions: Actions = {
+ default: async ({ params, locals }) => {
+ try {
+ const userId = locals.user?.id;
+ const organizationId = locals.org?.id;
+ const db = locals.db
+
+ if (!userId || !organizationId) {
+ return fail(401, { message: 'Unauthorized' });
+ }
+
+ // Check if the opportunity exists and belongs to the user's organization
+ const [opportunity] = await db
+ .select({ id: schema.opportunity.id })
+ .from(schema.opportunity)
+ .where(and(eq(schema.opportunity.id, params.opportunityId), eq(schema.opportunity.organizationId, organizationId!)));
+
+ if (!opportunity) {
+ return fail(404, { message: 'Opportunity not found' });
+ }
+
+ // Delete the opportunity (this will cascade delete related records)
+ await db.delete(schema.opportunity).where(eq(schema.opportunity.id, params.opportunityId));
+
+ // Return success response - let client handle redirect
+ return { success: true, message: 'Opportunity deleted successfully' };
+ } catch (err) {
+ console.error('Error deleting opportunity:', err);
+ return fail(500, { message: 'Failed to delete opportunity' });
+ }
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte
new file mode 100644
index 0000000..8f55770
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte
@@ -0,0 +1,135 @@
+
+
+
+ Delete Opportunity - BottleCRM
+
+
+
+
+
+
+
+
+
+
+
+
Delete Opportunity
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Confirm Deletion
+
+
+ This action cannot be undone.
+
+
+
+
+
+
+ You are about to delete:
+
+
+
Opportunity: {data.opportunity.name}
+
Account: {data.opportunity.account?.name || 'N/A'}
+
+ Amount:
+ {data.opportunity.amount
+ ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
+ data.opportunity.amount
+ )
+ : 'N/A'}
+
+
+ Stage:
+ {toLabel(data.opportunity.stage, OPPORTUNITY_STAGE_OPTIONS, 'N/A')}
+
+
+
+
+
+
+ Warning: Deleting this opportunity will also remove all associated:
+
+
+ Tasks and activities
+ Events and meetings
+ Comments and notes
+ Quote associations
+
+
+
+
+
+ Cancel
+
+
+
{
+ deleteLoading = true;
+ return ({ result }) => {
+ deleteLoading = false;
+ if (result.type === 'success') {
+ // Navigate to opportunities list on successful deletion
+ goto('/app/opportunities');
+ } else if (result.type === 'failure') {
+ // Handle error case - you could show a toast notification here
+ console.error('Failed to delete opportunity:', result.data?.message);
+ alert(result.data?.message || 'Failed to delete opportunity');
+ }
+ };
+ }}
+ >
+
+ {deleteLoading ? 'Deleting...' : 'Delete Opportunity'}
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.server.ts b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.server.ts
new file mode 100644
index 0000000..2513eec
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.server.ts
@@ -0,0 +1,111 @@
+import { error, fail, redirect } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+import { validateEnumOrNull } from '$lib/data/enum-helpers';
+import { OPPORTUNITY_STAGES, LEAD_SOURCES, FORECAST_CATEGORIES, OPPORTUNITY_TYPES } from '@opensource-startup-crm/constants';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const org = locals.org!
+ if (!org?.id) {
+ throw error(403, 'Organization access required');
+ }
+ const db = locals.db
+
+ const [row] = await db
+ .select({
+ opportunity: schema.opportunity,
+ account: schema.crmAccount,
+ owner: schema.user
+ })
+ .from(schema.opportunity)
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.opportunity.accountId))
+ .leftJoin(schema.user, eq(schema.user.id, schema.opportunity.ownerId))
+ .where(and(eq(schema.opportunity.id, params.opportunityId), eq(schema.opportunity.organizationId, org.id)));
+
+ if (!row) throw error(404, 'Opportunity not found');
+
+ return {
+ opportunity: row.opportunity,
+ account: row.account,
+ owner: row.owner
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ const org = locals.org!
+ if (!org?.id) {
+ throw error(403, 'Organization access required');
+ }
+
+ const db = locals.db
+
+ const form = await request.formData();
+ const name = form.get('name')?.toString().trim();
+ const amount = form.get('amount') ? parseFloat(form.get('amount')?.toString() || '') : null;
+ const expectedRevenue = form.get('expectedRevenue') ? parseFloat(form.get('expectedRevenue')?.toString() || '') : null;
+ const stageInput = form.get('stage')?.toString();
+ const probability = form.get('probability') ? parseFloat(form.get('probability')?.toString() || '') : null;
+ const closeDateValue = form.get('closeDate')?.toString();
+ const closeDate = closeDateValue ? new Date(closeDateValue) : null;
+ const leadSource = validateEnumOrNull(form.get('leadSource')?.toString(), LEAD_SOURCES);
+ const forecastCategory = validateEnumOrNull(form.get('forecastCategory')?.toString(), FORECAST_CATEGORIES);
+ const type = validateEnumOrNull(form.get('type')?.toString(), OPPORTUNITY_TYPES);
+ const nextStep = form.get('nextStep')?.toString() || null;
+ const description = form.get('description')?.toString() || null;
+
+ if (!name) {
+ return fail(400, { message: 'Opportunity name is required.' });
+ }
+ if (!stageInput) {
+ return fail(400, { message: 'Stage is required.' });
+ }
+ const stage = validateEnumOrNull(stageInput, OPPORTUNITY_STAGES);
+ if (!stage) {
+ return fail(400, { message: 'Invalid stage selected.' });
+ }
+ if (probability !== null && (probability < 0 || probability > 100)) {
+ return fail(400, { message: 'Probability must be between 0 and 100.' });
+ }
+ if (amount !== null && amount < 0) {
+ return fail(400, { message: 'Amount cannot be negative.' });
+ }
+ if (expectedRevenue !== null && expectedRevenue < 0) {
+ return fail(400, { message: 'Expected revenue cannot be negative.' });
+ }
+
+ try {
+ const [existing] = await db
+ .select({ id: schema.opportunity.id })
+ .from(schema.opportunity)
+ .where(and(eq(schema.opportunity.id, params.opportunityId), eq(schema.opportunity.organizationId, org.id)));
+
+ if (!existing) {
+ return fail(404, { message: 'Opportunity not found' });
+ }
+
+ await db
+ .update(schema.opportunity)
+ .set({
+ name,
+ amount: amount,
+ expectedRevenue: expectedRevenue,
+ stage,
+ probability: probability,
+ closeDate,
+ leadSource,
+ forecastCategory,
+ type,
+ nextStep,
+ description
+ })
+ .where(eq(schema.opportunity.id, params.opportunityId));
+
+ throw redirect(303, `/app/opportunities/${params.opportunityId}`);
+ } catch (err) {
+ console.error('Failed to update opportunity:', err);
+ return fail(500, { message: 'Failed to update opportunity. Please try again.' });
+ }
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte
new file mode 100644
index 0000000..466a6ed
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte
@@ -0,0 +1,356 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Opportunity
+
+ Update opportunity details and track progress
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic Information
+
+
+
+
+
+
+ Opportunity Name *
+
+
+
+
+
+
+
+
+ Amount
+
+
+
+
+
+
+
+
+ Expected Revenue
+
+
+
+
+
+
+
+
+ Stage *
+
+
+ {#each opportunityStages as s}
+ {s.label}
+ {/each}
+
+
+
+
+
+
+ Probability (%)
+
+
+
+
+
+
+
+
+ Close Date
+
+
+
+
+
+
+
+
+ Lead Source
+
+
+ Select source...
+ {#each leadSources as source}
+ {source.label}
+ {/each}
+
+
+
+
+
+
+ Forecast Category
+
+
+ Select category...
+ {#each forecastCategories as category}
+ {category.label}
+ {/each}
+
+
+
+
+
+
+ Type
+
+
+
+
+
+
+
+ Next Step
+
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+ {#if error}
+
+ {/if}
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Saving...
+ {:else}
+
+ Save Changes
+ {/if}
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/opportunities/new/+page.server.ts b/apps/web/src/routes/(app)/app/opportunities/new/+page.server.ts
new file mode 100644
index 0000000..4faf5c2
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/new/+page.server.ts
@@ -0,0 +1,144 @@
+import { redirect, fail } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { and, asc, eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const orgId = locals.org!.id;
+ const preSelectedAccountId = url.searchParams.get('accountId');
+ const db = locals.db
+
+ try {
+ const accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name, type: schema.crmAccount.type })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.organizationId, orgId), eq(schema.crmAccount.isActive, true), eq(schema.crmAccount.isDeleted, false)))
+ .orderBy(asc(schema.crmAccount.name));
+
+ const accountContacts =
+ await db
+ .select({ id: schema.contact.id, firstName: schema.contact.firstName, lastName: schema.contact.lastName, email: schema.contact.email })
+ .from(schema.contact)
+ .innerJoin(schema.accountContactRelationship, eq(schema.accountContactRelationship.contactId, schema.contact.id))
+ .where(and(eq(schema.contact.organizationId, orgId), preSelectedAccountId ? eq(schema.accountContactRelationship.accountId, preSelectedAccountId) : undefined))
+ .orderBy(asc(schema.contact.firstName), asc(schema.contact.lastName));
+
+ const users = await db
+ .select({ id: schema.user.id, name: schema.user.name, email: schema.user.email })
+ .from(schema.member)
+ .innerJoin(schema.user, eq(schema.member.userId, schema.user.id))
+ .where(eq(schema.member.organizationId, orgId))
+ .orderBy(asc(schema.user.name));
+
+ const preSelectedAccount = preSelectedAccountId ? accounts.find((a) => a.id === preSelectedAccountId) : null;
+
+ return {
+ accounts,
+ contacts: [],
+ accountContacts,
+ users,
+ preSelectedAccountId,
+ preSelectedAccountName: preSelectedAccount?.name || null
+ };
+ } catch (error) {
+ console.error('Error loading opportunity form data:', error);
+ return { accounts: [], contacts: [], accountContacts: [], users: [], preSelectedAccountId: null, preSelectedAccountName: null };
+ }
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ if (!locals.user || !locals.org) {
+ return fail(401, { error: 'Unauthorized' });
+ }
+
+ const db = locals.db
+
+ const formData = await request.formData();
+ const data = {
+ name: formData.get('name')?.toString(),
+ accountId: formData.get('accountId')?.toString(),
+ stage: formData.get('stage')?.toString(),
+ amount: formData.get('amount')?.toString(),
+ closeDate: formData.get('closeDate')?.toString(),
+ probability: formData.get('probability')?.toString(),
+ type: formData.get('type')?.toString(),
+ leadSource: formData.get('leadSource')?.toString(),
+ nextStep: formData.get('nextStep')?.toString(),
+ description: formData.get('description')?.toString(),
+ ownerId: formData.get('ownerId')?.toString(),
+ contactIds: formData.getAll('contactIds').map((id) => id.toString())
+ };
+
+ const errors: Record = {};
+ if (!data.name || data.name.length < 2) errors.name = 'Opportunity name must be at least 2 characters';
+ if (!data.accountId) errors.accountId = 'Account is required';
+ if (!data.stage) errors.stage = 'Stage is required';
+ const validStages = ['PROSPECTING', 'QUALIFICATION', 'PROPOSAL', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST'];
+ if (data.stage && !validStages.includes(data.stage)) errors.stage = 'Invalid stage selected';
+ if (data.amount && (isNaN(parseFloat(data.amount)) || parseFloat(data.amount) < 0)) errors.amount = 'Amount must be a valid positive number';
+ if (data.probability && (isNaN(parseFloat(data.probability)) || parseFloat(data.probability) < 0 || parseFloat(data.probability) > 100)) errors.probability = 'Probability must be between 0 and 100';
+
+ if (Object.keys(errors).length > 0) {
+ return fail(400, { errors, data });
+ }
+
+ try {
+ const [account] = await db
+ .select({ id: schema.crmAccount.id })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, data.accountId as string), eq(schema.crmAccount.organizationId, locals.org.id), eq(schema.crmAccount.isActive, true), eq(schema.crmAccount.isDeleted, false)));
+ if (!account) return fail(400, { error: 'Invalid account selected' });
+
+ if (!data.name || !data.accountId || !data.stage) return fail(400, { error: 'Missing required fields after validation' });
+
+ const parsedAmount = data.amount ? parseFloat(data.amount) : null;
+ const parsedProb = data.probability ? parseFloat(data.probability) : null;
+
+ const [opportunity] = await db
+ .insert(schema.opportunity)
+ .values({
+ name: data.name,
+ accountId: data.accountId,
+ stage: data.stage as any,
+ amount: parsedAmount as any,
+ closeDate: data.closeDate ? new Date(data.closeDate) : null,
+ probability: parsedProb as any,
+ type: data.type || null,
+ leadSource: data.leadSource || null,
+ nextStep: data.nextStep || null,
+ description: data.description || null,
+ ownerId: data.ownerId || locals.user.id,
+ organizationId: locals.org.id,
+ expectedRevenue: parsedAmount !== null && parsedProb !== null ? (parsedAmount * parsedProb) / 100 : null
+ })
+ .returning();
+
+ if (data.contactIds.length > 0) {
+ await Promise.all(
+ data.contactIds.map((cid) =>
+ db.insert(schema.contactToOpportunity).values({ contactId: cid, opportunityId: opportunity.id })
+ )
+ );
+ }
+
+ await db.insert(schema.auditLog).values({
+ action: 'CREATE',
+ entityType: 'Opportunity',
+ entityId: opportunity.id,
+ description: `Created opportunity: ${opportunity.name}`,
+ newValues: { name: opportunity.name, stage: opportunity.stage, amount: opportunity.amount } as any,
+ userId: locals.user.id,
+ organizationId: locals.org.id
+ });
+
+ throw redirect(302, `/app/opportunities/${opportunity.id}`);
+ } catch (error) {
+ if (error && typeof error === 'object' && 'status' in (error as any) && (error as any).status === 302) {
+ throw error as any;
+ }
+ console.error('Error creating opportunity:', error);
+ return fail(500, { error: 'Failed to create opportunity' });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/opportunities/new/+page.svelte b/apps/web/src/routes/(app)/app/opportunities/new/+page.svelte
new file mode 100644
index 0000000..95c257b
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/opportunities/new/+page.svelte
@@ -0,0 +1,476 @@
+
+
+
+ New Opportunity - BottleCRM
+
+
+
+
+
+
+
+
+
history.back()}
+ class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
+ >
+
+
+
+
+
+
+
+
New Opportunity
+
Create a new sales opportunity
+
+
+
+
+
+
+
+
+
+ {#if form?.error}
+
+ {/if}
+
+
{
+ if (isSubmitting) {
+ return;
+ }
+
+ isSubmitting = true;
+
+ return async ({ result, update }) => {
+ isSubmitting = false;
+ await update();
+
+ if (result.type === 'redirect') {
+ goto(result.location);
+ }
+ };
+ }}
+ class="space-y-8"
+ >
+
+
+
+
+ Basic Information
+
+
+
+
+
+
+ Opportunity Name *
+
+
+ {#if form?.errors?.name}
+
{form.errors.name}
+ {/if}
+
+
+
+
+
+ Account *
+
+
+ Select an account
+ {#each data.accounts as account}
+ {account.name} {account.type ? `(${account.type})` : ''}
+ {/each}
+
+ {#if data.preSelectedAccountId}
+
+
+ {/if}
+ {#if data.preSelectedAccountId}
+
+ Account pre-selected from {data.preSelectedAccountName}
+
+ {/if}
+ {#if form?.errors?.accountId}
+
{form.errors.accountId}
+ {/if}
+
+
+
+
+
+ Stage *
+
+
+ {#each opportunityStages as stage}
+ {stage.label}
+ {/each}
+
+ {#if form?.errors?.stage}
+
{form.errors.stage}
+ {/if}
+
+
+
+
+
+ Owner
+
+
+ Assign to me
+ {#each data.users as user (user.id)}
+ {user.name}
+ {/each}
+
+
+
+
+
+
+ Type
+
+
+ Select type
+ {#each opportunityTypeOptions as type}
+ {type.label}
+ {/each}
+
+
+
+
+
+
+
+
+
+ Financial Information
+
+
+
+
+
+
+ Amount ($)
+
+
+ {#if form?.errors?.amount}
+
{form.errors.amount}
+ {/if}
+
+
+
+
+
+ Probability (%)
+
+
+ {#if form?.errors?.probability}
+
{form.errors.probability}
+ {/if}
+
+
+
+
+
+ Expected Revenue
+
+
+ ${calculateExpectedRevenue()}
+
+
+
+
+
+
+ Expected Close Date
+
+
+
+
+
+
+
+ Lead Source
+
+
+ Select source
+ {#each sourceOptions as source}
+ {source.label}
+ {/each}
+
+
+
+
+
+
+
+
+ Additional Information
+
+
+
+
+ {#if data.accountContacts.length > 0}
+
+
+ Associated Contacts {data.preSelectedAccountId
+ ? `from ${data.preSelectedAccountName}`
+ : ''}
+
+
+ {#each data.accountContacts as contact}
+
+ handleContactToggle(contact.id)}
+ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:focus:ring-blue-400"
+ />
+
+ {contact.firstName}
+ {contact.lastName}
+ {#if contact.email}
+ ({contact.email})
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+ Next Step
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
history.back()}
+ class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-blue-400"
+ >
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Creating...
+ {:else}
+
+ Create Opportunity
+ {/if}
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/profile/+page.server.ts b/apps/web/src/routes/(app)/app/profile/+page.server.ts
new file mode 100644
index 0000000..7e59e22
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/profile/+page.server.ts
@@ -0,0 +1,118 @@
+import { schema } from '@opensource-startup-crm/database';
+import { fail, redirect } from '@sveltejs/kit';
+import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js';
+import { eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!locals.user) {
+ throw redirect(307, '/login');
+ }
+
+ const db = locals.db
+
+ // Get user with their organization memberships
+ const [user] = await db
+ .select({
+ id: schema.user.id,
+ email: schema.user.email,
+ name: schema.user.name,
+ image: schema.user.image,
+ phone: schema.user.phone,
+ isActive: schema.user.isActive,
+ lastLogin: schema.user.lastLogin,
+ createdAt: schema.user.createdAt
+ })
+ .from(schema.user)
+
+ .where(eq(schema.user.id, locals.user.id));
+
+ const orgMemberships = await db
+ .select({
+ organizationId: schema.member.organizationId,
+ organizationName: schema.organization.name,
+ role: schema.member.role,
+ joinedAt: schema.member.createdAt
+ })
+ .from(schema.member)
+ .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId))
+ .where(eq(schema.member.userId, locals.user.id));
+
+ if (!user) {
+ throw redirect(307, '/login');
+ }
+
+ return {
+ user: {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ image: user.image,
+ phone: user.phone,
+ isActive: user.isActive,
+ lastLogin: user.lastLogin,
+ createdAt: user.createdAt,
+ organizations: orgMemberships.map((m) => ({ organization: { id: m.organizationId, name: m.organizationName }, role: m.role, joinedAt: m.joinedAt }))
+ }
+ };
+}
+
+export const actions: Actions = {
+ updateProfile: async ({ request, locals }) => {
+ if (!locals.user) {
+ throw redirect(307, '/login');
+ }
+
+ const db = locals.db
+
+ const formData = await request.formData();
+ const name = formData.get('name')?.toString();
+ const phone = formData.get('phone')?.toString();
+
+ // Validate required fields
+ if (!name || name.trim().length === 0) {
+ return fail(400, {
+ error: 'Name is required',
+ data: { name, phone }
+ });
+ }
+
+ if (name.trim().length < 2) {
+ return fail(400, {
+ error: 'Name must be at least 2 characters long',
+ data: { name, phone }
+ });
+ }
+
+ // Validate phone if provided
+ let formattedPhone = null;
+ if (phone && phone.trim().length > 0) {
+ const phoneValidation = validatePhoneNumber(phone.trim());
+ if (!phoneValidation.isValid) {
+ return fail(400, {
+ error: phoneValidation.error || 'Please enter a valid phone number',
+ data: { name, phone }
+ });
+ }
+ formattedPhone = formatPhoneForStorage(phone.trim());
+ }
+
+ try {
+ await db
+ .update(schema.user)
+ .set({ name: name.trim(), phone: formattedPhone, updatedAt: new Date() })
+ .where(eq(schema.user.id, locals.user.id));
+
+ return {
+ success: true,
+ message: 'Profile updated successfully'
+ };
+ } catch (error) {
+ console.error('Error updating profile:', error);
+ return fail(500, {
+ error: 'Failed to update profile. Please try again.',
+ data: { name, phone }
+ });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/profile/+page.svelte b/apps/web/src/routes/(app)/app/profile/+page.svelte
new file mode 100644
index 0000000..874053e
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/profile/+page.svelte
@@ -0,0 +1,412 @@
+
+
+
+ Profile - BottleCRM
+
+
+
+
+
+
+
+
Profile
+
+ Manage your personal information and account settings
+
+
+
+ {#if isEditing}
+
+ Cancel
+ {:else}
+
+ Edit Profile
+ {/if}
+
+
+
+
+
+ {#if form?.success}
+
+ {/if}
+
+ {#if form?.error}
+
+ {/if}
+
+
+
+
+
+
+
+
+ {#if data.user.image}
+
+ {:else}
+
+
+ {getInitials(data.user.name)}
+
+
+ {/if}
+
+
+
+ {data.user.name || 'Unnamed User'}
+
+
{data.user.email}
+
+
+
+
+
+ {data.user.isActive ? 'Active' : 'Inactive'}
+
+
+
+
+
+
+
+
+ {#if isEditing}
+
+
+
+
+ Edit Profile Information
+
+
+
+
+
+
+ Full Name *
+
+
+
+
+
+
+
+ Phone Number
+
+
+ {#if phoneError}
+
+ {phoneError}
+
+ {/if}
+
+
+
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+
+
+
+ Saving...
+ {:else}
+
+ Save Changes
+ {/if}
+
+
+
+
+ {:else}
+
+
+
+ Profile Information
+
+
+
+
+
+
+
+ Email Address
+
+ {data.user.email}
+
+
+
+
+
+
+ Phone Number
+
+
+ {data.user.phone ? formatPhoneNumber(data.user.phone) : 'Not provided'}
+
+
+
+
+
+
+
+ Last Login
+
+
+ {formatDate(
+ data.user.lastLogin,
+ 'en-US',
+ { hour: '2-digit', minute: '2-digit' },
+ '-'
+ )}
+
+
+
+
+
+
+
+ Member Since
+
+
+ {formatDate(data.user.createdAt, 'en-US', undefined, '-')}
+
+
+
+
+ {/if}
+
+
+
+ {#if data.user.organizations && data.user.organizations.length > 0}
+
+
Organizations
+
+ {#each data.user.organizations as userOrg}
+
+
+
+
+
+
+
+ {userOrg.organization.name}
+
+
+ Joined {formatDate(userOrg.joinedAt, 'en-US', undefined, '-')}
+
+
+
+
+ {userOrg.role}
+
+
+ {/each}
+
+
+ {/if}
+
+
+
diff --git a/apps/web/src/routes/(app)/app/support/+page.svelte b/apps/web/src/routes/(app)/app/support/+page.svelte
new file mode 100644
index 0000000..367fec1
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/support/+page.svelte
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
BottleCRM Support
+
+ Empowering startups with free, open-source CRM solutions. Say goodbye to expensive
+ subscription fees.
+
+
+
+
+
+
+
+
+
+
+
Our Mission
+
+ BottleCRM addresses the high subscription costs of commercial CRM alternatives by
+ providing a completely free, open-source, and highly customizable solution. Clone it,
+ self-host it, and make it yours - forever free.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Community Support
+
+
+ Join our open-source community for free support, discussions, and collaboration.
+
+
+
+ Visit GitHub Repository
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Feature Requests & Ideas
+
+
+
+ Have an idea to make BottleCRM better? We'd love to hear from you! Share your feature
+ requests and help shape the future of open-source CRM.
+
+
+
+ Request Feature
+
+
+
+
+
+
+
+ Found a bug? Help us improve BottleCRM by reporting issues. Your feedback helps make the
+ platform more stable for everyone.
+
+
+
+ Report Bug
+
+
+
+
+
+
+
+
+
+
Security Issues
+
+
+ Security is our priority. If you discover any security vulnerabilities, please
+ report them privately. Do not create public GitHub issues for security concerns.
+
+
+
+ Report Security Issue
+
+
+
+
+
+
+
+
+
+
Custom CRM Development
+
+
+ Need BottleCRM tailored to your specific business needs? We offer professional customization
+ services including hosting, custom features, integrations, and ongoing support.
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/tasks/+page.server.ts b/apps/web/src/routes/(app)/app/tasks/+page.server.ts
new file mode 100644
index 0000000..3e7645f
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/+page.server.ts
@@ -0,0 +1,44 @@
+import { schema } from '@opensource-startup-crm/database';
+import type { Actions, PageServerLoad } from './$types';
+import { and, desc, eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db
+
+ const boards = await db
+ .select({ id: schema.board.id, name: schema.board.name })
+ .from(schema.board)
+ .where(and(eq(schema.board.ownerId, user.id), eq(schema.board.organizationId, org.id)))
+ .orderBy(desc(schema.board.createdAt));
+ return { boards };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db
+
+ const form = await request.formData();
+ const name = form.get('name')?.toString();
+ if (!name) return { status: 400 } as const;
+
+ const [board] = await db
+ .insert(schema.board)
+ .values({ id: crypto.randomUUID(), name, ownerId: user.id, organizationId: org.id })
+ .returning();
+
+ const defaultColumns = ["To Do", "In Progress", "Done"] as const;
+ for (let i = 0; i < defaultColumns.length; i += 1) {
+ await db.insert(schema.boardColumn).values({
+ id: crypto.randomUUID(),
+ name: defaultColumns[i],
+ boardId: board.id,
+ order: i + 1
+ });
+ }
+ return { success: true };
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/tasks/+page.svelte b/apps/web/src/routes/(app)/app/tasks/+page.svelte
new file mode 100644
index 0000000..29d40e2
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/+page.svelte
@@ -0,0 +1,139 @@
+
+
+
+
Boards
+
+ {#if data?.boards?.length}
+ {#each data.boards as board}
+
+ {board.name}
+
+ {/each}
+ {:else}
+
No boards found.
+ {/if}
+
+ +
+ Board name
+
+ Create Board
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/tasks/[task_id]/+page.server.ts b/apps/web/src/routes/(app)/app/tasks/[task_id]/+page.server.ts
new file mode 100644
index 0000000..cccdbf0
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/[task_id]/+page.server.ts
@@ -0,0 +1,171 @@
+import { schema } from '@opensource-startup-crm/database';
+import { fail, redirect } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { and, eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const user = locals.user;
+ const org = locals.org;
+ const db = locals.db
+ const { task_id } = params;
+
+ // Better-auth guard: ensure authenticated user and active organization
+ if (!user) throw redirect(307, '/login');
+ if (!org) throw redirect(307, '/org');
+
+ // Ensure the task belongs to the active organization
+ const [taskExists] = await db
+ .select({ id: schema.task.id })
+ .from(schema.task)
+ .where(and(eq(schema.task.id, task_id), eq(schema.task.organizationId, org.id)));
+
+ if (!taskExists) {
+ return fail(404, { message: 'Task not found or you do not have permission to view it.' });
+ }
+
+ // Ensure the current user is a member of the active organization
+ const [membership] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, user.id), eq(schema.member.organizationId, org.id)));
+
+ if (!membership) {
+ return fail(403, { message: 'You are not a member of this organization.' });
+ }
+
+ // Load task details
+ const [task] = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ description: schema.task.description,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ dueDate: schema.task.dueDate,
+ ownerId: schema.task.ownerId,
+ accountId: schema.task.accountId,
+ owner: { id: schema.user.id, name: schema.user.name, image: schema.user.image },
+ accountName: schema.crmAccount.name
+ })
+ .from(schema.task)
+ .leftJoin(schema.user, eq(schema.user.id, schema.task.ownerId))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.task.accountId))
+ .where(eq(schema.task.id, task_id));
+
+ // Users limited to active organization
+ const users = await db
+ .select({ id: schema.user.id, name: schema.user.name, image: schema.user.image })
+ .from(schema.user)
+ .innerJoin(schema.member, eq(schema.member.userId, schema.user.id))
+ .where(eq(schema.member.organizationId, org.id));
+
+ // Accounts limited to active organization
+ const accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name })
+ .from(schema.crmAccount)
+ .where(eq(schema.crmAccount.organizationId, org.id));
+
+ // Logged-in user details (with profile photo)
+ const [loggedInUserRow] = await db
+ .select({ id: schema.user.id, name: schema.user.name, image: schema.user.image })
+ .from(schema.user)
+ .where(eq(schema.user.id, user.id));
+
+ // Load task comments with author data
+ const comments = await db
+ .select({
+ id: schema.comment.id,
+ body: schema.comment.body,
+ createdAt: schema.comment.createdAt,
+ author: { id: schema.user.id, name: schema.user.name, image: schema.user.image }
+ })
+ .from(schema.comment)
+ .leftJoin(schema.user, eq(schema.user.id, schema.comment.authorId))
+ .where(eq(schema.comment.taskId, task_id));
+
+ const formattedTask = task
+ ? {
+ ...task,
+ dueDate: task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : task.dueDate,
+ account: task.accountName ? { name: task.accountName } : null,
+ comments
+ }
+ : null;
+
+ return {
+ task: formattedTask,
+ users,
+ accounts,
+ loggedInUser: loggedInUserRow ?? null
+ };
+};
+
+export const actions: Actions = {
+ addComment: async ({ request, params, locals }) => {
+ const user = locals.user;
+ const org = locals.org;
+ const db = locals.db
+
+ if (!user) throw redirect(307, '/login');
+ if (!org) throw redirect(307, '/org');
+
+ const formData = await request.formData();
+ const commentBody = formData.get('commentBody')?.toString();
+ const { task_id } = params;
+
+ const userId = user.id;
+
+ // check if the task is related to org.id and the user is related to the org
+ const [taskToUpdate] = await db
+ .select({ id: schema.task.id, organizationId: schema.task.organizationId })
+ .from(schema.task)
+ .where(and(eq(schema.task.id, params.task_id), eq(schema.task.organizationId, org.id)));
+
+ if (!taskToUpdate) {
+ return fail(404, { message: 'Task not found or you do not have permission to edit it.' });
+ }
+
+ const [userExistsInOrg] = await db
+ .select({ userId: schema.member.userId })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, userId), eq(schema.member.organizationId, org.id)));
+ if (!userExistsInOrg) {
+ return fail(400, { fieldError: ['ownerId', 'User is not part of this organization.'] });
+ }
+
+ if (!commentBody || commentBody.trim() === '') {
+ return fail(400, { error: true, message: 'Comment body cannot be empty.', commentBody });
+ }
+
+ try {
+ const body = commentBody.trim();
+ const [task] = await db
+ .select({ organizationId: schema.task.organizationId })
+ .from(schema.task)
+ .where(eq(schema.task.id, task_id));
+
+ if (!task) {
+ return fail(404, { error: true, message: 'Task not found.' });
+ }
+
+ if (!task.organizationId) {
+ // This case should ideally not happen if tasks always have an organizationId
+ return fail(500, { error: true, message: 'Task is not associated with an organization.' });
+ }
+
+ await db.insert(schema.comment).values({
+ body,
+ authorId: userId,
+ taskId: task_id,
+ organizationId: task.organizationId
+ });
+ // No need to return the comment, page will reload data.
+ // SvelteKit will invalidate the page data, causing `load` to re-run.
+ // The form will be reset by default with `enhance`.
+ return { success: true, message: 'Comment added successfully.' };
+ } catch (error) {
+ console.error('Error adding comment:', error);
+ return fail(500, { error: true, message: 'Failed to add comment.' });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/tasks/[task_id]/+page.svelte b/apps/web/src/routes/(app)/app/tasks/[task_id]/+page.svelte
new file mode 100644
index 0000000..2522a40
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/[task_id]/+page.svelte
@@ -0,0 +1,316 @@
+
+
+
+
+
+
+
+
+
goto('/app/tasks/list')}
+ class="flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
+ aria-label="Back to tasks"
+ >
+
+
+
+
+ Task Details
+
+
View and manage task information
+
+
+
task && goto(`/app/tasks/${task.id}/edit`)}
+ >
+
+ Edit
+
+
+
+
+
+ {#if task}
+ {@const statusItem = taskStatusVisualMap.find((s) => s.value === task!.status)}
+ {@const priorityItem = taskPriorityVisualMap.find((p) => p.value === task!.priority)}
+
+
+
+
+ {#if statusItem}
+
+ {statusItem.label}
+
+ {:else}
+
+ {toLabel(task.status, TASK_STATUS_OPTIONS, 'N/A')}
+
+ {/if}
+ {#if priorityItem}
+
+ {priorityItem.label}
+
+ {:else}
+
+ {toLabel(task.priority, TASK_PRIORITY_OPTIONS, 'Normal')}
+
+ {/if}
+
+
+ Due {formatDate(task.dueDate, 'en-US', undefined, '-')}
+
+
+
+
+ {task.subject}
+
+
+ {#if task.description}
+
+ {task.description}
+
+ {:else}
+
No description provided
+ {/if}
+
+
+
+
+
+
+
+
+
+ Task Owner
+
+
+ {#if task.owner?.image}
+
+ {:else}
+
+
+ {task.owner?.name?.charAt(0) || 'U'}
+
+
+ {/if}
+
+
+ {task.owner?.name || 'Unassigned'}
+
+
Owner
+
+
+
+
+
+
+
+
+ Related Account
+
+
+
+
+
+
+
+ {task.account?.name || 'No account assigned'}
+
+
Account
+
+
+
+
+
+
+
+
+
+
+
+
+
Comments
+ {#if task.comments && task.comments.length > 0}
+ ({task.comments.length})
+ {/if}
+
+
+
+
+ {#if form?.message}
+
+ {/if}
+
+ {#if form?.fieldError && Array.isArray(form.fieldError) && form.fieldError.includes('commentBody')}
+ {@const formData = form as any}
+ {#if 'commentBody' in formData}
+ {@const _ = newComment = ((formData as any).commentBody as string) || ''}
+ {/if}
+ {/if}
+
+
+
+ {#if task.comments && task.comments.length > 0}
+ {#each task.comments as c (c.id || c.createdAt)}
+
+ {#if c.author && c.author.image}
+
+ {:else}
+
+ {c.author?.name?.charAt(0) || 'U'}
+
+ {/if}
+
+
+ {c.author?.name || 'Unknown'}
+ {new Date(c.createdAt).toLocaleString()}
+
+
+ {c.body}
+
+
+
+ {/each}
+ {:else}
+
+
+
No comments yet
+
+ Be the first to add a comment
+
+
+ {/if}
+
+
+
+
+
+
+ Add a comment
+
+
+
+
+
+
+ Add Comment
+
+
+
+
+
+ {/if}
+
+
diff --git a/apps/web/src/routes/(app)/app/tasks/[task_id]/edit/+page.server.ts b/apps/web/src/routes/(app)/app/tasks/[task_id]/edit/+page.server.ts
new file mode 100644
index 0000000..7105b9b
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/[task_id]/edit/+page.server.ts
@@ -0,0 +1,114 @@
+import { schema } from '@opensource-startup-crm/database';
+import { fail, redirect } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { and, eq } from 'drizzle-orm';
+import { validateEnumOrDefault } from '$lib/data/enum-helpers';
+import { TASK_STATUSES, TASK_PRIORITIES } from '@opensource-startup-crm/constants';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const org = locals.org!;
+ const db = locals.db
+
+ const [task] = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ description: schema.task.description,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ dueDate: schema.task.dueDate,
+ ownerId: schema.task.ownerId,
+ accountId: schema.task.accountId
+ })
+ .from(schema.task)
+ .where(and(eq(schema.task.id, params.task_id), eq(schema.task.organizationId, org.id)));
+
+ if (!task) throw redirect(303, '/app/tasks');
+
+ const formattedTask = {
+ ...task,
+ dueDate: task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : task.dueDate
+ } as any;
+
+ const users = await db
+ .select({ id: schema.user.id, name: schema.user.name })
+ .from(schema.member)
+ .innerJoin(schema.user, eq(schema.member.userId, schema.user.id))
+ .where(eq(schema.member.organizationId, org.id));
+
+ const accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name })
+ .from(schema.crmAccount)
+ .where(eq(schema.crmAccount.organizationId, org.id));
+
+ return { task: formattedTask, users, accounts };
+};
+
+export const actions: Actions = {
+ update: async ({ request, params, locals }) => {
+ const formData = await request.formData();
+ const org = locals.org!;
+ const user = locals.user!;
+ const db = locals.db
+
+ const subject = formData.get('subject');
+ const description = formData.get('description');
+ const status = formData.get('status');
+ const priority = formData.get('priority');
+ const dueDateField = formData.get('dueDate');
+ const ownerId = formData.get('ownerId');
+ let accountId = formData.get('accountId');
+
+ if (!subject || typeof subject !== 'string' || subject.trim() === '') {
+ return fail(400, { fieldError: ['subject', 'Subject is required.'] });
+ }
+ if (!ownerId || typeof ownerId !== 'string') {
+ return fail(400, { fieldError: ['ownerId', 'Owner is required.'] });
+ }
+
+ const [taskToUpdate] = await db
+ .select({ id: schema.task.id })
+ .from(schema.task)
+ .where(and(eq(schema.task.id, params.task_id), eq(schema.task.organizationId, org.id)));
+
+ if (!taskToUpdate) {
+ return fail(404, { message: 'Task not found or you do not have permission to edit it.' });
+ }
+
+ const [userExistsInOrg] = await db
+ .select({ id: schema.member.id })
+ .from(schema.member)
+ .where(and(eq(schema.member.userId, user.id), eq(schema.member.organizationId, org.id)));
+ if (!userExistsInOrg) {
+ return fail(400, { fieldError: ['ownerId', 'User is not part of this organization.'] });
+ }
+
+
+ // Convert empty string or null accountId to null for Prisma
+ accountId = accountId === '' || accountId === 'null' ? null : accountId;
+
+ // Convert dueDate to Date object or null if empty (DB expects Date)
+ const dueDateValue: Date | null =
+ typeof dueDateField === 'string' && dueDateField.trim() !== '' ? new Date(dueDateField) : null;
+
+ try {
+ await db
+ .update(schema.task)
+ .set({
+ subject: subject.toString().trim(),
+ description: description ? description.toString().trim() : null,
+ status: validateEnumOrDefault(status, TASK_STATUSES, 'NOT_STARTED'),
+ priority: validateEnumOrDefault(priority, TASK_PRIORITIES, 'NORMAL'),
+ dueDate: dueDateValue || null,
+ ownerId: ownerId.toString(),
+ accountId: accountId ? accountId.toString() : null
+ })
+ .where(and(eq(schema.task.id, params.task_id), eq(schema.task.organizationId, org.id)));
+ } catch (error) {
+ console.error('Error updating task:', error);
+ return fail(500, { message: 'Failed to update task. Please try again.' });
+ }
+
+ throw redirect(303, `/app/tasks/${params.task_id}`);
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte b/apps/web/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte
new file mode 100644
index 0000000..98c705a
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Task
+
+ Update task details and settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if form?.message || form?.fieldError}
+
+
+
+
+
+ {#if form?.message}
+
{form.message}
+ {/if}
+ {#if form?.fieldError}
+
+ Error with field '{form.fieldError[0]}': {form.fieldError[1]}
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+ Subject *
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ Status
+
+
+ {#each taskStatusOptions as opt}
+ {opt.label}
+ {/each}
+
+
+
+
+ Priority
+
+
+ {#each taskPriorityOptions as opt}
+ {opt.label}
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+ Owner *
+
+
+
+ {#each users as user}
+ {user.name}
+ {/each}
+
+
+
+
+
+
+
+
+
+ Account
+ (Optional)
+
+
+
+ No account selected
+ {#each accounts as acc}
+ {acc.name}
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Save Changes
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/tasks/calendar/+page.server.ts b/apps/web/src/routes/(app)/app/tasks/calendar/+page.server.ts
new file mode 100644
index 0000000..c5b4455
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/calendar/+page.server.ts
@@ -0,0 +1,29 @@
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq, asc, isNotNull } from 'drizzle-orm';
+
+export async function load({ locals }) {
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db
+
+ const tasks = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ description: schema.task.description,
+ dueDate: schema.task.dueDate,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ createdAt: schema.task.createdAt,
+ updatedAt: schema.task.updatedAt
+ })
+ .from(schema.task)
+ .where(and(
+ eq(schema.task.ownerId, user.id),
+ eq(schema.task.organizationId, org.id),
+ isNotNull(schema.task.dueDate)
+ ))
+ .orderBy(asc(schema.task.dueDate));
+
+ return { tasks };
+}
diff --git a/apps/web/src/routes/(app)/app/tasks/calendar/+page.svelte b/apps/web/src/routes/(app)/app/tasks/calendar/+page.svelte
new file mode 100644
index 0000000..b266e59
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/calendar/+page.svelte
@@ -0,0 +1,347 @@
+
+
+
+
+
+
+
+
+
Task Calendar
+
+
Manage and track your tasks with ease
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {monthNames[month]}
+ {year}
+
+
+
+
+
+
+ Today
+
+
+
+
+
+
+ {#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day}
+
+ {day}
+
+ {/each}
+
+
+
+
+ {#each calendar as date, i}
+ {#if date}
+
selectDay(date)}
+ class="group relative h-16 border-b border-r border-gray-200 p-2 transition-colors last:border-r-0
+ hover:bg-blue-50 sm:h-20 dark:border-gray-600 dark:hover:bg-gray-700
+ {formatDate(date) === selectedDate
+ ? 'bg-blue-600 text-white dark:bg-blue-700'
+ : ''}
+ {isToday(date) && formatDate(date) !== selectedDate
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
+ : ''}
+ {!isToday(date) && formatDate(date) !== selectedDate
+ ? 'text-gray-900 dark:text-gray-100'
+ : ''}"
+ >
+
+ {date.getDate()}
+
+ {#if hasTasks(date)}
+
+
+ {tasksByDate[formatDate(date)].length}
+
+ {/if}
+
+ {:else}
+
+ {/if}
+ {/each}
+
+
+
+
+
+
+
+
+
+ Tasks for {new Date(selectedDate + 'T00:00:00').toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })}
+
+
+ {selectedTasks.length} task{selectedTasks.length !== 1 ? 's' : ''}
+
+
+
+
+ {#if selectedTasks.length > 0}
+
+ {:else}
+
+
+
+ No tasks scheduled for this date
+
+
+ Select a different date to view tasks
+
+
+ {/if}
+
+
+
+
+
+
This Month
+
+
+ Total Tasks
+
+ {totalMonthlyTasks}
+
+
+
+ Days with Tasks
+ {monthlyTasks.length}
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/tasks/list/+page.server.ts b/apps/web/src/routes/(app)/app/tasks/list/+page.server.ts
new file mode 100644
index 0000000..34db6e1
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/list/+page.server.ts
@@ -0,0 +1,33 @@
+import { schema } from '@opensource-startup-crm/database';
+import { redirect } from '@sveltejs/kit';
+import { eq, desc } from 'drizzle-orm';
+
+export async function load({ locals }) {
+ const org = locals.org!;
+ const db = locals.db
+
+ if (!org?.id) throw redirect(302, "/app")
+
+ const tasks = await db
+ .select({
+ id: schema.task.id,
+ subject: schema.task.subject,
+ status: schema.task.status,
+ priority: schema.task.priority,
+ createdAt: schema.task.createdAt,
+ ownerId: schema.task.ownerId,
+ accountId: schema.task.accountId,
+ ownerName: schema.user.name,
+ ownerUserId: schema.user.id,
+ accountName: schema.crmAccount.name,
+ description: schema.task.description,
+ dueDate: schema.task.dueDate,
+ })
+ .from(schema.task)
+ .leftJoin(schema.user, eq(schema.user.id, schema.task.ownerId))
+ .leftJoin(schema.crmAccount, eq(schema.crmAccount.id, schema.task.accountId))
+ .where(eq(schema.task.organizationId, org.id))
+ .orderBy(desc(schema.task.createdAt));
+
+ return { tasks };
+}
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/tasks/list/+page.svelte b/apps/web/src/routes/(app)/app/tasks/list/+page.svelte
new file mode 100644
index 0000000..c9b45c7
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/list/+page.svelte
@@ -0,0 +1,316 @@
+
+
+
+
+
+
+
+
+
Tasks
+
+ Manage and track your team's tasks
+
+
+
+
+ New Task
+
+
+
+
+
+
+ {#if data.tasks.length === 0}
+
+
+
+
+
No tasks yet
+
+ Get started by creating your first task
+
+
+
+ Create Task
+
+
+ {:else}
+
+
+
+
+
+
+ Task
+
+
+ Status
+
+
+ Priority
+
+
+ Due Date
+
+
+ Owner
+
+
+ Account
+
+
+ Actions
+
+
+
+
+ {#each data.tasks as task (task.id)}
+ {@const statusItem = taskStatusVisualMap.find(s => s.value === task.status)}
+ {@const StatusIcon = statusItem?.icon || AlertCircle}
+ {@const priorityItem = taskPriorityVisualMap.find(p => p.value === task.priority)}
+ {@const PriorityIcon = priorityItem?.icon || Clock}
+
+
+
+
+
+
+
+
+ {task.status || 'N/A'}
+
+
+
+
+
+
+
+
+ {task.priority || 'Normal'}
+
+
+
+
+
+
+ {formatDate(task.dueDate)}
+
+
+
+
+
+ {task.ownerName || 'Unassigned'}
+
+
+
+
+
+ {task.accountName || 'N/A'}
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+
+ {#each data.tasks as task (task.id)}
+ {@const statusItem = taskStatusVisualMap.find(s => s.value === task.status)}
+ {@const StatusIcon = statusItem?.icon || AlertCircle}
+ {@const priorityItem = taskPriorityVisualMap.find(p => p.value === task.priority)}
+ {@const PriorityIcon = priorityItem?.icon || Clock}
+
+
+
+
+
+
+
+ {task.status || 'N/A'}
+
+
+
+
+
+
+ {task.priority || 'Normal'}
+
+
+
+
+
+
+
+ Due: {formatDate(task.dueDate)}
+
+
+
+ {task.ownerName || 'Unassigned'}
+
+ {#if task.accountName}
+
+
+ {task.accountName}
+
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/tasks/new/+page.server.ts b/apps/web/src/routes/(app)/app/tasks/new/+page.server.ts
new file mode 100644
index 0000000..7234035
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/new/+page.server.ts
@@ -0,0 +1,136 @@
+import { schema } from '@opensource-startup-crm/database';
+import { fail, redirect } from '@sveltejs/kit';
+import { and, eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+import { validateEnumOrDefault } from '$lib/data/enum-helpers';
+import { TASK_STATUSES, TASK_PRIORITIES } from '@opensource-startup-crm/constants';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db
+
+ const urlAccountId = url.searchParams.get('accountId');
+
+ const users = await db
+ .select({ id: schema.user.id, name: schema.user.name })
+ .from(schema.user)
+ .innerJoin(schema.member, eq(schema.member.userId, schema.user.id))
+ .where(eq(schema.member.organizationId, org.id));
+
+ const accounts = await db
+ .select({ id: schema.crmAccount.id, name: schema.crmAccount.name })
+ .from(schema.crmAccount)
+ .where(eq(schema.crmAccount.organizationId, org.id));
+
+ if (urlAccountId) {
+ const accountExists = accounts.some(account => account.id === urlAccountId);
+ if (!accountExists) {
+ throw redirect(303, '/app/tasks/new');
+ }
+ }
+
+ return { users, accounts };
+}
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ const user = locals.user!;
+ const org = locals.org!;
+ const db = locals.db
+
+ const formData = await request.formData();
+
+ const subject = formData.get('subject')?.toString();
+ const status = validateEnumOrDefault(formData.get('status'), TASK_STATUSES, 'NOT_STARTED');
+ const priority = validateEnumOrDefault(formData.get('priority'), TASK_PRIORITIES, 'NORMAL');
+ const dueDateStr = formData.get('dueDate')?.toString();
+ const ownerId = formData.get('ownerId')?.toString();
+ let accountId = formData.get('accountId')?.toString();
+ const description = formData.get('description')?.toString();
+
+ console.log('Form data received:', { subject, status, priority, dueDateStr, ownerId, accountId, description });
+
+ if (!subject) {
+ return fail(400, {
+ error: 'Subject is required.',
+ subject, status, priority, dueDate: dueDateStr, ownerId, accountId, description
+ });
+ }
+ if (!ownerId) {
+ return fail(400, {
+ error: 'Owner is required.',
+ subject, status, priority, dueDate: dueDateStr, ownerId, accountId, description
+ });
+ }
+
+ // Validate ownerId
+ const [taskOwner] = await db
+ .select({ id: schema.user.id })
+ .from(schema.user)
+ .innerJoin(schema.member, eq(schema.member.userId, schema.user.id))
+ .where(and(eq(schema.user.id, ownerId!), eq(schema.member.organizationId, org!.id)));
+ if (!taskOwner) {
+ return fail(400, {
+ error: 'Invalid owner selected or owner does not belong to this organization.',
+ subject, status, priority, dueDate: dueDateStr, ownerId, accountId, description
+ });
+ }
+
+ // Validate accountId if provided
+ if (accountId && accountId !== "" && accountId !== "null") {
+ const [taskAccount] = await db
+ .select({ id: schema.crmAccount.id })
+ .from(schema.crmAccount)
+ .where(and(eq(schema.crmAccount.id, accountId!), eq(schema.crmAccount.organizationId, org.id)));
+ if (!taskAccount) {
+ return fail(400, {
+ error: 'Invalid account selected or account does not belong to this organization.',
+ subject, status, priority, dueDate: dueDateStr, ownerId, accountId, description
+ });
+ }
+ } else {
+ accountId = undefined;
+ }
+
+ const dueDate = dueDateStr ? new Date(dueDateStr) : null;
+
+ try {
+ const taskData = {
+ id: crypto.randomUUID(),
+ subject,
+ status,
+ priority,
+ dueDate,
+ description: description || null,
+ ownerId: ownerId,
+ createdById: user.id,
+ organizationId: org.id,
+ ...(accountId && { accountId })
+ };
+
+ console.log('Creating task with data:', taskData);
+
+ const [task] = await db.insert(schema.task).values(taskData).returning();
+
+ console.log('Task created successfully:', task);
+
+ if (accountId) {
+ throw redirect(303, `/app/accounts/${accountId}`);
+ } else {
+ throw redirect(303, '/app/tasks/list');
+ }
+
+ } catch (e) {
+ if (e && typeof e === 'object' && 'status' in e && e.status === 303) {
+ throw e;
+ }
+
+ console.error('Failed to create task:', e);
+ return fail(500, {
+ error: 'Failed to create task. Please try again.',
+ subject, status, priority, dueDate: dueDateStr, ownerId, accountId, description
+ });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(app)/app/tasks/new/+page.svelte b/apps/web/src/routes/(app)/app/tasks/new/+page.svelte
new file mode 100644
index 0000000..3f82743
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/tasks/new/+page.svelte
@@ -0,0 +1,354 @@
+
+
+
+
+
+
+
+
+
Create New Task
+
+ {#if selectedAccount}
+ Add a new task for {selectedAccount.name}
+ {:else}
+ Add a new task to keep track of your work
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {#if form?.error}
+
+ {/if}
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="p-6"
+ >
+
+ {#if urlAccountId}
+
+ {/if}
+
+
+
+
+
+
+ Task Details
+
+
+
+
+
+
+ Subject *
+
+
+
+
+
+
+
+
+
+ Status
+
+
+ {#each taskStatusOptions as opt}
+ {opt.label}
+ {/each}
+
+
+
+
+
+
+ Priority
+
+
+ {#each taskPriorityOptions as opt}
+ {opt.label}
+ {/each}
+
+
+
+
+
+
+ Due Date
+
+
+
+
+
+
+
+
+
+
+
+ Assignment
+
+
+
+
+
+
+ Owner *
+
+
+ Select owner
+ {#each data.users as user (user.id)}
+ {user.name}
+ {/each}
+
+
+
+
+
+
+
+ Related Account
+
+ {#if urlAccountId}
+
+
+
+ {selectedAccount?.name || 'Loading...'}
+
+
+
+ Account pre-selected from URL
+
+ {:else}
+
+
+ Select account (optional)
+ {#each data.accounts as account (account.id)}
+ {account.name}
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+
+
+
+ Creating Task...
+ {:else}
+ Create Task
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(app)/app/users/+page.server.ts b/apps/web/src/routes/(app)/app/users/+page.server.ts
new file mode 100644
index 0000000..cab5aef
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/users/+page.server.ts
@@ -0,0 +1,89 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+type UserRole = 'member' | 'admin' | 'owner';
+
+export const load: PageServerLoad = async ({ locals, request }) => {
+ const user = locals.user;
+ const orgId = locals.session?.activeOrganizationId;
+ if (!user || !orgId) {
+ throw redirect(307, '/login');
+ }
+
+ // Get organization details and members via Better Auth organization plugin
+ const organization = await locals.auth.api.getFullOrganization({ query: { organizationId: orgId }, headers: request.headers });
+
+ return { organization, user: { id: user.id } };
+};
+
+export const actions: Actions = {
+ update: async ({ request, locals }) => {
+ const user = locals.user;
+ const orgId = locals.session?.activeOrganizationId;
+ if (!user || !orgId) return fail(401, { error: 'Unauthorized' });
+
+ const formData = await request.formData();
+ const name = formData.get('name')?.toString().trim();
+ const description = formData.get('description')?.toString().trim();
+ if (!name) return fail(400, { error: 'Name is required' });
+
+ try {
+ await locals.auth.api.updateOrganization({ body: { organizationId: orgId, data: { name, description } }, headers: request.headers });
+ return { success: true };
+ } catch {
+ return fail(500, { error: 'Failed to update organization' });
+ }
+ },
+
+ add_user: async ({ request, locals }) => {
+ const user = locals.user;
+ const orgId = locals.session?.activeOrganizationId;
+ if (!user || !orgId) return fail(401, { error: 'Unauthorized' });
+
+ const formData = await request.formData();
+ const userId = formData.get('user_id')?.toString().trim();
+ const role = formData.get('role')?.toString() as UserRole;
+ if (!userId || !role) return fail(400, { error: 'User and role are required' });
+
+ try {
+ await locals.auth.api.addMember({ body: { organizationId: orgId, userId, role }, headers: request.headers });
+ return { success: true };
+ } catch {
+ return fail(500, { error: 'Failed to add user' });
+ }
+ },
+
+ edit_role: async ({ request, locals }) => {
+ const user = locals.user;
+ const orgId = locals.session?.activeOrganizationId;
+ if (!user || !orgId) return fail(401, { error: 'Unauthorized' });
+
+ const formData = await request.formData();
+ const member_id = formData.get('member_id')?.toString();
+ const role = formData.get('role')?.toString() as UserRole;
+ if (!member_id || !role) return fail(400, { error: 'Member and role are required' });
+
+ try {
+ await locals.auth.api.updateMemberRole({ body: { organizationId: orgId, memberId: member_id, role }, headers: request.headers });
+ return { success: true };
+ } catch {
+ return fail(500, { error: 'Failed to update role' });
+ }
+ },
+
+ remove_user: async ({ request, locals }) => {
+ const user = locals.user;
+ const orgId = locals.session?.activeOrganizationId;
+ if (!user || !orgId) return fail(401, { error: 'Unauthorized' });
+
+ const formData = await request.formData();
+ const member_id = formData.get('member_id')?.toString();
+ if (!member_id) return fail(400, { error: 'Member is required' });
+
+ try {
+ await locals.auth.api.removeMember({ body: { organizationId: orgId, memberIdOrEmail: member_id }, headers: request.headers });
+ return { success: true };
+ } catch {
+ return fail(500, { error: 'Failed to remove user' });
+ }
+ }
+};
diff --git a/apps/web/src/routes/(app)/app/users/+page.svelte b/apps/web/src/routes/(app)/app/users/+page.svelte
new file mode 100644
index 0000000..126a759
--- /dev/null
+++ b/apps/web/src/routes/(app)/app/users/+page.svelte
@@ -0,0 +1,476 @@
+
+
+
+
+
+
+
+
Organization Settings
+
+ Manage your organization and team members
+
+
+
+
+ Logout
+
+
+
+
+
+
+
+
+
+
+
+
+
{org.name}
+
+ {#if org.domain}
+
+
+ {org.domain}
+
+ {/if}
+
+
+ {users.length} member{users.length !== 1 ? 's' : ''}
+
+
+
+
+ {#if !editing}
+
+
+
+ {/if}
+
+
+ {#if org.description && !editing}
+
{org.description}
+ {/if}
+
+ {#if editing}
+
+
+
+
+ Organization Name *
+
+
+
+
+
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Save Changes
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+ Add New Member
+
+
+
+
+ Email Address *
+
+
+
+
+
+ Role
+
+
+ User
+ Admin
+
+
+
+
+ Add Member
+
+
+
+
+
+
+
+
+
+
+
+ Member
+
+
+ Role
+
+
+ Joined
+
+
+ Actions
+
+
+
+
+ {#each users as user, i}
+
+
+
+ {#if user.avatar}
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {user.name}
+ {#if user.isSelf}
+
+ You
+
+ {/if}
+
+
{user.email}
+
+
+
+
+ {#if user.isSelf}
+
+ {#snippet roleIcon(role: RoleKey)}
+ {@const RoleIcon = roleIcons[role] || User}
+
+ {/snippet}
+ {@render roleIcon(user.role ?? 'user')}
+ {user.role}
+
+ {:else if user.editingRole}
+
+
+ User Role
+
+ User
+ Admin
+
+
+
+
+ {
+ users[i].editingRole = false;
+ }}
+ title="Cancel"
+ >
+
+
+
+ {:else}
+ {
+ users[i].editingRole = true;
+ }}
+ title="Click to edit role"
+ >
+ {#snippet roleIcon(role: RoleKey)}
+ {@const RoleIcon = roleIcons[role] || User}
+
+ {/snippet}
+ {@render roleIcon(user.role ?? 'user')}
+ {user.role}
+
+
+ {/if}
+
+
+ {user.joined}
+
+
+ {#if user.isSelf}
+ —
+ {:else}
+ {
+ if (!confirm('Remove this user from the organization?')) {
+ e.preventDefault();
+ }
+ }}
+ >
+
+
+
+
+
+ {/if}
+
+
+ {/each}
+
+
+
+
+
+
+
+
diff --git a/src/routes/(no-layout)/bounce/+page.svelte b/apps/web/src/routes/(no-layout)/bounce/+page.svelte
similarity index 100%
rename from src/routes/(no-layout)/bounce/+page.svelte
rename to apps/web/src/routes/(no-layout)/bounce/+page.svelte
diff --git a/apps/web/src/routes/(no-layout)/login/+page.svelte b/apps/web/src/routes/(no-layout)/login/+page.svelte
new file mode 100644
index 0000000..ff83800
--- /dev/null
+++ b/apps/web/src/routes/(no-layout)/login/+page.svelte
@@ -0,0 +1,399 @@
+
+
+
+ Login | BottleCRM - Free Open-Source CRM for Startups
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if mounted}
+
+
+
+
+ Free & Open Source CRM
+
+
+
+
+
+ The completely free, self-hostable CRM solution built specifically for startups
+ and growing businesses.
+
+
+
+
+
+
Why Choose BottleCRM?
+
+ {#each benefits as benefit, i}
+
+
+ {benefit}
+
+ {/each}
+
+
+
+
+
+ {#each features as feature, i}
+ {@const FeatureIcon = feature.icon}
+
+ {/each}
+
+
+
+
+
+ {/if}
+
+
+
+
+ {#if mounted}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome Back
+
+ Sign in to your free BottleCRM account and start managing your customer
+ relationships more effectively.
+
+
+
+
+
+
+
+
+
+ Free CRM Features
+
+
+
+
+ Unlimited contacts & users
+
+
+
+ Self-hosted solution
+
+
+
+ No subscription fees
+
+
+
+
+
+
+
+
Sign in with Google or use your email
+
+
+
+
+
+
+
+
+
+
+ {#if errorMsg}
+
{errorMsg}
+ {/if}
+
+
+
+
+ {isEmailLoading ? 'Signing in...' : 'Sign In'}
+
+
+ {isEmailLoading ? 'Signing up...' : 'Sign Up'}
+
+
+
+
+
+
+
+
+ Your data is secure and private
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(no-layout)/logout/+page.server.ts b/apps/web/src/routes/(no-layout)/logout/+page.server.ts
new file mode 100644
index 0000000..ae2c118
--- /dev/null
+++ b/apps/web/src/routes/(no-layout)/logout/+page.server.ts
@@ -0,0 +1,11 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, request }) => {
+ if (locals.session?.id) {
+ try {
+ await locals.auth.api.signOut({ headers: request.headers });
+ } catch (e) { }
+ }
+ throw redirect(303, '/login');
+};
diff --git a/apps/web/src/routes/(no-layout)/org/+page.server.ts b/apps/web/src/routes/(no-layout)/org/+page.server.ts
new file mode 100644
index 0000000..f630fb7
--- /dev/null
+++ b/apps/web/src/routes/(no-layout)/org/+page.server.ts
@@ -0,0 +1,39 @@
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { and, eq } from 'drizzle-orm';
+import { fail, redirect } from '@sveltejs/kit';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const user = locals.user;
+ const db = locals.db
+ if (!user) return { orgs: [] };
+
+ const rows = await db
+ .select({
+ id: schema.organization.id,
+ name: schema.organization.name,
+ logo: schema.organization.logo,
+ role: schema.member.role
+ })
+ .from(schema.member)
+ .innerJoin(
+ schema.organization,
+ eq(schema.member.organizationId, schema.organization.id)
+ )
+ .where(eq(schema.member.userId, user.id));
+
+ const orgs = rows.map((r) => ({ id: r.id, name: r.name, logo: r.logo, role: r.role }));
+ return { orgs };
+};
+
+export const actions: Actions = {
+ select: async ({ request, locals }) => {
+ const form = await request.formData();
+ const orgId = form.get('orgId')?.toString();
+ if (!orgId) return fail(400, { error: 'Organization is required' });
+
+ await locals.auth.api.setActiveOrganization({ body: { organizationId: orgId }, headers: request.headers });
+
+ throw redirect(303, '/app');
+ }
+};
diff --git a/apps/web/src/routes/(no-layout)/org/+page.svelte b/apps/web/src/routes/(no-layout)/org/+page.svelte
new file mode 100644
index 0000000..c8c90fa
--- /dev/null
+++ b/apps/web/src/routes/(no-layout)/org/+page.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
Select Organization
+
Choose an organization to continue
+
+
+
+ Logout
+
+
+
+
+ {#if orgs.length > 0}
+
+
+
+
+ {#each orgs as org}
+
selectOrg(org)}
+ type="button"
+ aria-label="Select {org.name} organization"
+ >
+
+
+
+
+
+
+
+
+ {org.name}
+
+
+ {org.role?.toLowerCase() || 'Member'}
+
+
+
+
+
+
+ Select Organization
+
+
+
+ {/each}
+
+ {:else}
+
+
+
+
+
No organizations found
+
Create your first organization to get started
+
+
+ Create Organization
+
+
+ {/if}
+
+
+ {#if orgs.length > 0}
+
+
+
+ {/if}
+
+
diff --git a/apps/web/src/routes/(no-layout)/org/new/+page.server.ts b/apps/web/src/routes/(no-layout)/org/new/+page.server.ts
new file mode 100644
index 0000000..87101b1
--- /dev/null
+++ b/apps/web/src/routes/(no-layout)/org/new/+page.server.ts
@@ -0,0 +1,86 @@
+import type { Actions, PageServerLoad } from './$types';
+import { schema } from '@opensource-startup-crm/database';
+import { eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async () => { };
+
+export const actions: Actions = {
+ default: async ({ request, cookies, locals }) => {
+ // Get the user from locals
+ const user = locals.user;
+ const db = locals.db
+
+ if (!user) {
+ return {
+ error: {
+ name: 'You must be logged in to create an organization'
+ }
+ };
+ }
+
+ // Get the submitted form data
+ const formData = await request.formData();
+ const orgName = formData.get('org_name')?.toString();
+
+ if (!orgName) {
+ return {
+ error: {
+ name: 'Organization name is required'
+ }
+ };
+ }
+
+ try {
+ // Check if organization with the same name already exists
+ const [existingOrg] = await db
+ .select({ id: schema.organization.id })
+ .from(schema.organization)
+ .where(eq(schema.organization.name, orgName));
+
+ if (existingOrg) {
+ return {
+ error: {
+ name: 'Organization with this name already exists'
+ }
+ };
+ }
+
+ // Create organization and membership
+ const newOrgId = crypto.randomUUID();
+ await db.insert(schema.organization).values({
+ id: newOrgId,
+ name: orgName,
+ createdAt: new Date()
+ } as any);
+
+ await db.insert(schema.member).values({
+ id: crypto.randomUUID(),
+ organizationId: newOrgId,
+ userId: user.id,
+ role: 'ADMIN',
+ createdAt: new Date()
+ } as any);
+
+ // Set org cookie for the newly created org
+ cookies.set('org', newOrgId, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'strict'
+ });
+
+ // Redirect to home page after successful creation
+ return {
+ data: {
+ name: orgName
+ }
+ }
+ } catch (err) {
+ console.error('Error creating organization:', err);
+ return {
+ error: {
+ name: 'An unexpected error occurred while creating the organization.'
+ }
+ };
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(no-layout)/org/new/+page.svelte b/apps/web/src/routes/(no-layout)/org/new/+page.svelte
new file mode 100644
index 0000000..9d226fe
--- /dev/null
+++ b/apps/web/src/routes/(no-layout)/org/new/+page.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
Create Organization
+
Set up your new organization to get started
+
+
+
+
+
+
+
+
+ Organization Name
+
+
+
+
+
+ {#if form?.error}
+
+ {/if}
+
+
+ {#if form?.data}
+
+
+
+ Organization "{form.data.name}" created successfully!
+
+
+ {/if}
+
+
+
+ Create Organization
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(site)/+layout.svelte b/apps/web/src/routes/(site)/+layout.svelte
new file mode 100644
index 0000000..922d70c
--- /dev/null
+++ b/apps/web/src/routes/(site)/+layout.svelte
@@ -0,0 +1,400 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if isMenuOpen}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+ {#if isMenuOpen}
+
+ {/if}
+
+
+
+
+ {@render children()}
+
+
+
+
+
+
+
+
+
Stay Updated with BottleCRM
+
Get the latest updates on new features, best practices, and CRM tips delivered to your inbox.
+
{
+ if (submitter) (submitter as HTMLButtonElement).disabled = true;
+ return async ({ result, update }) => {
+ if (result.type === 'success') {
+ newsletterMessage = (result.data?.message as string) || 'Successfully subscribed to newsletter!';
+ showNewsletterMessage = true;
+ newsletterForm?.reset();
+ setTimeout(() => { showNewsletterMessage = false; }, 5000);
+ } else if (result.type === 'failure') {
+ newsletterMessage = (result.data?.message as string) || 'Failed to subscribe. Please try again.';
+ showNewsletterMessage = true;
+ setTimeout(() => { showNewsletterMessage = false; }, 5000);
+ }
+ if (submitter) (submitter as HTMLButtonElement).disabled = false;
+ await update();
+ };
+ }}
+ bind:this={newsletterForm}
+ >
+ {#if showNewsletterMessage}
+
+ {newsletterMessage}
+
+ {/if}
+
+ Enter your email address
+
+
+ Subscribe Free
+
+
+
+
+
+
+
+
+
+
+
+
BottleCRM
+
+
The only CRM you'll ever need - completely free, open-source, and designed for startups. Build better customer relationships without breaking the bank.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
© {new Date().getFullYear()} BottleCRM by MicroPyramid . Open Source & Free Forever.
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(site)/+page.server.ts b/apps/web/src/routes/(site)/+page.server.ts
new file mode 100644
index 0000000..cb2dcf3
--- /dev/null
+++ b/apps/web/src/routes/(site)/+page.server.ts
@@ -0,0 +1,75 @@
+import { fail, type Actions } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { eq } from 'drizzle-orm';
+
+export const actions: Actions = {
+ subscribe: async ({ request, getClientAddress, locals }) => {
+ const db = locals.db
+
+ const formData = await request.formData();
+ const email = formData.get('email')?.toString().trim();
+
+ if (!email) {
+ return fail(400, { message: 'Email is required' });
+ }
+
+ // Basic email validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return fail(400, { message: 'Please enter a valid email address' });
+ }
+
+ // Restrict emails with '+' character
+ if (email.includes('+')) {
+ return fail(400, { message: 'Please enter a valid email address' });
+ }
+
+ try {
+ // Check if email already exists
+ const [existingSubscriber] = await db
+ .select()
+ .from(schema.newsletterSubscriber)
+ .where(eq(schema.newsletterSubscriber.email, email));
+
+ if (existingSubscriber) {
+ if (existingSubscriber.isActive) {
+ return fail(400, { message: 'You are already subscribed to our newsletter' });
+ } else {
+ // Reactivate subscription
+ await db
+ .update(schema.newsletterSubscriber)
+ .set({
+ isActive: true,
+ subscribedAt: new Date(),
+ unsubscribedAt: null,
+ confirmationToken: crypto.randomUUID() as string,
+ isConfirmed: false,
+ confirmedAt: null,
+ ipAddress: getClientAddress(),
+ userAgent: request.headers.get('user-agent') || null
+ })
+ .where(eq(schema.newsletterSubscriber.email, email));
+
+ return { success: true, message: 'Successfully resubscribed to newsletter' };
+ }
+ }
+
+ // Create new subscription
+ await db.insert(schema.newsletterSubscriber).values({
+ id: crypto.randomUUID(),
+ email,
+ isActive: true,
+ confirmationToken: crypto.randomUUID() as string,
+ isConfirmed: false,
+ ipAddress: getClientAddress(),
+ userAgent: request.headers.get('user-agent') || null
+ });
+
+ return { success: true, message: 'Successfully subscribed to newsletter' };
+
+ } catch (error) {
+ console.error('Newsletter subscription error:', error);
+ return fail(500, { message: 'Failed to subscribe. Please try again later.' });
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(site)/+page.svelte b/apps/web/src/routes/(site)/+page.svelte
new file mode 100644
index 0000000..6730b95
--- /dev/null
+++ b/apps/web/src/routes/(site)/+page.svelte
@@ -0,0 +1,1243 @@
+
+
+
+
+ BottleCRM: Free Open Source CRM for Startups & Small Business
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🚀 Free Forever • No Credit Card Required
+
+
+
+
+ Stop paying $50-300/month for CRM subscriptions. BottleCRM is a 100% free, open-source,
+ and self-hostable customer relationship management solution built specifically for
+ startups and growing businesses.
+
+
+
+
+
+
+ No monthly fees - Save thousands per year
+
+
+
+ Complete data ownership - Host on your servers
+
+
+
+ Unlimited users and customization
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Everything your startup or small business needs to manage customer relationships, automate
+ sales processes, and drive sustainable growth. All features included, no premium tiers.
+
+
+
+
+
+
+
+
+
+
+
Smart Contact Management System
+
+ Centralize customer data with our intuitive contact management system. Track customer
+ interactions, manage qualified leads, and build stronger customer relationships. Perfect
+ for startups, small businesses, and growing enterprises looking for efficient CRM
+ software.
+
+
+
+
+
+ 360° Customer View
+
+
+
+ Lead Scoring
+
+
+
+ Contact Segmentation
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Visual Sales Pipeline Management
+
+ Streamline your sales process with our drag-and-drop sales pipeline. Track deals from lead
+ qualification to closing, forecast revenue accurately, and implement powerful sales
+ automation workflows to boost your conversion rates.
+
+
+
+
+
+ Deal Tracking
+
+
+
+ Sales Forecasting
+
+
+
+ Automation Workflows
+
+
+
+
+
+
+
+
+
+
+
Advanced Task & Project Management
+
+ Never miss a follow-up with integrated task management and project tracking. Set
+ reminders, assign tasks to team members, track project milestones, and ensure maximum
+ productivity across your sales and marketing teams.
+
+
+
+
+
+ Task Automation
+
+
+
+ Team Collaboration
+
+
+
+ Deadline Tracking
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Real-time Analytics & Reporting
+
+ Make data-driven decisions with comprehensive CRM analytics and business intelligence.
+ Track sales performance, monitor marketing campaign effectiveness, and generate detailed
+ reports to optimize your customer acquisition strategies.
+
+
+
+
+
+ Custom Dashboards
+
+
+
+ Performance Metrics
+
+
+
+ ROI Tracking
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
+ Automated Invoice & Billing Management
+
+
+ Streamline your billing process with integrated invoicing software. Create professional
+ invoices, track payments, manage recurring billing, and integrate with popular payment
+ gateways. Perfect for service-based businesses and SaaS startups.
+
+
+
+
+
+ Payment Tracking
+
+
+
+ Recurring Billing
+
+
+
+ Payment Integration
+
+
+
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
Mobile CRM & Cloud Access
+
+ Access your CRM data anywhere with our native mobile app and responsive web interface.
+ Work offline, sync data automatically, and manage your business on-the-go. Perfect for
+ sales teams, remote workers, and field service management.
+
+
+
+
+
+ Native Mobile App
+
+
+
+ Cross-platform Support
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📱 Now Available on Mobile
+
+
+
+
+ Introducing the BottleCRM mobile app - manage your customers, leads, and sales pipeline from
+ anywhere. Built with Flutter for seamless cross-platform performance.
+
+
+
+
+
+
+
Mobile CRM Features
+
+
+
+
+
+
+
+
Manage Contacts & Leads
+
+ Access your complete customer database, add new leads, and update contact
+ information on the go.
+
+
+
+
+
+
+
+
+
+
Track Sales Pipeline
+
+ Monitor deal progress, update opportunity stages, and never miss a follow-up while
+ on the field.
+
+
+
+
+
+
+
+
+
+
Task Management
+
+ Create tasks, set reminders, and manage your daily schedule with integrated task
+ management.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ B
+
+
BottleCRM
+
Mobile CRM App
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Free Download
+
+
+ Open Source
+
+
+
+
+
+
+
+
+
+
Flutter
+
Cross-platform mobile framework
+
+
+
Free
+
No app store fees or subscriptions
+
+
+
Open Source
+
Fully customizable mobile CRM
+
+
+
+
+
+
+
+
+
+
+
+
+ Compare BottleCRM with typical commercial CRM solutions and see how much you can save while
+ getting powerful features.
+
+
+
+
+
+
+
+
+ CRM Category
+ Typical Pricing
+ Open Source
+ Self-Hosted
+ Customizable
+ User Limit
+
+
+
+
+
+
+ BottleCRM
+ Recommended
+
+
+ Free
+
+
+
+
+
+
+ Yes
+ Unlimited
+
+
+
+ Enterprise CRM Solutions
+
+ $25-300+/user/month
+
+
+
+
+
+
+ Yes
+ Per seat pricing
+
+
+
+ Popular Cloud CRMs
+
+ $50-3200+/month
+
+
+
+
+
+
+ Limited
+ Contact-based pricing
+
+
+
+ Traditional CRM Platforms
+
+ $15-99+/user/month
+
+
+
+
+
+
+ Limited
+ Per seat pricing
+
+
+
+
+
+
+
+
+ Potential Annual Savings with BottleCRM:
+ $3,000 - $36,000+ per year for a typical team
+
+
+ See detailed cost comparison
+
+
+
+
+
+
+
+
+
+
+
+
+ Everything you need to know about BottleCRM and free CRM software.
+
+
+
+
+
+
toggleFaq(0)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 0}
+ >
+
+
+ Is BottleCRM really free to use?
+
+
+
+
+
+ {#if activeFaq === 0}
+
+
+ Yes! BottleCRM is 100% free and open-source. You can download, install, customize, and
+ use it without any subscription fees or hidden costs. We also offer optional paid
+ support services for hosting and customization.
+
+
+ {/if}
+
+
+
+
toggleFaq(1)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 1}
+ >
+
+
+ How does BottleCRM compare to traditional CRM platforms?
+
+
+
+
+
+ {#if activeFaq === 1}
+
+
+ BottleCRM offers many of the same core features as enterprise CRM platforms but
+ without the high monthly costs. While some commercial CRMs might have more advanced
+ features, BottleCRM provides everything most startups and small businesses need to
+ manage customer relationships effectively.
+
+
+ {/if}
+
+
+
+
toggleFaq(2)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 2}
+ >
+
+
+ Can I self-host BottleCRM on my own servers?
+
+
+
+
+
+ {#if activeFaq === 2}
+
+
+ Absolutely! BottleCRM is designed to be self-hosted. You have complete control over
+ your data and can deploy it on your own servers, cloud infrastructure, or local
+ environment. This ensures data privacy and eliminates vendor lock-in.
+
+
+ {/if}
+
+
+
+
toggleFaq(3)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 3}
+ >
+
+
+ What technology stack does BottleCRM use?
+
+
+
+
+
+ {#if activeFaq === 3}
+
+
+ BottleCRM is built with modern web technologies: SvelteKit 2.21.x for the frontend,
+ Prisma for database management, TailwindCSS for styling, and includes integration
+ capabilities with various third-party services.
+
+
+ {/if}
+
+
+
+
toggleFaq(4)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 4}
+ >
+
+
+ Do you provide support for BottleCRM implementation?
+
+
+
+
+
+ {#if activeFaq === 4}
+
+
+ Yes! While the software is free, we offer paid professional services including hosting
+ setup, custom development, data migration, training, and ongoing technical support to
+ help you get the most out of BottleCRM.
+
+
+ {/if}
+
+
+
+
toggleFaq(5)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 5}
+ >
+
+
+ Is BottleCRM suitable for my industry?
+
+
+
+
+
+ {#if activeFaq === 5}
+
+
+ BottleCRM is industry-agnostic and works well for most businesses including SaaS
+ startups, consulting firms, e-commerce businesses, real estate agencies, and
+ service-based companies. Its customizable nature allows adaptation to various
+ industry-specific needs.
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+ BottleCRM is completely free, open-source CRM software hosted on GitHub. Download,
+ customize, self-host, and contribute to the project without any licensing restrictions.
+ Perfect for startups seeking a cost-effective CRM alternative to expensive
+ subscription-based solutions.
+
+
+
+
+
Zero Licensing Costs
+
+ Download and use forever without any subscription fees or hidden costs
+
+
+
+
Complete Customization
+
+ Modify source code to match your exact business requirements
+
+
+
+
Self-Hosting Options
+
+ Host on your own servers for complete data control and privacy
+
+
+
+
Active Community
+
+ Benefit from community contributions and collaborative improvements
+
+
+
+
+
+
+
+
+
+
+
+
+
MicroPyramid/opensource-startup-crm
+
Free Open Source CRM: SvelteKit + Prisma
+
+
+
+
+
+
+
# Clone and install BottleCRM
+
+ $ git clone https://github.com/MicroPyramid/opensource-startup-crm.git
+
+
$ cd opensource-startup-crm
+
$ pnpm install && pnpm run dev
+
# 🎉 Your free CRM is ready!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Join the growing community of startups and small businesses who are ditching expensive CRM
+ subscriptions. Start managing customer relationships more effectively today with BottleCRM -
+ completely free, forever.
+
+
+
+
+
💰 Your Annual Savings Calculator
+
+
+
$3,000+
+
5 Users vs Enterprise CRM
+
+
+
$6,000+
+
10 Users vs Cloud CRM
+
+
+
$18,000+
+
30 Users vs Commercial CRM
+
+
+
+
+
+
+
+ 🚀 No credit card • No setup fees • No user limits • No vendor lock-in
+
+
+
+
+
+
+
+
+
+
+
+ BottleCRM is empowering startups and small businesses worldwide to build better customer
+ relationships without breaking the bank.
+
+
+
+
+
+
+
Free & Open Source
+
+ No hidden costs, licensing fees, or subscription charges ever.
+
+
+
+
+
+
Fresh & Modern
+
+ Built with the latest technologies and modern best practices.
+
+
+
+
+
+
Open License
+
Complete freedom to use, modify, and distribute.
+
+
+
+
+
Self-Hosted
+
+ Run on your own servers with complete control over your data.
+
+
+
+
+
+
+
+ Developed with modern, secure, high-performance technologies:
+
+
+
+ SvelteKit 2.21+
+
+
+ Prisma ORM
+
+
+ TailwindCSS 4.1+
+
+
+
+
+
diff --git a/apps/web/src/routes/(site)/blog/+page.server.ts b/apps/web/src/routes/(site)/blog/+page.server.ts
new file mode 100644
index 0000000..10aa605
--- /dev/null
+++ b/apps/web/src/routes/(site)/blog/+page.server.ts
@@ -0,0 +1,65 @@
+import { schema } from '@opensource-startup-crm/database';
+import { count, desc, eq } from 'drizzle-orm';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ const db = locals.db
+ try {
+ // Pagination parameters
+ const page = parseInt(url.searchParams.get('page') || '1', 10);
+ const pageSize = 5; // Number of posts per page
+ const skip = (page - 1) * pageSize;
+ // console.log('Page:', page, 'Skip:', skip, 'Page Size:', pageSize);
+ // Fetch posts with pagination
+ const posts = await db
+ .select({
+ id: schema.blogPost.id,
+ title: schema.blogPost.title,
+ slug: schema.blogPost.slug,
+ excerpt: schema.blogPost.excerpt,
+ createdAt: schema.blogPost.createdAt,
+ updatedAt: schema.blogPost.updatedAt
+ })
+ .from(schema.blogPost)
+ .where(eq(schema.blogPost.draft, false))
+ .orderBy(desc(schema.blogPost.createdAt))
+ .limit(pageSize)
+ .offset(skip);
+
+ // console.log('Fetched Posts:', posts);
+ // Get total count for pagination
+ const [{ count: totalPosts }] = await db
+ .select({ count: count() })
+ .from(schema.blogPost)
+ .where(eq(schema.blogPost.draft, false));
+
+ // Calculate pagination values
+ const totalPages = Math.ceil(Number(totalPosts ?? 0) / pageSize);
+
+ return {
+ posts,
+ pagination: {
+ page,
+ pageSize,
+ totalPosts,
+ totalPages,
+ hasNextPage: page < totalPages,
+ hasPreviousPage: page > 1
+ }
+ };
+ } catch (error) {
+ console.error('Error loading blog posts:', error);
+ return {
+ posts: [],
+ pagination: {
+ page: 1,
+ pageSize: 5,
+ totalPosts: 0,
+ totalPages: 0,
+ hasNextPage: false,
+ hasPreviousPage: false
+ },
+ error: 'Failed to load blog posts'
+ };
+ }
+}
diff --git a/apps/web/src/routes/(site)/blog/+page.svelte b/apps/web/src/routes/(site)/blog/+page.svelte
new file mode 100644
index 0000000..e2fc706
--- /dev/null
+++ b/apps/web/src/routes/(site)/blog/+page.svelte
@@ -0,0 +1,191 @@
+
+
+
+ Blog | BottleCRM
+
+
+
+
+
+
+
+
+ The latest news, articles, and resources from BottleCRM
+
+
+
+
+
+
+
+ {#if posts && posts.length > 0}
+
+ {#each posts as post}
+
+
+
+
+ {formatDate(
+ post.createdAt,
+ 'en-US',
+ {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ },
+ '-'
+ )}
+
+
+
+
+
+ {post.title}
+
+
+
+ {#if post.excerpt}
+
+ {limitWords(post.excerpt, 40)}
+
+ {/if}
+
+
+
+
+
+ {/each}
+
+
+
+ {#if pagination.totalPages > 1}
+
+
+ Showing {(pagination.page - 1) * pagination.pageSize + 1}
+ to
+ {Math.min(pagination.page * pagination.pageSize, pagination.totalPosts)}
+ of
+ {pagination.totalPosts} blog posts
+
+
+ changePage(1)}
+ class="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
+ disabled={!pagination.hasPreviousPage}
+ >
+ First
+
+ changePage(pagination.page - 1)}
+ class="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
+ disabled={!pagination.hasPreviousPage}
+ >
+ Previous
+
+
+ {pagination.page} / {pagination.totalPages}
+
+ changePage(pagination.page + 1)}
+ class="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
+ disabled={!pagination.hasNextPage}
+ >
+ Next
+
+ changePage(pagination.totalPages)}
+ class="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
+ disabled={!pagination.hasNextPage}
+ >
+ Last
+
+
+
+ {/if}
+ {:else}
+
+
+
+
+
No blog posts yet
+
Check back soon for new content!
+
+ {/if}
+
+
diff --git a/apps/web/src/routes/(site)/blog/[slug]/+page.server.ts b/apps/web/src/routes/(site)/blog/[slug]/+page.server.ts
new file mode 100644
index 0000000..1fef484
--- /dev/null
+++ b/apps/web/src/routes/(site)/blog/[slug]/+page.server.ts
@@ -0,0 +1,56 @@
+import { schema } from '@opensource-startup-crm/database';
+import { error } from '@sveltejs/kit';
+import { and, eq } from 'drizzle-orm';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const db = locals.db
+
+ const { slug } = params;
+
+ try {
+ const rows = await db
+ .select({
+ post: {
+ id: schema.blogPost.id,
+ title: schema.blogPost.title,
+ slug: schema.blogPost.slug,
+ excerpt: schema.blogPost.excerpt,
+ draft: schema.blogPost.draft,
+ createdAt: schema.blogPost.createdAt,
+ updatedAt: schema.blogPost.updatedAt
+ },
+ block: {
+ id: schema.blogContentBlock.id,
+ blogId: schema.blogContentBlock.blogId,
+ type: schema.blogContentBlock.type,
+ content: schema.blogContentBlock.content,
+ displayOrder: schema.blogContentBlock.displayOrder,
+ draft: schema.blogContentBlock.draft,
+ createdAt: schema.blogContentBlock.createdAt,
+ updatedAt: schema.blogContentBlock.updatedAt
+ }
+ })
+ .from(schema.blogPost)
+ .leftJoin(
+ schema.blogContentBlock,
+ eq(schema.blogContentBlock.blogId, schema.blogPost.id)
+ )
+ .where(and(eq(schema.blogPost.slug, slug), eq(schema.blogPost.draft, false)))
+ .orderBy(schema.blogContentBlock.displayOrder);
+
+ if (!rows || rows.length === 0) {
+ throw error(404, 'Blog post not found');
+ }
+
+ const post = rows[0].post;
+ const contentBlocks = rows
+ .filter((r) => r.block && r.block.id)
+ .map((r) => r.block);
+
+ return { post: { ...post, contentBlocks } };
+ } catch (err) {
+ console.error('Error loading blog post:', err);
+ throw error(404, 'Blog post not found');
+ }
+}
diff --git a/apps/web/src/routes/(site)/blog/[slug]/+page.svelte b/apps/web/src/routes/(site)/blog/[slug]/+page.svelte
new file mode 100644
index 0000000..83de00c
--- /dev/null
+++ b/apps/web/src/routes/(site)/blog/[slug]/+page.svelte
@@ -0,0 +1,112 @@
+
+
+
+ {post.title} | BottleCRM Blog
+
+
+
+
+
+
+
+
+
+
+
+ Blog
+
+
+
+
+
+
+
+ {post.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@html renderedContent}
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(site)/contact/+page.server.ts b/apps/web/src/routes/(site)/contact/+page.server.ts
new file mode 100644
index 0000000..feb49f1
--- /dev/null
+++ b/apps/web/src/routes/(site)/contact/+page.server.ts
@@ -0,0 +1,106 @@
+import { schema } from '@opensource-startup-crm/database';
+import { fail, type Actions } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async () => ({});
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ const db = locals.db
+
+ const data = await request.formData();
+ const name = data.get('name');
+ const email = data.get('email');
+ const serviceType = data.get('serviceType');
+ const message = data.get('message');
+
+ // Server-side validation
+ const errors: Record = {};
+
+ if (!name || name.toString().trim() === '') {
+ errors.name = 'Name is required';
+ }
+
+ if (!email || email.toString().trim() === '') {
+ errors.email = 'Email is required';
+ } else if (!/\S+@\S+\.\S+/.test(email.toString())) {
+ errors.email = 'Email is invalid';
+ }
+
+ if (!serviceType || serviceType.toString().trim() === '') {
+ errors.serviceType = 'Please select a service type';
+ }
+
+ if (!message || message.toString().trim() === '') {
+ errors.message = 'Message is required';
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return fail(400, {
+ errors,
+ name: name?.toString() || '',
+ email: email?.toString() || '',
+ serviceType: serviceType?.toString() || '',
+ message: message?.toString() || ''
+ });
+ }
+
+ try {
+ // Get client information from headers
+ const userAgent = request.headers.get('user-agent');
+ const forwarded = request.headers.get('x-forwarded-for');
+ const realIp = request.headers.get('x-real-ip');
+ const cfConnectingIp = request.headers.get('cf-connecting-ip');
+ const referrer = request.headers.get('referer');
+
+ // Determine IP address (priority: CF > X-Real-IP > X-Forwarded-For)
+ let ipAddress = cfConnectingIp || realIp;
+ if (!ipAddress && forwarded) {
+ ipAddress = forwarded.split(',')[0].trim();
+ }
+
+
+ // Store submission in database
+ await db.insert(schema.contactSubmission).values({
+ id: crypto.randomUUID(),
+ name: name?.toString().trim() || '',
+ email: email?.toString().trim() || '',
+ reason: serviceType?.toString().trim() || '',
+ message: message?.toString().trim() || '',
+ ipAddress: ipAddress || null,
+ userAgent: userAgent || null,
+ referrer: referrer || null
+ });
+
+
+ return {
+ success: true,
+ message: 'Thank you for your message! We\'ll get back to you within 24 hours.'
+ };
+
+ } catch (error) {
+ console.error('Error saving contact submission:', error);
+
+ // More specific error handling
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'P1001') {
+ return fail(500, {
+ error: 'Database connection failed. Please try again later.',
+ name: name?.toString() || '',
+ email: email?.toString() || '',
+ serviceType: serviceType?.toString() || '',
+ message: message?.toString() || ''
+ });
+ }
+
+ return fail(500, {
+ error: 'Sorry, there was an error submitting your message. Please try again later.',
+ name: name?.toString() || '',
+ email: email?.toString() || '',
+ serviceType: serviceType?.toString() || '',
+ message: message?.toString() || ''
+ });
+ } finally {
+
+ }
+ }
+};
\ No newline at end of file
diff --git a/apps/web/src/routes/(site)/contact/+page.svelte b/apps/web/src/routes/(site)/contact/+page.svelte
new file mode 100644
index 0000000..2573623
--- /dev/null
+++ b/apps/web/src/routes/(site)/contact/+page.svelte
@@ -0,0 +1,691 @@
+
+
+
+
+ Contact BottleCRM | Free CRM & Professional Services
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@html `
+
+
+ `}
+
+
+
+
+
+
+
+
+ Free Software • Professional Support Available
+
+
+
+
+
+ The software is free forever. Get professional setup, hosting, customization, and training services to accelerate your CRM implementation and maximize your ROI.
+
+
+
+
+
+
Free Community Support
+
GitHub
+
+
+
+
Professional Setup
+
Starting at $197
+
+
+
+
Custom Development
+
Bespoke solutions
+
+
+
+
+
+
+
+
+
+
+
+
+ From free community help to enterprise-grade professional services
+
+
+
+
+ {#if mounted}
+
+
+
+
+
+
Free Community Support
+
Get help from our community and documentation
+
Free
+
+
+
+
+
+ Community forum access
+
+
+
+ Documentation & guides
+
+
+
+ GitHub issue support
+
+
+
+
formData.serviceType = 'free-support'}
+ class="w-full py-2 px-4 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 font-medium transition-colors duration-200">
+ Select This Service
+
+
+
+
+
+
+
+
+ Most Popular
+
+
+
+
+
+
+
+
Professional Setup & Installation
+
Expert installation, configuration, and basic customization
+
$197 one-time
+
+
+
+
+
+ Professional installation
+
+
+
+ SSL & domain setup
+
+
+
+ Basic customization
+
+
+
+ 1-month support
+
+
+
+
formData.serviceType = 'professional-setup'}
+ class="w-full py-2 px-4 rounded-lg bg-blue-600 text-white hover:bg-blue-700 font-medium transition-colors duration-200">
+ Select This Service
+
+
+
+
+
+
+
+
+
Enterprise Setup & Customization
+
Complete enterprise deployment with advanced features
+
$497 one-time
+
+
+
+
+
+ Advanced customization
+
+
+
+ Third-party integrations
+
+
+
+ Team training
+
+
+
+ 3-month support
+
+
+
+
formData.serviceType = 'enterprise-setup'}
+ class="w-full py-2 px-4 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 font-medium transition-colors duration-200">
+ Select This Service
+
+
+
+
+
+
+
+
+
Custom Development
+
Bespoke features and integrations for your specific needs
+
Custom quote
+
+
+
+
+
+ Custom feature development
+
+
+
+ API integrations
+
+
+
+ Workflow automation
+
+
+
+ Ongoing maintenance
+
+
+
+
formData.serviceType = 'custom-development'}
+ class="w-full py-2 px-4 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 font-medium transition-colors duration-200">
+ Select This Service
+
+
+
+
+
+
+
+
+
Managed Hosting & Maintenance
+
We handle hosting, updates, backups, and maintenance
+
$97-297/month
+
+
+
+
+
+ Managed hosting
+
+
+
+ Automatic updates
+
+
+
+ Daily backups
+
+
+
+ 24/7 monitoring
+
+
+
+
formData.serviceType = 'hosting-management'}
+ class="w-full py-2 px-4 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 font-medium transition-colors duration-200">
+ Select This Service
+
+
+
+
+
+
+
+
+
Training & Consultation
+
Team training and strategic CRM consultation
+
$97-197/hour
+
+
+
+
+
+ Team training sessions
+
+
+
+ Best practices consultation
+
+
+
+ CRM strategy planning
+
+
+
+ Ongoing support
+
+
+
+
formData.serviceType = 'training-consultation'}
+ class="w-full py-2 px-4 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 font-medium transition-colors duration-200">
+ Select This Service
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+ Tell us about your project and we'll get back to you within 24 hours.
+
+
+ {#if form?.success}
+
+
+
+
+
Message Sent Successfully!
+
{form.message}
+
+
+
+ {:else if form?.error}
+
+ {/if}
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-6">
+
+
+
+
+ Name *
+
+
+ {#if form?.errors?.name}
+
{form.errors.name}
+ {/if}
+
+
+
+
+ Email *
+
+
+ {#if form?.errors?.email}
+
{form.errors.email}
+ {/if}
+
+
+
+
+
+
+
+ What can we help you with? *
+
+
+
+
+
+
Professional Setup ($197)
+
Expert installation & setup
+
+
+
+
+
+
+
Custom Development
+
Bespoke features & integrations
+
+
+
+
+
+
+
Managed Hosting
+
We handle hosting & maintenance
+
+
+
+
+
+
+
Other / Not Sure
+
Let's discuss your needs
+
+
+
+ {#if form?.errors?.serviceType}
+ {form.errors.serviceType}
+ {/if}
+
+
+
+
+
+ Tell us about your project *
+
+
+ {#if form?.errors?.message}
+
{form.errors.message}
+ {/if}
+
+
+
+ {#if isSubmitting}
+
+ Sending Message...
+ {:else}
+
+ Send Message
+ {/if}
+
+
+
+ We typically respond within 24 hours during business days
+
+
+
+
+
+
+
+
+ Contact Information
+
+
+
+
+
+
+
Email
+
bottlecrm@micropyramid.com
+
Response within 24 hours
+
+
+
+
+
+
+
+
+
+
Business Hours
+
Monday - Friday
+
9 AM - 6 PM IST
+
+
+
+
+
+
+
+
Frequently Asked Questions
+
+
+ How quickly can you set up BottleCRM for my business?
+ Basic setup can be completed within 24-48 hours. Professional Setup typically takes 3-5 business days, while Enterprise Setup with custom features may take 1-2 weeks depending on complexity.
+
+
+ Do you provide training for my team?
+ Yes! Training is included in our Professional and Enterprise Setup packages. We also offer dedicated training sessions at $97-197/hour for teams who need additional support.
+
+
+ Can you migrate data from our current CRM?
+ We're currently developing data migration scripts for popular CRM platforms including Salesforce, HubSpot, and Pipedrive. These tools will be available soon. In the meantime, we can help you manually export/import your data and provide guidance on the best migration approach for your specific needs.
+
+
+ What if I need ongoing support after setup?
+ We offer various ongoing support options from monthly managed hosting ($97-297/month) to hourly consultation ($97-197/hour). You can also access our free community support anytime.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ While BottleCRM is free, our professional services save you time and ensure optimal implementation.
+
+
+
+
+
+
+
+
+
Faster Implementation
+
Get up and running in days, not weeks. Our experts handle the technical setup.
+
+
+
+
+
+
+
Best Practices
+
Benefit from our experience with dozens of CRM implementations.
+
+
+
+
+
+
+
Team Training
+
Ensure your team knows how to use BottleCRM effectively from day one.
+
+
+
+
+
+
+
Ongoing Support
+
Get priority support when you need help or have questions.
+
+
+
+
+
+
+
+
+
+
+ The software is free forever. Professional services help you implement it faster and more effectively.
+
+
+
+
+
+ Try BottleCRM Free
+
+
document.querySelector('form')?.scrollIntoView({behavior: 'smooth'})} class="inline-flex items-center justify-center px-8 py-4 text-lg font-bold rounded-xl text-white border-2 border-white hover:bg-white/10 transition-all duration-200">
+
+ Get Professional Help
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(site)/customization/+page.svelte b/apps/web/src/routes/(site)/customization/+page.svelte
new file mode 100644
index 0000000..edee499
--- /dev/null
+++ b/apps/web/src/routes/(site)/customization/+page.svelte
@@ -0,0 +1,1220 @@
+
+
+
+ BottleCRM Customization: Free Open Source CRM & Custom Development
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 100% Customizable • Open Source Freedom
+
+
+
+
+
+ Transform your free CRM into the perfect solution for your business. From simple branding
+ changes to complex integrations - customize everything with complete source code access or
+ professional development services.
+
+
+
+
+
+
+
100%
+
Source Code Access
+
+
+
∞
+
Customization Possibilities
+
+
+
MIT
+
Open Source License
+
+
+
+
+
+
+
+
+
+
+
+
+ From simple visual changes to complex enterprise modifications - choose the level of
+ customization that fits your needs and technical expertise.
+
+
+
+
+
+
+
+
+
+
+
Visual Customization
+
+ Beginner
+ 2-8 hours
+ DIY Friendly
+
+
+
+
+
+
+ Transform the look and feel of your CRM to match your brand identity perfectly.
+
+
+
+
+
+ Custom color schemes and themes
+
+
+
+ Logo and branding integration
+
+
+
+ Custom CSS styling
+
+
+
+ Layout modifications
+
+
+
+ Font and typography changes
+
+
+
+ Dashboard widget customization
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Functional Customization
+
+ Intermediate
+ 8-24 hours
+ DIY Friendly
+
+
+
+
+
+
+ Add new features and modify existing workflows to match your business processes.
+
+
+
+
+
+ Custom fields and data types
+
+
+
+ Workflow automation rules
+
+
+
+ Custom reporting dashboards
+
+
+
+ Email template modifications
+
+
+
+ User role and permission customization
+
+
+
+ Pipeline stage customization
+
+
+
+
+
+
+
+
+
+
+
+
+
Integration & API Development
+
+ Advanced
+ 24-80 hours
+ Professional Recommended
+
+
+
+
+
+
+ Connect BottleCRM with your existing tools and develop custom integrations.
+
+
+
+
+
+ Third-party API integrations
+
+
+
+ Custom webhook implementations
+
+
+
+ Payment gateway integration
+
+
+
+ Email service provider setup
+
+
+
+ Social media platform connections
+
+
+
+ Custom import/export tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enterprise Customization
+
+ Expert
+ 80+ hours
+ Professional Recommended
+
+
+
+
+
+
+ Advanced modifications for large teams and complex business requirements.
+
+
+
+
+
+ Custom module development
+
+
+
+ Advanced security implementations
+
+
+
+ Multi-tenant architecture
+
+
+
+ Custom authentication systems
+
+
+
+ Performance optimization
+
+
+
+ Scalability enhancements
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See how businesses across different industries have customized BottleCRM to fit their unique
+ requirements.
+
+
+
+
+
+
+
🏠
+
Real Estate CRM
+
+ Custom property management fields, automated follow-up sequences, and integration with MLS
+ systems.
+
+
+ Property Fields
+ MLS Integration
+ Auto Follow-ups
+
+
+
+
+
+
🛒
+
E-commerce Integration
+
+ Shopify integration, automated order tracking, customer lifetime value calculations, and
+ purchase history.
+
+
+ Shopify API
+ Order Tracking
+ LTV Analytics
+
+
+
+
+
+
🏥
+
Healthcare Practice
+
+ Patient management, appointment scheduling, HIPAA compliance features, and insurance
+ tracking.
+
+
+ Patient Records
+ HIPAA Compliant
+ Appointment System
+
+
+
+
+
+
💻
+
SaaS Startup CRM
+
+ Trial tracking, usage analytics, churn prediction, and automated upgrade campaigns.
+
+
+ Trial Management
+ Usage Analytics
+ Churn Prevention
+
+
+
+
+
+
+
+
+
+
+
+
+ Choose your path: customize it yourself with our free resources, or let our experts handle
+ it for you.
+
+
+
+
+
+
+
+
+ Perfect for Developers
+
+
+
+
+
+
+
+
DIY Customization Kit
+
+ Everything you need to customize BottleCRM yourself with comprehensive guides and
+ resources.
+
+
+
+
+ /Forever
+
+
+
+ Download Free
+
+
+
+
+
+
+ Complete source code access
+
+
+
+ Detailed customization documentation
+
+
+
+ Video tutorials and guides
+
+
+
+ Community forum support
+
+
+
+ Example customization templates
+
+
+
+ Development environment setup guide
+
+
+
+ Basic theming tools
+
+
+
+ CSS framework documentation
+
+
+
+
+
+
+
+
+
+ Most Popular
+
+
+
+
+
+
+
+
Professional Customization
+
+ Expert customization services for businesses that want professional results without the
+ learning curve.
+
+
+
+
+ /per project
+
+
+
+ Get Professional Help
+
+
+
+
+
+
+ Everything in DIY Kit
+
+
+
+ Custom theme development
+
+
+
+ Logo and branding integration
+
+
+
+ Up to 5 custom fields
+
+
+
+ Basic workflow automation
+
+
+
+ Email template customization
+
+
+
+ Mobile responsive optimization
+
+
+
+ 2 rounds of revisions
+
+
+
+ 1-week delivery
+
+
+
+ 30-day support included
+
+
+
+
+
+
+
+
+ Full Service
+
+
+
+
+
+
+
+
Enterprise Customization
+
+ Complete custom development for complex business requirements and enterprise-level
+ implementations.
+
+
+
+
+ /per project
+
+
+
+ Contact Enterprise Team
+
+
+
+
+
+
+ Everything in Professional
+
+
+
+ Unlimited custom fields and modules
+
+
+
+ Advanced workflow automation
+
+
+
+ Third-party integrations (up to 3)
+
+
+
+ Custom reporting dashboards
+
+
+
+ Advanced user role management
+
+
+
+ Performance optimization
+
+
+
+ Security enhancements
+
+
+
+ 3-week delivery
+
+
+
+ 90-day priority support
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Planning to customize BottleCRM yourself? Here are the technical skills and technologies
+ you'll need to know.
+
+
+
+
+
+
+
+
+
+
+
Frontend Development
+
+
+
+
+
+ Svelte 5.x
+
+
+
+ SvelteKit 2.21+
+
+
+
+ JavaScript/TypeScript
+
+
+
+ TailwindCSS 4.1+
+
+
+
+ HTML5/CSS3
+
+
+
+
+
+
+
+
+
+
+
Backend Development
+
+
+
+
+
+ Node.js
+
+
+
+ Prisma ORM
+
+
+
+ Database Design
+
+
+
+ API Development
+
+
+
+ Authentication
+
+
+
+
+
+
+
+
+
+
+
DevOps & Deployment
+
+
+
+
+
+ Docker
+
+
+
+ Linux/Ubuntu
+
+
+
+ Cloud Platforms
+
+
+
+ SSL Configuration
+
+
+
+ Domain Setup
+
+
+
+
+
+
+
+
Don't have the technical skills?
+
+ No problem! Our professional customization services handle all the technical complexity
+ for you.
+
+
+
+ Get Professional Help
+
+
+
+
+
+
+
+
+
+
+
+
+ Everything you need to know about customizing BottleCRM for your business.
+
+
+
+
+
+
+
toggleFaq(0)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 0}
+ >
+
+
+ Can I customize BottleCRM without coding knowledge?
+
+
+
+
+
+ {#if activeFaq === 0}
+
+
+ Yes! Basic visual customizations like colors, logos, and simple layout changes can be
+ done through configuration files. For more advanced customizations, you can use our
+ Professional Customization service or learn from our detailed tutorials.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(1)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 1}
+ >
+
+
+ Will my customizations be lost when BottleCRM updates?
+
+
+
+
+
+ {#if activeFaq === 1}
+
+
+ Not if done properly! We provide guidelines for creating update-safe customizations.
+ Our professional service includes future-proofing your customizations to survive
+ software updates.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(2)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 2}
+ >
+
+
+ How long does professional customization take?
+
+
+
+
+
+ {#if activeFaq === 2}
+
+
+ Simple customizations (branding, basic fields) take 1-2 weeks. Complex integrations
+ and custom modules can take 3-6 weeks. We provide detailed timelines before starting
+ any project.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(3)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 3}
+ >
+
+
+ Can you integrate BottleCRM with our existing tools?
+
+
+
+
+
+ {#if activeFaq === 3}
+
+
+ Absolutely! We've integrated BottleCRM with hundreds of tools including Shopify,
+ WordPress, QuickBooks, Mailchimp, Slack, and many others. If it has an API, we can
+ likely integrate it.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(4)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 4}
+ >
+
+
+ Do you provide training after customization?
+
+
+
+
+
+ {#if activeFaq === 4}
+
+
+ Yes! All our customization services include comprehensive training for your team,
+ documentation of changes made, and ongoing support to ensure you get the most out of
+ your customized CRM.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(5)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 5}
+ >
+
+
+ Can I start with DIY and upgrade to professional service later?
+
+
+
+
+
+ {#if activeFaq === 5}
+
+
+ Definitely! Many clients start with basic customizations and later hire us for more
+ complex features. We can work with existing customizations and enhance them further.
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Whether you're a developer ready to dive into the code or a business owner who needs
+ professional help, we have the perfect solution for your customization needs.
+
+
+
+
+
+ 🚀 Free source code • Professional services • 30-day money-back guarantee
+
+
+
diff --git a/apps/web/src/routes/(site)/faq/+page.svelte b/apps/web/src/routes/(site)/faq/+page.svelte
new file mode 100644
index 0000000..1387d0e
--- /dev/null
+++ b/apps/web/src/routes/(site)/faq/+page.svelte
@@ -0,0 +1,1375 @@
+
+
+
+ BottleCRM FAQ | Free Open Source CRM for Startups & SMBs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@html `
+
+ `}
+
+
+
+
+
+
+
+
+ Frequently Asked Questions
+
+
+
+
+
+ Get answers to common questions about our free, open-source CRM software. Learn about
+ pricing, features, technical requirements, and how to get started.
+
+
+
+
+
+
+
+
+
+
+
+
+ Find answers organized by topic to help you get started with BottleCRM.
+
+
+
+
+
+
+
+
+
+
+
+ General Questions
+
+
+
+
+
+
+
toggleFaq('general-0')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'general-0'}
+ >
+
+
+ What is BottleCRM and why is it free?
+
+
+
+
+
+ {#if activeFaq === 'general-0'}
+
+
+
+ BottleCRM is a 100% free, open-source Customer Relationship Management (CRM)
+ software built specifically for startups and small businesses. It's free because
+ we believe every business should have access to powerful CRM tools without
+ expensive subscription fees. We make revenue through optional paid support
+ services, not by charging for the software itself.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('general-1')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'general-1'}
+ >
+
+
+ Is BottleCRM really completely free forever?
+
+
+
+
+
+ {#if activeFaq === 'general-1'}
+
+
+
+ Yes! BottleCRM core software is completely free with no hidden costs, user limits,
+ trial periods, or subscription fees. You can download, use, modify, and distribute
+ it under the MIT open-source license. The only costs are optional professional
+ services like setup assistance, customization, and hosting support.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('general-2')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'general-2'}
+ >
+
+
+ How does BottleCRM compare to commercial CRM platforms?
+
+
+
+
+
+ {#if activeFaq === 'general-2'}
+
+
+
+ BottleCRM provides many of the same core features as commercial CRM platforms but
+ without monthly subscription costs. While enterprise solutions might have more
+ advanced features, BottleCRM includes everything most startups and small
+ businesses need: contact management, sales pipeline, task management, reporting,
+ and email integration. You can save significant costs compared to paid
+ alternatives.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('general-3')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'general-3'}
+ >
+
+
+ Who should use BottleCRM?
+
+
+
+
+
+ {#if activeFaq === 'general-3'}
+
+
+
+ BottleCRM is perfect for startups, small businesses, consulting firms,
+ freelancers, real estate agencies, and any organization that needs to manage
+ customer relationships without paying expensive subscription fees. It's also ideal
+ for developers who want a customizable, self-hosted CRM solution.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('general-4')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'general-4'}
+ >
+
+
+ What features are included in the free version?
+
+
+
+
+
+ {#if activeFaq === 'general-4'}
+
+
+
+ All features are included! You get unlimited contacts and leads, complete sales
+ pipeline management, task and project management, basic reporting and analytics,
+ email integration, mobile responsive interface, full source code access, and
+ community support. There are no premium tiers or feature restrictions.
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Pricing & Business Model
+
+
+
+
+
+
+
toggleFaq('pricing-0')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'pricing-0'}
+ >
+
+
+ If the software is free, how do you make money?
+
+
+
+
+
+ {#if activeFaq === 'pricing-0'}
+
+
+
+ We offer optional paid professional services including setup assistance ($197),
+ enterprise deployment ($497), custom development, hosting services, training, and
+ ongoing technical support. This sustainable model allows us to maintain and
+ improve the free software while helping businesses that need expert assistance.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('pricing-1')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'pricing-1'}
+ >
+
+
+ What's included in the Professional Support ($197)?
+
+
+
+
+
+ {#if activeFaq === 'pricing-1'}
+
+
+
+ Professional Support includes: professional installation & setup, custom domain
+ configuration, SSL certificate setup, database optimization, basic customization
+ (colors, logo), email configuration, 1-month support, video walkthrough session,
+ and comprehensive documentation.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('pricing-2')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'pricing-2'}
+ >
+
+
+ What's included in the Enterprise Setup ($497)?
+
+
+
+
+
+ {#if activeFaq === 'pricing-2'}
+
+
+
+ Enterprise Setup includes everything in Professional Support plus: advanced custom
+ development, third-party integrations, advanced reporting setup, custom workflows
+ & automation, team training sessions (2 hours), advanced security configuration,
+ performance optimization, 3-month priority support, and migration from existing
+ CRM.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('pricing-3')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'pricing-3'}
+ >
+
+
+ Do you offer refunds for support services?
+
+
+
+
+
+ {#if activeFaq === 'pricing-3'}
+
+
+
+ Yes! We offer a 30-day money-back guarantee on all support services. If you're not
+ satisfied with our professional setup, customization, or support, we'll refund
+ your payment. The free software remains yours to keep regardless.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('pricing-4')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'pricing-4'}
+ >
+
+
+ Can I switch from paid support back to free?
+
+
+
+
+
+ {#if activeFaq === 'pricing-4'}
+
+
+
+ Absolutely! There are no contracts or ongoing commitments. Support services are
+ one-time purchases to help you get started. Once your CRM is set up, you maintain
+ full control over your self-hosted installation and can manage it yourself.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('pricing-5')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'pricing-5'}
+ >
+
+
+ Do you offer custom pricing for large organizations?
+
+
+
+
+
+ {#if activeFaq === 'pricing-5'}
+
+
+
+ Yes! For organizations with specific requirements, we offer custom development,
+ enterprise consulting, dedicated hosting, and bespoke CRM solutions. Contact us to
+ discuss your needs and get custom pricing that fits your budget and requirements.
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Technical Questions
+
+
+
+
+
+
+
toggleFaq('technical-0')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'technical-0'}
+ >
+
+
+ What technology stack does BottleCRM use?
+
+
+
+
+
+ {#if activeFaq === 'technical-0'}
+
+
+
+ BottleCRM is built with modern web technologies: SvelteKit 2.21+ for the frontend,
+ Prisma for database management, TailwindCSS 4.1+ for styling, and @lucide/svelte
+ for icons. It supports multiple databases including PostgreSQL, MySQL, and SQLite.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('technical-1')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'technical-1'}
+ >
+
+
+ Can I self-host BottleCRM on my own servers?
+
+
+
+
+
+ {#if activeFaq === 'technical-1'}
+
+
+
+ Yes! BottleCRM is designed to be self-hosted. You can deploy it on your own
+ servers, cloud infrastructure (AWS, Google Cloud, Azure), VPS, or even run it
+ locally. This gives you complete control over your data and eliminates vendor
+ lock-in.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('technical-2')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'technical-2'}
+ >
+
+
+ What are the system requirements for BottleCRM?
+
+
+
+
+
+ {#if activeFaq === 'technical-2'}
+
+
+
+ BottleCRM has minimal system requirements: Node.js 18+, any modern database
+ (PostgreSQL, MySQL, SQLite), and at least 512MB RAM. It can run on a basic VPS,
+ cloud instance, or even a Raspberry Pi for small deployments.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('technical-3')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'technical-3'}
+ >
+
+
+ Is BottleCRM mobile-friendly?
+
+
+
+
+
+ {#if activeFaq === 'technical-3'}
+
+
+
+ Yes! BottleCRM features a fully responsive design that works perfectly on
+ smartphones, tablets, and desktops. The interface automatically adapts to
+ different screen sizes, ensuring a great user experience across all devices.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('technical-4')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'technical-4'}
+ >
+
+
+ Can I customize BottleCRM for my specific business needs?
+
+
+
+
+
+ {#if activeFaq === 'technical-4'}
+
+
+
+ Absolutely! BottleCRM is open-source, so you have complete access to the source
+ code. You can modify the interface, add custom fields, integrate with third-party
+ services, create custom reports, and tailor it to your exact business
+ requirements.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('technical-5')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'technical-5'}
+ >
+
+
+ Does BottleCRM integrate with other tools and services?
+
+
+
+
+
+ {#if activeFaq === 'technical-5'}
+
+
+
+ BottleCRM includes email integration and can be extended to integrate with various
+ third-party services like payment processors, marketing tools, accounting
+ software, and communication platforms. Custom integrations can be developed as
+ needed.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('technical-6')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'technical-6'}
+ >
+
+
+ How do I migrate data from my existing CRM?
+
+
+
+
+
+ {#if activeFaq === 'technical-6'}
+
+
+
+ BottleCRM supports data import through CSV files and API integrations. For complex
+ migrations from existing CRM platforms, our Enterprise Setup service includes
+ professional data migration assistance to help you transition smoothly.
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Support & Community
+
+
+
+
+
+
+
toggleFaq('support-0')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'support-0'}
+ >
+
+
+ What kind of support is available for the free version?
+
+
+
+
+
+ {#if activeFaq === 'support-0'}
+
+
+
+ Free users have access to community support through our GitHub repository,
+ documentation, and community forums. You can report bugs, request features, and
+ get help from other users and contributors.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('support-1')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'support-1'}
+ >
+
+
+ How quickly can I get help with paid support services?
+
+
+
+
+
+ {#if activeFaq === 'support-1'}
+
+
+
+ Paid support customers receive priority assistance with response times of 24-48
+ hours for Professional Support and 12-24 hours for Enterprise Setup. Emergency
+ support is available for critical issues.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('support-2')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'support-2'}
+ >
+
+
+ Do you provide training for my team?
+
+
+
+
+
+ {#if activeFaq === 'support-2'}
+
+
+
+ Yes! Our Enterprise Setup service includes 2 hours of team training sessions. We
+ also offer additional training packages and can create custom training materials
+ for your specific use case and business processes.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('support-3')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'support-3'}
+ >
+
+
+ Is there documentation available?
+
+
+
+
+
+ {#if activeFaq === 'support-3'}
+
+
+
+ Yes! We provide comprehensive documentation including installation guides, user
+ manuals, API documentation, customization tutorials, and best practices.
+ Documentation is continuously updated with new features and improvements.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('support-4')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'support-4'}
+ >
+
+
+ Can I contribute to BottleCRM development?
+
+
+
+
+
+ {#if activeFaq === 'support-4'}
+
+
+
+ Absolutely! BottleCRM is open-source and we welcome contributions. You can submit
+ bug reports, feature requests, code improvements, documentation updates, and
+ translations. Check our GitHub repository for contribution guidelines.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('support-5')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'support-5'}
+ >
+
+
+ How often is BottleCRM updated?
+
+
+
+
+
+ {#if activeFaq === 'support-5'}
+
+
+
+ We regularly release updates with bug fixes, security patches, and new features.
+ Major updates are released quarterly, while minor updates and patches are released
+ as needed. All updates are free for all users.
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Business & Legal
+
+
+
+
+
+
+
toggleFaq('business-0')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'business-0'}
+ >
+
+
+ What license does BottleCRM use?
+
+
+
+
+
+ {#if activeFaq === 'business-0'}
+
+
+
+ BottleCRM is released under the MIT License, which is one of the most permissive
+ open-source licenses. This means you can use, modify, distribute, and even sell
+ BottleCRM without any licensing fees or restrictions.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('business-1')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'business-1'}
+ >
+
+
+ Who owns the data in my BottleCRM installation?
+
+
+
+
+
+ {#if activeFaq === 'business-1'}
+
+
+
+ You own 100% of your data! Since BottleCRM is self-hosted, all your customer data,
+ contacts, sales information, and business intelligence remain on your servers
+ under your complete control. There's no vendor lock-in or data hostage situations.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('business-2')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'business-2'}
+ >
+
+
+ Is BottleCRM secure for business use?
+
+
+
+
+
+ {#if activeFaq === 'business-2'}
+
+
+
+ Yes! BottleCRM follows security best practices including data encryption, secure
+ authentication, SQL injection protection, and CSRF protection. Since you control
+ the hosting environment, you can implement additional security measures as needed
+ for your compliance requirements.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('business-3')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'business-3'}
+ >
+
+
+ Can BottleCRM handle GDPR compliance?
+
+
+
+
+
+ {#if activeFaq === 'business-3'}
+
+
+
+ BottleCRM provides the technical foundation for GDPR compliance including data
+ export, data deletion, and audit trails. Since you control the data and hosting,
+ you can implement the necessary policies and procedures to meet your specific
+ compliance requirements.
+
+
+
+ {/if}
+
+
+
+
toggleFaq('business-4')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'business-4'}
+ >
+
+
+ Is BottleCRM suitable for regulated industries?
+
+
+
+
+
+ {#if activeFaq === 'business-4'}
+
+
+
+ BottleCRM can be adapted for regulated industries since you have complete control
+ over the hosting environment and data handling. However, you're responsible for
+ ensuring your implementation meets industry-specific compliance requirements
+ (HIPAA, SOX, etc.).
+
+
+
+ {/if}
+
+
+
+
toggleFaq('business-5')}
+ class="w-full px-6 py-6 text-left transition-colors duration-200 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none"
+ aria-expanded={activeFaq === 'business-5'}
+ >
+
+
+ What happens if BottleCRM development stops?
+
+
+
+
+
+ {#if activeFaq === 'business-5'}
+
+
+
+ Since BottleCRM is open-source, you'll always have access to the complete source
+ code. Even if we stop development, you can continue using, maintaining, and
+ developing the software independently or with the help of the community.
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+ Can't find what you're looking for? Here are some helpful resources and ways to get in
+ touch.
+
+
+
+
+
+
+
+
+
Get Started
+
Download and try BottleCRM for free right now.
+
+ Download Free
+
+
+
+
+
+
+
+
+
GitHub Support
+
Report issues and get community help.
+
+ Open Issue
+
+
+
+
+
+
+
+
+
Contact Support
+
Get professional help and paid support.
+
+ Contact Us
+
+
+
+
+
+
+
+
+
+
+
+
+ Now that you know everything about BottleCRM, why not give it a try? It's completely free, and
+ you can have it running in minutes.
+
+
+
+
+
+ 🚀 No credit card • No setup fees • No user limits • No vendor lock-in
+
+
+
diff --git a/apps/web/src/routes/(site)/features/+page.svelte b/apps/web/src/routes/(site)/features/+page.svelte
new file mode 100644
index 0000000..3d6e48a
--- /dev/null
+++ b/apps/web/src/routes/(site)/features/+page.svelte
@@ -0,0 +1,1496 @@
+
+
+
+ BottleCRM Features: Free & Open Source CRM Software
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enterprise Features • Zero Cost • Open Source
+
+
+
+
+
+ Discover powerful features that rival premium CRM solutions. From contact management to
+ advanced analytics, everything you need without subscription fees or vendor lock-in.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BottleCRM provides enterprise-grade functionality typically found in premium CRM solutions.
+ All features are included in our free, open-source platform with no hidden costs or
+ limitations.
+
+
+
+
+
+
+
Core CRM Features
+
Essential customer relationship management tools
+
+
+
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
Advanced Contact Management
+
+ Centralize all customer information in one place with our comprehensive contact
+ management system. Store detailed customer profiles, track interaction history, manage
+ contact segmentation, and build 360-degree customer views.
+
+
+
+
+
+ Unlimited contact storage
+
+
+
+ Custom fields and tags
+
+
+
+ Contact segmentation
+
+
+
+ Interaction timeline
+
+
+
+ Duplicate detection
+
+
+
+ Import/Export tools
+
+
+
+ Advanced search & filtering
+
+
+
+ Contact scoring
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Sales Pipeline Management
+
+ Visualize and manage your entire sales process with intuitive pipeline management. Track
+ deals from initial contact to closing, forecast revenue, and optimize conversion rates
+ with automated workflows.
+
+
+
+
+
+ Drag-and-drop pipeline
+
+
+
+ Custom deal stages
+
+
+
+ Revenue forecasting
+
+
+
+ Deal probability tracking
+
+
+
+ Sales velocity metrics
+
+
+
+ Win/loss analysis
+
+
+
+ Activity reminders
+
+
+
+ Team collaboration
+
+
+
+
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
Task & Activity Management
+
+ Never miss a follow-up with comprehensive task management. Schedule activities, set
+ reminders, assign tasks to team members, and track completion rates to ensure maximum
+ productivity.
+
+
+
+
+
+ Task scheduling & reminders
+
+
+
+ Activity logging
+
+
+
+ Team task assignment
+
+
+
+ Priority management
+
+
+
+ Calendar integration
+
+
+
+ Recurring tasks
+
+
+
+ Task templates
+
+
+
+ Progress tracking
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Communication Center
+
+ Manage all customer communications from one central hub. Track emails, phone calls,
+ meetings, and notes. Maintain complete conversation history and ensure no communication
+ falls through the cracks.
+
+
+
+
+
+ Email integration
+
+
+
+ Call logging
+
+
+
+ Meeting notes
+
+
+
+ Communication timeline
+
+
+
+ Email templates
+
+
+
+ Auto-response setup
+
+
+
+ Message scheduling
+
+
+
+ Team communication
+
+
+
+
+
+
+
+
+
+
Analytics & Reporting
+
Business intelligence and performance insights
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Real-time Analytics Dashboard
+
+ Get instant insights into your business performance with customizable dashboards.
+ Monitor key metrics, track trends, and make data-driven decisions to accelerate growth.
+
+
+
+
+
+ Customizable dashboards
+
+
+
+ Real-time data updates
+
+
+
+ Key performance indicators
+
+
+
+ Visual chart library
+
+
+
+ Goal tracking
+
+
+
+ Comparative analysis
+
+
+
+ Export capabilities
+
+
+
+ Mobile dashboard access
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
Advanced Reporting System
+
+ Generate comprehensive reports on sales performance, customer behavior, and business
+ metrics. Create custom reports, schedule automated delivery, and share insights with
+ stakeholders.
+
+
+
+
+
+ Custom report builder
+
+
+
+ Automated report scheduling
+
+
+
+ Multiple export formats
+
+
+
+ Interactive charts
+
+
+
+ Data filtering options
+
+
+
+ Report templates
+
+
+
+ Team sharing
+
+
+
+ Historical comparisons
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Sales Performance Analytics
+
+ Track and analyze sales team performance with detailed metrics. Monitor individual and
+ team achievements, identify top performers, and optimize sales strategies.
+
+
+
+
+
+ Individual performance tracking
+
+
+
+ Team leaderboards
+
+
+
+ Conversion rate analysis
+
+
+
+ Activity reports
+
+
+
+ Revenue attribution
+
+
+
+ Performance benchmarking
+
+
+
+ Commission calculations
+
+
+
+ Sales coaching insights
+
+
+
+
+
+
+
+
+
+
Business Management
+
Comprehensive business process automation
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Invoice & Billing Management
+
+ Streamline your billing process with integrated invoicing. Create professional invoices,
+ track payments, manage recurring billing, and integrate with payment gateways.
+
+
+
+
+
+ Professional invoice templates
+
+
+
+ Automated invoice generation
+
+
+
+ Payment tracking
+
+
+
+ Recurring billing setup
+
+
+
+ Payment gateway integration
+
+
+
+ Tax calculations
+
+
+
+ Late payment reminders
+
+
+
+ Financial reporting
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Customer Support Center
+
+ Provide exceptional customer support with integrated ticketing system. Track support
+ requests, manage resolution workflows, and maintain customer satisfaction.
+
+
+
+
+
+ Ticket management system
+
+
+
+ Support workflow automation
+
+
+
+ Customer satisfaction surveys
+
+
+
+ Knowledge base integration
+
+
+
+ Multi-channel support
+
+
+
+ SLA tracking
+
+
+
+ Escalation rules
+
+
+
+ Support analytics
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Marketing Campaign Management
+
+ Plan, execute, and track marketing campaigns with integrated tools. Manage email
+ campaigns, track ROI, and nurture leads through automated marketing workflows.
+
+
+
+
+
+ Email campaign builder
+
+
+
+ Marketing automation
+
+
+
+ Lead nurturing workflows
+
+
+
+ Campaign performance tracking
+
+
+
+ A/B testing
+
+
+
+ Landing page integration
+
+
+
+ Social media management
+
+
+
+ ROI measurement
+
+
+
+
+
+
+
+
+
+
Technical Features
+
Modern technology and infrastructure capabilities
+
+
+
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
Mobile-First Design
+
+ Access your CRM from anywhere with our responsive, mobile-optimized interface. Work
+ offline, sync data automatically, and manage your business on-the-go.
+
+
+
+
+
+ Responsive web design
+
+
+
+ Offline data access
+
+
+
+ Automatic synchronization
+
+
+
+ Touch-optimized interface
+
+
+
+ Mobile notifications
+
+
+
+ Cross-device compatibility
+
+
+
+ Progressive web app
+
+
+
+ Fast loading times
+
+
+
+
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
Data Security & Privacy
+
+ Protect your business data with enterprise-grade security features. Enjoy encrypted data
+ storage, user access controls, and complete data ownership through self-hosting.
+
+
+
+
+
+ End-to-end encryption
+
+
+
+ Role-based access control
+
+
+
+ Data backup & recovery
+
+
+
+ Audit trail logging
+
+
+
+ GDPR compliance tools
+
+
+
+ Two-factor authentication
+
+
+
+ IP restrictions
+
+
+
+ Security monitoring
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
API & Integrations
+
+ Connect BottleCRM with your existing tools through our comprehensive API. Build custom
+ integrations, automate workflows, and create a unified business ecosystem.
+
+
+
+
+
+ RESTful API access
+
+
+
+ Webhook support
+
+
+
+ Third-party integrations
+
+
+
+ Custom field mapping
+
+
+
+ Data import/export
+
+
+
+ Zapier integration
+
+
+
+ OAuth authentication
+
+
+
+ API documentation
+
+
+
+
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
Self-Hosting & Deployment
+
+ Deploy BottleCRM on your own infrastructure for complete control. Choose from various
+ hosting options, scale as needed, and maintain full data sovereignty.
+
+
+
+
+
+ Docker containerization
+
+
+
+ Cloud deployment guides
+
+
+
+ Database flexibility
+
+
+
+ Scalable architecture
+
+
+
+ Load balancing support
+
+
+
+ Automated backups
+
+
+
+ Environment configuration
+
+
+
+ Performance monitoring
+
+
+
+
+
+
+
+
+
+
Customization & Flexibility
+
Adapt BottleCRM to your unique business needs
+
+
+
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
Complete Customization
+
+ Tailor BottleCRM to your exact business needs with extensive customization options.
+ Modify workflows, create custom fields, and adapt the interface to match your processes.
+
+
+
+
+
+ Custom field creation
+
+
+
+ Workflow customization
+
+
+
+ UI theme customization
+
+
+
+ Form builder
+
+
+
+ Custom data validation
+
+
+
+ Business rule engine
+
+
+
+ Layout customization
+
+
+
+ Brand integration
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
Multi-language Support
+
+ Use BottleCRM in your preferred language with comprehensive internationalization
+ support. Easily add new languages and adapt to local business requirements.
+
+
+
+
+
+ Multiple language support
+
+
+
+ RTL text support
+
+
+
+ Currency localization
+
+
+
+ Date/time formatting
+
+
+
+ Number formatting
+
+
+
+ Timezone handling
+
+
+
+ Cultural adaptations
+
+
+
+ Translation management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BottleCRM is built using cutting-edge technologies to ensure performance, security, and
+ maintainability.
+
+
+
+
+
+
+
SvelteKit 2.21+
+
Modern web framework
+
+
+
+
Prisma ORM
+
Database management
+
+
+
+
TailwindCSS 4.1+
+
Modern styling
+
+
+
+
Open Source
+
MIT License
+
+
+
+
+
+
+
+
+
+
+
+ See what's available now and what's coming soon in BottleCRM development roadmap.
+
+
+
+
+
+
+
+
+
+
+
+
Available Now
+
Ready to use features
+
+
+
+
+
+
+ Advanced Contact Management
+
+
+
+ Task & Activity Management
+
+
+
+ Mobile-First Design
+
+
+
+ Data Security & Privacy
+
+
+
+ Self-Hosting & Deployment
+
+
+
+ Complete Customization
+
+
+
+
+
+
+
+
+
+
+
+
Coming Soon
+
In active development
+
+
+
+
+
+
+ Sales Pipeline Management
+
+
+
+ Real-time Analytics Dashboard
+
+
+
+ Invoice & Billing Management
+
+
+
+ Communication Center
+
+
+
+ API & Integrations
+
+
+
+ Multi-language Support
+
+
+
+
+
+
+
+ Want to contribute to feature development or request a specific feature?
+
+
+
+
+
+
+
+
+
+
+
+
+ Unlike traditional CRM solutions, BottleCRM offers enterprise-grade features without the
+ enterprise price tag.
+
+
+
+
+
+
+
+
+
+
Cost-Effective Solution
+
+ Save thousands on CRM costs while getting professional features. No per-user fees, no
+ monthly subscriptions, no surprise charges.
+
+
+
+
+ $0 monthly fees
+
+
+
+ Unlimited users
+
+
+
+ No feature restrictions
+
+
+
+
+
+
+
+
+
+
Complete Data Control
+
+ Your data stays yours. Self-host on your infrastructure with full control over security,
+ privacy, and compliance.
+
+
+
+
+ Self-hosted deployment
+
+
+
+ No vendor lock-in
+
+
+
+ GDPR compliance ready
+
+
+
+
+
+
+
+
+
+
Unlimited Customization
+
+ Modify and extend BottleCRM to fit your exact needs. Open-source means no limitations on
+ customization.
+
+
+
+
+ Full source code access
+
+
+
+ Custom integrations
+
+
+
+ Community support
+
+
+
+
+
+
+
+
+
+
+
+
+ Join businesses that have chosen BottleCRM for enterprise-grade features without enterprise
+ costs.
+
+
+
+
+
+ 🚀 No subscription fees • Enterprise features included • Deploy anywhere
+
+
+
diff --git a/apps/web/src/routes/(site)/features/account-management/+page.svelte b/apps/web/src/routes/(site)/features/account-management/+page.svelte
new file mode 100644
index 0000000..aae4ba5
--- /dev/null
+++ b/apps/web/src/routes/(site)/features/account-management/+page.svelte
@@ -0,0 +1,1091 @@
+
+
+
+
+ Free Account Management CRM | Unlimited Contacts & Customer Database
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account Management • 100% Free • Unlimited Contacts
+
+
+
+
+
+ Centralize customer information, track every interaction, and build stronger relationships
+ with our comprehensive, free account management system. Everything you need to manage
+ unlimited contacts and accounts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Everything you need to manage customer accounts, contacts, and relationships in one
+ comprehensive, free platform.
+
+
+
+
+
+
+
+
+
+
+
+
Centralized Contact Database
+
+ Store unlimited contacts with comprehensive profile information. Never lose track of
+ important customer details with our centralized contact management system.
+
+
+ Our contact database serves as the foundation of your CRM, allowing you to store
+ detailed information about every customer, prospect, and business contact. With
+ unlimited storage capacity and rich data fields, you can maintain comprehensive records
+ that grow with your business.
+
+
+
+
+
+ Unlimited contact storage
+
+
+
+ Rich profile information
+
+
+
+ Contact relationship mapping
+
+
+
+ Duplicate detection & merging
+
+
+
+ Historical data preservation
+
+
+
+ Cross-reference capabilities
+
+
+
+
+
+
+
+
+
+
+
+
+
Advanced Contact Search & Filtering
+
+ Find any contact instantly with powerful search capabilities and advanced filtering
+ options. Organize and segment your contacts for targeted communication.
+
+
+ Our intelligent search system allows you to quickly locate contacts using any piece of
+ information - name, company, email, phone, or custom fields. Advanced filters help you
+ create targeted lists for marketing campaigns and sales outreach.
+
+
+
+
+
+ Global search functionality
+
+
+
+ Multi-criteria filtering
+
+
+
+ Saved search queries
+
+
+
+ Real-time search results
+
+
+
+ Custom search operators
+
+
+
+ Bulk action capabilities
+
+
+
+
+
+
+
+
+
+
+
+
+
Contact Segmentation & Tagging
+
+ Organize contacts into meaningful groups with custom tags and segments. Create targeted
+ lists for personalized marketing and sales activities.
+
+
+ Effective contact segmentation is crucial for personalized communication. Our tagging
+ and segmentation tools allow you to group contacts based on any criteria, from
+ demographics to purchase behavior, enabling more targeted and effective outreach.
+
+
+
+
+
+ Custom tag creation
+
+
+
+ Automated segmentation rules
+
+
+
+ Behavioral segmentation
+
+
+
+ Demographic grouping
+
+
+
+ Interest-based categories
+
+
+
+ Dynamic list updates
+
+
+
+
+
+
+
+
+
+
+
+
+
Contact Interaction Timeline
+
+ Track every interaction with your contacts in a visual timeline. Maintain complete
+ communication history and never miss important context.
+
+
+ Understanding the complete history of your relationship with each contact is essential
+ for effective customer management. Our timeline feature captures every email, call,
+ meeting, and interaction, providing valuable context for future communications.
+
+
+
+
+
+ Complete interaction history
+
+
+
+ Visual timeline view
+
+
+
+ Multi-channel tracking
+
+
+
+ Automated activity logging
+
+
+
+ Context preservation
+
+
+
+ Team collaboration notes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Handle every aspect of customer account management with these powerful features.
+
+
+
+
+
+
+
+
+
+
+
Company & Organization Management
+
+ Manage company accounts with multiple contacts, organizational hierarchies, and
+ account-level information tracking.
+
+
+
+
+
+ Multi-contact accounts
+
+
+
+ Organizational charts
+
+
+
+ Account hierarchies
+
+
+
+ Company profile management
+
+
+
+ Revenue tracking per account
+
+
+
+ Account status management
+
+
+
+
+
+
+
+
+
+
+
Contact Information Management
+
+ Store comprehensive contact details including personal information, communication
+ preferences, and custom data fields.
+
+
+
+
+
+ Personal & professional details
+
+
+
+ Multiple contact methods
+
+
+
+ Communication preferences
+
+
+
+ Custom field support
+
+
+
+ Photo & document storage
+
+
+
+ Social media integration
+
+
+
+
+
+
+
+
+
+
+
Geographic & Location Tracking
+
+ Track contact locations, territories, and geographic information for better sales
+ territory management and local marketing.
+
+
+
+
+
+ Address management
+
+
+
+ Territory assignment
+
+
+
+ Geographic analytics
+
+
+
+ Location-based filtering
+
+
+
+ Regional reporting
+
+
+
+ Map visualization
+
+
+
+
+
+
+
+
+
+
+
Contact Communication Hub
+
+ Centralize all communication channels including email, phone, social media, and in-person
+ meetings with full tracking.
+
+
+
+
+
+ Email integration
+
+
+
+ Call logging
+
+
+
+ Meeting scheduling
+
+
+
+ Social media tracking
+
+
+
+ Communication templates
+
+
+
+ Response tracking
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Secure, reliable, and efficient data handling for all your account management needs.
+
+
+
+
+
+
+
+
+
+
+
Data Import & Export
+
+ Easily migrate your existing contact data or export for backup and analysis purposes.
+
+
+
+
+
+ CSV/Excel import
+
+
+
+ Multiple data formats
+
+
+
+ Field mapping tools
+
+
+
+ Bulk data validation
+
+
+
+ Export scheduling
+
+
+
+ Data transformation
+
+
+
+
+
+
+
+
+
+
+
Data Security & Privacy
+
+ Protect sensitive contact information with enterprise-grade security and privacy controls.
+
+
+
+
+
+ Data encryption
+
+
+
+ Access control
+
+
+
+ Audit trails
+
+
+
+ GDPR compliance
+
+
+
+ Data retention policies
+
+
+
+ Privacy settings
+
+
+
+
+
+
+
+
+
+
+
Real-time Data Sync
+
+ Keep contact information synchronized across all devices and team members in real-time.
+
+
+
+
+
+ Automatic synchronization
+
+
+
+ Conflict resolution
+
+
+
+ Offline data access
+
+
+
+ Multi-device support
+
+
+
+ Team collaboration
+
+
+
+ Version control
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See how different teams can leverage BottleCRM's account management features.
+
+
+
+
+
+
+
+
+
+
+
+ Complete prospect information at your fingertips
+
+
+
+ Track sales interactions and follow-ups
+
+
+
+ Identify warm leads and opportunities
+
+
+
+ Maintain consistent communication history
+
+
+
+ Coordinate team efforts on large accounts
+
+
+
+
+
+
+
+
+
+
+
+ Create targeted marketing segments
+
+
+
+ Track campaign engagement and responses
+
+
+
+ Personalize marketing communications
+
+
+
+ Measure marketing ROI by contact
+
+
+
+ Generate qualified leads for sales
+
+
+
+
+
+
+
+
+
+
+
Customer Support
+
+
+
+
+
+ Access complete customer history instantly
+
+
+
+ Provide personalized support experiences
+
+
+
+ Track support interactions and resolutions
+
+
+
+ Identify recurring issues by account
+
+
+
+ Maintain customer satisfaction records
+
+
+
+
+
+
+
+
+
+
+
Business Owners
+
+
+
+
+
+ Get complete view of customer relationships
+
+
+
+ Track business growth and customer acquisition
+
+
+
+ Identify most valuable accounts and contacts
+
+
+
+ Make data-driven business decisions
+
+
+
+ Ensure no customer falls through cracks
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get started with BottleCRM account management in three simple steps.
+
+
+
+
+
+
+
+
+
+
Data Migration & Setup
+
+ Import existing contact data and configure account structure
+
+
+
+
+
+ Import contact databases
+
+
+
+ Set up custom fields
+
+
+
+ Configure user permissions
+
+
+
+ Establish data validation rules
+
+
+
+
+
+
+
+
+
+
+
Team Training & Onboarding
+
Train your team on account management best practices
+
+
+
+
+ User training sessions
+
+
+
+ Workflow establishment
+
+
+
+ Data entry standards
+
+
+
+ Regular usage monitoring
+
+
+
+
+
+
+
+
+
+
+
Process Optimization
+
+ Fine-tune your account management processes for maximum efficiency
+
+
+
+
+
+ Performance monitoring
+
+
+
+ Process refinement
+
+
+
+ Advanced feature adoption
+
+
+
+ Continuous improvement
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Compare our account management features with traditional CRM solutions.
+
+
+
+
+
+
+
+
+ Feature
+ BottleCRM
+ Enterprise CRM A
+ Popular CRM B
+ Commercial CRM C
+
+
+
+
+ Unlimited Contacts
+
+ Paid plans only
+ Limited in free
+ Paid only
+
+
+ Custom Fields
+
+
+
+
+
+
+ Self-Hosting Option
+
+ Limited
+ Not available
+ Enterprise only
+
+
+ Monthly Cost Range
+ $0
+ $25-300/user*
+ $50-3200/month*
+ $15-99/user*
+
+
+ Open Source
+
+ Proprietary
+ Proprietary
+ Proprietary
+
+
+
+
+
+
+
+
+ Potential Annual Savings: $3,000 - $36,000 per year for
+ a 10-person team*
+
+
+ * Pricing estimates based on publicly available information and market research. Actual
+ pricing may vary. Comparison is for illustrative purposes only and does not represent
+ specific vendor quotes.
+
+
+
+
+
+
+
+
+
+
+ Start managing unlimited contacts and accounts today with BottleCRM's comprehensive, free
+ account management system.
+
+
+
+
+
+ 🚀 No credit card required • Unlimited contacts • Self-host anywhere • Full customization
+
+
+
diff --git a/apps/web/src/routes/(site)/features/contact-management/+page.svelte b/apps/web/src/routes/(site)/features/contact-management/+page.svelte
new file mode 100644
index 0000000..85ba18f
--- /dev/null
+++ b/apps/web/src/routes/(site)/features/contact-management/+page.svelte
@@ -0,0 +1,1399 @@
+
+
+
+
+ Free Contact Management Software | Unlimited CRM - BottleCRM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@html `
+
+ `}
+
+
+
+
+
+
+
+
+
+ Unlimited Contacts • Always Free
+
+
+
+
+
+ Stop paying $2-10 per contact. BottleCRM gives you unlimited contact storage, advanced
+ search, custom fields, and mobile access - all completely free. Build stronger customer
+ relationships without breaking the bank.
+
+
+
+
+
+ Unlimited contacts and custom fields
+
+
+
+ Advanced search and smart filtering
+
+
+
+ Complete interaction history and timeline
+
+
+
+ Mobile-optimized with offline access
+
+
+
+
+
+
+
+
+
💰 Industry Cost Comparison*
+
+
+
+
+ 1,000 contacts in Enterprise CRM:
+ $25,000+/year
+
+
+ 1,000 contacts in Popular CRM:
+ $3,000-8,000/year
+
+
+ 1,000 contacts in Standard CRM:
+ $1,500-3,000/year
+
+
+
+ Unlimited contacts in BottleCRM:
+ $0
+
+
+
+
+
+ Save $1,500 - $25,000+ per year!
+
+
+
+ *Based on industry-standard per-contact pricing models as of 2024
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Everything you need to organize, track, and nurture your customer relationships. All
+ features included for free, no premium tiers or hidden costs.
+
+
+
+
+ {#if mounted}
+
+
+
+ Save $2-10 per contact vs competitors
+
+
+
+
+
+
+
+
Unlimited Contact Storage
+
+ Store unlimited contacts with complete profile information, interaction history, and
+ custom fields. No per-contact pricing or storage limits.
+
+
+
+
+
+ Unlimited contacts
+
+
+
+ Custom fields
+
+
+
+ Rich profiles
+
+
+
+ No storage limits
+
+
+
+
+
+
+
+ Enterprise-grade search for free
+
+
+
+
+
+
+
+
Advanced Search & Filtering
+
+ Find contacts instantly with powerful search capabilities, smart filters, and custom
+ tags. Search by name, company, tags, location, or any custom field.
+
+
+
+
+
+ Instant search
+
+
+
+ Smart filters
+
+
+
+ Custom tags
+
+
+
+ Bulk operations
+
+
+
+
+
+
+
+ 360° customer view
+
+
+
+
+
+
Contact Interaction Timeline
+
+ Track every interaction with your contacts including emails, calls, meetings, and notes.
+ Build stronger relationships with complete interaction history.
+
+
+
+
+
+ Complete timeline
+
+
+
+ Interaction tracking
+
+
+
+ Meeting history
+
+
+
+ Notes & tasks
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
+ Smart Contact Segmentation
+ • Coming Soon
+
+
+ Organize contacts into dynamic segments based on behavior, demographics, or custom
+ criteria. Target your outreach and marketing efforts effectively.
+
+
+
+
+
+ Dynamic segments
+
+
+
+ Behavioral targeting
+
+
+
+ Custom criteria
+
+
+
+ Automated grouping
+
+
+
+
+
+ This feature is in development and will be available soon!
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
+ Bulk Import & Export
+ • Coming Soon
+
+
+ Easily import contacts from CSV, Excel, or other CRM systems. Export your data anytime
+ with no vendor lock-in. Your data stays yours.
+
+
+
+
+
+ CSV/Excel import
+
+
+
+ CRM migration
+
+
+
+ Data export
+
+
+
+ No vendor lock-in
+
+
+
+
+
+ This feature is in development and will be available soon!
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
+ Mobile-First Contact Access
+ • Coming Soon
+
+
+ Access and manage contacts on any device with our responsive interface. Work offline and
+ sync automatically when connected.
+
+
+
+
+
+ Mobile optimized
+
+
+
+ Offline access
+
+
+
+ Auto sync
+
+
+
+ Cross-platform
+
+
+
+
+
+ This feature is in development and will be available soon!
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Experience the intuitive interface and powerful features of BottleCRM's contact management
+ system.
+
+
+
+
+
+
+
+
+ Search contacts
+
+
+
+
+ Filter
+
+
+
+
+ Add Contact
+
+
+
+
+
+
+
+
Sarah Johnson
+
+
+ TechStart Inc.
+
+
+
+ Qualified
+
+
+
+
+
+
+ sarah@techstart.com
+
+
+
+ +1 (555) 123-4567
+
+
+
+ San Francisco, CA
+
+
+
+ Last contact: 2 days ago
+
+
+
+
+ Hot Lead
+ Enterprise
+
+
+
+
+
+ View
+
+
+
+ Edit
+
+
+
+
+
+
+
+
Michael Chen
+
+
+ Growth Co.
+
+
+
+ Negotiation
+
+
+
+
+
+
+ m.chen@growthco.com
+
+
+
+ +1 (555) 987-6543
+
+
+
+ New York, NY
+
+
+
+ Last contact: 1 week ago
+
+
+
+
+ Proposal Sent
+ Decision Maker
+
+
+
+
+
+ View
+
+
+
+ Edit
+
+
+
+
+
+
+
+
Emma Rodriguez
+
+
+ Scale Solutions
+
+
+
New
+
+
+
+
+
+ emma@scalesolutions.com
+
+
+
+ +1 (555) 456-7890
+
+
+
+ Austin, TX
+
+
+
+ Last contact: 3 days ago
+
+
+
+
+ New Contact
+ SMB
+
+
+
+
+
+ View
+
+
+
+ Edit
+
+
+
+
+
+
+
+ This is just a preview. The actual BottleCRM interface includes advanced filtering, bulk
+ operations, custom fields, and much more.
+
+
+
+ Try the Full Interface
+
+
+
+
+
+
+
+
+
+
+
+
+ We're continuously improving BottleCRM with new contact management features based on user
+ feedback.
+
+
+
+
+
+
+
+
+
Smart Contact Segmentation
+
+ Automatically segment contacts based on behavior, engagement, and custom criteria for
+ targeted campaigns.
+
+
+ Q2 2024
+
+
+
+
+
+
+
+
Bulk Import & Export
+
+ Import thousands of contacts from CSV/Excel files and export your data in multiple formats
+ with advanced mapping.
+
+
+ Q2 2024
+
+
+
+
+
+
+
+
Mobile-First Contact Access
+
+ Native mobile app with offline sync, push notifications, and optimized contact management
+ on the go.
+
+
+ Q3 2024
+
+
+
+
+
+
Want to influence our roadmap?
+
+ Join our community and help us prioritize which features to build next. Your feedback shapes
+ BottleCRM's future!
+
+
+
+
+
+
+
+
+
+
+
+
+ Get started with professional contact management in just 4 easy steps.
+
+
+
+
+ {#if mounted}
+
+
+
+
+
+
Import Your Contacts
+
+ Upload from CSV, Excel, or migrate from your existing CRM
+
+
+
+
+
+ Drag & drop files
+
+
+
+ Map fields automatically
+
+
+
+ Validate data
+
+
+
+
+
+
+
+
+
+
Organize & Segment
+
+ Create tags, segments, and custom fields for better organization
+
+
+
+
+
+ Create custom tags
+
+
+
+ Set up segments
+
+
+
+ Add custom fields
+
+
+
+
+
+
+
+
+
+
Track Interactions
+
Log emails, calls, meetings, and notes for each contact
+
+
+
+
+ Log communications
+
+
+
+ Schedule follow-ups
+
+
+
+ Add notes
+
+
+
+
+
+
+
+
+
+
Analyze & Optimize
+
+ Use insights to improve relationships and conversion rates
+
+
+
+
+
+ View analytics
+
+
+
+ Track engagement
+
+
+
+ Optimize outreach
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ See how BottleCRM's contact management compares to typical CRM pricing models.
+
+
+
+
+
+
+
+ Platform Type
+ Contact Limit
+ Typical Pricing
+ Custom Fields
+ Data Export
+ Mobile App
+ API Access
+ Self-Hosted
+
+
+
+
+
+
+ BottleCRM
+ Free & Open Source
+
+
+ Unlimited
+ $0
+ Unlimited
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enterprise CRM Platforms
+
+ Plan-dependent
+ $25-300+/user
+ Limited by tier
+
+ Often restricted
+
+
+
+
+
+ Premium tiers
+
+
+
+
+
+
+
+ Popular Cloud CRMs
+
+ Limited on free plans
+ $15-100+/month
+ Tier-restricted
+
+ Basic exports
+
+
+
+
+
+ Paid features
+
+
+
+
+
+
+
+ Mid-Market CRM Solutions
+
+ Per-user limitations
+ $15-99/user
+ Limited
+
+
+
+
+
+
+
+ Higher tiers
+
+
+
+
+
+
+
+
+
+
+
+ *Pricing and features based on publicly available information as of 2024. Individual
+ platform offerings may vary.
+
+
+
+
+
+
+
+
+
+
+
+ Common questions about BottleCRM's contact management features.
+
+
+
+
+
+
toggleFaq(0)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 0}
+ >
+
+
+ How many contacts can I store in BottleCRM?
+
+
+
+
+
+ {#if activeFaq === 0}
+
+
+ Unlimited! Unlike other CRM systems that charge per contact or have storage limits,
+ BottleCRM allows you to store as many contacts as you need without any additional
+ costs.
+
+
+ {/if}
+
+
+
+
toggleFaq(1)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 1}
+ >
+
+
+ Can I import contacts from my existing CRM or spreadsheet?
+
+
+
+
+
+ {#if activeFaq === 1}
+
+
+ Yes, BottleCRM supports importing contacts from CSV files, Excel spreadsheets, and
+ most popular CRM systems. Our import wizard helps you map fields and ensures data
+ integrity during the migration process.
+
+
+ {/if}
+
+
+
+
toggleFaq(2)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 2}
+ >
+
+
+ Is my contact data secure and private?
+
+
+
+
+
+ {#if activeFaq === 2}
+
+
+ Absolutely. Since BottleCRM is self-hosted, your contact data never leaves your
+ servers. You have complete control over data security, privacy, and compliance with
+ regulations like GDPR.
+
+
+ {/if}
+
+
+
+
toggleFaq(3)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 3}
+ >
+
+
+ Can I customize contact fields for my business needs?
+
+
+
+
+
+ {#if activeFaq === 3}
+
+
+ Yes, BottleCRM allows unlimited custom fields. Add industry-specific information,
+ custom tags, dropdown selections, and any other data points relevant to your business
+ without restrictions.
+
+
+ {/if}
+
+
+
+
toggleFaq(4)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 4}
+ >
+
+
+ Does BottleCRM work on mobile devices?
+
+
+
+
+
+ {#if activeFaq === 4}
+
+
+ BottleCRM is fully responsive and works perfectly on smartphones and tablets. You can
+ access, search, and manage contacts on any device with a modern web browser.
+
+
+ {/if}
+
+
+
+
toggleFaq(5)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 5}
+ >
+
+
+ How does contact management in BottleCRM compare to paid alternatives?
+
+
+
+
+
+ {#if activeFaq === 5}
+
+
+ BottleCRM provides enterprise-grade contact management features for free, including
+ unlimited contacts, custom fields, advanced search, and data export. Most commercial
+ CRM systems charge $25-100+ per user per month for similar functionality, often with
+ contact limits and feature restrictions.
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Stop paying per contact or hitting storage limits. Start building better customer
+ relationships with BottleCRM's powerful, free contact management system.
+
+
+
+
+
+
+
∞
+
Unlimited Contacts
+
+
+
+
100%
+
Data Ownership
+
+
+
+
+ 🚀 No setup fees • No user limits • No vendor lock-in • Free forever
+
+
+
diff --git a/apps/web/src/routes/(site)/features/lead-management/+page.svelte b/apps/web/src/routes/(site)/features/lead-management/+page.svelte
new file mode 100644
index 0000000..61cce88
--- /dev/null
+++ b/apps/web/src/routes/(site)/features/lead-management/+page.svelte
@@ -0,0 +1,1127 @@
+
+
+
+ Free CRM Lead Management Software | BottleCRM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Convert More Leads • Accelerate Growth • 100% Free
+
+
+
+
+
+ Capture, score, nurture, and convert leads with BottleCRM's comprehensive lead management
+ system. Stop losing potential customers and start maximizing your sales pipeline.
+
+
+
+
+
+
+
Smart Lead Scoring
+
Automatically identify your hottest prospects
+
+
+
+
Automated Nurturing
+
Guide leads through personalized journeys
+
+
+
+
Higher Conversions
+
Increase lead-to-customer conversion rates
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ From initial capture to final conversion, BottleCRM streamlines every step of your lead
+ management process.
+
+
+
+
+ {#if mounted}
+
+
+
+
+
+
+
+
+
+
+
Lead Capture
+
+ Capture leads from websites, forms, emails, and social media automatically
+
+
+
+
+
+
+ Website form integration
+
+
+
+ Email parsing & extraction
+
+
+
+ Social media monitoring
+
+
+
+ Manual lead entry
+
+
+
+ API integrations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lead Qualification
+
Score and qualify leads based on fit and interest levels
+
+
+
+
+
+ Automated lead scoring
+
+
+
+ BANT qualification
+
+
+
+ Behavioral analysis
+
+
+
+ Demographic matching
+
+
+
+ Engagement tracking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lead Nurturing
+
+ Engage leads with personalized content and follow-up sequences
+
+
+
+
+
+
+ Email campaigns
+
+
+
+ Content personalization
+
+
+
+ Automated follow-ups
+
+
+
+ Multi-channel outreach
+
+
+
+ Engagement monitoring
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lead Conversion
+
+ Convert qualified leads into sales opportunities and customers
+
+
+
+
+
+
+ Opportunity creation
+
+
+
+ Sales handoff process
+
+
+
+ Conversion tracking
+
+
+
+ ROI measurement
+
+
+
+ Performance analytics
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Everything you need to capture, manage, and convert leads into customers.
+
+
+
+
+ {#if mounted}
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
+
+
Smart Lead Capture & Import
+
+ Capture leads from multiple sources including web forms, email campaigns, social media,
+ and manual entry. Automatically parse contact information and prevent duplicates.
+
+
+
+
+
+
+ Multi-channel lead capture
+
+
+
+ Web form integration
+
+
+
+ Email signature parsing
+
+
+
+ Social media lead import
+
+
+
+ Bulk CSV import/export
+
+
+
+ Duplicate detection & merging
+
+
+
+ API-based lead creation
+
+
+
+ Real-time lead notifications
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
+
+
Lead Scoring & Qualification
+
+ Automatically score leads based on behavior, demographics, and engagement. Identify hot
+ prospects and prioritize follow-up activities for maximum conversion.
+
+
+
+
+
+
+ Customizable scoring rules
+
+
+
+ Behavioral tracking
+
+
+
+ Demographic scoring
+
+
+
+ Engagement metrics
+
+
+
+ Lead temperature indicators
+
+
+
+ Qualification frameworks
+
+
+
+ Automated lead routing
+
+
+
+ Score history tracking
+
+
+
+
+
+
+
+
+
+ Available Now
+
+
+
+
+
+
+
+
+
+
Advanced Lead Segmentation
+
+ Organize leads into meaningful segments based on industry, size, behavior, or custom
+ criteria. Create targeted campaigns and personalized follow-up strategies.
+
+
+
+
+
+
+ Dynamic segmentation rules
+
+
+
+ Industry-based grouping
+
+
+
+ Company size filtering
+
+
+
+ Geographic segmentation
+
+
+
+ Behavior-based segments
+
+
+
+ Custom field filtering
+
+
+
+ Segment performance tracking
+
+
+
+ Automated segment updates
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
+
+
+
+
+
Lead Nurturing Workflows
+
+ Create automated nurturing sequences to guide leads through your sales funnel. Send
+ personalized emails, schedule follow-ups, and track engagement.
+
+
+
+
+
+
+ Drag-and-drop workflow builder
+
+
+
+ Email sequence automation
+
+
+
+ Trigger-based actions
+
+
+
+ Personalization tokens
+
+
+
+ A/B testing capabilities
+
+
+
+ Multi-channel nurturing
+
+
+
+ Engagement tracking
+
+
+
+ Conversion optimization
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Monitor key lead metrics and optimize your conversion funnel with actionable insights.
+
+
+
+
+ {#if mounted}
+
+
+
+
Lead Conversion Rate
+
+ Track the percentage of leads that convert to customers
+
+
+
+
Industry average: 2-5%
+
+
+
+
+
+
+
+
Lead Response Time
+
+ Monitor how quickly your team responds to new leads
+
+
+
+
Best practice: Under 1 hour
+
+
+
+
+
+
+
+
Lead Source ROI
+
+ Identify which channels generate the highest quality leads
+
+
+
+
Optimize based on performance
+
+
+
+
+
+
Sales Cycle Length
+
Average time from lead to customer conversion
+
+
+
Varies by industry
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ All the tools you need to manage leads effectively, integrated into one platform.
+
+
+
+
+ {#if mounted}
+
+
+
+
+
+
Lead Dashboard
+
+ Comprehensive overview of all lead activities and metrics
+
+
+
+
+
+ Real-time lead count
+
+
+
+ Conversion metrics
+
+
+
+ Source analysis
+
+
+
+ Team performance
+
+
+
+
+
+
+
+
+
+
Lead Database
+
Centralized storage for all lead information and history
+
+
+
+
+ Contact details
+
+
+
+ Interaction history
+
+
+
+ Document storage
+
+
+
+ Custom fields
+
+
+
+
+
+
+
+
+
+
Communication Hub
+
Manage all lead communications from one central location
+
+
+
+
+ Email integration
+
+
+
+ Call logging
+
+
+
+ Meeting notes
+
+
+
+ Message templates
+
+
+
+
+
+
+
+
+
+
Reporting Engine
+
Generate detailed reports on lead performance and trends
+
+
+
+
+ Custom reports
+
+
+
+ Automated delivery
+
+
+
+ Performance analytics
+
+
+
+ Export options
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
100% Free Forever
+
+ No per-lead fees, no monthly subscriptions. Use all lead management features
+ completely free.
+
+
+
+
+
+
+
+
Complete Data Ownership
+
+ Self-host your lead data for complete privacy and control. No vendor lock-in.
+
+
+
+
+
+
+
+
Fully Customizable
+
+ Adapt lead scoring, workflows, and processes to match your unique business needs.
+
+
+
+
+
+
+
+
Open Source
+
+ Built on open-source technologies with active community support and development.
+
+
+
+
+
+
+
+
+
+ Lead Management ROI Calculator
+
+
+
+
+
+
+
Faster Lead Response Time
+
+
+
+
+
+
+25%
+
Conversion Rate
+
+
+
+
+
+
Potential Annual Savings
+
+
vs. paid lead management tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Common questions about BottleCRM's lead management capabilities.
+
+
+
+
+
+
+ How does lead scoring work in BottleCRM?
+
+
+ BottleCRM's lead scoring system automatically evaluates leads based on demographic
+ information, behavioral data, and engagement levels. You can customize scoring rules to
+ match your ideal customer profile and business priorities.
+
+
+
+
+
+ Can I import leads from other systems?
+
+
+ Yes, BottleCRM supports bulk lead import from CSV files, API integrations, and direct
+ migration from other CRM systems. Our duplicate detection ensures clean data integration.
+
+
+
+
+
+ How are lead nurturing workflows automated?
+
+
+ Create automated sequences based on triggers like lead source, behavior, or time delays.
+ Send personalized emails, assign tasks, and move leads through your sales funnel
+ automatically.
+
+
+
+
+
What lead analytics are available?
+
+ Track conversion rates, lead sources, response times, and pipeline velocity. Generate
+ custom reports to identify your best-performing lead channels and optimize your strategy.
+
+
+
+
+
+
+
+
+
+
+
+ Stop losing leads and start converting more prospects into customers. Try BottleCRM's lead
+ management features today.
+
+
+
+
+
+ 🚀 No credit card • Unlimited leads • Self-host anywhere • Open source
+
+
+
diff --git a/apps/web/src/routes/(site)/features/sales-pipeline/+page.svelte b/apps/web/src/routes/(site)/features/sales-pipeline/+page.svelte
new file mode 100644
index 0000000..726a5e6
--- /dev/null
+++ b/apps/web/src/routes/(site)/features/sales-pipeline/+page.svelte
@@ -0,0 +1,1327 @@
+
+
+
+ Free Sales Pipeline CRM | Visual Deal Tracking & Forecasting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@html ``}
+
+
+
+
+
+
+
+
+
+
+
+ Coming Soon • Visual Sales Pipeline Management
+
+
+
+
+
+ Transform your sales workflow with BottleCRM's intuitive visual pipeline. Track deals from
+ first contact to closed-won, forecast revenue accurately, and automate your sales process
+ for maximum efficiency.
+
+
+
+
+
+
+ Drag-and-drop deal management
+
+
+
+ Automated sales forecasting
+
+
+
+ Real-time collaboration and insights
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monitor key sales metrics and track your pipeline performance at a glance
+
+
+
+
+ {#if mounted}
+
+
+
Total Pipeline Value
+
+ +12.5%
+
+
+
$1.31M
+
+
+
+
+
Deals in Pipeline
+
+ +8
+
+
+
77
+
+
+
+
+
Average Deal Size
+
+ +5.2%
+
+
+
$17,013
+
+
+
+
+
Conversion Rate
+
+ +2.1%
+
+
+
23.5%
+
+
+
+
+
Sales Cycle
+
+ -3 days
+
+
+
28 days
+
+
+
+
+
Win Rate
+
+ +4.8%
+
+
+
65.2%
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Experience how BottleCRM's visual pipeline makes deal management intuitive and efficient.
+ Drag deals between stages and watch your sales process come to life.
+
+
+
+
+
+
+
+
+
+
+
Initial prospects and incoming leads
+
$120,000
+
+
+
+ {#if mounted}
+
+
+
+ TechStart Inc.
+
+
+
+
+
+
+
+
+ Value:
+ $15,000
+
+
+ Probability:
+ 20%
+
+
+ Contact:
+ John Smith
+
+
+
+
+
+
+
+
+
+
+
+ Digital Solutions
+
+
+
+
+
+
+
+
+ Value:
+ $8,500
+
+
+ Probability:
+ 15%
+
+
+ Contact:
+ Sarah Johnson
+
+
+
+
+
+
+
+ {/if}
+
+
+
+ Add Deal
+
+
+
+
+
+
+
+
+
Leads that meet qualification criteria
+
$180,000
+
+
+
+ {#if mounted}
+
+
+
+ Innovation Labs
+
+
+
+
+
+
+
+
+ Value:
+ $25,000
+
+
+ Probability:
+ 40%
+
+
+ Contact:
+ Emily Davis
+
+
+
+
+
+
+
+ {/if}
+
+
+
+ Add Deal
+
+
+
+
+
+
+
+
+
Proposals sent and under review
+
$240,000
+
+
+
+ {#if mounted}
+
+
+
+ Enterprise Corp
+
+
+
+
+
+
+
+
+ Value:
+ $45,000
+
+
+ Probability:
+ 60%
+
+
+ Contact:
+ Lisa Anderson
+
+
+
+
+
+
+
+ {/if}
+
+
+
+ Add Deal
+
+
+
+
+
+
+
+
+
+
+ Negotiation
+
+
8
+
+
Active contract negotiations
+
$320,000
+
+
+
+ {#if mounted}
+
+
+
+ Global Systems
+
+
+
+
+
+
+
+
+ Value:
+ $75,000
+
+
+ Probability:
+ 80%
+
+
+ Contact:
+ Jennifer Lee
+
+
+
+
+
+
+
+ {/if}
+
+
+
+ Add Deal
+
+
+
+
+
+
+
+
+
+
+ Closed Won
+
+
15
+
+
Successfully closed deals
+
$450,000
+
+
+
+ {#if mounted}
+
+
+
+ Success Stories
+
+
+
+
+
+
+
+
+ Value:
+ $60,000
+
+
+ Probability:
+ 100%
+
+
+ Contact:
+ Robert Chen
+
+
+
+
+
+
+
+ {/if}
+
+
+
+ Add Deal
+
+
+
+
+
+
+
+
+
+
+
+ Filter Deals
+
+
+
+ Date Range
+
+
+
+ Refresh
+
+
+
+
+ Pipeline Value:
+ $1,310,000
+ +12.5% this month
+
+
+
+
+
+
+
+
+
+
+
+ Everything you need to manage your sales pipeline effectively and close more deals faster.
+
+
+
+
+ {#if mounted}
+
+
+
+
+
+
Visual Deal Tracking
+
+ See all your deals at a glance with our intuitive kanban-style pipeline view. Drag and
+ drop deals between stages effortlessly.
+
+
+
+
+
+ Instant pipeline overview
+
+
+
+ Drag-and-drop interface
+
+
+
+ Real-time updates
+
+
+
+ Color-coded stages
+
+
+
+
+
+
+
+
+
+
Sales Forecasting
+
+ Predict future revenue with probability-based forecasting. Track sales velocity and
+ identify bottlenecks in your process.
+
+
+
+
+
+ Revenue predictions
+
+
+
+ Probability weighting
+
+
+
+ Velocity tracking
+
+
+
+ Trend analysis
+
+
+
+
+
+
+
+
+
+
Deal Management
+
+ Manage individual deals with detailed information, activity tracking, and automated
+ reminders for follow-ups.
+
+
+
+
+
+ Deal details
+
+
+
+ Activity timeline
+
+
+
+ Automated reminders
+
+
+
+ Contact integration
+
+
+
+
+
+
+
+
Pipeline Analytics
+
+ Analyze your sales performance with detailed metrics, conversion rates, and
+ stage-by-stage insights.
+
+
+
+
+
+ Conversion rates
+
+
+
+ Stage analytics
+
+
+
+ Performance metrics
+
+
+
+ Historical data
+
+
+
+
+
+
+
+
+
+
Workflow Automation
+
+ Automate routine tasks like follow-up emails, stage transitions, and notification alerts
+ to boost productivity.
+
+
+
+
+
+ Automated workflows
+
+
+
+ Smart notifications
+
+
+
+ Task automation
+
+
+
+ Email sequences
+
+
+
+
+
+
+
+
+
+
Team Collaboration
+
+ Share pipeline visibility with your team, assign deals, and collaborate on closing
+ opportunities together.
+
+
+
+
+
+ Team visibility
+
+
+
+ Deal assignment
+
+
+
+ Collaborative notes
+
+
+
+ Activity sharing
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+ Transform your sales process from chaotic spreadsheets to organized, visual workflow
+ management. See immediate improvements in deal closure rates and sales team productivity.
+
+
+
+
+
+
+
+ Increase Conversion Rates by 30%
+
+
+ Visual pipeline management helps identify bottlenecks and optimize your sales
+ process for better conversion rates.
+
+
+
+
+
+
+
+
Reduce Sales Cycle by 25%
+
+ Automated workflows and clear process visualization help sales teams move deals
+ through stages faster.
+
+
+
+
+
+
+
+
+ Improve Forecast Accuracy by 40%
+
+
+ Probability-based forecasting and historical data analysis provide more accurate
+ revenue predictions.
+
+
+
+
+
+
+
+
+
Pipeline Performance Impact
+
+
+
+
+ Deal Closure Rate
+ +30%
+
+
+
+
+
+
+ Sales Team Productivity
+ +45%
+
+
+
+
+
+
+ Forecast Accuracy
+ +40%
+
+
+
+
+
+
+ Customer Follow-up
+ +60%
+
+
+
+
+
+
+
+ Average ROI: 320% within 6 months of implementation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sales Pipeline Features Coming Soon
+
+ Our development team is actively working on the visual sales pipeline features. Want to
+ contribute to the development or get early access? Join our open-source community and be part
+ of building the future of free CRM software.
+
+
+
+
+
+
+
+
+
+
+
+ Start using BottleCRM today and experience the power of visual sales pipeline management. Join
+ the community building the future of free CRM software.
+
+
+
+
+
+ 🚀 No credit card required • Open source • Self-host anywhere
+
+
+
+
+
+{#if showDealModal && selectedDeal}
+
+
+
+
+
+
+
+
+
+ Deal Details: {selectedDeal.company}
+
+
+
+
+
+
Deal Value
+
+ {formatCurrency(selectedDeal.value)}
+
+
+
+
Probability
+
{selectedDeal.probability}%
+
+
+
+
+
Primary Contact
+
{selectedDeal.contact}
+
+
+
+
Last Activity
+
{selectedDeal.lastActivity}
+
+
+
+
+
+
+
+ Close
+
+
+
+
+
+{/if}
diff --git a/apps/web/src/routes/(site)/migration/+page.svelte b/apps/web/src/routes/(site)/migration/+page.svelte
new file mode 100644
index 0000000..bd928b8
--- /dev/null
+++ b/apps/web/src/routes/(site)/migration/+page.svelte
@@ -0,0 +1,970 @@
+
+
+
+ Migrate to BottleCRM – Free CRM Migration Tools & Service
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@html `
+
+ `}
+
+
+
+
+
+
+
+
+ Migration Tools Coming Soon • CSV Import Available Now
+
+
+
+
+
+ Migrate from expensive subscription-based CRM platforms to BottleCRM. Keep all your data,
+ gain complete control, and eliminate monthly fees forever.
+
+
+
+
+
+
+
$0
+
Migration Cost (DIY)
+
+
+
100%
+
Data Preserved
+
+
+
+
+
+
+
+
+
+
+
+
+
+ We're building migration tools for all major CRM platforms. See what you can expect when
+ migrating from popular systems.
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
🏢
+
Enterprise CRM Platform
+
+ Complex
+ 2-4 weeks
+
+
+
+
+
+
Data Types:
+
+ Contacts
+ Leads
+ Opportunities
+ Accounts
+ Tasks
+ Notes
+
+
+
+
+
Challenges:
+
+
+
+ Custom fields mapping
+
+
+
+ Complex workflow recreation
+
+
+
+ API rate limitations
+
+
+
+
+
+
+ Typical Savings:
+ $3,000-36,000/year
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
🧡
+
Marketing-Focused CRM
+
+ Moderate
+ 1-2 weeks
+
+
+
+
+
+
Data Types:
+
+ Contacts
+ Companies
+ Deals
+ Tasks
+ Email history
+
+
+
+
+
Challenges:
+
+
+
+ Marketing automation setup
+
+
+
+ Custom properties mapping
+
+
+
+
+
+
+ Typical Savings:
+ $6,000-60,000/year
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
🟢
+
Sales Pipeline CRM
+
+ Simple
+ 1 week
+
+
+
+
+
+
Data Types:
+
+ Contacts
+ Organizations
+ Deals
+ Activities
+ Notes
+
+
+
+
+
Challenges:
+
+
+
+ Pipeline stage mapping
+
+
+
+ Activity type recreation
+
+
+
+
+
+
+ Typical Savings:
+ $1,800-20,000/year
+
+
+
+
+
+
+
+
+
+ Coming Soon
+
+
+
+
+
🔴
+
All-in-One CRM Suite
+
+ Complex
+ 2-3 weeks
+
+
+
+
+
+
Data Types:
+
+ Leads
+ Contacts
+ Accounts
+ Deals
+ Tasks
+ Events
+
+
+
+
+
Challenges:
+
+
+
+ Custom module recreation
+
+
+
+ Workflow rule mapping
+
+
+
+
+
+
+ Typical Savings:
+ $1,200-15,000/year
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get ready for a smooth migration with our comprehensive preparation guide. Complete these
+ steps before migration tools are released.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Data Audit & Cleanup
+
Estimated time: 1-2 days
+
+
+
Review and clean your existing CRM data before migration
+
+
+
+
+
+
+ Remove duplicate contacts and leads
+
+
+
+ Standardize data formats (phone, email, addresses)
+
+
+
+ Archive outdated or irrelevant records
+
+
+
+ Document custom fields and their purposes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Export Data from Current CRM
+
Estimated time: 2-4 hours
+
+
+
Gather all necessary data from your existing system
+
+
+
+
+
+
+ Export contacts with all custom fields
+
+
+
+ Download deal/opportunity data
+
+
+
+ Save task and activity history
+
+
+
+ Backup email integration settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Plan Your BottleCRM Setup
+
Estimated time: 1 day
+
+
+
Configure BottleCRM to match your business needs
+
+
+
+
+
+
+ Define your sales pipeline stages
+
+
+
+ Set up custom fields and properties
+
+
+
+ Configure user roles and permissions
+
+
+
+ Plan integration requirements
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Test with Sample Data
+
Estimated time: 1-2 days
+
+
+
Validate the migration process with a small data set
+
+
+
+
+
+
+ Import a small batch of test data
+
+
+
+ Verify data integrity and formatting
+
+
+
+ Test critical workflows and processes
+
+
+
+ Train team on new interface
+
+
+
+
+
+
+
+
+
+
Need Professional Help?
+
+ Our migration experts can handle the entire process for you, including data cleanup,
+ custom field mapping, and workflow recreation.
+
+
+
+ Get Professional Migration Service
+
+
+
+
+
+
+
+
+
+
+
+
+ Experience the freedom of open-source CRM with enterprise-level features.
+
+
+
+
+
+
+
+
+
Eliminate Subscription Costs
+
+ Stop paying monthly fees. One-time setup, lifetime ownership.
+
+
$0/month
+
+
+
+
+
+
+
Complete Data Control
+
+ Self-host on your servers. No vendor lock-in or data hostage situations.
+
+
100% Yours
+
+
+
+
+
+
+
Unlimited Customization
+
Modify source code to fit your exact business needs.
+
∞ Flexible
+
+
+
+
+
+
+
No User Limits
+
Add unlimited users without additional per-seat costs.
+
∞ Users
+
+
+
+
+
+
+
Open Source Freedom
+
MIT license gives you complete freedom to use and modify.
+
MIT License
+
+
+
+
+
+
+
Future-Proof
+
Never worry about price increases or feature limitations.
+
Forever
+
+
+
+
+
+
+
+
+
+
+ Get notified the moment our automated migration tools are ready. Plus receive migration
+ guides, tips, and early access to new features.
+
+
+ {#if !subscribed}
+
+
+ Email address for updates
+
+
+ Notify Me
+
+
+
+ No spam. Unsubscribe anytime. We respect your privacy.
+
+
+ {:else}
+
+
+
You're all set!
+
We'll notify you when migration tools are ready.
+
+ {/if}
+
+
+
+
+
Migration Guides
+
Step-by-step instructions
+
+
+
+
Early Access
+
Beta testing opportunities
+
+
+
+
Expert Support
+
Migration assistance
+
+
+
+
+
+
+
+
+
+
+
Everything you need to know about migrating to BottleCRM.
+
+
+
+
+
+
toggleFaq(0)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 0}
+ >
+
+
+ When will the migration tools be available?
+
+
+
+
+
+ {#if activeFaq === 0}
+
+
+ We're actively developing automated migration tools for popular CRM platforms. Follow
+ our GitHub repository or subscribe to updates to be notified when they're ready.
+ CSV/Excel imports are available now for immediate migration.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(1)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 1}
+ >
+
+
+ Will I lose any data during migration?
+
+
+
+
+
+ {#if activeFaq === 1}
+
+
+ BottleCRM migration tools are designed to preserve all your important data. We
+ recommend keeping backups of your original data and testing with sample data first.
+ Our professional migration service includes data integrity verification.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(2)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 2}
+ >
+
+
+ How long does a typical migration take?
+
+
+
+
+
+ {#if activeFaq === 2}
+
+
+ Migration time varies by source CRM and data complexity. Simple CSV imports take
+ hours, while complex CRM migrations can take 1-4 weeks. Our professional migration
+ service can accelerate this process significantly.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(3)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 3}
+ >
+
+
+ Can you help with custom field migration?
+
+
+
+
+
+ {#if activeFaq === 3}
+
+
+ Yes! Our professional migration service includes custom field mapping, workflow
+ recreation, and business process migration. We ensure your BottleCRM setup matches
+ your current business needs.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(4)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 4}
+ >
+
+
+ What if my current CRM has complex workflows?
+
+
+
+
+
+ {#if activeFaq === 4}
+
+
+ BottleCRM supports custom workflows and automation. Our migration service includes
+ analyzing your current workflows and recreating them in BottleCRM, often with
+ improvements and optimizations.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(5)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 5}
+ >
+
+
+ Is there a cost for migration assistance?
+
+
+
+
+
+ {#if activeFaq === 5}
+
+
+ Basic migration guidance is free through our community. Professional migration
+ services are available starting at $497, which includes data migration, custom field
+ setup, and workflow configuration.
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Start preparing for your migration today. Download BottleCRM, explore the features, and
+ experience the freedom of open-source CRM.
+
+
+
+
+
+ 🚀 CSV import available now • Automated tools coming soon • Professional migration services
+ available
+
+
+
diff --git a/apps/web/src/routes/(site)/pricing/+page.svelte b/apps/web/src/routes/(site)/pricing/+page.svelte
new file mode 100644
index 0000000..6098ab9
--- /dev/null
+++ b/apps/web/src/routes/(site)/pricing/+page.svelte
@@ -0,0 +1,891 @@
+
+
+
+
+ BottleCRM Pricing – Free Open Source CRM & Affordable Support
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 100% Free Forever • No Credit Card Required
+
+
+
+
+
+ Stop paying $1,800-50,000 per year for CRM software. BottleCRM gives you enterprise-grade
+ features without the enterprise price tag. Professional support available when you need it.
+
+
+
+
+
+
+
+
+
100%
+
Data Ownership
+
+
+
+
+
+
+
+
+
+
+
+
+ Start with our free CRM software. Add professional support services when you need expert
+ help with setup, customization, or deployment.
+
+
+
+
+
+
+
+
+ 100% Free
+
+
+
+
+
+
+
+
BottleCRM Core
+
+ Complete CRM solution with all essential features. Perfect for startups and small
+ businesses.
+
+
+
+
+ /Forever
+
+
+
+ Download Free
+
+
+
+
+
+
+ Unlimited contacts & leads
+
+
+
+ Complete sales pipeline
+
+
+
+ Task & project management
+
+
+
+ Basic reporting & analytics
+
+
+
+ Email integration
+
+
+
+ Mobile responsive interface
+
+
+
+ Self-hosting ready
+
+
+
+ Full source code access
+
+
+
+ MIT open-source license
+
+
+
+ Community support
+
+
+
+
+
+
+
+
+
+ Most Popular
+
+
+
+
+
+
+
+
Professional Support
+
+ Get expert help with setup, customization, and deployment. Perfect for teams that want
+ quick implementation.
+
+
+
+
+ /one-time
+
+
+
+ Get Support
+
+
+
+
+
+
+ Everything in Core (free)
+
+
+
+ Professional installation & setup
+
+
+
+ Custom domain configuration
+
+
+
+ SSL certificate setup
+
+
+
+ Database optimization
+
+
+
+ Basic customization (colors, logo)
+
+
+
+ Email configuration
+
+
+
+ 1-month support included
+
+
+
+ Video walkthrough session
+
+
+
+ Documentation & best practices
+
+
+
+
+
+
+
+
+ Full Service
+
+
+
+
+
+
+
+
Enterprise Setup
+
+ Complete enterprise deployment with advanced customization and training. Ideal for
+ growing companies.
+
+
+
+
+ /one-time
+
+
+
+ Contact Sales
+
+
+
+
+
+
+ Everything in Professional Support
+
+
+
+ Advanced custom development
+
+
+
+ Third-party integrations
+
+
+
+ Advanced reporting setup
+
+
+
+ Custom workflows & automation
+
+
+
+ Team training sessions (2 hours)
+
+
+
+ Advanced security configuration
+
+
+
+ Performance optimization
+
+
+
+ 3-month priority support
+
+
+
+ Migration from existing CRM
+
+
+
+
+
+
+
+ Need something custom? We also offer bespoke development, hosting, and enterprise
+ consulting.
+
+
+
+ Contact us for custom pricing
+
+
+
+
+
+
+
+
+
+
+
+ See how much money your business can save by switching to BottleCRM from typical
+ subscription-based CRM solutions.
+
+
+
+
+
+
+
Savings Calculator
+
+
+
+
+ Team Size (Number of Users)
+
+
+
+ 1
+ {teamSize} users
+ 100+
+
+
+
+
+
+ Compare Against Typical Market Pricing
+
+
+ Enterprise CRM A (~$25/user/month)
+ Popular CRM B (~$50/user/month)
+ Business CRM C (~$15/user/month)
+
+
+ *Pricing based on publicly available market research
+
+
+
+
+
+
+
Your Potential Savings
+
+
+
+ Annual cost with {selectedCompetitor}:
+ ${calculatedSavings.toLocaleString()}
+
+
+ Annual cost with BottleCRM:
+ $0
+
+
+ Total Annual Savings:
+
+
+
+
+
+
+ Note: Even with our most expensive Enterprise Setup ($497), you'd
+ still save ${(calculatedSavings - 497).toLocaleString()} in the first year alone!
+
+
+
+
+
+
+
+
+
+ Disclaimer: Pricing comparisons are based on publicly available information
+ and market research as of 2024. Actual costs may vary. We recommend checking current pricing
+ with individual vendors for exact comparisons.
+
+
+
+
+
+
+
+
+
+
+
+
+ See how BottleCRM compares to typical enterprise CRM solutions across key factors.
+
+
+
+
+
+
+
+ Feature
+ BottleCRM
+ Enterprise CRM A
+ Popular CRM B
+ Business CRM C
+
+
+
+
+ Monthly Cost (per user)
+ $0
+ ~$25/user
+ ~$50/user
+ ~$15/user
+
+
+ Annual Cost (10 users)
+ $0
+ ~$3,000
+ ~$6,000
+ ~$1,800
+
+
+ Setup/Onboarding
+ Optional ($197-497)
+ Varies*
+ Varies*
+ Varies*
+
+
+ User Limits
+ Unlimited
+ Per seat**
+ Limited**
+ Per seat**
+
+
+ Storage Limits
+ Unlimited
+ Limited**
+ Limited**
+ Limited**
+
+
+ Self-Hosted Option
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Open Source
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Data Ownership
+ 100% yours
+ Vendor hosted
+ Vendor hosted
+ Vendor hosted
+
+
+
+
+
+
+
+ ** Features and limitations vary by plan tier.
+ * Pricing varies by vendor and implementation requirements. Information based
+ on publicly available market research as of 2024. Please verify current pricing and features
+ with individual vendors.
+
+
+
+
+
+
+
+
+
+
+
+
+ Everything you need to know about BottleCRM pricing and support services.
+
+
+
+
+
+
+
toggleFaq(0)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 0}
+ >
+
+
+ Is BottleCRM really completely free?
+
+
+
+
+
+ {#if activeFaq === 0}
+
+
+ Yes! BottleCRM core software is 100% free with no hidden costs, user limits, or
+ subscription fees. You only pay if you want professional setup, customization, or
+ ongoing support services.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(1)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 1}
+ >
+
+
+ What's included in the free version?
+
+
+
+
+
+ {#if activeFaq === 1}
+
+
+ The free version includes all core CRM features: unlimited contacts, sales pipeline,
+ task management, reporting, email integration, and mobile access. It's the complete
+ software with full source code.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(2)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 2}
+ >
+
+
+ Why do you charge for support if the software is free?
+
+
+
+
+
+ {#if activeFaq === 2}
+
+
+ The software remains free forever. Support services help businesses save time on
+ setup, customization, and training. This sustainable model allows us to maintain and
+ improve the free software.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(3)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 3}
+ >
+
+
+ Can I switch from paid support back to free?
+
+
+
+
+
+ {#if activeFaq === 3}
+
+
+ Absolutely! There are no contracts or ongoing commitments. Support services are
+ one-time purchases. You maintain full control over your self-hosted installation.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(4)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 4}
+ >
+
+
+ How much can I save compared to other CRMs?
+
+
+
+
+
+ {#if activeFaq === 4}
+
+
+ For a 10-person team, you can save $1,800-6,000 per year compared to typical
+ enterprise CRM solutions. Enterprise teams often save $10,000-50,000 annually. Actual
+ savings depend on your current CRM vendor and plan.
+
+
+ {/if}
+
+
+
+
+
toggleFaq(5)}
+ class="w-full px-6 py-5 text-left transition-colors duration-200 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
+ aria-expanded={activeFaq === 5}
+ >
+
+
+ Do you offer refunds for support services?
+
+
+
+
+
+ {#if activeFaq === 5}
+
+
+ Yes, we offer a 30-day money-back guarantee on all support services. If you're not
+ satisfied with the professional setup or customization, we'll refund your payment.
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Join the growing community of businesses that ditched expensive CRM subscriptions. Start with
+ our free software today, add professional support when you need it.
+
+
+
+
+
+ 🚀 No credit card required • 30-day money-back guarantee on services
+
+
+
+
+
diff --git a/apps/web/src/routes/(site)/privacy-policy/+page.svelte b/apps/web/src/routes/(site)/privacy-policy/+page.svelte
new file mode 100644
index 0000000..77fa3b2
--- /dev/null
+++ b/apps/web/src/routes/(site)/privacy-policy/+page.svelte
@@ -0,0 +1,471 @@
+
+
+
+
+ Privacy Policy | BottleCRM - Free Open Source CRM for Data Privacy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your Privacy, Our Priority
+
+
+
+
+ At BottleCRM, we believe in complete transparency about how we handle your data. As a
+ self-hostable, open-source CRM, your privacy and data ownership are fundamental rights.
+
+
+
+
+
+ Last Updated: {lastUpdated}
+
+
+
+ GDPR Compliant
+
+
+
+
+
+
+
+
+
+
+
+
+ BottleCRM is built on the foundation of user privacy and data ownership.
+
+
+
+
+
+
+
+
+
Data Ownership
+
+ When you self-host BottleCRM, you maintain complete ownership and control of your data.
+
+
+
+
+
+
+
+
Self-Hosting Privacy
+
+ Host on your own servers for maximum privacy and compliance with data protection
+ regulations.
+
+
+
+
+
+
+
+
No Data Mining
+
+ We don't collect, analyze, or monetize your business data. Your information stays private.
+
+
+
+
+
+
+
+
Transparency
+
+ Open-source code means you can inspect exactly how your data is handled and stored.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Information We Collect
+
+
+
+
+
+
+
Self-Hosted Deployments
+
+ When you self-host BottleCRM, we do not collect any of your business data, customer
+ information, or usage analytics. All data remains on your servers under your
+ complete control.
+
+
+
+
+
+
+ Website Analytics (bottlecrm.io only)
+
+
+
+
+ Basic website analytics (page views, visitor count, referral sources)
+
+
+
+ IP addresses (anonymized and not linked to personal identity)
+
+
+
+ Browser type and device information (for optimization purposes)
+
+
+
+
Information You Provide
+
+
+
+ Contact information when you reach out for support or inquiries
+
+
+
+ Feedback and suggestions submitted through our channels
+
+
+
+ Professional service requests and consultation details
+
+
+
+
+
+
+
How We Use Your Information
+
+
+
+
What We DO Use Information For:
+
+ • Responding to support requests and inquiries
+ • Improving our website and documentation
+ • Providing professional services when requested
+ • Communicating important updates about BottleCRM
+
+
+
+
+
What We DON'T Use Information For:
+
+ • Selling or sharing with third parties
+ • Marketing campaigns or advertisements
+ • Building user profiles or tracking
+ • Any form of data monetization
+
+
+
+
+
+
+
+
+
+ Data Storage & Security
+
+
+
+
Self-Hosted Security
+
+ When you self-host BottleCRM, you are responsible for implementing appropriate security
+ measures. We provide documentation and best practices to help secure your installation.
+
+
+ • Use HTTPS/SSL encryption for web traffic
+ • Implement proper database security and access controls
+ • Regular security updates and patches
+ • Backup and disaster recovery procedures
+
+
+
+
Website Security
+
+ Our website (bottlecrm.io) is protected with industry-standard security measures including
+ SSL encryption, regular security audits, and secure hosting infrastructure.
+
+
+
+
+
+
Your Privacy Rights
+
+
+
+
Data Subject Rights (GDPR)
+
+
+
+ Right to access your personal data
+
+
+
+ Right to rectification (correction)
+
+
+
+ Right to erasure ("right to be forgotten")
+
+
+
+ Right to data portability
+
+
+
+
+
+
Self-Hosting Advantages
+
+
+
+ Complete control over data location
+
+
+
+ No third-party data sharing
+
+
+
+ Compliance with local regulations
+
+
+
+ Custom data retention policies
+
+
+
+
+
+
+
+
+
Third-Party Services
+
+
+
Website Analytics
+
+ We use privacy-focused analytics tools to understand website usage. These tools are
+ configured to respect user privacy and comply with data protection regulations.
+
+
+
+
Self-Hosted Integrations
+
+ BottleCRM may support integrations with third-party services (email providers, payment
+ processors, etc.). When you configure these integrations in your self-hosted instance, you
+ are responsible for reviewing and accepting the privacy policies of those services.
+
+
+
+
+
+
Contact Information & Policy Updates
+
+
+
Questions About This Policy?
+
+ If you have any questions about this Privacy Policy or how we handle your data, please
+ contact us:
+
+
+
+
+
+
Policy Updates
+
+ We may update this Privacy Policy from time to time to reflect changes in our practices
+ or legal requirements. When we make significant changes, we will notify users through
+ our website and GitHub repository. The "Last Updated" date at the top of this policy
+ indicates when the most recent changes were made.
+
+
+
+
+
+
+
Open Source Transparency
+
+
+
+ Complete Transparency: As an open-source project, you can inspect our entire
+ codebase to verify how data is handled, stored, and processed. This level of transparency
+ is impossible with proprietary CRM solutions.
+
+
+
+
+
+
+
+
+
+
+
+
Take Control of Your Data Privacy
+
+ Ready to switch to a CRM solution that truly respects your privacy? Self-host BottleCRM and
+ maintain complete control over your business data.
+
+
+
+
diff --git a/apps/web/src/routes/(site)/terms-of-service/+page.svelte b/apps/web/src/routes/(site)/terms-of-service/+page.svelte
new file mode 100644
index 0000000..ec89d4d
--- /dev/null
+++ b/apps/web/src/routes/(site)/terms-of-service/+page.svelte
@@ -0,0 +1,596 @@
+
+
+
+
+ Terms of Service | Free Open Source CRM - BottleCRM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@html `
+
+ `}
+
+
+
+
+
+
+ {#if mounted}
+
+
+
+ Legal Information
+
+
+
+
+ Transparent terms for our free, open-source CRM software and optional professional
+ services
+
+
+
+
+ Last updated: {lastUpdated}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {#if mounted}
+
+ {/if}
+
+
+
+
+
+
+
+
+ {#if mounted}
+
+
+
Welcome to BottleCRM
+
+ BottleCRM is a free, open-source Customer Relationship Management (CRM) software
+ designed specifically for startups and small businesses. These Terms of Service
+ ("Terms") govern your use of the BottleCRM software and any related services we
+ provide.
+
+
+
+ {/if}
+
+
+ {#if mounted}
+
+ {/if}
+
+
+ {#if mounted}
+
+ {/if}
+
+
+ {#if mounted}
+
+ {/if}
+
+
+ {#if mounted}
+
+ {/if}
+
+
+ {#if mounted}
+
+ {/if}
+
+
+ {#if mounted}
+
+ {/if}
+
+
+ {#if mounted}
+
+ {/if}
+
+
+
+
+
+
+
+ {#if mounted}
+
+
Ready to Get Started?
+
+ Now that you understand our terms, download BottleCRM and start managing your customer
+ relationships for free.
+
+
+
+ {/if}
+
+
diff --git a/apps/web/src/routes/(site)/unsubscribe/+page.server.ts b/apps/web/src/routes/(site)/unsubscribe/+page.server.ts
new file mode 100644
index 0000000..a0d9484
--- /dev/null
+++ b/apps/web/src/routes/(site)/unsubscribe/+page.server.ts
@@ -0,0 +1,81 @@
+import { fail } from '@sveltejs/kit';
+import { schema } from '@opensource-startup-crm/database';
+import { eq } from 'drizzle-orm';
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ const db = locals.db
+
+ const token = url.searchParams.get('token');
+
+ if (!token) {
+ return {
+ error: 'Invalid unsubscribe link. Please check your email for the correct link.'
+ };
+ }
+
+ try {
+ const [subscriber] = await db
+ .select()
+ .from(schema.newsletterSubscriber)
+ .where(eq(schema.newsletterSubscriber.confirmationToken, token));
+
+ if (!subscriber) {
+ return {
+ error: 'Invalid unsubscribe token. This link may have expired or already been used.'
+ };
+ }
+
+ return {
+ subscriber: {
+ email: subscriber.email,
+ token: subscriber.confirmationToken
+ }
+ };
+ } catch (error) {
+ console.error('Unsubscribe load error:', error);
+ return {
+ error: 'An error occurred while processing your request. Please try again later.'
+ };
+ }
+}
+
+export const actions: Actions = {
+ unsubscribe: async ({ request, locals }) => {
+ const db = locals.db
+
+ const formData = await request.formData();
+ const token = formData.get('token')?.toString();
+
+ if (!token) {
+ return fail(400, { message: 'Invalid unsubscribe token' });
+ }
+
+ try {
+ const [subscriber] = await db
+ .select()
+ .from(schema.newsletterSubscriber)
+ .where(eq(schema.newsletterSubscriber.confirmationToken, token));
+
+ if (!subscriber) {
+ return fail(404, { message: 'Subscriber not found or already unsubscribed' });
+ }
+
+ if (!subscriber.isActive) {
+ return { success: true, message: 'You have already been unsubscribed from our newsletter' };
+ }
+
+ // Update subscriber to inactive
+ await db
+ .update(schema.newsletterSubscriber)
+ .set({ isActive: false, unsubscribedAt: new Date() })
+ .where(eq(schema.newsletterSubscriber.confirmationToken, token));
+
+ return { success: true, message: 'Successfully unsubscribed from newsletter' };
+
+ } catch (error) {
+ console.error('Unsubscribe error:', error);
+ return fail(500, { message: 'Failed to unsubscribe. Please try again later.' });
+ }
+ }
+};
diff --git a/apps/web/src/routes/(site)/unsubscribe/+page.svelte b/apps/web/src/routes/(site)/unsubscribe/+page.svelte
new file mode 100644
index 0000000..64cbea4
--- /dev/null
+++ b/apps/web/src/routes/(site)/unsubscribe/+page.svelte
@@ -0,0 +1,122 @@
+
+
+
+ Unsubscribe - BottleCRM Newsletter
+
+
+
+
+
+
+
+
+
Newsletter Unsubscribe
+
We're sorry to see you go!
+
+
+ {#if data.error}
+
+ {:else if data.subscriber}
+
+
You are about to unsubscribe the email address:
+
+ {data.subscriber.email}
+
+
+ {#if showMessage}
+
+ {/if}
+
+
{
+ if (submitter) (submitter as HTMLButtonElement).disabled = true;
+ return async ({ result, update }) => {
+ if (result.type === 'success') {
+ message = (result.data?.message as string) || 'Successfully unsubscribed!';
+ showMessage = true;
+ isSuccess = true;
+ } else if (result.type === 'failure') {
+ message =
+ (result.data?.message as string) || 'Failed to unsubscribe. Please try again.';
+ showMessage = true;
+ isSuccess = false;
+ if (submitter) (submitter as HTMLButtonElement).disabled = false;
+ }
+ await update();
+ };
+ }}
+ bind:this={unsubscribeForm}
+ >
+
+
+
+
+ Confirm Unsubscribe
+
+
+
+
+
+
+
+
+
Before you go...
+
+ Consider adjusting your email preferences instead of unsubscribing completely. You might
+ be interested in:
+
+
+ • Product updates and new features
+ • CRM best practices and tips
+ • Weekly industry insights
+
+
+ {/if}
+
+
+
diff --git a/apps/web/src/routes/sitemap.xml/+server.ts b/apps/web/src/routes/sitemap.xml/+server.ts
new file mode 100644
index 0000000..def6422
--- /dev/null
+++ b/apps/web/src/routes/sitemap.xml/+server.ts
@@ -0,0 +1,66 @@
+import { schema } from '@opensource-startup-crm/database';
+import { dev } from '$app/environment';
+import { desc, eq } from 'drizzle-orm';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({locals}) => {
+ const db = locals.db
+
+ try {
+ // Base URL for the site (adjust for production)
+ const baseUrl = dev ? 'http://localhost:5173' : 'https://bottlecrm.io';
+
+ // Fetch all published blog posts
+ const blogPosts = await db
+ .select({ slug: schema.blogPost.slug, updatedAt: schema.blogPost.updatedAt })
+ .from(schema.blogPost)
+ .where(eq(schema.blogPost.draft, false))
+ .orderBy(desc(schema.blogPost.updatedAt));
+
+ // Define manual URLs you want to include
+ const manualUrls = [
+ { url: '/', priority: '1.0', changefreq: 'daily' },
+ { url: '/about', priority: '0.8', changefreq: 'monthly' },
+ { url: '/contact', priority: '0.8', changefreq: 'monthly' },
+ ];
+
+ // Start building the XML string
+ let xml = '\n';
+ xml += '\n';
+
+ // Add manual URLs to the sitemap
+ manualUrls.forEach((page) => {
+ xml += ' \n';
+ xml += ` ${baseUrl}${page.url} \n`;
+ xml += ` ${page.changefreq} \n`;
+ xml += ` ${page.priority} \n`;
+ xml += ' \n';
+ });
+
+ // Add blog posts to the sitemap
+ blogPosts.forEach((post) => {
+ xml += ' \n';
+ xml += ` ${baseUrl}/blog/${post.slug} \n`;
+ xml += ` ${post.updatedAt.toISOString()} \n`;
+ xml += ' weekly \n';
+ xml += ' 0.7 \n';
+ xml += ' \n';
+ });
+
+ // Close the XML string
+ xml += ' ';
+
+ // Return XML response
+ return new Response(xml, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ 'Cache-Control': 'max-age=3600'
+ }
+ });
+ } catch (error) {
+ console.error('Error generating sitemap:', error);
+ return new Response('Error generating sitemap', { status: 500 });
+ } finally {
+ // no persistent connection to close with drizzle
+ }
+}
diff --git a/apps/web/static/funding.json b/apps/web/static/funding.json
new file mode 100644
index 0000000..b7b663a
--- /dev/null
+++ b/apps/web/static/funding.json
@@ -0,0 +1,99 @@
+{
+ "version": "v1.0.0",
+
+ "entity": {
+ "type": "organisation",
+ "role": "owner",
+ "name": "MicroPyramid Informatics Private Limited",
+ "email": "hello@micropyramid.com",
+ "phone": "+91-9959166266",
+ "description": "MicroPyramid is a technology company specializing in web development, mobile applications, and SaaS solutions. We build modern, scalable software solutions for businesses of all sizes, with expertise in Python, Django, React, and other cutting-edge technologies.",
+ "webpageUrl": {
+ "url": "https://micropyramid.com",
+ "wellKnown": ""
+ }
+ },
+
+ "projects": [{
+ "guid": "startup-crm",
+ "name": "BottleCRM",
+ "description": "BottleCRM is a dynamic, SaaS CRM platform designed to streamline the entire CRM needs of startups and enterprises. Built with modern web technologies including SvelteKit, Svelte 5, and Prisma, it offers a seamless experience for users through robust role-based access control (RBAC). Each user role is equipped with tailored functionalities to enhance efficiency, engagement, and management, ensuring a streamlined and secure business process.",
+ "webpageUrl": {
+ "url": "https://bottlecrm.io",
+ "wellKnown": ""
+ },
+ "repositoryUrl": {
+ "url": "https://github.com/MicroPyramid/opensource-startup-crm",
+ "wellKnown": ""
+ },
+ "licenses": ["spdx:MIT"],
+ "tags": ["crm", "saas", "business-tools", "customer-management", "sales", "svelte", "typescript", "web-application", "enterprise", "startup"]
+ }],
+
+ "funding": {
+ "channels": [{
+ "guid": "paypal",
+ "type": "payment-provider",
+ "address": "paypal@micropyramid.com",
+ "description": "PayPal payments for international transactions"
+ }, {
+ "guid": "bank-transfer",
+ "type": "bank",
+ "address": "Contact hello@micropyramid.com for bank details",
+ "description": "Direct bank transfers for larger contributions"
+ }, {
+ "guid": "github-sponsors",
+ "type": "payment-provider",
+ "address": "https://github.com/sponsors/micropyramid",
+ "description": "GitHub Sponsors for recurring monthly support"
+ }],
+
+ "plans": [{
+ "guid": "monthly-supporter",
+ "status": "active",
+ "name": "Monthly Supporter",
+ "description": "Monthly support to help maintain and improve BottleCRM with regular updates, bug fixes, and new features.",
+ "amount": 25,
+ "currency": "USD",
+ "frequency": "monthly",
+ "channels": ["paypal", "github-sponsors"]
+ }, {
+ "guid": "yearly-backer",
+ "status": "active",
+ "name": "Yearly Backer",
+ "description": "Annual support for sustained development and maintenance of BottleCRM platform.",
+ "amount": 250,
+ "currency": "USD",
+ "frequency": "yearly",
+ "channels": ["paypal", "bank-transfer", "github-sponsors"]
+ }, {
+ "guid": "enterprise-sponsor",
+ "status": "active",
+ "name": "Enterprise Sponsor",
+ "description": "Enterprise-level sponsorship for priority support, custom features, and dedicated development resources.",
+ "amount": 1000,
+ "currency": "USD",
+ "frequency": "monthly",
+ "channels": ["bank-transfer", "paypal"]
+ }, {
+ "guid": "one-time-donation",
+ "status": "active",
+ "name": "One-time Contribution",
+ "description": "One-time donation to support BottleCRM development. Any amount is appreciated.",
+ "amount": 0,
+ "currency": "USD",
+ "frequency": "one-time",
+ "channels": ["paypal", "github-sponsors"]
+ }],
+
+ "history": [{
+ "year": 2025,
+ "income": 0,
+ "expenses": 5000,
+ "taxes": 0,
+ "currency": "USD",
+ "description": "Initial development phase with investment in infrastructure, development tools, and hosting."
+ }]
+ }
+}
+
diff --git a/apps/web/static/logo.png b/apps/web/static/logo.png
new file mode 100644
index 0000000..1433123
Binary files /dev/null and b/apps/web/static/logo.png differ
diff --git a/apps/web/static/logo_original.png b/apps/web/static/logo_original.png
new file mode 100644
index 0000000..fbac8d9
Binary files /dev/null and b/apps/web/static/logo_original.png differ
diff --git a/svelte.config.js b/apps/web/svelte.config.js
similarity index 58%
rename from svelte.config.js
rename to apps/web/svelte.config.js
index 6af2866..47a60cb 100644
--- a/svelte.config.js
+++ b/apps/web/svelte.config.js
@@ -1,4 +1,4 @@
-import adapter from '@sveltejs/adapter-node';
+import adapter from '@sveltejs/adapter-cloudflare';
const config = { kit: { adapter: adapter() } };
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..f0f4e67
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ },
+ "include": ["src", "./worker-configuration.d.ts"]
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/vite.config.js b/apps/web/vite.config.js
similarity index 100%
rename from vite.config.js
rename to apps/web/vite.config.js
diff --git a/apps/web/worker-configuration.d.ts b/apps/web/worker-configuration.d.ts
new file mode 100644
index 0000000..9887fa9
--- /dev/null
+++ b/apps/web/worker-configuration.d.ts
@@ -0,0 +1,7370 @@
+/* eslint-disable */
+// Generated by Wrangler by running `wrangler types` (hash: eded04cfd74e0a07605669271fb41406)
+// Runtime types generated with workerd@1.20250712.0 2025-07-21 nodejs_compat
+declare namespace Cloudflare {
+ interface Env {
+ ENV_TYPE: "prod" | "dev";
+ BASE_URL: string;
+ BETTER_AUTH_SECRET: string;
+ HYPERDRIVE: Hyperdrive;
+ ASSETS: Fetcher;
+ GOOGLE_CLIENT_ID?: string;
+ GOOGLE_CLIENT_SECRET?: string;
+ }
+}
+interface Env extends Cloudflare.Env { }
+type StringifyValues> = {
+ [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
+};
+declare namespace NodeJS {
+ interface ProcessEnv extends StringifyValues> { }
+}
+
+// Begin runtime types
+/*! *****************************************************************************
+Copyright (c) Cloudflare. All rights reserved.
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at http://www.apache.org/licenses/LICENSE-2.0
+THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
+WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+MERCHANTABLITY OR NON-INFRINGEMENT.
+See the Apache Version 2.0 License for specific language governing permissions
+and limitations under the License.
+***************************************************************************** */
+/* eslint-disable */
+// noinspection JSUnusedGlobalSymbols
+declare var onmessage: never;
+/**
+ * An abnormal event (called an exception) which occurs as a result of calling a method or accessing a property of a web API.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
+ */
+declare class DOMException extends Error {
+ constructor(message?: string, name?: string);
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) */
+ readonly message: string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) */
+ readonly name: string;
+ /**
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
+ */
+ readonly code: number;
+ static readonly INDEX_SIZE_ERR: number;
+ static readonly DOMSTRING_SIZE_ERR: number;
+ static readonly HIERARCHY_REQUEST_ERR: number;
+ static readonly WRONG_DOCUMENT_ERR: number;
+ static readonly INVALID_CHARACTER_ERR: number;
+ static readonly NO_DATA_ALLOWED_ERR: number;
+ static readonly NO_MODIFICATION_ALLOWED_ERR: number;
+ static readonly NOT_FOUND_ERR: number;
+ static readonly NOT_SUPPORTED_ERR: number;
+ static readonly INUSE_ATTRIBUTE_ERR: number;
+ static readonly INVALID_STATE_ERR: number;
+ static readonly SYNTAX_ERR: number;
+ static readonly INVALID_MODIFICATION_ERR: number;
+ static readonly NAMESPACE_ERR: number;
+ static readonly INVALID_ACCESS_ERR: number;
+ static readonly VALIDATION_ERR: number;
+ static readonly TYPE_MISMATCH_ERR: number;
+ static readonly SECURITY_ERR: number;
+ static readonly NETWORK_ERR: number;
+ static readonly ABORT_ERR: number;
+ static readonly URL_MISMATCH_ERR: number;
+ static readonly QUOTA_EXCEEDED_ERR: number;
+ static readonly TIMEOUT_ERR: number;
+ static readonly INVALID_NODE_TYPE_ERR: number;
+ static readonly DATA_CLONE_ERR: number;
+ get stack(): any;
+ set stack(value: any);
+}
+type WorkerGlobalScopeEventMap = {
+ fetch: FetchEvent;
+ scheduled: ScheduledEvent;
+ queue: QueueEvent;
+ unhandledrejection: PromiseRejectionEvent;
+ rejectionhandled: PromiseRejectionEvent;
+};
+declare abstract class WorkerGlobalScope extends EventTarget {
+ EventTarget: typeof EventTarget;
+}
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) */
+interface Console {
+ "assert"(condition?: boolean, ...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) */
+ clear(): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) */
+ count(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) */
+ countReset(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) */
+ debug(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) */
+ dir(item?: any, options?: any): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) */
+ dirxml(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) */
+ error(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) */
+ group(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) */
+ groupCollapsed(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) */
+ groupEnd(): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) */
+ info(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) */
+ log(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) */
+ table(tabularData?: any, properties?: string[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) */
+ time(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) */
+ timeEnd(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) */
+ timeLog(label?: string, ...data: any[]): void;
+ timeStamp(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) */
+ trace(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) */
+ warn(...data: any[]): void;
+}
+declare const console: Console;
+type BufferSource = ArrayBufferView | ArrayBuffer;
+type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array;
+declare namespace WebAssembly {
+ class CompileError extends Error {
+ constructor(message?: string);
+ }
+ class RuntimeError extends Error {
+ constructor(message?: string);
+ }
+ type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128";
+ interface GlobalDescriptor {
+ value: ValueType;
+ mutable?: boolean;
+ }
+ class Global {
+ constructor(descriptor: GlobalDescriptor, value?: any);
+ value: any;
+ valueOf(): any;
+ }
+ type ImportValue = ExportValue | number;
+ type ModuleImports = Record;
+ type Imports = Record;
+ type ExportValue = Function | Global | Memory | Table;
+ type Exports = Record;
+ class Instance {
+ constructor(module: Module, imports?: Imports);
+ readonly exports: Exports;
+ }
+ interface MemoryDescriptor {
+ initial: number;
+ maximum?: number;
+ shared?: boolean;
+ }
+ class Memory {
+ constructor(descriptor: MemoryDescriptor);
+ readonly buffer: ArrayBuffer;
+ grow(delta: number): number;
+ }
+ type ImportExportKind = "function" | "global" | "memory" | "table";
+ interface ModuleExportDescriptor {
+ kind: ImportExportKind;
+ name: string;
+ }
+ interface ModuleImportDescriptor {
+ kind: ImportExportKind;
+ module: string;
+ name: string;
+ }
+ abstract class Module {
+ static customSections(module: Module, sectionName: string): ArrayBuffer[];
+ static exports(module: Module): ModuleExportDescriptor[];
+ static imports(module: Module): ModuleImportDescriptor[];
+ }
+ type TableKind = "anyfunc" | "externref";
+ interface TableDescriptor {
+ element: TableKind;
+ initial: number;
+ maximum?: number;
+ }
+ class Table {
+ constructor(descriptor: TableDescriptor, value?: any);
+ readonly length: number;
+ get(index: number): any;
+ grow(delta: number, value?: any): number;
+ set(index: number, value?: any): void;
+ }
+ function instantiate(module: Module, imports?: Imports): Promise;
+ function validate(bytes: BufferSource): boolean;
+}
+/**
+ * This ServiceWorker API interface represents the global execution context of a service worker.
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope)
+ */
+interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
+ DOMException: typeof DOMException;
+ WorkerGlobalScope: typeof WorkerGlobalScope;
+ btoa(data: string): string;
+ atob(data: string): string;
+ setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+ setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearTimeout(timeoutId: number | null): void;
+ setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+ setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearInterval(timeoutId: number | null): void;
+ queueMicrotask(task: Function): void;
+ structuredClone(value: T, options?: StructuredSerializeOptions): T;
+ reportError(error: any): void;
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+ self: ServiceWorkerGlobalScope;
+ crypto: Crypto;
+ caches: CacheStorage;
+ scheduler: Scheduler;
+ performance: Performance;
+ Cloudflare: Cloudflare;
+ readonly origin: string;
+ Event: typeof Event;
+ ExtendableEvent: typeof ExtendableEvent;
+ CustomEvent: typeof CustomEvent;
+ PromiseRejectionEvent: typeof PromiseRejectionEvent;
+ FetchEvent: typeof FetchEvent;
+ TailEvent: typeof TailEvent;
+ TraceEvent: typeof TailEvent;
+ ScheduledEvent: typeof ScheduledEvent;
+ MessageEvent: typeof MessageEvent;
+ CloseEvent: typeof CloseEvent;
+ ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader;
+ ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader;
+ ReadableStream: typeof ReadableStream;
+ WritableStream: typeof WritableStream;
+ WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter;
+ TransformStream: typeof TransformStream;
+ ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy;
+ CountQueuingStrategy: typeof CountQueuingStrategy;
+ ErrorEvent: typeof ErrorEvent;
+ EventSource: typeof EventSource;
+ ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest;
+ ReadableStreamDefaultController: typeof ReadableStreamDefaultController;
+ ReadableByteStreamController: typeof ReadableByteStreamController;
+ WritableStreamDefaultController: typeof WritableStreamDefaultController;
+ TransformStreamDefaultController: typeof TransformStreamDefaultController;
+ CompressionStream: typeof CompressionStream;
+ DecompressionStream: typeof DecompressionStream;
+ TextEncoderStream: typeof TextEncoderStream;
+ TextDecoderStream: typeof TextDecoderStream;
+ Headers: typeof Headers;
+ Body: typeof Body;
+ Request: typeof Request;
+ Response: typeof Response;
+ WebSocket: typeof WebSocket;
+ WebSocketPair: typeof WebSocketPair;
+ WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair;
+ AbortController: typeof AbortController;
+ AbortSignal: typeof AbortSignal;
+ TextDecoder: typeof TextDecoder;
+ TextEncoder: typeof TextEncoder;
+ navigator: Navigator;
+ Navigator: typeof Navigator;
+ URL: typeof URL;
+ URLSearchParams: typeof URLSearchParams;
+ URLPattern: typeof URLPattern;
+ Blob: typeof Blob;
+ File: typeof File;
+ FormData: typeof FormData;
+ Crypto: typeof Crypto;
+ SubtleCrypto: typeof SubtleCrypto;
+ CryptoKey: typeof CryptoKey;
+ CacheStorage: typeof CacheStorage;
+ Cache: typeof Cache;
+ FixedLengthStream: typeof FixedLengthStream;
+ IdentityTransformStream: typeof IdentityTransformStream;
+ HTMLRewriter: typeof HTMLRewriter;
+}
+declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void;
+declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void;
+/**
+ * Dispatches a synthetic event event to target and returns true if either event's cancelable attribute value is false or its preventDefault() method was not invoked, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
+ */
+declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */
+declare function btoa(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */
+declare function atob(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */
+declare function clearTimeout(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */
+declare function clearInterval(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */
+declare function queueMicrotask(task: Function): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */
+declare function structuredClone(value: T, options?: StructuredSerializeOptions): T;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */
+declare function reportError(error: any): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */
+declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+declare const self: ServiceWorkerGlobalScope;
+/**
+* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
+* The Workers runtime implements the full surface of this API, but with some differences in
+* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
+* compared to those implemented in most browsers.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
+*/
+declare const crypto: Crypto;
+/**
+* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
+*/
+declare const caches: CacheStorage;
+declare const scheduler: Scheduler;
+/**
+* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
+* as well as timing of subrequests and other operations.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
+*/
+declare const performance: Performance;
+declare const Cloudflare: Cloudflare;
+declare const origin: string;
+declare const navigator: Navigator;
+interface TestController {
+}
+interface ExecutionContext {
+ waitUntil(promise: Promise): void;
+ passThroughOnException(): void;
+ props: any;
+}
+type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise;
+type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise;
+type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise;
+interface ExportedHandler {
+ fetch?: ExportedHandlerFetchHandler;
+ tail?: ExportedHandlerTailHandler;
+ trace?: ExportedHandlerTraceHandler;
+ tailStream?: ExportedHandlerTailStreamHandler;
+ scheduled?: ExportedHandlerScheduledHandler;
+ test?: ExportedHandlerTestHandler;
+ email?: EmailExportedHandler;
+ queue?: ExportedHandlerQueueHandler;
+}
+interface StructuredSerializeOptions {
+ transfer?: any[];
+}
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) */
+declare abstract class PromiseRejectionEvent extends Event {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) */
+ readonly promise: Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) */
+ readonly reason: any;
+}
+declare abstract class Navigator {
+ sendBeacon(url: string, body?: (ReadableStream | string | (ArrayBuffer | ArrayBufferView) | Blob | FormData | URLSearchParams | URLSearchParams)): boolean;
+ readonly userAgent: string;
+ readonly hardwareConcurrency: number;
+ readonly language: string;
+ readonly languages: string[];
+}
+/**
+* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
+* as well as timing of subrequests and other operations.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
+*/
+interface Performance {
+ /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancetimeorigin) */
+ readonly timeOrigin: number;
+ /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */
+ now(): number;
+}
+interface AlarmInvocationInfo {
+ readonly isRetry: boolean;
+ readonly retryCount: number;
+}
+interface Cloudflare {
+ readonly compatibilityFlags: Record;
+}
+interface DurableObject {
+ fetch(request: Request): Response | Promise;
+ alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise;
+ webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise;
+ webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise;
+ webSocketError?(ws: WebSocket, error: unknown): void | Promise;
+}
+type DurableObjectStub = Fetcher & {
+ readonly id: DurableObjectId;
+ readonly name?: string;
+};
+interface DurableObjectId {
+ toString(): string;
+ equals(other: DurableObjectId): boolean;
+ readonly name?: string;
+}
+interface DurableObjectNamespace {
+ newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId;
+ idFromName(name: string): DurableObjectId;
+ idFromString(id: string): DurableObjectId;
+ get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace;
+}
+type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high";
+interface DurableObjectNamespaceNewUniqueIdOptions {
+ jurisdiction?: DurableObjectJurisdiction;
+}
+type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me";
+interface DurableObjectNamespaceGetDurableObjectOptions {
+ locationHint?: DurableObjectLocationHint;
+}
+interface DurableObjectState {
+ waitUntil(promise: Promise): void;
+ readonly id: DurableObjectId;
+ readonly storage: DurableObjectStorage;
+ container?: Container;
+ blockConcurrencyWhile(callback: () => Promise): Promise;
+ acceptWebSocket(ws: WebSocket, tags?: string[]): void;
+ getWebSockets(tag?: string): WebSocket[];
+ setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void;
+ getWebSocketAutoResponse(): WebSocketRequestResponsePair | null;
+ getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null;
+ setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
+ getHibernatableWebSocketEventTimeout(): number | null;
+ getTags(ws: WebSocket): string[];
+ abort(reason?: string): void;
+}
+interface DurableObjectTransaction {
+ get(key: string, options?: DurableObjectGetOptions): Promise;
+ get(keys: string[], options?: DurableObjectGetOptions): Promise>;
+ list(options?: DurableObjectListOptions): Promise>;
+ put(key: string, value: T, options?: DurableObjectPutOptions): Promise;
+ put(entries: Record, options?: DurableObjectPutOptions): Promise;
+ delete(key: string, options?: DurableObjectPutOptions): Promise;
+ delete(keys: string[], options?: DurableObjectPutOptions): Promise;
+ rollback(): void;
+ getAlarm(options?: DurableObjectGetAlarmOptions): Promise;
+ setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise;
+ deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise;
+}
+interface DurableObjectStorage {
+ get(key: string, options?: DurableObjectGetOptions): Promise;
+ get(keys: string[], options?: DurableObjectGetOptions): Promise>;
+ list(options?: DurableObjectListOptions): Promise>;
+ put(key: string, value: T, options?: DurableObjectPutOptions): Promise;
+ put(entries: Record, options?: DurableObjectPutOptions): Promise;
+ delete(key: string, options?: DurableObjectPutOptions): Promise;
+ delete(keys: string[], options?: DurableObjectPutOptions): Promise;
+ deleteAll(options?: DurableObjectPutOptions): Promise;
+ transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise;
+ getAlarm(options?: DurableObjectGetAlarmOptions): Promise;
+ setAlarm(scheduledTime: number | Date, options?: DurableObjectSetAlarmOptions): Promise;
+ deleteAlarm(options?: DurableObjectSetAlarmOptions): Promise;
+ sync(): Promise;
+ sql: SqlStorage;
+ transactionSync(closure: () => T): T;
+ getCurrentBookmark(): Promise;
+ getBookmarkForTime(timestamp: number | Date): Promise;
+ onNextSessionRestoreBookmark(bookmark: string): Promise;
+}
+interface DurableObjectListOptions {
+ start?: string;
+ startAfter?: string;
+ end?: string;
+ prefix?: string;
+ reverse?: boolean;
+ limit?: number;
+ allowConcurrency?: boolean;
+ noCache?: boolean;
+}
+interface DurableObjectGetOptions {
+ allowConcurrency?: boolean;
+ noCache?: boolean;
+}
+interface DurableObjectGetAlarmOptions {
+ allowConcurrency?: boolean;
+}
+interface DurableObjectPutOptions {
+ allowConcurrency?: boolean;
+ allowUnconfirmed?: boolean;
+ noCache?: boolean;
+}
+interface DurableObjectSetAlarmOptions {
+ allowConcurrency?: boolean;
+ allowUnconfirmed?: boolean;
+}
+declare class WebSocketRequestResponsePair {
+ constructor(request: string, response: string);
+ get request(): string;
+ get response(): string;
+}
+interface AnalyticsEngineDataset {
+ writeDataPoint(event?: AnalyticsEngineDataPoint): void;
+}
+interface AnalyticsEngineDataPoint {
+ indexes?: ((ArrayBuffer | string) | null)[];
+ doubles?: number[];
+ blobs?: ((ArrayBuffer | string) | null)[];
+}
+/**
+ * An event which takes place in the DOM.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event)
+ */
+declare class Event {
+ constructor(type: string, init?: EventInit);
+ /**
+ * Returns the type of event, e.g. "click", "hashchange", or "submit".
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type)
+ */
+ get type(): string;
+ /**
+ * Returns the event's phase, which is one of NONE, CAPTURING_PHASE, AT_TARGET, and BUBBLING_PHASE.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase)
+ */
+ get eventPhase(): number;
+ /**
+ * Returns true or false depending on how event was initialized. True if event invokes listeners past a ShadowRoot node that is the root of its target, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed)
+ */
+ get composed(): boolean;
+ /**
+ * Returns true or false depending on how event was initialized. True if event goes through its target's ancestors in reverse tree order, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/bubbles)
+ */
+ get bubbles(): boolean;
+ /**
+ * Returns true or false depending on how event was initialized. Its return value does not always carry meaning, but true can indicate that part of the operation during which event was dispatched, can be canceled by invoking the preventDefault() method.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelable)
+ */
+ get cancelable(): boolean;
+ /**
+ * Returns true if preventDefault() was invoked successfully to indicate cancelation, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/defaultPrevented)
+ */
+ get defaultPrevented(): boolean;
+ /**
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/returnValue)
+ */
+ get returnValue(): boolean;
+ /**
+ * Returns the object whose event listener's callback is currently being invoked.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/currentTarget)
+ */
+ get currentTarget(): EventTarget | undefined;
+ /**
+ * Returns the object to which event is dispatched (its target).
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target)
+ */
+ get target(): EventTarget | undefined;
+ /**
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement)
+ */
+ get srcElement(): EventTarget | undefined;
+ /**
+ * Returns the event's timestamp as the number of milliseconds measured relative to the time origin.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/timeStamp)
+ */
+ get timeStamp(): number;
+ /**
+ * Returns true if event was dispatched by the user agent, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted)
+ */
+ get isTrusted(): boolean;
+ /**
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
+ */
+ get cancelBubble(): boolean;
+ /**
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
+ */
+ set cancelBubble(value: boolean);
+ /**
+ * Invoking this method prevents event from reaching any registered event listeners after the current one finishes running and, when dispatched in a tree, also prevents event from reaching any other objects.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation)
+ */
+ stopImmediatePropagation(): void;
+ /**
+ * If invoked when the cancelable attribute value is true, and while executing a listener for the event with passive set to false, signals to the operation that caused event to be dispatched that it needs to be canceled.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault)
+ */
+ preventDefault(): void;
+ /**
+ * When dispatched in a tree, invoking this method prevents event from reaching any objects other than the current object.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation)
+ */
+ stopPropagation(): void;
+ /**
+ * Returns the invocation target objects of event's path (objects on which listeners will be invoked), except for any nodes in shadow trees of which the shadow root's mode is "closed" that are not reachable from event's currentTarget.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath)
+ */
+ composedPath(): EventTarget[];
+ static readonly NONE: number;
+ static readonly CAPTURING_PHASE: number;
+ static readonly AT_TARGET: number;
+ static readonly BUBBLING_PHASE: number;
+}
+interface EventInit {
+ bubbles?: boolean;
+ cancelable?: boolean;
+ composed?: boolean;
+}
+type EventListener = (event: EventType) => void;
+interface EventListenerObject {
+ handleEvent(event: EventType): void;
+}
+type EventListenerOrEventListenerObject = EventListener | EventListenerObject;
+/**
+ * EventTarget is a DOM interface implemented by objects that can receive events and may have listeners for them.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget)
+ */
+declare class EventTarget = Record> {
+ constructor();
+ /**
+ * Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
+ *
+ * The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
+ *
+ * When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
+ *
+ * When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
+ *
+ * When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
+ *
+ * If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
+ *
+ * The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener)
+ */
+ addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void;
+ /**
+ * Removes the event listener in target's event listener list with the same type, callback, and options.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener)
+ */
+ removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void;
+ /**
+ * Dispatches a synthetic event event to target and returns true if either event's cancelable attribute value is false or its preventDefault() method was not invoked, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
+ */
+ dispatchEvent(event: EventMap[keyof EventMap]): boolean;
+}
+interface EventTargetEventListenerOptions {
+ capture?: boolean;
+}
+interface EventTargetAddEventListenerOptions {
+ capture?: boolean;
+ passive?: boolean;
+ once?: boolean;
+ signal?: AbortSignal;
+}
+interface EventTargetHandlerObject {
+ handleEvent: (event: Event) => any | undefined;
+}
+/**
+ * A controller object that allows you to abort one or more DOM requests as and when desired.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)
+ */
+declare class AbortController {
+ constructor();
+ /**
+ * Returns the AbortSignal object associated with this object.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal)
+ */
+ get signal(): AbortSignal;
+ /**
+ * Invoking this method will set this object's AbortSignal's aborted flag and signal to any observers that the associated activity is to be aborted.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort)
+ */
+ abort(reason?: any): void;
+}
+/**
+ * A signal object that allows you to communicate with a DOM request (such as a Fetch) and abort it if required via an AbortController object.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal)
+ */
+declare abstract class AbortSignal extends EventTarget {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) */
+ static abort(reason?: any): AbortSignal;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static) */
+ static timeout(delay: number): AbortSignal;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) */
+ static any(signals: AbortSignal[]): AbortSignal;
+ /**
+ * Returns true if this AbortSignal's AbortController has signaled to abort, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted)
+ */
+ get aborted(): boolean;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/reason) */
+ get reason(): any;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */
+ get onabort(): any | null;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */
+ set onabort(value: any | null);
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) */
+ throwIfAborted(): void;
+}
+interface Scheduler {
+ wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise;
+}
+interface SchedulerWaitOptions {
+ signal?: AbortSignal;
+}
+/**
+ * Extends the lifetime of the install and activate events dispatched on the global scope as part of the service worker lifecycle. This ensures that any functional events (like FetchEvent) are not dispatched until it upgrades database schemas and deletes the outdated cache entries.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent)
+ */
+declare abstract class ExtendableEvent extends Event {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) */
+ waitUntil(promise: Promise): void;
+}
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) */
+declare class CustomEvent extends Event {
+ constructor(type: string, init?: CustomEventCustomEventInit);
+ /**
+ * Returns any custom data event was created with. Typically used for synthetic events.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent/detail)
+ */
+ get detail(): T;
+}
+interface CustomEventCustomEventInit {
+ bubbles?: boolean;
+ cancelable?: boolean;
+ composed?: boolean;
+ detail?: any;
+}
+/**
+ * A file-like object of immutable, raw data. Blobs represent data that isn't necessarily in a JavaScript-native format. The File interface is based on Blob, inheriting blob functionality and expanding it to support files on the user's system.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob)
+ */
+declare class Blob {
+ constructor(type?: ((ArrayBuffer | ArrayBufferView) | string | Blob)[], options?: BlobOptions);
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */
+ get size(): number;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */
+ get type(): string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */
+ slice(start?: number, end?: number, type?: string): Blob;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/arrayBuffer) */
+ arrayBuffer(): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/bytes) */
+ bytes(): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */
+ text(): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream) */
+ stream(): ReadableStream;
+}
+interface BlobOptions {
+ type?: string;
+}
+/**
+ * Provides information about files and allows JavaScript in a web page to access their content.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File)
+ */
+declare class File extends Blob {
+ constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions);
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */
+ get name(): string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */
+ get lastModified(): number;
+}
+interface FileOptions {
+ type?: string;
+ lastModified?: number;
+}
+/**
+* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
+*/
+declare abstract class CacheStorage {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open) */
+ open(cacheName: string): Promise;
+ readonly default: Cache;
+}
+/**
+* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
+*/
+declare abstract class Cache {
+ /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#delete) */
+ delete(request: RequestInfo | URL, options?: CacheQueryOptions): Promise;
+ /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) */
+ match(request: RequestInfo | URL, options?: CacheQueryOptions): Promise;
+ /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/#put) */
+ put(request: RequestInfo | URL, response: Response): Promise;
+}
+interface CacheQueryOptions {
+ ignoreMethod?: boolean;
+}
+/**
+* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
+* The Workers runtime implements the full surface of this API, but with some differences in
+* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
+* compared to those implemented in most browsers.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
+*/
+declare abstract class Crypto {
+ /**
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle)
+ */
+ get subtle(): SubtleCrypto;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) */
+ getRandomValues(buffer: T): T;
+ /**
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/randomUUID)
+ */
+ randomUUID(): string;
+ DigestStream: typeof DigestStream;
+}
+/**
+ * This Web Crypto API interface provides a number of low-level cryptographic functions. It is accessed via the Crypto.subtle properties available in a window context (via Window.crypto).
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto)
+ */
+declare abstract class SubtleCrypto {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt) */
+ encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) */
+ decrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, cipherText: ArrayBuffer | ArrayBufferView): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign) */
+ sign(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, data: ArrayBuffer | ArrayBufferView): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify) */
+ verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) */
+ digest(algorithm: string | SubtleCryptoHashAlgorithm, data: ArrayBuffer | ArrayBufferView): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) */
+ generateKey(algorithm: string | SubtleCryptoGenerateKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey) */
+ deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) */
+ deriveBits(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, length?: number | null): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey) */
+ importKey(format: string, keyData: (ArrayBuffer | ArrayBufferView) | JsonWebKey, algorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey) */
+ exportKey(format: string, key: CryptoKey): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) */
+ wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) */
+ unwrapKey(format: string, wrappedKey: ArrayBuffer | ArrayBufferView, unwrappingKey: CryptoKey, unwrapAlgorithm: string | SubtleCryptoEncryptAlgorithm, unwrappedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise;
+ timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean;
+}
+/**
+ * The CryptoKey dictionary of the Web Crypto API represents a cryptographic key.
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey)
+ */
+declare abstract class CryptoKey {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type) */
+ readonly type: string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable) */
+ readonly extractable: boolean;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/algorithm) */
+ readonly algorithm: CryptoKeyKeyAlgorithm | CryptoKeyAesKeyAlgorithm | CryptoKeyHmacKeyAlgorithm | CryptoKeyRsaKeyAlgorithm | CryptoKeyEllipticKeyAlgorithm | CryptoKeyArbitraryKeyAlgorithm;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/usages) */
+ readonly usages: string[];
+}
+interface CryptoKeyPair {
+ publicKey: CryptoKey;
+ privateKey: CryptoKey;
+}
+interface JsonWebKey {
+ kty: string;
+ use?: string;
+ key_ops?: string[];
+ alg?: string;
+ ext?: boolean;
+ crv?: string;
+ x?: string;
+ y?: string;
+ d?: string;
+ n?: string;
+ e?: string;
+ p?: string;
+ q?: string;
+ dp?: string;
+ dq?: string;
+ qi?: string;
+ oth?: RsaOtherPrimesInfo[];
+ k?: string;
+}
+interface RsaOtherPrimesInfo {
+ r?: string;
+ d?: string;
+ t?: string;
+}
+interface SubtleCryptoDeriveKeyAlgorithm {
+ name: string;
+ salt?: (ArrayBuffer | ArrayBufferView);
+ iterations?: number;
+ hash?: (string | SubtleCryptoHashAlgorithm);
+ $public?: CryptoKey;
+ info?: (ArrayBuffer | ArrayBufferView);
+}
+interface SubtleCryptoEncryptAlgorithm {
+ name: string;
+ iv?: (ArrayBuffer | ArrayBufferView);
+ additionalData?: (ArrayBuffer | ArrayBufferView);
+ tagLength?: number;
+ counter?: (ArrayBuffer | ArrayBufferView);
+ length?: number;
+ label?: (ArrayBuffer | ArrayBufferView);
+}
+interface SubtleCryptoGenerateKeyAlgorithm {
+ name: string;
+ hash?: (string | SubtleCryptoHashAlgorithm);
+ modulusLength?: number;
+ publicExponent?: (ArrayBuffer | ArrayBufferView);
+ length?: number;
+ namedCurve?: string;
+}
+interface SubtleCryptoHashAlgorithm {
+ name: string;
+}
+interface SubtleCryptoImportKeyAlgorithm {
+ name: string;
+ hash?: (string | SubtleCryptoHashAlgorithm);
+ length?: number;
+ namedCurve?: string;
+ compressed?: boolean;
+}
+interface SubtleCryptoSignAlgorithm {
+ name: string;
+ hash?: (string | SubtleCryptoHashAlgorithm);
+ dataLength?: number;
+ saltLength?: number;
+}
+interface CryptoKeyKeyAlgorithm {
+ name: string;
+}
+interface CryptoKeyAesKeyAlgorithm {
+ name: string;
+ length: number;
+}
+interface CryptoKeyHmacKeyAlgorithm {
+ name: string;
+ hash: CryptoKeyKeyAlgorithm;
+ length: number;
+}
+interface CryptoKeyRsaKeyAlgorithm {
+ name: string;
+ modulusLength: number;
+ publicExponent: ArrayBuffer | ArrayBufferView;
+ hash?: CryptoKeyKeyAlgorithm;
+}
+interface CryptoKeyEllipticKeyAlgorithm {
+ name: string;
+ namedCurve: string;
+}
+interface CryptoKeyArbitraryKeyAlgorithm {
+ name: string;
+ hash?: CryptoKeyKeyAlgorithm;
+ namedCurve?: string;
+ length?: number;
+}
+declare class DigestStream extends WritableStream {
+ constructor(algorithm: string | SubtleCryptoHashAlgorithm);
+ readonly digest: Promise;
+ get bytesWritten(): number | bigint;
+}
+/**
+ * A decoder for a specific method, that is a specific character encoding, like utf-8, iso-8859-2, koi8, cp1261, gbk, etc. A decoder takes a stream of bytes as input and emits a stream of code points. For a more scalable, non-native library, see StringView – a C-like representation of strings based on typed arrays.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder)
+ */
+declare class TextDecoder {
+ constructor(label?: string, options?: TextDecoderConstructorOptions);
+ /**
+ * Returns the result of running encoding's decoder. The method can be invoked zero or more times with options's stream set to true, and then once without options's stream (or set to false), to process a fragmented input. If the invocation without options's stream (or set to false) has no input, it's clearest to omit both arguments.
+ *
+ * ```
+ * var string = "", decoder = new TextDecoder(encoding), buffer;
+ * while(buffer = next_chunk()) {
+ * string += decoder.decode(buffer, {stream:true});
+ * }
+ * string += decoder.decode(); // end-of-queue
+ * ```
+ *
+ * If the error mode is "fatal" and encoding's decoder returns error, throws a TypeError.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode)
+ */
+ decode(input?: (ArrayBuffer | ArrayBufferView), options?: TextDecoderDecodeOptions): string;
+ get encoding(): string;
+ get fatal(): boolean;
+ get ignoreBOM(): boolean;
+}
+/**
+ * TextEncoder takes a stream of code points as input and emits a stream of bytes. For a more scalable, non-native library, see StringView – a C-like representation of strings based on typed arrays.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder)
+ */
+declare class TextEncoder {
+ constructor();
+ /**
+ * Returns the result of running UTF-8's encoder.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode)
+ */
+ encode(input?: string): Uint8Array;
+ /**
+ * Runs the UTF-8 encoder on source, stores the result of that operation into destination, and returns the progress made as an object wherein read is the number of converted code units of source and written is the number of bytes modified in destination.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto)
+ */
+ encodeInto(input: string, buffer: ArrayBuffer | ArrayBufferView): TextEncoderEncodeIntoResult;
+ get encoding(): string;
+}
+interface TextDecoderConstructorOptions {
+ fatal: boolean;
+ ignoreBOM: boolean;
+}
+interface TextDecoderDecodeOptions {
+ stream: boolean;
+}
+interface TextEncoderEncodeIntoResult {
+ read: number;
+ written: number;
+}
+/**
+ * Events providing information related to errors in scripts or in files.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent)
+ */
+declare class ErrorEvent extends Event {
+ constructor(type: string, init?: ErrorEventErrorEventInit);
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/filename) */
+ get filename(): string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/message) */
+ get message(): string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/lineno) */
+ get lineno(): number;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/colno) */
+ get colno(): number;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/error) */
+ get error(): any;
+}
+interface ErrorEventErrorEventInit {
+ message?: string;
+ filename?: string;
+ lineno?: number;
+ colno?: number;
+ error?: any;
+}
+/**
+ * Provides a way to easily construct a set of key/value pairs representing form fields and their values, which can then be easily sent using the XMLHttpRequest.send() method. It uses the same format a form would use if the encoding type were set to "multipart/form-data".
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData)
+ */
+declare class FormData {
+ constructor();
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) */
+ append(name: string, value: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) */
+ append(name: string, value: Blob, filename?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete) */
+ delete(name: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get) */
+ get(name: string): (File | string) | null;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll) */
+ getAll(name: string): (File | string)[];
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) */
+ has(name: string): boolean;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) */
+ set(name: string, value: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) */
+ set(name: string, value: Blob, filename?: string): void;
+ /* Returns an array of key, value pairs for every entry in the list. */
+ entries(): IterableIterator<[
+ key: string,
+ value: File | string
+ ]>;
+ /* Returns a list of keys in the list. */
+ keys(): IterableIterator;
+ /* Returns a list of values in the list. */
+ values(): IterableIterator<(File | string)>;
+ forEach(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void;
+ [Symbol.iterator](): IterableIterator<[
+ key: string,
+ value: File | string
+ ]>;
+}
+interface ContentOptions {
+ html?: boolean;
+}
+declare class HTMLRewriter {
+ constructor();
+ on(selector: string, handlers: HTMLRewriterElementContentHandlers): HTMLRewriter;
+ onDocument(handlers: HTMLRewriterDocumentContentHandlers): HTMLRewriter;
+ transform(response: Response): Response;
+}
+interface HTMLRewriterElementContentHandlers {
+ element?(element: Element): void | Promise;
+ comments?(comment: Comment): void | Promise;
+ text?(element: Text): void | Promise;
+}
+interface HTMLRewriterDocumentContentHandlers {
+ doctype?(doctype: Doctype): void | Promise;
+ comments?(comment: Comment): void | Promise;
+ text?(text: Text): void | Promise;
+ end?(end: DocumentEnd): void | Promise;
+}
+interface Doctype {
+ readonly name: string | null;
+ readonly publicId: string | null;
+ readonly systemId: string | null;
+}
+interface Element {
+ tagName: string;
+ readonly attributes: IterableIterator;
+ readonly removed: boolean;
+ readonly namespaceURI: string;
+ getAttribute(name: string): string | null;
+ hasAttribute(name: string): boolean;
+ setAttribute(name: string, value: string): Element;
+ removeAttribute(name: string): Element;
+ before(content: string | ReadableStream | Response, options?: ContentOptions): Element;
+ after(content: string | ReadableStream | Response, options?: ContentOptions): Element;
+ prepend(content: string | ReadableStream | Response, options?: ContentOptions): Element;
+ append(content: string | ReadableStream | Response, options?: ContentOptions): Element;
+ replace(content: string | ReadableStream | Response, options?: ContentOptions): Element;
+ remove(): Element;
+ removeAndKeepContent(): Element;
+ setInnerContent(content: string | ReadableStream | Response, options?: ContentOptions): Element;
+ onEndTag(handler: (tag: EndTag) => void | Promise): void;
+}
+interface EndTag {
+ name: string;
+ before(content: string | ReadableStream | Response, options?: ContentOptions): EndTag;
+ after(content: string | ReadableStream | Response, options?: ContentOptions): EndTag;
+ remove(): EndTag;
+}
+interface Comment {
+ text: string;
+ readonly removed: boolean;
+ before(content: string, options?: ContentOptions): Comment;
+ after(content: string, options?: ContentOptions): Comment;
+ replace(content: string, options?: ContentOptions): Comment;
+ remove(): Comment;
+}
+interface Text {
+ readonly text: string;
+ readonly lastInTextNode: boolean;
+ readonly removed: boolean;
+ before(content: string | ReadableStream | Response, options?: ContentOptions): Text;
+ after(content: string | ReadableStream | Response, options?: ContentOptions): Text;
+ replace(content: string | ReadableStream | Response, options?: ContentOptions): Text;
+ remove(): Text;
+}
+interface DocumentEnd {
+ append(content: string, options?: ContentOptions): DocumentEnd;
+}
+/**
+ * This is the event type for fetch events dispatched on the service worker global scope. It contains information about the fetch, including the request and how the receiver will treat the response. It provides the event.respondWith() method, which allows us to provide a response to this fetch.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent)
+ */
+declare abstract class FetchEvent extends ExtendableEvent {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request) */
+ readonly request: Request;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith) */
+ respondWith(promise: Response | Promise): void;
+ passThroughOnException(): void;
+}
+type HeadersInit = Headers | Iterable> | Record;
+/**
+ * This Fetch API interface allows you to perform various actions on HTTP request and response headers. These actions include retrieving, setting, adding to, and removing. A Headers object has an associated header list, which is initially empty and consists of zero or more name and value pairs. You can add to this using methods like append() (see Examples.) In all methods of this interface, header names are matched by case-insensitive byte sequence.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers)
+ */
+declare class Headers {
+ constructor(init?: HeadersInit);
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) */
+ get(name: string): string | null;
+ getAll(name: string): string[];
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) */
+ getSetCookie(): string[];
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) */
+ has(name: string): boolean;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) */
+ set(name: string, value: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) */
+ append(name: string, value: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) */
+ delete(name: string): void;
+ forEach(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void;
+ /* Returns an iterator allowing to go through all key/value pairs contained in this object. */
+ entries(): IterableIterator<[
+ key: string,
+ value: string
+ ]>;
+ /* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */
+ keys(): IterableIterator;
+ /* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */
+ values(): IterableIterator;
+ [Symbol.iterator](): IterableIterator<[
+ key: string,
+ value: string
+ ]>;
+}
+type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData;
+declare abstract class Body {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
+ get body(): ReadableStream | null;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */
+ get bodyUsed(): boolean;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */
+ arrayBuffer(): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) */
+ bytes(): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/text) */
+ text(): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */
+ json(): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/formData) */
+ formData(): Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */
+ blob(): Promise;
+}
+/**
+ * This Fetch API interface represents the response to a request.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)
+ */
+declare var Response: {
+ prototype: Response;
+ new(body?: BodyInit | null, init?: ResponseInit): Response;
+ error(): Response;
+ redirect(url: string, status?: number): Response;
+ json(any: any, maybeInit?: (ResponseInit | Response)): Response;
+};
+/**
+ * This Fetch API interface represents the response to a request.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)
+ */
+interface Response extends Body {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/clone) */
+ clone(): Response;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/status) */
+ status: number;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/statusText) */
+ statusText: string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers) */
+ headers: Headers;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok) */
+ ok: boolean;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/redirected) */
+ redirected: boolean;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url) */
+ url: string;
+ webSocket: WebSocket | null;
+ cf: any | undefined;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type) */
+ type: "default" | "error";
+}
+interface ResponseInit {
+ status?: number;
+ statusText?: string;
+ headers?: HeadersInit;
+ cf?: any;
+ webSocket?: (WebSocket | null);
+ encodeBody?: "automatic" | "manual";
+}
+type RequestInfo> = Request | string;
+/**
+ * This Fetch API interface represents a resource request.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request)
+ */
+declare var Request: {
+ prototype: Request;
+ new >(input: RequestInfo | URL, init?: RequestInit): Request;
+};
+/**
+ * This Fetch API interface represents a resource request.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request)
+ */
+interface Request> extends Body {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) */
+ clone(): Request;
+ /**
+ * Returns request's HTTP method, which is "GET" by default.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method)
+ */
+ method: string;
+ /**
+ * Returns the URL of request as a string.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/url)
+ */
+ url: string;
+ /**
+ * Returns a Headers object consisting of the headers associated with request. Note that headers added in the network layer by the user agent will not be accounted for in this object, e.g., the "Host" header.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers)
+ */
+ headers: Headers;
+ /**
+ * Returns the redirect mode associated with request, which is a string indicating how redirects for the request will be handled during fetching. A request will follow redirects by default.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/redirect)
+ */
+ redirect: string;
+ fetcher: Fetcher | null;
+ /**
+ * Returns the signal associated with request, which is an AbortSignal object indicating whether or not request has been aborted, and its abort event handler.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal)
+ */
+ signal: AbortSignal;
+ cf: Cf | undefined;
+ /**
+ * Returns request's subresource integrity metadata, which is a cryptographic hash of the resource being fetched. Its value consists of multiple hashes separated by whitespace. [SRI]
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/integrity)
+ */
+ integrity: string;
+ /**
+ * Returns a boolean indicating whether or not request can outlive the global in which it was created.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive)
+ */
+ keepalive: boolean;
+ /**
+ * Returns the cache mode associated with request, which is a string indicating how the request will interact with the browser's cache when fetching.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache)
+ */
+ cache?: "no-store";
+}
+interface RequestInit {
+ /* A string to set request's method. */
+ method?: string;
+ /* A Headers object, an object literal, or an array of two-item arrays to set request's headers. */
+ headers?: HeadersInit;
+ /* A BodyInit object or null to set request's body. */
+ body?: BodyInit | null;
+ /* A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */
+ redirect?: string;
+ fetcher?: (Fetcher | null);
+ cf?: Cf;
+ /* A string indicating how the request will interact with the browser's cache to set request's cache. */
+ cache?: "no-store";
+ /* A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */
+ integrity?: string;
+ /* An AbortSignal to set request's signal. */
+ signal?: (AbortSignal | null);
+ encodeResponseBody?: "automatic" | "manual";
+}
+type Service = Fetcher;
+type Fetcher = (T extends Rpc.EntrypointBranded ? Rpc.Provider : unknown) & {
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+ connect(address: SocketAddress | string, options?: SocketOptions): Socket;
+};
+interface KVNamespaceListKey {
+ name: Key;
+ expiration?: number;
+ metadata?: Metadata;
+}
+type KVNamespaceListResult = {
+ list_complete: false;
+ keys: KVNamespaceListKey[];
+ cursor: string;
+ cacheStatus: string | null;
+} | {
+ list_complete: true;
+ keys: KVNamespaceListKey[];
+ cacheStatus: string | null;
+};
+interface KVNamespace {
+ get(key: Key, options?: Partial>): Promise;
+ get(key: Key, type: "text"): Promise;
+ get(key: Key, type: "json"): Promise;
+ get(key: Key, type: "arrayBuffer"): Promise;
+ get(key: Key, type: "stream"): Promise;
+ get(key: Key, options?: KVNamespaceGetOptions<"text">): Promise;
+ get(key: Key, options?: KVNamespaceGetOptions<"json">): Promise;
+ get(key: Key, options?: KVNamespaceGetOptions<"arrayBuffer">): Promise;
+ get(key: Key, options?: KVNamespaceGetOptions<"stream">): Promise;
+ get(key: Array, type: "text"): Promise>;
+ get(key: Array, type: "json"): Promise>;
+ get(key: Array, options?: Partial>): Promise>;
+ get(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>;
+ get(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>;
+ list(options?: KVNamespaceListOptions): Promise>;
+ put(key: Key, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: KVNamespacePutOptions): Promise;
+ getWithMetadata(key: Key, options?: Partial>): Promise>;
+ getWithMetadata(key: Key, type: "text"): Promise>;
+ getWithMetadata