diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a431deb --- /dev/null +++ b/.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/.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..43ea859 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ vite.config.ts.timestamp-* generated/* src/generated/* +.github/prompts/* 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..ed29b88 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,135 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +BottleCRM is a SaaS CRM platform built with SvelteKit, designed for startups and enterprises with role-based access control (RBAC). The application features multi-tenancy through organizations, with strict data isolation enforced at the database level. + +## Technology Stack + +- **Frontend**: SvelteKit 2.x with Svelte 5.x +- **Styling**: TailwindCSS 4.x +- **Database**: PostgreSQL with Prisma ORM +- **Icons**: Lucide Svelte +- **Validation**: Zod +- **Package Manager**: pnpm +- **Type Checking**: JSDoc style type annotations (no TypeScript) + +## Development Commands + +```bash +# Development server +pnpm run dev + +# Build for production +pnpm run build + +# Preview production build +pnpm run preview + +# Type checking +pnpm run check + +# Type checking with watch mode +pnpm run check:watch + +# Linting and formatting (both required to pass) +pnpm run lint + +# Format code +pnpm run format + +# Database operations +npx prisma migrate dev +npx prisma generate +npx prisma studio +``` + +## Architecture Overview + +### Multi-Tenant Structure +- **Organizations**: Top-level tenant containers with strict data isolation +- **Users**: Can belong to multiple organizations with different roles (ADMIN/USER) +- **Super Admin**: Users with @micropyramid.com email domain have platform-wide access + +### Core CRM Entities +- **Leads**: Initial prospects that can be converted to Accounts/Contacts/Opportunities +- **Accounts**: Company/organization records +- **Contacts**: Individual people associated with accounts +- **Opportunities**: Sales deals with pipeline stages +- **Tasks/Events**: Activity management +- **Cases**: Customer support tickets +- **Products/Quotes**: Sales catalog and quotation system + +### Authentication & Authorization +- Session-based authentication using cookies (`session`, `org`, `org_name`) +- Organization selection required after login via `/org` route +- Route protection in `src/hooks.server.js`: + - `/app/*` routes require authentication and organization membership + - `/admin/*` routes restricted to @micropyramid.com domain users + - `/org` route for organization selection + +### Data Access Control +- All database queries must include organization filtering +- User can only access data from organizations they belong to +- Prisma schema enforces relationships with `organizationId` foreign keys + +### Route Structure +- `(site)`: Public marketing pages +- `(no-layout)`: Auth pages (login, org selection) +- `(app)`: Main CRM application (requires auth + org membership) +- `(admin)`: Platform administration (requires @micropyramid.com email) + +### Key Files +- `src/hooks.server.js`: Authentication, org membership validation, route protection +- `src/lib/prisma.js`: Database client configuration +- `src/lib/stores/auth.js`: Authentication state management +- `prisma/schema.prisma`: Complete database schema with RBAC models + +## Form Development +- All form labels must be properly associated with form controls for accessibility +- Use Zod for form validation +- Follow existing patterns in `/contacts`, `/leads`, `/accounts` for consistency + +## Coding Standards + +### Type Safety +- **NO TypeScript**: This project uses JavaScript with JSDoc style type annotations only +- **JSDoc Comments**: Use JSDoc syntax for type information and documentation +- **Type Checking**: Use `pnpm run check` to validate types via JSDoc annotations +- **Function Parameters**: Document parameter types using JSDoc `@param` tags +- **Return Types**: Document return types using JSDoc `@returns` tags + +### JSDoc Examples +```javascript +/** + * Updates a contact in the database + * @param {string} contactId - The contact identifier + * @param {Object} updateData - The data to update + * @param {string} updateData.name - Contact name + * @param {string} updateData.email - Contact email + * @param {string} organizationId - Organization ID for data isolation + * @returns {Promise} The updated contact object + */ +async function updateContact(contactId, updateData, organizationId) { + // Implementation +} + +/** + * @typedef {Object} User + * @property {string} id - User ID + * @property {string} email - User email + * @property {string} name - User name + * @property {string[]} organizationIds - Array of organization IDs + */ + +/** @type {User|null} */ +let currentUser = null; +``` + +## Security Requirements +- Never expose cross-organization data +- Always filter queries by user's organization membership +- Validate user permissions before any data operations +- Use parameterized queries via Prisma to prevent SQL injection \ 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..c6a0b89 --- /dev/null +++ b/DEV.md @@ -0,0 +1,22 @@ +# BottleCRM + +never use $app from sveltekit. ref: https://kit.svelte.dev/docs/packaging#best-practices + +# dev +nvm use 22.13.0 +npx prisma generate +npx prisma migrate dev + +for development, run both sveltkeit and wss +npm run dev +npm run wss + +# before comitting for deployment +run `npx eslint src --ext .svelte` +run `pnpm run build` to check for errors, warnings and fix. +run `npx svelte-check` to check for errors and warnings to fix + +# prod +nvm use 22.13.0 +npx prisma generate +npx prisma migrate deploy \ No newline at end of file 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..183f077 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,214 @@ -# 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 with Prisma ORM +- **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 + +1. **Clone the repository:** ```bash -# create a new project in the current directory -npx sv create +git clone https://github.com/micropyramid/svelte-crm.git +cd svelte-crm +``` -# create a new project in my-app -npx sv create my-app +2. **Set up Node.js version:** +```bash +nvm use 22.13.0 +``` + +3. **Install dependencies:** +```bash +pnpm install ``` -## Developing +4. **Configure environment variables:** +Create a `.env` file based on the following template: +```env +# Database Configuration +DATABASE_URL="postgresql://postgres:password@localhost:5432/bottlecrm?schema=public" -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +# JWT Secret (required for authentication) +# Generate a secure secret using openssl: +# openssl rand -base64 32 +JWT_SECRET="" +# Google OAuth (Optional) +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +GOOGLE_LOGIN_DOMAIN="http://localhost:5173" +``` + +5. **Set up the database:** ```bash -npm run dev +# Generate Prisma client +npx prisma generate + +# Run database migrations +npx prisma migrate dev -# or start the server and open the app in a new browser tab -npm run dev -- --open +# (Optional) Open Prisma Studio to view data +npx prisma studio +``` + +6. **Start the development server:** +```bash +pnpm run dev ``` -## Building +### Development Workflow -To create a production version of your app: +Before committing code, ensure quality checks pass: ```bash -npm run build +# Type checking +pnpm run check + +# Linting and formatting +pnpm run lint + +# Build verification +pnpm run build +``` + +### Production Deployment + +```bash +# Set Node.js version +nvm use 22.13.0 + +# Generate Prisma client +npx prisma generate + +# Run production migrations +npx prisma migrate deploy + +# Build application +pnpm run build + +# Start production server +pnpm run 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). -You can preview the production build with `npm run preview`. +--- -> 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/api/README.md b/api/README.md new file mode 100644 index 0000000..74e4fce --- /dev/null +++ b/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/api/config/logger.js b/api/config/logger.js new file mode 100644 index 0000000..fed578c --- /dev/null +++ b/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/api/middleware/auth.js b/api/middleware/auth.js new file mode 100644 index 0000000..fd84259 --- /dev/null +++ b/api/middleware/auth.js @@ -0,0 +1,115 @@ +import jwt from 'jsonwebtoken'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +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.' }); + } + + // First verify JWT signature and decode + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Check if token exists in database and is not revoked + const dbToken = await prisma.jwtToken.findUnique({ + where: { token }, + include: { + user: { + include: { + organizations: { + include: { + organization: true + } + } + } + } + } + }); + + if (!dbToken) { + return res.status(401).json({ error: 'Invalid token. Token not found.' }); + } + + if (dbToken.isRevoked) { + return res.status(401).json({ error: 'Token has been revoked.' }); + } + + if (dbToken.expiresAt < new Date()) { + // Mark token as expired in database + await prisma.jwtToken.update({ + where: { id: dbToken.id }, + data: { isRevoked: true } + }); + return res.status(401).json({ error: 'Token has expired.' }); + } + + if (!dbToken.user) { + return res.status(401).json({ error: 'Invalid token. User not found.' }); + } + + // Update last used timestamp + await prisma.jwtToken.update({ + where: { id: dbToken.id }, + data: { lastUsedAt: new Date() } + }); + + req.user = dbToken.user; + req.userId = dbToken.user.id; + req.tokenId = dbToken.id; + next(); + } catch (error) { + if (error.name === 'JsonWebTokenError') { + return res.status(401).json({ error: 'Invalid token format.' }); + } + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token has expired.' }); + } + 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.user.organizations.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/api/middleware/errorHandler.js b/api/middleware/errorHandler.js new file mode 100644 index 0000000..b8af078 --- /dev/null +++ b/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/api/middleware/requestLogger.js b/api/middleware/requestLogger.js new file mode 100644 index 0000000..d6989d8 --- /dev/null +++ b/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/api/routes/accounts.js b/api/routes/accounts.js new file mode 100644 index 0000000..6d8f385 --- /dev/null +++ b/api/routes/accounts.js @@ -0,0 +1,137 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken, requireOrganization } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +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 prisma.account.findMany({ + where: { organizationId: req.organizationId }, + orderBy: { createdAt: 'desc' }, + include: { + owner: { + select: { id: true, firstName: true, lastName: true, email: true } + } + } + }); + + 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 prisma.account.create({ + data: { + name, + industry, + phone, + email, + website, + organizationId: req.organizationId, + ownerId: req.userId + }, + include: { + owner: { + select: { id: true, firstName: true, lastName: true, email: true } + } + } + }); + + res.status(201).json(account); + } 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/api/routes/auth.js b/api/routes/auth.js new file mode 100644 index 0000000..10cf93a --- /dev/null +++ b/api/routes/auth.js @@ -0,0 +1,323 @@ +import express from 'express'; +import jwt from 'jsonwebtoken'; +import { OAuth2Client } from 'google-auth-library'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); +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' }); + } + + let user = await prisma.user.upsert({ + where: { email: payload.email }, + update: { + profilePhoto: payload.picture, + lastLogin: new Date(), + // Update name fields if they exist in the token + ...(payload.name && { name: payload.name }) + }, + create: { + email: payload.email, + name: payload.name || `${payload.given_name || ''} ${payload.family_name || ''}`.trim(), + profilePhoto: payload.picture, + user_id: payload.sub, // Use 'sub' field which is the stable Google user ID + lastLogin: new Date() + }, + include: { + organizations: { + include: { + organization: true + } + } + } + }); + + // 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); + + // Store JWT token in database + await prisma.jwtToken.create({ + data: { + token: JWTtoken, + userId: user.id, + expiresAt: expiresAt, + deviceInfo: req.get('User-Agent'), + ipAddress: req.ip || req.socket.remoteAddress + } + }); + + // Format response to match SvelteKit patterns + const userResponse = { + id: user.id, + email: user.email, + name: user.name, + profileImage: user.profilePhoto, + }; + + res.json({ + success: true, + JWTtoken, + user: userResponse, + organizations: user.organizations.map(uo => ({ + id: uo.organization.id, + name: uo.organization.name, + role: uo.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 { + // Revoke the current token + await prisma.jwtToken.update({ + where: { id: req.tokenId }, + data: { + isRevoked: true, + updatedAt: new Date() + } + }); + + res.json({ + success: true, + message: 'Successfully logged out' + }); + } 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 { + // Revoke all tokens for the user + const result = await prisma.jwtToken.updateMany({ + where: { + userId: req.userId, + isRevoked: false + }, + data: { + isRevoked: true, + updatedAt: new Date() + } + }); + + res.json({ + success: true, + message: 'Successfully revoked all tokens', + revokedCount: result.count + }); + } 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/api/routes/contacts.js b/api/routes/contacts.js new file mode 100644 index 0000000..7d78f0f --- /dev/null +++ b/api/routes/contacts.js @@ -0,0 +1,261 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken, requireOrganization } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +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 { + const contacts = await prisma.contact.findMany({ + where: { organizationId: req.organizationId }, + orderBy: { createdAt: 'desc' }, + include: { + relatedAccounts: { + include: { + account: { + select: { id: true, name: true } + } + } + }, + owner: { + select: { id: true, name: true, email: true } + } + } + }); + + res.json({ contacts }); + } 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 + let account = null; + if (accountId) { + account = await prisma.account.findFirst({ + where: { + id: accountId, + 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 prisma.contact.findFirst({ + where: { + email: email, + 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 prisma.contact.create({ + data: { + 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 + } + }); + + // Create account-contact relationship if accountId is provided + if (accountId) { + await prisma.accountContactRelationship.create({ + data: { + accountId: accountId, + contactId: contact.id, + isPrimary: true + } + }); + } + + // Fetch the created contact with relationships + const createdContact = await prisma.contact.findUnique({ + where: { id: contact.id }, + include: { + relatedAccounts: { + include: { + account: { + select: { id: true, name: true } + } + } + }, + owner: { + select: { id: true, name: true, email: true } + } + } + }); + + res.status(201).json(createdContact); + } 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 contact = await prisma.contact.findFirst({ + where: { + id: id, + organizationId: req.organizationId + }, + include: { + relatedAccounts: { + include: { + account: { + select: { id: true, name: true, type: true, website: true, phone: true } + } + } + }, + owner: { + select: { id: true, name: true, email: true } + }, + organization: { + select: { id: true, name: true } + } + } + }); + + if (!contact) { + return res.status(404).json({ error: 'Contact not found' }); + } + + res.json(contact); + } 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/api/routes/dashboard.js b/api/routes/dashboard.js new file mode 100644 index 0000000..317ac4f --- /dev/null +++ b/api/routes/dashboard.js @@ -0,0 +1,374 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken, requireOrganization } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +/** + * @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 [ + totalLeads, + totalOpportunities, + totalAccounts, + totalContacts, + pendingTasks, + recentLeads, + recentOpportunities, + upcomingTasks, + recentActivities + ] = await Promise.all([ + // Count metrics + prisma.lead.count({ + where: { organizationId, isConverted: false } + }), + prisma.opportunity.count({ + where: { organizationId, stage: { not: 'CLOSED_WON' } } + }), + prisma.account.count({ + where: { organizationId, isActive: true } + }), + prisma.contact.count({ + where: { organizationId } + }), + prisma.task.count({ + where: { + organizationId, + status: { not: 'Completed' }, + ownerId: userId + } + }), + + // Recent data + prisma.lead.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + firstName: true, + lastName: true, + company: true, + status: true, + createdAt: true + } + }), + prisma.opportunity.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + account: { + select: { name: true } + } + } + }), + prisma.task.findMany({ + where: { + organizationId, + ownerId: userId, + status: { not: 'Completed' }, + dueDate: { gte: new Date() } + }, + orderBy: { dueDate: 'asc' }, + take: 5, + select: { + id: true, + subject: true, + status: true, + priority: true, + dueDate: true + } + }), + prisma.auditLog.findMany({ + where: { organizationId }, + orderBy: { timestamp: 'desc' }, + take: 10, + include: { + user: { + select: { name: true } + } + } + }) + ]); + + // Calculate opportunity revenue + const opportunityRevenue = await prisma.opportunity.aggregate({ + where: { organizationId }, + _sum: { amount: true } + }); + + const response = { + success: true, + metrics: { + totalLeads, + totalOpportunities, + totalAccounts, + totalContacts, + pendingTasks, + opportunityRevenue: opportunityRevenue._sum.amount || 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 [ + totalLeads, + totalOpportunities, + totalAccounts, + totalContacts, + pendingTasks, + opportunityRevenue + ] = await Promise.all([ + prisma.lead.count({ + where: { organizationId, isConverted: false } + }), + prisma.opportunity.count({ + where: { organizationId, stage: { not: 'CLOSED_WON' } } + }), + prisma.account.count({ + where: { organizationId, isActive: true } + }), + prisma.contact.count({ + where: { organizationId } + }), + prisma.task.count({ + where: { + organizationId, + status: { not: 'Completed' }, + ownerId: userId + } + }), + prisma.opportunity.aggregate({ + where: { organizationId }, + _sum: { amount: true } + }) + ]); + + const response = { + success: true, + metrics: { + totalLeads, + totalOpportunities, + totalAccounts, + totalContacts, + pendingTasks, + opportunityRevenue: opportunityRevenue._sum.amount || 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/api/routes/leads.js b/api/routes/leads.js new file mode 100644 index 0000000..72de36e --- /dev/null +++ b/api/routes/leads.js @@ -0,0 +1,511 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken, requireOrganization } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +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 clause for filtering + let whereClause = { + organizationId: req.organizationId + }; + + // Add search filter (search in firstName, lastName, email, company) + if (search) { + whereClause.OR = [ + { + firstName: { + contains: search, + mode: 'insensitive' + } + }, + { + lastName: { + contains: search, + mode: 'insensitive' + } + }, + { + email: { + contains: search, + mode: 'insensitive' + } + }, + { + company: { + contains: search, + mode: 'insensitive' + } + } + ]; + } + + // Add status filter + if (status) { + whereClause.status = status; + } + + // Add leadSource filter + if (leadSource) { + whereClause.leadSource = leadSource; + } + + // Add industry filter + if (industry) { + whereClause.industry = { + contains: industry, + mode: 'insensitive' + }; + } + + // Add rating filter + if (rating) { + whereClause.rating = rating; + } + + // Add converted filter + if (converted !== undefined) { + whereClause.isConverted = converted === 'true'; + } + + const [leads, total] = await Promise.all([ + prisma.lead.findMany({ + where: whereClause, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + owner: { + select: { id: true, name: true, email: true } + } + } + }), + prisma.lead.count({ + where: whereClause + }) + ]); + + // Calculate pagination info + const totalPages = Math.ceil(total / limit); + const hasNext = page < totalPages; + const hasPrev = page > 1; + + res.json({ + success: true, + leads, + 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 prisma.lead.findFirst({ + where: { + id: req.params.id, + organizationId: req.organizationId + }, + include: { + owner: { + select: { id: true, name: true, email: true } + } + } + }); + + if (!lead) { + return res.status(404).json({ error: 'Lead not found' }); + } + + res.json(lead); + } 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 lead = await prisma.lead.create({ + data: { + 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 + }, + include: { + owner: { + select: { id: true, name: true, email: true } + } + } + }); + + res.status(201).json(lead); + } 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/api/routes/opportunities.js b/api/routes/opportunities.js new file mode 100644 index 0000000..9c16649 --- /dev/null +++ b/api/routes/opportunities.js @@ -0,0 +1,136 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken, requireOrganization } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +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 opportunities = await prisma.opportunity.findMany({ + where: { organizationId: req.organizationId }, + orderBy: { createdAt: 'desc' }, + include: { + account: { + select: { id: true, name: true } + }, + owner: { + select: { id: true, firstName: true, lastName: true, email: true } + } + } + }); + + res.json({ opportunities }); + } 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 prisma.account.findFirst({ + where: { + id: accountId, + organizationId: req.organizationId + } + }); + + if (!account) { + return res.status(400).json({ error: 'Account not found in your organization' }); + } + } + + const opportunity = await prisma.opportunity.create({ + data: { + name, + amount: parseFloat(amount), + closeDate: new Date(closeDate), + stage, + accountId, + organizationId: req.organizationId, + ownerId: req.userId + }, + include: { + account: { + select: { id: true, name: true } + }, + owner: { + select: { id: true, firstName: true, lastName: true, email: true } + } + } + }); + + res.status(201).json(opportunity); + } 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/api/routes/organizations.js b/api/routes/organizations.js new file mode 100644 index 0000000..5fcdaff --- /dev/null +++ b/api/routes/organizations.js @@ -0,0 +1,390 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +/** + * @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 + const [organizations, totalCount] = await Promise.all([ + prisma.organization.findMany({ + where: whereClause, + include: { + users: { + where: { + userId: userId + }, + select: { + role: true, + joinedAt: true + } + } + }, + orderBy: { + name: 'asc' + }, + skip: skip, + take: limitNum + }), + prisma.organization.count({ + where: whereClause + }) + ]); + + // 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.users[0]?.role || 'USER', + joinedAt: org.users[0]?.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 + const existingOrg = await prisma.organization.findFirst({ + where: { + name: { + equals: name.trim(), + mode: 'insensitive' + } + } + }); + + if (existingOrg) { + 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 and add user as admin + const organization = await prisma.organization.create({ + data: { + name: name.trim(), + domain: domain?.trim() || null, + logo: logo?.trim() || null, + website: website?.trim() || null, + industry: industry?.trim() || null, + description: description?.trim() || null, + users: { + create: { + userId: userId, + role: 'ADMIN' + } + } + }, + include: { + users: { + where: { + userId: userId + }, + select: { + role: true, + joinedAt: true + } + } + } + }); + + // 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.users[0]?.role || 'ADMIN', + joinedAt: organization.users[0]?.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/api/routes/tasks.js b/api/routes/tasks.js new file mode 100644 index 0000000..057c090 --- /dev/null +++ b/api/routes/tasks.js @@ -0,0 +1,863 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { verifyToken, requireOrganization } from '../middleware/auth.js'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +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 }) + }; + + const tasks = await prisma.task.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: parseInt(limit), + skip: parseInt(offset), + include: { + owner: { + select: { id: true, name: true, email: true } + }, + createdBy: { + select: { id: true, name: true, email: true } + }, + account: { + select: { id: true, name: true, type: true } + }, + contact: { + select: { id: true, firstName: true, lastName: true, email: true } + }, + lead: { + select: { id: true, firstName: true, lastName: true, email: true, company: true } + }, + opportunity: { + select: { id: true, name: true, amount: true, status: true } + }, + case: { + select: { id: true, caseNumber: true, subject: true, status: true } + } + } + }); + + // Get total count for pagination + const totalCount = await prisma.task.count({ where }); + + res.json({ + tasks, + pagination: { + total: totalCount, + limit: parseInt(limit), + offset: parseInt(offset), + hasMore: parseInt(offset) + parseInt(limit) < totalCount + } + }); + } 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 userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: finalOwnerId, + organizationId: req.organizationId + }, + include: { + user: true + } + }); + + if (!userOrg) { + return res.status(400).json({ error: 'Owner must be a member of this organization' }); + } + + // Validate related entities if provided + if (accountId) { + const account = await prisma.account.findFirst({ + where: { + id: accountId, + organizationId: req.organizationId + } + }); + if (!account) { + return res.status(400).json({ error: 'Account not found in your organization' }); + } + } + + if (contactId) { + const contact = await prisma.contact.findFirst({ + where: { + id: contactId, + organizationId: req.organizationId + } + }); + if (!contact) { + return res.status(400).json({ error: 'Contact not found in your organization' }); + } + } + + if (leadId) { + const lead = await prisma.lead.findFirst({ + where: { + id: leadId, + organizationId: req.organizationId + } + }); + if (!lead) { + return res.status(400).json({ error: 'Lead not found in your organization' }); + } + } + + if (opportunityId) { + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: opportunityId, + organizationId: req.organizationId + } + }); + if (!opportunity) { + return res.status(400).json({ error: 'Opportunity not found in your organization' }); + } + } + + if (caseId) { + const caseRecord = await prisma.case.findFirst({ + where: { + id: caseId, + organizationId: req.organizationId + } + }); + if (!caseRecord) { + return res.status(400).json({ error: 'Case not found in your organization' }); + } + } + + // Create the task + const task = await prisma.task.create({ + data: { + 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 + }, + include: { + owner: { + select: { id: true, name: true, email: true } + }, + createdBy: { + select: { id: true, name: true, email: true } + }, + account: { + select: { id: true, name: true, type: true } + }, + contact: { + select: { id: true, firstName: true, lastName: true, email: true } + }, + lead: { + select: { id: true, firstName: true, lastName: true, email: true, company: true } + }, + opportunity: { + select: { id: true, name: true, amount: true, status: true } + }, + case: { + select: { id: true, caseNumber: true, subject: true, status: true } + } + } + }); + + res.status(201).json(task); + } 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 task = await prisma.task.findFirst({ + where: { + id: id, + organizationId: req.organizationId + }, + include: { + owner: { + select: { id: true, name: true, email: true } + }, + createdBy: { + select: { id: true, name: true, email: true } + }, + account: { + select: { id: true, name: true, type: true, website: true, phone: true } + }, + contact: { + select: { id: true, firstName: true, lastName: true, email: true, phone: true, title: true } + }, + lead: { + select: { id: true, firstName: true, lastName: true, email: true, phone: true, company: true, status: true } + }, + opportunity: { + select: { id: true, name: true, amount: true, status: true, stage: true, closeDate: true } + }, + case: { + select: { id: true, caseNumber: true, subject: true, status: true, priority: true } + }, + comments: { + include: { + author: { + select: { id: true, name: true, email: true } + } + }, + orderBy: { createdAt: 'desc' } + }, + organization: { + select: { id: true, name: true } + } + } + }); + + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + res.json(task); + } 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 prisma.task.findFirst({ + where: { + id: id, + organizationId: req.organizationId + } + }); + + 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 userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: ownerId, + organizationId: req.organizationId + } + }); + + if (!userOrg) { + 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 prisma.account.findFirst({ + where: { + id: accountId, + organizationId: req.organizationId + } + }); + if (!account) { + return res.status(400).json({ error: 'Account not found in your organization' }); + } + } + } + + if (contactId !== undefined) { + if (contactId) { + const contact = await prisma.contact.findFirst({ + where: { + id: contactId, + organizationId: req.organizationId + } + }); + if (!contact) { + return res.status(400).json({ error: 'Contact not found in your organization' }); + } + } + } + + if (leadId !== undefined) { + if (leadId) { + const lead = await prisma.lead.findFirst({ + where: { + id: leadId, + organizationId: req.organizationId + } + }); + if (!lead) { + return res.status(400).json({ error: 'Lead not found in your organization' }); + } + } + } + + if (opportunityId !== undefined) { + if (opportunityId) { + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: opportunityId, + organizationId: req.organizationId + } + }); + if (!opportunity) { + return res.status(400).json({ error: 'Opportunity not found in your organization' }); + } + } + } + + if (caseId !== undefined) { + if (caseId) { + const caseRecord = await prisma.case.findFirst({ + where: { + id: caseId, + organizationId: req.organizationId + } + }); + 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 task = await prisma.task.update({ + where: { id: id }, + data: updateData, + include: { + owner: { + select: { id: true, name: true, email: true } + }, + createdBy: { + select: { id: true, name: true, email: true } + }, + account: { + select: { id: true, name: true, type: true } + }, + contact: { + select: { id: true, firstName: true, lastName: true, email: true } + }, + lead: { + select: { id: true, firstName: true, lastName: true, email: true, company: true } + }, + opportunity: { + select: { id: true, name: true, amount: true, status: true } + }, + case: { + select: { id: true, caseNumber: true, subject: true, status: true } + } + } + }); + + res.json(task); + } 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 prisma.task.findFirst({ + where: { + id: id, + organizationId: req.organizationId + } + }); + + 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 prisma.task.delete({ + where: { 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 prisma.task.findFirst({ + where: { + id: id, + organizationId: req.organizationId + } + }); + + if (!existingTask) { + return res.status(404).json({ error: 'Task not found' }); + } + + // Create the comment + const comment = await prisma.comment.create({ + data: { + body, + isPrivate, + authorId: req.userId, + organizationId: req.organizationId, + taskId: id + }, + include: { + author: { + select: { id: true, name: true, email: true } + } + } + }); + + res.status(201).json(comment); + } catch (error) { + console.error('Add task comment error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/package.json b/package.json index bb48261..8b94524 100644 --- a/package.json +++ b/package.json @@ -11,31 +11,40 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", "format": "prettier --write .", - "lint": "prettier --check . && eslint ." + "lint": "prettier --check . && eslint .", + "api:dev": "nodemon server.js", + "api:start": "node server.js" }, "devDependencies": { - "@eslint/compat": "^1.2.5", - "@eslint/js": "^9.18.0", - "@sveltejs/adapter-node": "^5.2.12", - "@sveltejs/kit": "^2.20.4", - "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@eslint/compat": "^1.3.1", + "@eslint/js": "^9.31.0", + "@sveltejs/adapter-node": "^5.2.13", + "@sveltejs/kit": "^2.25.2", + "@sveltejs/vite-plugin-svelte": "^6.1.0", "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.2", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-svelte": "^3.0.0", - "flowbite": "^3.1.2", - "flowbite-svelte": "^0.48.4", - "globals": "^16.0.0", - "prettier": "^3.4.2", - "prettier-plugin-svelte": "^3.3.3", - "prettier-plugin-tailwindcss": "^0.6.11", - "prisma": "6.5.0", - "svelte": "^5.25.6", - "svelte-check": "^4.1.2", - "tailwindcss": "^4.1.2", - "typescript": "^5.0.0", - "vite": "^6.2.5" + "@tailwindcss/vite": "^4.1.11", + "@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", + "eslint": "^9.31.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.11.0", + "globals": "^16.3.0", + "nodemon": "^3.1.10", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-tailwindcss": "^0.6.14", + "prisma": "6.12.0", + "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": [ @@ -43,13 +52,27 @@ ] }, "dependencies": { - "@fortawesome/free-brands-svg-icons": "^6.7.2", - "@fortawesome/free-solid-svg-icons": "^6.7.2", - "@prisma/client": "6.5.0", - "axios": "^1.8.4", - "flowbite-svelte-blocks": "^1.1.4", - "flowbite-svelte-icons": "^2.1.0", - "svelte-fa": "^4.0.3", - "uuid": "^11.1.0" + "@lucide/svelte": "^0.525.0", + "@prisma/client": "6.12.0", + "axios": "^1.11.0", + "bcryptjs": "^3.0.2", + "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", + "jsonwebtoken": "^9.0.2", + "libphonenumber-js": "^1.12.10", + "marked": "^16.1.1", + "morgan": "^1.10.1", + "svelte-highlight": "^7.8.3", + "svelte-meta-tags": "^4.4.0", + "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/pnpm-lock.yaml b/pnpm-lock.yaml index 920d0e0..146bea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,97 +8,160 @@ importers: .: dependencies: - '@fortawesome/free-brands-svg-icons': - specifier: ^6.7.2 - version: 6.7.2 - '@fortawesome/free-solid-svg-icons': - specifier: ^6.7.2 - version: 6.7.2 + '@lucide/svelte': + specifier: ^0.525.0 + version: 0.525.0(svelte@5.36.14) '@prisma/client': - specifier: 6.5.0 - version: 6.5.0(prisma@6.5.0(typescript@5.8.3))(typescript@5.8.3) + specifier: 6.12.0 + version: 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3) axios: - specifier: ^1.8.4 - version: 1.8.4 - flowbite-svelte-blocks: - specifier: ^1.1.4 - version: 1.1.4(rollup@4.39.0)(svelte@5.25.12) - flowbite-svelte-icons: - specifier: ^2.1.0 - version: 2.1.1(svelte@5.25.12)(tailwind-merge@3.2.0) - svelte-fa: - specifier: ^4.0.3 - version: 4.0.3(svelte@5.25.12) + specifier: ^1.11.0 + version: 1.11.0 + bcryptjs: + specifier: ^3.0.2 + version: 3.0.2 + 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 + 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: '@eslint/compat': - specifier: ^1.2.5 - version: 1.2.8(eslint@9.24.0(jiti@2.4.2)) + specifier: ^1.3.1 + version: 1.3.1(eslint@9.31.0(jiti@2.4.2)) '@eslint/js': - specifier: ^9.18.0 - version: 9.24.0 + specifier: ^9.31.0 + version: 9.31.0 '@sveltejs/adapter-node': - specifier: ^5.2.12 - version: 5.2.12(@sveltejs/kit@2.20.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2))) + 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.20.4 - version: 2.20.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)) + 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: ^5.0.3 - version: 5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)) + 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.3) + version: 0.5.16(tailwindcss@4.1.11) '@tailwindcss/vite': - specifier: ^4.1.2 - version: 4.1.3(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)) + 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 eslint: - specifier: ^9.18.0 - version: 9.24.0(jiti@2.4.2) + specifier: ^9.31.0 + version: 9.31.0(jiti@2.4.2) eslint-config-prettier: - specifier: ^10.0.1 - version: 10.1.2(eslint@9.24.0(jiti@2.4.2)) + specifier: ^10.1.8 + version: 10.1.8(eslint@9.31.0(jiti@2.4.2)) eslint-plugin-svelte: - specifier: ^3.0.0 - version: 3.5.1(eslint@9.24.0(jiti@2.4.2))(svelte@5.25.12) - flowbite: - specifier: ^3.1.2 - version: 3.1.2(rollup@4.39.0) - flowbite-svelte: - specifier: ^0.48.4 - version: 0.48.6(rollup@4.39.0)(svelte@5.25.12) + specifier: ^3.11.0 + version: 3.11.0(eslint@9.31.0(jiti@2.4.2))(svelte@5.36.14) globals: - specifier: ^16.0.0 - version: 16.0.0 + specifier: ^16.3.0 + version: 16.3.0 + nodemon: + specifier: ^3.1.10 + version: 3.1.10 prettier: - specifier: ^3.4.2 - version: 3.5.3 + specifier: ^3.6.2 + version: 3.6.2 prettier-plugin-svelte: - specifier: ^3.3.3 - version: 3.3.3(prettier@3.5.3)(svelte@5.25.12) + specifier: ^3.4.0 + version: 3.4.0(prettier@3.6.2)(svelte@5.36.14) prettier-plugin-tailwindcss: - specifier: ^0.6.11 - version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.25.12))(prettier@3.5.3) + 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.5.0 - version: 6.5.0(typescript@5.8.3) + specifier: 6.12.0 + version: 6.12.0(typescript@5.8.3) svelte: - specifier: ^5.25.6 - version: 5.25.12 + specifier: ^5.36.14 + version: 5.36.14 svelte-check: - specifier: ^4.1.2 - version: 4.1.5(picomatch@4.0.2)(svelte@5.25.12)(typescript@5.8.3) + 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.2 - version: 4.1.3 + specifier: ^4.1.11 + version: 4.1.11 typescript: - specifier: ^5.0.0 + specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^6.2.5 - version: 6.2.6(jiti@2.4.2)(lightningcss@1.29.2) + specifier: ^7.0.5 + version: 7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1) packages: @@ -106,6 +169,28 @@ packages: 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' + + '@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==} + '@esbuild/aix-ppc64@0.25.2': resolution: {integrity: sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==} engines: {node: '>=18'} @@ -256,8 +341,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.5.1': - resolution: {integrity: sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==} + '@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 @@ -266,68 +351,47 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/compat@1.2.8': - resolution: {integrity: sha512-LqCYHdWL/QqKIJuZ/ucMAv8d4luKGs4oCPgpt8mWztQAtPrHfXKQ/XAUc8ljCHAfJCn6SvkpTcGt5Tsh8saowA==} + '@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: ^9.10.0 + eslint: ^8.40 || 9 peerDependenciesMeta: eslint: optional: true - '@eslint/config-array@0.20.0': - resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==} + '@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.2.1': - resolution: {integrity: sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==} + '@eslint/config-helpers@0.3.0': + resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.12.0': - resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==} + '@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.13.0': - resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==} + '@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.24.0': - resolution: {integrity: sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==} + '@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.2.8': - resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} + '@eslint/plugin-kit@0.3.1': + resolution: {integrity: sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.6.9': - resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} - - '@floating-ui/dom@1.6.13': - resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==} - - '@floating-ui/utils@0.2.9': - resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} - - '@fortawesome/fontawesome-common-types@6.7.2': - resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==} - engines: {node: '>=6'} - - '@fortawesome/free-brands-svg-icons@6.7.2': - resolution: {integrity: sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==} - engines: {node: '>=6'} - - '@fortawesome/free-solid-svg-icons@6.7.2': - resolution: {integrity: sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==} - engines: {node: '>=6'} - '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -348,6 +412,10 @@ packages: 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.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -366,14 +434,19 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@lucide/svelte@0.525.0': + resolution: {integrity: sha512-dyUxkXzepagLUzL8jHQNdeH286nC66ClLACsg+Neu/bjkRJWPWMzkT+H0DKlE70QdkicGCfs1ZGmXCc351hmZA==} + peerDependencies: + svelte: ^5 + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - - '@prisma/client@6.5.0': - resolution: {integrity: sha512-M6w1Ql/BeiGoZmhMdAZUXHu5sz5HubyVcKukbLs3l0ELcQb8hTUJxtGEChhv4SVJ0QJlwtLnwOLgIRQhpsm9dw==} + '@prisma/client@6.12.0': + resolution: {integrity: sha512-wn98bJ3Cj6edlF4jjpgXwbnQIo/fQLqqQHPk2POrZPxTlhY3+n90SSIF3LMRVa8VzRFC/Gec3YKJRxRu+AIGVA==} engines: {node: '>=18.18'} peerDependencies: prisma: '*' @@ -384,23 +457,23 @@ packages: typescript: optional: true - '@prisma/config@6.5.0': - resolution: {integrity: sha512-sOH/2Go9Zer67DNFLZk6pYOHj+rumSb0VILgltkoxOjYnlLqUpHPAN826vnx8HigqnOCxj9LRhT6U7uLiIIWgw==} + '@prisma/config@6.12.0': + resolution: {integrity: sha512-HovZWzhWEMedHxmjefQBRZa40P81N7/+74khKFz9e1AFjakcIQdXgMWKgt20HaACzY+d1LRBC+L4tiz71t9fkg==} - '@prisma/debug@6.5.0': - resolution: {integrity: sha512-fc/nusYBlJMzDmDepdUtH9aBsJrda2JNErP9AzuHbgUEQY0/9zQYZdNlXmKoIWENtio+qarPNe/+DQtrX5kMcQ==} + '@prisma/debug@6.12.0': + resolution: {integrity: sha512-plbz6z72orcqr0eeio7zgUrZj5EudZUpAeWkFTA/DDdXEj28YHDXuiakvR6S7sD6tZi+jiwQEJAPeV6J6m/tEQ==} - '@prisma/engines-version@6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60': - resolution: {integrity: sha512-iK3EmiVGFDCmXjSpdsKGNqy9hOdLnvYBrJB61far/oP03hlIxrb04OWmDjNTwtmZ3UZdA5MCvI+f+3k2jPTflQ==} + '@prisma/engines-version@6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc': + resolution: {integrity: sha512-70vhecxBJlRr06VfahDzk9ow4k1HIaSfVUT3X0/kZoHCMl9zbabut4gEXAyzJZxaCGi5igAA7SyyfBI//mmkbQ==} - '@prisma/engines@6.5.0': - resolution: {integrity: sha512-FVPQYHgOllJklN9DUyujXvh3hFJCY0NX86sDmBErLvoZjy2OXGiZ5FNf3J/C4/RZZmCypZBYpBKEhx7b7rEsdw==} + '@prisma/engines@6.12.0': + resolution: {integrity: sha512-4BRZZUaAuB4p0XhTauxelvFs7IllhPmNLvmla0bO1nkECs8n/o1pUvAVbQ/VOrZR5DnF4HED0PrGai+rIOVePA==} - '@prisma/fetch-engine@6.5.0': - resolution: {integrity: sha512-3LhYA+FXP6pqY8FLHCjewyE8pGXXJ7BxZw2rhPq+CZAhvflVzq4K8Qly3OrmOkn6wGlz79nyLQdknyCG2HBTuA==} + '@prisma/fetch-engine@6.12.0': + resolution: {integrity: sha512-EamoiwrK46rpWaEbLX9aqKDPOd8IyLnZAkiYXFNuq0YsU0Z8K09/rH8S7feOWAVJ3xzeSgcEJtBlVDrajM9Sag==} - '@prisma/get-platform@6.5.0': - resolution: {integrity: sha512-xYcvyJwNMg2eDptBYFqFLUCfgi+wZLcj6HDMsj0Qw0irvauG4IKmkbywnqwok0B+k+W+p+jThM2DKTSmoPCkzw==} + '@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==} @@ -420,15 +493,6 @@ packages: rollup: optional: true - '@rollup/plugin-node-resolve@15.3.1': - resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.78.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'} @@ -452,206 +516,321 @@ packages: 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==} + '@sveltejs/acorn-typescript@1.0.5': resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==} peerDependencies: acorn: ^8.9.0 - '@sveltejs/adapter-node@5.2.12': - resolution: {integrity: sha512-0bp4Yb3jKIEcZWVcJC/L1xXp9zzJS4hDwfb4VITAkfT4OVdkspSHsx7YhqJDbb2hgLl6R9Vs7VQR+fqIVOxPUQ==} + '@sveltejs/adapter-node@5.2.13': + resolution: {integrity: sha512-yS2TVFmIrxjGhYaV5/iIUrJ3mJl6zjaYn0lBD70vTLnYvJeqf3cjvLXeXCUCuYinhSBoyF4DpfGla49BnIy7sQ==} peerDependencies: '@sveltejs/kit': ^2.4.0 - '@sveltejs/kit@2.20.5': - resolution: {integrity: sha512-zT/97KvVUo19jEGZa972ls7KICjPCB53j54TVxnEFT5VEwL16G+YFqRVwJbfxh7AmS7/Ptr1rKF7Qt4FBMDNlw==} + '@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 + '@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 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 - '@sveltejs/vite-plugin-svelte-inspector@4.0.1': - resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22} + '@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': ^5.0.0 + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 svelte: ^5.0.0 - vite: ^6.0.0 + vite: ^6.3.0 || ^7.0.0 - '@sveltejs/vite-plugin-svelte@5.0.3': - resolution: {integrity: sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22} + '@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.0.0 + vite: ^6.3.0 || ^7.0.0 - '@tailwindcss/node@4.1.3': - resolution: {integrity: sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==} + '@tailwindcss/node@4.1.11': + resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==} - '@tailwindcss/oxide-android-arm64@4.1.3': - resolution: {integrity: sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==} + '@tailwindcss/oxide-android-arm64@4.1.11': + resolution: {integrity: sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.3': - resolution: {integrity: sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==} + '@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.3': - resolution: {integrity: sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==} + '@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.3': - resolution: {integrity: sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==} + '@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.3': - resolution: {integrity: sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==} + '@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.3': - resolution: {integrity: sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==} + '@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.3': - resolution: {integrity: sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==} + '@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.3': - resolution: {integrity: sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==} + '@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.3': - resolution: {integrity: sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==} + '@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-win32-arm64-msvc@4.1.3': - resolution: {integrity: sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==} + '@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.3': - resolution: {integrity: sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==} + '@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.3': - resolution: {integrity: sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==} + '@tailwindcss/oxide@4.1.11': + resolution: {integrity: sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==} engines: {node: '>= 10'} '@tailwindcss/typography@0.5.16': @@ -659,25 +838,87 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.1.3': - resolution: {integrity: sha512-lUI/QaDxLtlV52Lho6pu07CG9pSnRYLOPmKGIQjyHdTBagemc6HmgZxyjGAQ/5HMPrNeWBfTVIpQl0/jLXvWHQ==} + '@tailwindcss/vite@4.1.11': + resolution: {integrity: sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==} peerDependencies: - vite: ^5.2.0 || ^6 + 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/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/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==} - '@yr/monotone-cubic-spline@1.0.3': - resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} + '@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==} @@ -689,6 +930,15 @@ packages: 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==} @@ -696,8 +946,9 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - apexcharts@3.54.1: - resolution: {integrity: sha512-E4et0h/J1U3r3EwS/WlqJCQIbepKbp6wGUmaAwJOMjHUP4Ci0gxanLa7FR3okx6p9coi4st6J853/Cb1NP0vpA==} + 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==} @@ -706,11 +957,14 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.8.4: - resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -719,13 +973,53 @@ packages: 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 + + 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'} + + 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'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + 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'} @@ -734,35 +1028,86 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + 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@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + 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@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==} + 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'} + + 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'} @@ -772,8 +1117,23 @@ packages: engines: {node: '>=4'} hasBin: true - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + 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: '*' @@ -792,17 +1152,42 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + 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@17.2.1: + resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} + engines: {node: '>=12'} + 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==} + + 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'} + enhanced-resolve@5.18.1: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} @@ -823,28 +1208,26 @@ packages: 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.25.2: resolution: {integrity: sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==} engines: {node: '>=18'} hasBin: true + 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.2: - resolution: {integrity: sha512-Epgp/EofAUeEpIdZkW60MHKvPyru1ruQJxPL+WIycnaPApuseK0Zpkrh/FwL9oIpQvIhJwV7ptOy0DWUjTlCiA==} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' - eslint-plugin-svelte@3.5.1: - resolution: {integrity: sha512-Qn1slddZHfqYiDO6IN8/iN3YL+VuHlgYjm30FT+hh0Jf/TX0jeZMTJXQMajFm5f6f6hURi+XO8P+NPYD+T4jkg==} + 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 @@ -857,6 +1240,10 @@ packages: 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} @@ -865,8 +1252,12 @@ packages: resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.24.0: - resolution: {integrity: sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==} + 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: @@ -882,12 +1273,16 @@ packages: 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@1.4.6: - resolution: {integrity: sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==} + esrap@2.1.0: + resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -904,6 +1299,23 @@ packages: 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'} + + 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==} @@ -913,18 +1325,33 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fdir@6.4.3: - resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} + 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'} + 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'} @@ -936,31 +1363,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - flowbite-datepicker@1.3.2: - resolution: {integrity: sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==} - - flowbite-svelte-blocks@1.1.4: - resolution: {integrity: sha512-+5CuefgXKmAGvnI3AqG5wKTQme2zum/DRrVeYtZ1rM6+t91e1aWDKvM358ij7irdjNLQ619ezjHqqJYKQPWZWg==} - engines: {node: '>=16.0.0', npm: '>=7.0.0'} - peerDependencies: - svelte: ^4.0.0 || ^5.0.0 - - flowbite-svelte-icons@2.1.1: - resolution: {integrity: sha512-VNNMcekjbM1bQEGgbdGsdYR9mRdTj/L0A5ba0P1tiFv5QB9GvbvJMABJoiD80eqpZUkfR2QVOmiZfgCwHicT/Q==} - peerDependencies: - svelte: ^5.0.0 - tailwind-merge: ^3.0.0 - - flowbite-svelte@0.48.6: - resolution: {integrity: sha512-/PmeR3ipHHvda8vVY9MZlymaRoJsk8VddEeoLzIygfYwJV68ey8gHuQPC1dq9J6NDCTE5+xOPtBiYUtVjCfvZw==} - peerDependencies: - svelte: ^3.55.1 || ^4.0.0 || ^5.0.0 - - flowbite@2.5.2: - resolution: {integrity: sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==} - - flowbite@3.1.2: - resolution: {integrity: sha512-MkwSgbbybCYgMC+go6Da5idEKUFfMqc/AmSjm/2ZbdmvoKf5frLPq/eIhXc9P+rC8t9boZtUXzHDgt5whZ6A/Q==} + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} follow-redirects@1.15.9: resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} @@ -971,10 +1375,25 @@ packages: debug: optional: true - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + 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.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} @@ -983,6 +1402,14 @@ packages: 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'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -991,18 +1418,34 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + 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.0.0: - resolution: {integrity: sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==} + 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'} @@ -1010,6 +1453,14 @@ packages: 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'} @@ -1026,6 +1477,29 @@ packages: 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'} + + 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'} @@ -1034,13 +1508,32 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-meta-resolve@4.1.0: - resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} - 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==} + + 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'} @@ -1056,12 +1549,23 @@ packages: 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==} @@ -1073,6 +1577,9 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} 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==} @@ -1082,6 +1589,22 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + 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==} @@ -1089,75 +1612,81 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - known-css-properties@0.35.0: - resolution: {integrity: sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==} + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-darwin-arm64@1.29.2: - resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==} + 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.29.2: - resolution: {integrity: sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==} + 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.29.2: - resolution: {integrity: sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==} + 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.29.2: - resolution: {integrity: sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==} + 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.29.2: - resolution: {integrity: sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==} + 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.29.2: - resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} + 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.29.2: - resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} + 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.29.2: - resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} + 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.29.2: - resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} + 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.29.2: - resolution: {integrity: sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==} + 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.29.2: - resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==} + lightningcss@1.30.1: + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} lilconfig@2.1.0: @@ -1174,34 +1703,101 @@ packages: 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==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + 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'} - mini-svg-data-uri@1.4.4: - resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} - hasBin: true + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.0.2: + resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} + engines: {node: '>= 18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + 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'} @@ -1210,6 +1806,9 @@ packages: 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==} @@ -1221,6 +1820,57 @@ packages: 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-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@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + 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'} + + 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'} + + 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'} @@ -1237,10 +1887,18 @@ packages: 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'} @@ -1248,9 +1906,17 @@ packages: 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'} + 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'} @@ -1291,21 +1957,27 @@ packages: 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} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-plugin-svelte@3.3.3: - resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==} + 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.11: - resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==} + 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': '*' @@ -1325,6 +1997,10 @@ packages: 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': @@ -1356,13 +2032,13 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.5.3: - resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true - prisma@6.5.0: - resolution: {integrity: sha512-yUGXmWqv5F4PByMSNbYFxke/WbnyTLjnJ5bKr8fLkcnY7U5rU9rUTh/+Fja+gOrRxEgtCbCtca94IeITj4j/pg==} + prisma@6.12.0: + resolution: {integrity: sha512-pmV7NEqQej9WjizN6RSNIwf7Y+jeh9mY1JEX2WjGxJi4YZWexClhde1yz/FuvAM+cTwzchcMytu2m4I6wPkIzg==} engines: {node: '>=18.18'} hasBin: true peerDependencies: @@ -1371,13 +2047,40 @@ packages: typescript: optional: true + 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==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + 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'} + + 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'} @@ -1396,18 +2099,54 @@ packages: 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 + + 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@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'} @@ -1416,6 +2155,29 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + 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-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'} @@ -1424,10 +2186,28 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 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@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'} @@ -1436,16 +2216,21 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - svelte-check@4.1.5: - resolution: {integrity: sha512-Gb0T2IqBNe1tLB9EB1Qh+LOe+JB8wt2/rNBDGvkxQVvk8vNeAoG+vZgFB/3P5+zC7RWlyBlzm9dVjZFph/maIg==} + 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-eslint-parser@1.1.2: - resolution: {integrity: sha512-vqFBRamDKo1l70KMfxxXj1/0Cco5TfMDnqaAjgz6D8PyoMhfMcDOLRkAwPg8WkMyZjMtQL3wW66TZ0x59iqO2w==} + 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 @@ -1453,72 +2238,97 @@ packages: svelte: optional: true - svelte-fa@4.0.3: - resolution: {integrity: sha512-saZ8yACM0k9Aexey+2NXU1W0MBosU5lBsRgqFCJKM+Taw7d0HyimPaPAjmvY/Xkyi3UwEYL/Sdu1IZJv/p0Flw==} + 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: ^4.0.0 || ^5.0.0 + svelte: ^5.0.0 - svelte@5.25.12: - resolution: {integrity: sha512-4Y3mRN4fuZicNwBeb7sPPEUmiNIoN4lwf2NWD6CJdtYM3xVoOvjXhHQayIRbE0pTHG0mgk88n8WZvuOiNbtD8Q==} + svelte@5.36.14: + resolution: {integrity: sha512-okgNwfVa4FfDGOgd0ndooKjQz1LknUFDGfEJp6QNjYP6B4hDG0KktOP+Pta3ZtE8s+JELsYP+7nqMrJzQLkf5A==} engines: {node: '>=18'} - svg.draggable.js@2.2.2: - resolution: {integrity: sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==} - engines: {node: '>= 0.8.0'} - - svg.easing.js@2.0.0: - resolution: {integrity: sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==} - engines: {node: '>= 0.8.0'} + swagger-jsdoc@6.2.8: + resolution: {integrity: sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==} + engines: {node: '>=12.0.0'} + hasBin: true - svg.filter.js@2.0.2: - resolution: {integrity: sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==} - engines: {node: '>= 0.8.0'} + swagger-parser@10.0.3: + resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==} + engines: {node: '>=10'} - svg.js@2.7.1: - resolution: {integrity: sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==} + swagger-ui-dist@5.27.0: + resolution: {integrity: sha512-tS6LRyBhY6yAqxrfsA9IYpGWPUJOri6sclySa7TdC7XQfGLvTwDY531KLgfQwHEtQsn+sT4JlUspbeQDBVGWig==} - svg.pathmorphing.js@0.1.3: - resolution: {integrity: sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==} - engines: {node: '>= 0.8.0'} + 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' - svg.resize.js@1.4.3: - resolution: {integrity: sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==} - engines: {node: '>= 0.8.0'} + tailwindcss@4.1.11: + resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==} - svg.select.js@2.1.2: - resolution: {integrity: sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==} - engines: {node: '>= 0.8.0'} + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} - svg.select.js@3.0.1: - resolution: {integrity: sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==} - engines: {node: '>= 0.8.0'} + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} - tailwind-merge@2.6.0: - resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} - tailwind-merge@3.2.0: - resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} - tailwindcss@4.1.3: - resolution: {integrity: sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} - tapable@2.2.1: - resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} - engines: {node: '>=6'} + 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'} + 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 + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1529,19 +2339,27 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true - vite@6.2.6: - resolution: {integrity: sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + 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': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@types/node': ^20.19.0 || >=22.12.0 jiti: '>=1.21.0' - less: '*' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' + 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 @@ -1569,34 +2387,65 @@ packages: yaml: optional: true - vitefu@1.0.6: - resolution: {integrity: sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==} + 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 + 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 + 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@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'} + 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': @@ -1604,6 +2453,35 @@ snapshots: '@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 + + '@colors/colors@1.6.0': {} + + '@dabh/diagnostics@2.0.3': + dependencies: + colorspace: 1.1.4 + enabled: 2.0.0 + kuler: 2.0.0 + '@esbuild/aix-ppc64@0.25.2': optional: true @@ -1679,40 +2557,40 @@ snapshots: '@esbuild/win32-x64@0.25.2': optional: true - '@eslint-community/eslint-utils@4.5.1(eslint@9.24.0(jiti@2.4.2))': + '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0(jiti@2.4.2))': dependencies: - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.31.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} - '@eslint/compat@1.2.8(eslint@9.24.0(jiti@2.4.2))': + '@eslint/compat@1.3.1(eslint@9.31.0(jiti@2.4.2))': optionalDependencies: - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.31.0(jiti@2.4.2) - '@eslint/config-array@0.20.0': + '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0 + debug: 4.4.1(supports-color@5.5.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.2.1': {} + '@eslint/config-helpers@0.3.0': {} - '@eslint/core@0.12.0': + '@eslint/core@0.14.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/core@0.13.0': + '@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.0 - espree: 10.3.0 + 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 @@ -1722,36 +2600,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.24.0': {} + '@eslint/js@9.31.0': {} '@eslint/object-schema@2.1.6': {} - '@eslint/plugin-kit@0.2.8': + '@eslint/plugin-kit@0.3.1': dependencies: - '@eslint/core': 0.13.0 + '@eslint/core': 0.14.0 levn: 0.4.1 - '@floating-ui/core@1.6.9': - dependencies: - '@floating-ui/utils': 0.2.9 - - '@floating-ui/dom@1.6.13': - dependencies: - '@floating-ui/core': 1.6.9 - '@floating-ui/utils': 0.2.9 - - '@floating-ui/utils@0.2.9': {} - - '@fortawesome/fontawesome-common-types@6.7.2': {} - - '@fortawesome/free-brands-svg-icons@6.7.2': - dependencies: - '@fortawesome/fontawesome-common-types': 6.7.2 - - '@fortawesome/free-solid-svg-icons@6.7.2': - dependencies: - '@fortawesome/fontawesome-common-types': 6.7.2 - '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -1765,6 +2622,10 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -1782,49 +2643,50 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@polka/url@1.0.0-next.29': {} + '@jsdevtools/ono@7.1.3': {} + + '@lucide/svelte@0.525.0(svelte@5.36.14)': + dependencies: + svelte: 5.36.14 - '@popperjs/core@2.11.8': {} + '@polka/url@1.0.0-next.29': {} - '@prisma/client@6.5.0(prisma@6.5.0(typescript@5.8.3))(typescript@5.8.3)': + '@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3)': optionalDependencies: - prisma: 6.5.0(typescript@5.8.3) + prisma: 6.12.0(typescript@5.8.3) typescript: 5.8.3 - '@prisma/config@6.5.0': + '@prisma/config@6.12.0': dependencies: - esbuild: 0.25.2 - esbuild-register: 3.6.0(esbuild@0.25.2) - transitivePeerDependencies: - - supports-color + jiti: 2.4.2 - '@prisma/debug@6.5.0': {} + '@prisma/debug@6.12.0': {} - '@prisma/engines-version@6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60': {} + '@prisma/engines-version@6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc': {} - '@prisma/engines@6.5.0': + '@prisma/engines@6.12.0': dependencies: - '@prisma/debug': 6.5.0 - '@prisma/engines-version': 6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60 - '@prisma/fetch-engine': 6.5.0 - '@prisma/get-platform': 6.5.0 + '@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@6.5.0': + '@prisma/fetch-engine@6.12.0': dependencies: - '@prisma/debug': 6.5.0 - '@prisma/engines-version': 6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60 - '@prisma/get-platform': 6.5.0 + '@prisma/debug': 6.12.0 + '@prisma/engines-version': 6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc + '@prisma/get-platform': 6.12.0 - '@prisma/get-platform@6.5.0': + '@prisma/get-platform@6.12.0': dependencies: - '@prisma/debug': 6.5.0 + '@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.3(picomatch@4.0.2) + fdir: 6.4.6(picomatch@4.0.2) is-reference: 1.2.1 magic-string: 0.30.17 picomatch: 4.0.2 @@ -1837,16 +2699,6 @@ snapshots: optionalDependencies: rollup: 4.39.0 - '@rollup/plugin-node-resolve@15.3.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/plugin-node-resolve@16.0.1(rollup@4.39.0)': dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.39.0) @@ -1868,198 +2720,357 @@ snapshots: '@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': {} + '@sveltejs/acorn-typescript@1.0.5(acorn@8.14.1)': dependencies: acorn: 8.14.1 - '@sveltejs/adapter-node@5.2.12(@sveltejs/kit@2.20.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)))': + '@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.20.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)) + '@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.20.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2))': + '@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/vite-plugin-svelte': 5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)) + '@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 - import-meta-resolve: 4.1.0 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.25.12 - vite: 6.2.6(jiti@2.4.2)(lightningcss@1.29.2) + 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@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2))': + '@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': 5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)) - debug: 4.4.0 - svelte: 5.25.12 - vite: 6.2.6(jiti@2.4.2)(lightningcss@1.29.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)) + 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@5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.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))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.25.12)(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)) - debug: 4.4.0 + '@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.25.12 - vite: 6.2.6(jiti@2.4.2)(lightningcss@1.29.2) - vitefu: 1.0.6(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)) + 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.3': + '@tailwindcss/node@4.1.11': dependencies: + '@ampproject/remapping': 2.3.0 enhanced-resolve: 5.18.1 jiti: 2.4.2 - lightningcss: 1.29.2 - tailwindcss: 4.1.3 + 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.3': + '@tailwindcss/oxide-android-arm64@4.1.11': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.3': + '@tailwindcss/oxide-darwin-arm64@4.1.11': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.3': + '@tailwindcss/oxide-darwin-x64@4.1.11': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.3': + '@tailwindcss/oxide-freebsd-x64@4.1.11': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.3': + '@tailwindcss/oxide-linux-arm64-gnu@4.1.11': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.3': + '@tailwindcss/oxide-linux-arm64-musl@4.1.11': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.3': + '@tailwindcss/oxide-linux-x64-gnu@4.1.11': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.3': + '@tailwindcss/oxide-linux-x64-musl@4.1.11': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.3': + '@tailwindcss/oxide-wasm32-wasi@4.1.11': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.3': + '@tailwindcss/oxide-win32-arm64-msvc@4.1.11': optional: true - '@tailwindcss/oxide@4.1.3': + '@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.3 - '@tailwindcss/oxide-darwin-arm64': 4.1.3 - '@tailwindcss/oxide-darwin-x64': 4.1.3 - '@tailwindcss/oxide-freebsd-x64': 4.1.3 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.3 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.3 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.3 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.3 - '@tailwindcss/oxide-linux-x64-musl': 4.1.3 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.3 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.3 - - '@tailwindcss/typography@0.5.16(tailwindcss@4.1.3)': + '@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.3 + tailwindcss: 4.1.11 - '@tailwindcss/vite@4.1.3(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2))': + '@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.3 - '@tailwindcss/oxide': 4.1.3 - tailwindcss: 4.1.3 - vite: 6.2.6(jiti@2.4.2)(lightningcss@1.29.2) - - '@types/cookie@0.6.0': {} + '@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/estree@1.0.7': {} + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.2 - '@types/json-schema@7.0.15': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.1.0 - '@types/resolve@1.20.2': {} + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.1.0 - '@yr/monotone-cubic-spline@1.0.3': {} + '@types/cookie@0.6.0': {} - acorn-jsx@5.3.2(acorn@8.14.1): + '@types/cors@2.8.19': dependencies: - acorn: 8.14.1 + '@types/node': 24.1.0 - acorn@8.14.1: {} + '@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/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: @@ -2072,26 +3083,23 @@ snapshots: dependencies: color-convert: 2.0.1 - apexcharts@3.54.1: + anymatch@3.1.3: dependencies: - '@yr/monotone-cubic-spline': 1.0.3 - svg.draggable.js: 2.2.2 - svg.easing.js: 2.0.0 - svg.filter.js: 2.0.2 - svg.pathmorphing.js: 0.1.3 - svg.resize.js: 1.4.3 - svg.select.js: 3.0.1 + normalize-path: 3.0.0 + picomatch: 2.3.1 argparse@2.0.1: {} aria-query@5.3.2: {} + async@3.2.6: {} + asynckit@0.4.0: {} - axios@1.8.4: + axios@1.11.0: dependencies: follow-redirects: 1.15.9 - form-data: 4.0.2 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -2100,16 +3108,57 @@ snapshots: 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: {} + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + 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 + + buffer-equal-constant-time@1.0.1: {} + + bytes@3.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: {} chalk@4.1.2: @@ -2117,28 +3166,83 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.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@3.0.0: {} + 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@6.2.0: {} + + commander@9.5.0: + optional: true + commondir@1.0.1: {} concat-map@0.0.1: {} + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + 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 @@ -2147,9 +3251,19 @@ snapshots: cssesc@3.0.0: {} - debug@4.4.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 deep-is@0.1.4: {} @@ -2157,16 +3271,34 @@ snapshots: delayed-stream@1.0.0: {} - detect-libc@2.0.3: {} + depd@2.0.0: {} + + detect-libc@2.0.4: {} devalue@5.1.1: {} + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dotenv@17.2.1: {} + 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: {} + + enabled@2.0.0: {} + + encodeurl@2.0.0: {} + enhanced-resolve@5.18.1: dependencies: graceful-fs: 4.2.11 @@ -2187,13 +3319,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - esbuild-register@3.6.0(esbuild@0.25.2): - dependencies: - debug: 4.4.0 - esbuild: 0.25.2 - transitivePeerDependencies: - - supports-color - esbuild@0.25.2: optionalDependencies: '@esbuild/aix-ppc64': 0.25.2 @@ -2222,26 +3347,29 @@ snapshots: '@esbuild/win32-ia32': 0.25.2 '@esbuild/win32-x64': 0.25.2 + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.2(eslint@9.24.0(jiti@2.4.2)): + eslint-config-prettier@10.1.8(eslint@9.31.0(jiti@2.4.2)): dependencies: - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.31.0(jiti@2.4.2) - eslint-plugin-svelte@3.5.1(eslint@9.24.0(jiti@2.4.2))(svelte@5.25.12): + eslint-plugin-svelte@3.11.0(eslint@9.31.0(jiti@2.4.2))(svelte@5.36.14): dependencies: - '@eslint-community/eslint-utils': 4.5.1(eslint@9.24.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.4.2)) '@jridgewell/sourcemap-codec': 1.5.0 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.31.0(jiti@2.4.2) esutils: 2.0.3 - known-css-properties: 0.35.0 + 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.1.2(svelte@5.25.12) + svelte-eslint-parser: 1.3.0(svelte@5.36.14) optionalDependencies: - svelte: 5.25.12 + svelte: 5.36.14 transitivePeerDependencies: - ts-node @@ -2250,20 +3378,27 @@ snapshots: 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@9.24.0(jiti@2.4.2): + eslint-visitor-keys@4.2.1: {} + + eslint@9.31.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.5.1(eslint@9.24.0(jiti@2.4.2)) + '@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.20.0 - '@eslint/config-helpers': 0.2.1 - '@eslint/core': 0.12.0 + '@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.24.0 - '@eslint/plugin-kit': 0.2.8 + '@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 @@ -2272,11 +3407,11 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0 + debug: 4.4.1(supports-color@5.5.0) escape-string-regexp: 4.0.0 - eslint-scope: 8.3.0 - eslint-visitor-keys: 4.2.0 - espree: 10.3.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 @@ -2304,11 +3439,17 @@ snapshots: 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@1.4.6: + esrap@2.1.0: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -2322,20 +3463,83 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + 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.3(picomatch@4.0.2): + 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 + 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 @@ -2348,67 +3552,49 @@ snapshots: flatted@3.3.3: {} - flowbite-datepicker@1.3.2(rollup@4.39.0): - dependencies: - '@rollup/plugin-node-resolve': 15.3.1(rollup@4.39.0) - flowbite: 2.5.2(rollup@4.39.0) - transitivePeerDependencies: - - rollup - - flowbite-svelte-blocks@1.1.4(rollup@4.39.0)(svelte@5.25.12): - dependencies: - flowbite: 2.5.2(rollup@4.39.0) - svelte: 5.25.12 - tailwind-merge: 2.6.0 - transitivePeerDependencies: - - rollup - - flowbite-svelte-icons@2.1.1(svelte@5.25.12)(tailwind-merge@3.2.0): - dependencies: - svelte: 5.25.12 - tailwind-merge: 3.2.0 - - flowbite-svelte@0.48.6(rollup@4.39.0)(svelte@5.25.12): - dependencies: - '@floating-ui/dom': 1.6.13 - apexcharts: 3.54.1 - flowbite: 3.1.2(rollup@4.39.0) - svelte: 5.25.12 - tailwind-merge: 3.2.0 - transitivePeerDependencies: - - rollup - - flowbite@2.5.2(rollup@4.39.0): - dependencies: - '@popperjs/core': 2.11.8 - flowbite-datepicker: 1.3.2(rollup@4.39.0) - mini-svg-data-uri: 1.4.4 - transitivePeerDependencies: - - rollup - - flowbite@3.1.2(rollup@4.39.0): - dependencies: - '@popperjs/core': 2.11.8 - flowbite-datepicker: 1.3.2(rollup@4.39.0) - mini-svg-data-uri: 1.4.4 - postcss: 8.5.3 - transitivePeerDependencies: - - rollup + fn.name@1.1.0: {} follow-redirects@1.15.9: {} - form-data@4.0.2: + 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.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 + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2427,18 +3613,54 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + 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.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: {} @@ -2451,6 +3673,31 @@ snapshots: 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 + + ignore-by-default@1.0.1: {} + ignore@5.3.2: {} import-fresh@3.3.1: @@ -2458,10 +3705,25 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-meta-resolve@4.1.0: {} - imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + 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 @@ -2474,6 +3736,10 @@ snapshots: 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 @@ -2482,6 +3748,8 @@ snapshots: dependencies: '@types/estree': 1.0.7 + is-stream@2.0.1: {} + isexe@2.0.0: {} jiti@2.4.2: {} @@ -2490,69 +3758,112 @@ snapshots: dependencies: argparse: 2.0.1 + 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: {} + 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@4.1.5: {} - known-css-properties@0.35.0: {} + known-css-properties@0.37.0: {} + + kuler@2.0.0: {} levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-darwin-arm64@1.29.2: + libphonenumber-js@1.12.10: {} + + lightningcss-darwin-arm64@1.30.1: optional: true - lightningcss-darwin-x64@1.29.2: + lightningcss-darwin-x64@1.30.1: optional: true - lightningcss-freebsd-x64@1.29.2: + lightningcss-freebsd-x64@1.30.1: optional: true - lightningcss-linux-arm-gnueabihf@1.29.2: + lightningcss-linux-arm-gnueabihf@1.30.1: optional: true - lightningcss-linux-arm64-gnu@1.29.2: + lightningcss-linux-arm64-gnu@1.30.1: optional: true - lightningcss-linux-arm64-musl@1.29.2: + lightningcss-linux-arm64-musl@1.30.1: optional: true - lightningcss-linux-x64-gnu@1.29.2: + lightningcss-linux-x64-gnu@1.30.1: optional: true - lightningcss-linux-x64-musl@1.29.2: + lightningcss-linux-x64-musl@1.30.1: optional: true - lightningcss-win32-arm64-msvc@1.29.2: + lightningcss-win32-arm64-msvc@1.30.1: optional: true - lightningcss-win32-x64-msvc@1.29.2: + lightningcss-win32-x64-msvc@1.30.1: optional: true - lightningcss@1.29.2: + lightningcss@1.30.1: dependencies: - detect-libc: 2.0.3 + detect-libc: 2.0.4 optionalDependencies: - lightningcss-darwin-arm64: 1.29.2 - lightningcss-darwin-x64: 1.29.2 - lightningcss-freebsd-x64: 1.29.2 - lightningcss-linux-arm-gnueabihf: 1.29.2 - lightningcss-linux-arm64-gnu: 1.29.2 - lightningcss-linux-arm64-musl: 1.29.2 - lightningcss-linux-x64-gnu: 1.29.2 - lightningcss-linux-x64-musl: 1.29.2 - lightningcss-win32-arm64-msvc: 1.29.2 - lightningcss-win32-x64-msvc: 1.29.2 + 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: {} @@ -2564,38 +3875,144 @@ snapshots: 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: {} + + 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 + 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 - mini-svg-data-uri@1.4.4: {} + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 + minipass@7.1.2: {} + + minizlib@3.0.2: + dependencies: + minipass: 7.1.2 + + mkdirp@3.0.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: {} natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + 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: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + 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 @@ -2617,14 +4034,22 @@ snapshots: 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: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.2: {} postcss-load-config@3.1.4(postcss@8.5.3): @@ -2658,35 +4083,68 @@ snapshots: 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 + prelude-ls@1.2.1: {} - prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.25.12): + prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.36.14): dependencies: - prettier: 3.5.3 - svelte: 5.25.12 + prettier: 3.6.2 + svelte: 5.36.14 - prettier-plugin-tailwindcss@0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.25.12))(prettier@3.5.3): + 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.5.3 + prettier: 3.6.2 optionalDependencies: - prettier-plugin-svelte: 3.3.3(prettier@3.5.3)(svelte@5.25.12) + prettier-plugin-svelte: 3.4.0(prettier@3.6.2)(svelte@5.36.14) - prettier@3.5.3: {} + prettier@3.6.2: {} - prisma@6.5.0(typescript@5.8.3): + prisma@6.12.0(typescript@5.8.3): dependencies: - '@prisma/config': 6.5.0 - '@prisma/engines': 6.5.0 + '@prisma/config': 6.12.0 + '@prisma/engines': 6.12.0 optionalDependencies: - fsevents: 2.3.3 typescript: 5.8.3 - transitivePeerDependencies: - - supports-color + + 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: {} + punycode@2.3.1: {} + 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 + + 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: {} resolve-from@4.0.0: {} @@ -2723,20 +4181,129 @@ snapshots: '@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 + + 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@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: {} + 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-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 @@ -2745,27 +4312,45 @@ snapshots: source-map-js@1.2.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@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.1.5(picomatch@4.0.2)(svelte@5.25.12)(typescript@5.8.3): + 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.3(picomatch@4.0.2) + fdir: 6.4.6(picomatch@4.0.2) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.25.12 + svelte: 5.36.14 typescript: 5.8.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.1.2(svelte@5.25.12): + 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 @@ -2774,13 +4359,18 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.3) postcss-selector-parser: 7.1.0 optionalDependencies: - svelte: 5.25.12 + svelte: 5.36.14 + + svelte-highlight@7.8.3: + dependencies: + highlight.js: 11.11.1 - svelte-fa@4.0.3(svelte@5.25.12): + svelte-meta-tags@4.4.0(svelte@5.36.14): dependencies: - svelte: 5.25.12 + schema-dts: 1.1.5 + svelte: 5.36.14 - svelte@5.25.12: + svelte@5.36.14: dependencies: '@ampproject/remapping': 2.3.0 '@jridgewell/sourcemap-codec': 1.5.0 @@ -2791,59 +4381,88 @@ snapshots: axobject-query: 4.1.0 clsx: 2.1.1 esm-env: 1.2.2 - esrap: 1.4.6 + esrap: 2.1.0 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.17 zimmerframe: 1.1.2 - svg.draggable.js@2.2.2: + swagger-jsdoc@6.2.8(openapi-types@12.1.3): dependencies: - svg.js: 2.7.1 + 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 - svg.easing.js@2.0.0: + swagger-parser@10.0.3(openapi-types@12.1.3): dependencies: - svg.js: 2.7.1 + '@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.3) + transitivePeerDependencies: + - openapi-types - svg.filter.js@2.0.2: + swagger-ui-dist@5.27.0: dependencies: - svg.js: 2.7.1 + '@scarf/scarf': 1.4.0 - svg.js@2.7.1: {} - - svg.pathmorphing.js@0.1.3: + swagger-ui-express@5.0.1(express@5.1.0): dependencies: - svg.js: 2.7.1 + express: 5.1.0 + swagger-ui-dist: 5.27.0 - svg.resize.js@1.4.3: - dependencies: - svg.js: 2.7.1 - svg.select.js: 2.1.2 + tailwindcss@4.1.11: {} - svg.select.js@2.1.2: - dependencies: - svg.js: 2.7.1 + tapable@2.2.1: {} - svg.select.js@3.0.1: + tar@7.4.3: dependencies: - svg.js: 2.7.1 + '@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 - tailwind-merge@2.6.0: {} + text-hex@1.0.0: {} - tailwind-merge@3.2.0: {} + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.6(picomatch@4.0.2) + picomatch: 4.0.2 - tailwindcss@4.1.3: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 - tapable@2.2.1: {} + toidentifier@1.0.1: {} totalist@3.0.1: {} + touch@3.1.1: {} + + triple-beam@1.4.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: {} + undefsafe@2.0.5: {} + + undici-types@7.8.0: {} + + unpipe@1.0.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2852,28 +4471,74 @@ snapshots: uuid@11.1.0: {} - vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2): + 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 - postcss: 8.5.3 - rollup: 4.39.0 + 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.29.2 + lightningcss: 1.30.1 - vitefu@1.0.6(vite@6.2.6(jiti@2.4.2)(lightningcss@1.29.2)): + vitefu@1.1.1(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)): optionalDependencies: - vite: 6.2.6(jiti@2.4.2)(lightningcss@1.29.2) + 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 + 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@5.0.0: {} + yaml@1.10.2: {} + yaml@2.0.0-1: {} + yocto-queue@0.1.0: {} + 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/prisma/migrations/20250411050438_/migration.sql b/prisma/migrations/20250411050438_/migration.sql deleted file mode 100644 index e3a85a9..0000000 --- a/prisma/migrations/20250411050438_/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "User" DROP COLUMN "password", -ADD COLUMN "session_id" TEXT; diff --git a/prisma/migrations/20250411050729_/migration.sql b/prisma/migrations/20250411050729_/migration.sql deleted file mode 100644 index d478c9f..0000000 --- a/prisma/migrations/20250411050729_/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[session_id]` on the table `User` will be added. If there are existing duplicate values, this will fail. - -*/ --- CreateIndex -CREATE UNIQUE INDEX "User_session_id_key" ON "User"("session_id"); diff --git a/prisma/migrations/20250411050857_/migration.sql b/prisma/migrations/20250411050857_/migration.sql deleted file mode 100644 index 2f93496..0000000 --- a/prisma/migrations/20250411050857_/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[user_id]` on the table `User` will be added. If there are existing duplicate values, this will fail. - - Added the required column `user_id` to the `User` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "User" ADD COLUMN "user_id" TEXT NOT NULL; - --- CreateIndex -CREATE UNIQUE INDEX "User_user_id_key" ON "User"("user_id"); diff --git a/prisma/migrations/20250411051058_/migration.sql b/prisma/migrations/20250411051058_/migration.sql deleted file mode 100644 index ed0dc7e..0000000 --- a/prisma/migrations/20250411051058_/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `lastLoginAt` on the `User` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "User" DROP COLUMN "lastLoginAt", -ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/prisma/migrations/20250410140929_/migration.sql b/prisma/migrations/20250522125603_/migration.sql similarity index 72% rename from prisma/migrations/20250410140929_/migration.sql rename to prisma/migrations/20250522125603_/migration.sql index 4e1444b..36b34e5 100644 --- a/prisma/migrations/20250410140929_/migration.sql +++ b/prisma/migrations/20250522125603_/migration.sql @@ -1,5 +1,5 @@ -- CreateEnum -CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'SALES_REP', 'SALES_MANAGER', 'SUPPORT_REP', 'MARKETING', 'READ_ONLY'); +CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER'); -- CreateEnum CREATE TYPE "LeadSource" AS ENUM ('WEB', 'PHONE_INQUIRY', 'PARTNER_REFERRAL', 'COLD_CALL', 'TRADE_SHOW', 'EMPLOYEE_REFERRAL', 'ADVERTISEMENT', 'OTHER'); @@ -16,21 +16,23 @@ CREATE TYPE "CaseStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'CLOSED'); -- CreateEnum CREATE TYPE "QuoteStatus" AS ENUM ('DRAFT', 'NEEDS_REVIEW', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'PRESENTED', 'ACCEPTED'); +-- CreateEnum +CREATE TYPE "AuditAction" AS ENUM ('CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'EXPORT', 'IMPORT', 'VIEW', 'OTHER'); + -- CreateTable CREATE TABLE "User" ( "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, "email" TEXT NOT NULL, "name" TEXT, - "password" TEXT NOT NULL, - "role" "UserRole" NOT NULL DEFAULT 'READ_ONLY', + "session_id" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "profilePhoto" TEXT, "phone" TEXT, - "title" TEXT, "department" TEXT, "isActive" BOOLEAN NOT NULL DEFAULT true, - "lastLoginAt" TIMESTAMP(3), + "lastLogin" TIMESTAMP(3), CONSTRAINT "User_pkey" PRIMARY KEY ("id") ); @@ -54,8 +56,7 @@ CREATE TABLE "Organization" ( -- CreateTable CREATE TABLE "UserOrganization" ( "id" TEXT NOT NULL, - "role" "UserRole" NOT NULL DEFAULT 'READ_ONLY', - "isPrimary" BOOLEAN NOT NULL DEFAULT false, + "role" "UserRole" NOT NULL DEFAULT 'USER', "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "userId" TEXT NOT NULL, "organizationId" TEXT NOT NULL, @@ -71,7 +72,13 @@ CREATE TABLE "Account" ( "industry" TEXT, "website" TEXT, "phone" TEXT, - "addressId" TEXT, + "street" TEXT, + "city" TEXT, + "state" TEXT, + "postalCode" TEXT, + "country" TEXT, + "latitude" DOUBLE PRECISION, + "longitude" DOUBLE PRECISION, "annualRevenue" DOUBLE PRECISION, "description" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -81,6 +88,12 @@ CREATE TABLE "Account" ( "tickerSymbol" TEXT, "rating" TEXT, "sicCode" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "isDeleted" BOOLEAN NOT NULL DEFAULT false, + "deletedAt" TIMESTAMP(3), + "deletedById" TEXT, + "closedAt" TIMESTAMP(3), + "closureReason" TEXT, "ownerId" TEXT NOT NULL, "organizationId" TEXT NOT NULL, @@ -96,13 +109,18 @@ CREATE TABLE "Contact" ( "phone" TEXT, "title" TEXT, "department" TEXT, + "street" TEXT, + "city" TEXT, + "state" TEXT, + "postalCode" TEXT, + "country" TEXT, + "latitude" DOUBLE PRECISION, + "longitude" DOUBLE PRECISION, "description" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, - "accountId" TEXT, "ownerId" TEXT NOT NULL, "organizationId" TEXT NOT NULL, - "addressId" TEXT, CONSTRAINT "Contact_pkey" PRIMARY KEY ("id") ); @@ -243,6 +261,7 @@ CREATE TABLE "Case" ( "origin" TEXT, "type" TEXT, "reason" TEXT, + "dueDate" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "closedAt" TIMESTAMP(3), @@ -298,8 +317,16 @@ CREATE TABLE "Quote" ( "discountAmount" DECIMAL(18,2) NOT NULL DEFAULT 0.00, "taxAmount" DECIMAL(18,2) NOT NULL DEFAULT 0.00, "grandTotal" DECIMAL(18,2) NOT NULL DEFAULT 0.00, - "billingAddressId" TEXT, - "shippingAddressId" TEXT, + "billingStreet" TEXT, + "billingCity" TEXT, + "billingState" TEXT, + "billingPostalCode" TEXT, + "billingCountry" TEXT, + "shippingStreet" TEXT, + "shippingCity" TEXT, + "shippingState" TEXT, + "shippingPostalCode" TEXT, + "shippingCountry" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "preparedById" TEXT NOT NULL, @@ -327,17 +354,116 @@ CREATE TABLE "QuoteLineItem" ( ); -- CreateTable -CREATE TABLE "Address" ( +CREATE TABLE "AccountContactRelationship" ( "id" TEXT NOT NULL, - "street" TEXT, - "city" TEXT, - "state" TEXT, - "postalCode" TEXT, - "country" TEXT, - "latitude" DOUBLE PRECISION, - "longitude" DOUBLE PRECISION, + "role" TEXT, + "isPrimary" BOOLEAN NOT NULL DEFAULT false, + "startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "endDate" TIMESTAMP(3), + "description" TEXT, + "accountId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AccountContactRelationship_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" "AuditAction" NOT NULL, + "entityType" TEXT NOT NULL, + "entityId" TEXT, + "description" TEXT, + "oldValues" JSONB, + "newValues" JSONB, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Board" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "ownerId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Board_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BoardMember" ( + "id" TEXT NOT NULL, + "boardId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BoardMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BoardColumn" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "boardId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BoardColumn_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BoardTask" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "order" INTEGER NOT NULL, + "columnId" TEXT NOT NULL, + "assigneeId" TEXT, + "dueDate" TIMESTAMP(3), + "completed" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BoardTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BoardTaskActivity" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BoardTaskActivity_pkey" PRIMARY KEY ("id") +); - CONSTRAINT "Address_pkey" PRIMARY KEY ("id") +-- CreateTable +CREATE TABLE "BlogPost" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "content" TEXT NOT NULL, + "excerpt" TEXT, + "published" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "authorId" TEXT NOT NULL, + + CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id") ); -- CreateTable @@ -356,9 +482,15 @@ CREATE TABLE "_CaseToSolution" ( CONSTRAINT "_CaseToSolution_AB_pkey" PRIMARY KEY ("A","B") ); +-- CreateIndex +CREATE UNIQUE INDEX "User_user_id_key" ON "User"("user_id"); + -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +-- CreateIndex +CREATE UNIQUE INDEX "User_session_id_key" ON "User"("session_id"); + -- CreateIndex CREATE INDEX "UserOrganization_userId_idx" ON "UserOrganization"("userId"); @@ -401,6 +533,36 @@ CREATE INDEX "QuoteLineItem_quoteId_idx" ON "QuoteLineItem"("quoteId"); -- CreateIndex CREATE INDEX "QuoteLineItem_productId_idx" ON "QuoteLineItem"("productId"); +-- CreateIndex +CREATE INDEX "AccountContactRelationship_contactId_idx" ON "AccountContactRelationship"("contactId"); + +-- CreateIndex +CREATE INDEX "AccountContactRelationship_accountId_idx" ON "AccountContactRelationship"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountContactRelationship_contactId_accountId_key" ON "AccountContactRelationship"("contactId", "accountId"); + +-- CreateIndex +CREATE INDEX "AuditLog_timestamp_idx" ON "AuditLog"("timestamp"); + +-- CreateIndex +CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId"); + +-- CreateIndex +CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId"); + +-- CreateIndex +CREATE INDEX "AuditLog_organizationId_idx" ON "AuditLog"("organizationId"); + +-- CreateIndex +CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action"); + +-- CreateIndex +CREATE UNIQUE INDEX "BoardMember_boardId_userId_key" ON "BoardMember"("boardId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug"); + -- CreateIndex CREATE INDEX "_ContactToOpportunity_B_index" ON "_ContactToOpportunity"("B"); @@ -414,7 +576,7 @@ ALTER TABLE "UserOrganization" ADD CONSTRAINT "UserOrganization_userId_fkey" FOR ALTER TABLE "UserOrganization" ADD CONSTRAINT "UserOrganization_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Account" ADD CONSTRAINT "Account_addressId_fkey" FOREIGN KEY ("addressId") REFERENCES "Address"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Account" ADD CONSTRAINT "Account_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "Account" ADD CONSTRAINT "Account_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -422,12 +584,6 @@ ALTER TABLE "Account" ADD CONSTRAINT "Account_ownerId_fkey" FOREIGN KEY ("ownerI -- AddForeignKey ALTER TABLE "Account" ADD CONSTRAINT "Account_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "Contact" ADD CONSTRAINT "Contact_addressId_fkey" FOREIGN KEY ("addressId") REFERENCES "Address"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Contact" ADD CONSTRAINT "Contact_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "Contact" ADD CONSTRAINT "Contact_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -542,12 +698,6 @@ ALTER TABLE "Comment" ADD CONSTRAINT "Comment_accountId_fkey" FOREIGN KEY ("acco -- AddForeignKey ALTER TABLE "Comment" ADD CONSTRAINT "Comment_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE SET NULL ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "Quote" ADD CONSTRAINT "QuoteBillingAddress_fk" FOREIGN KEY ("billingAddressId") REFERENCES "Address"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Quote" ADD CONSTRAINT "QuoteShippingAddress_fk" FOREIGN KEY ("shippingAddressId") REFERENCES "Address"("id") ON DELETE SET NULL ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "Quote" ADD CONSTRAINT "Quote_preparedById_fkey" FOREIGN KEY ("preparedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -569,6 +719,48 @@ ALTER TABLE "QuoteLineItem" ADD CONSTRAINT "QuoteLineItem_quoteId_fkey" FOREIGN -- AddForeignKey ALTER TABLE "QuoteLineItem" ADD CONSTRAINT "QuoteLineItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "AccountContactRelationship" ADD CONSTRAINT "AccountContactRelationship_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountContactRelationship" ADD CONSTRAINT "AccountContactRelationship_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Board" ADD CONSTRAINT "Board_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Board" ADD CONSTRAINT "Board_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardMember" ADD CONSTRAINT "BoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardMember" ADD CONSTRAINT "BoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardColumn" ADD CONSTRAINT "BoardColumn_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardTask" ADD CONSTRAINT "BoardTask_columnId_fkey" FOREIGN KEY ("columnId") REFERENCES "BoardColumn"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardTask" ADD CONSTRAINT "BoardTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardTaskActivity" ADD CONSTRAINT "BoardTaskActivity_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "BoardTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardTaskActivity" ADD CONSTRAINT "BoardTaskActivity_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BlogPost" ADD CONSTRAINT "BlogPost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "_ContactToOpportunity" ADD CONSTRAINT "_ContactToOpportunity_A_fkey" FOREIGN KEY ("A") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250524154126_/migration.sql b/prisma/migrations/20250524154126_/migration.sql new file mode 100644 index 0000000..bf5a78a --- /dev/null +++ b/prisma/migrations/20250524154126_/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Comment" ADD COLUMN "taskId" TEXT; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250525103520_/migration.sql b/prisma/migrations/20250525103520_/migration.sql new file mode 100644 index 0000000..8d7b526 --- /dev/null +++ b/prisma/migrations/20250525103520_/migration.sql @@ -0,0 +1,46 @@ +/* + Warnings: + + - You are about to drop the column `authorId` on the `BlogPost` table. All the data in the column will be lost. + - You are about to drop the column `content` on the `BlogPost` table. All the data in the column will be lost. + - You are about to drop the column `published` on the `BlogPost` table. All the data in the column will be lost. + - Added the required column `seoDescription` to the `BlogPost` table without a default value. This is not possible if the table is not empty. + - Added the required column `seoTitle` to the `BlogPost` table without a default value. This is not possible if the table is not empty. + - Made the column `excerpt` on table `BlogPost` required. This step will fail if there are existing NULL values in that column. + +*/ +-- CreateEnum +CREATE TYPE "ContentBlockType" AS ENUM ('MARKDOWN', 'CODE', 'IMAGE'); + +-- DropForeignKey +ALTER TABLE "BlogPost" DROP CONSTRAINT "BlogPost_authorId_fkey"; + +-- AlterTable +ALTER TABLE "BlogPost" DROP COLUMN "authorId", +DROP COLUMN "content", +DROP COLUMN "published", +ADD COLUMN "draft" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "seoDescription" TEXT NOT NULL, +ADD COLUMN "seoTitle" TEXT NOT NULL, +ADD COLUMN "userId" TEXT, +ALTER COLUMN "excerpt" SET NOT NULL; + +-- CreateTable +CREATE TABLE "BlogContentBlock" ( + "id" TEXT NOT NULL, + "blogId" TEXT NOT NULL, + "type" "ContentBlockType" NOT NULL, + "content" TEXT NOT NULL, + "displayOrder" INTEGER NOT NULL, + "draft" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BlogContentBlock_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "BlogPost" ADD CONSTRAINT "BlogPost_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BlogContentBlock" ADD CONSTRAINT "BlogContentBlock_blogId_fkey" FOREIGN KEY ("blogId") REFERENCES "BlogPost"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250527121431_add_newsletter_subscriber/migration.sql b/prisma/migrations/20250527121431_add_newsletter_subscriber/migration.sql new file mode 100644 index 0000000..3b1ef19 --- /dev/null +++ b/prisma/migrations/20250527121431_add_newsletter_subscriber/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "NewsletterSubscriber" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "subscribedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "unsubscribedAt" TIMESTAMP(3), + "confirmationToken" TEXT, + "isConfirmed" BOOLEAN NOT NULL DEFAULT false, + "confirmedAt" TIMESTAMP(3), + "ipAddress" TEXT, + "userAgent" TEXT, + + CONSTRAINT "NewsletterSubscriber_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "NewsletterSubscriber_email_key" ON "NewsletterSubscriber"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "NewsletterSubscriber_confirmationToken_key" ON "NewsletterSubscriber"("confirmationToken"); + +-- CreateIndex +CREATE INDEX "NewsletterSubscriber_email_idx" ON "NewsletterSubscriber"("email"); + +-- CreateIndex +CREATE INDEX "NewsletterSubscriber_isActive_idx" ON "NewsletterSubscriber"("isActive"); + +-- CreateIndex +CREATE INDEX "NewsletterSubscriber_subscribedAt_idx" ON "NewsletterSubscriber"("subscribedAt"); diff --git a/prisma/migrations/20250528002056_/migration.sql b/prisma/migrations/20250528002056_/migration.sql new file mode 100644 index 0000000..8ee218d --- /dev/null +++ b/prisma/migrations/20250528002056_/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Contact" DROP CONSTRAINT "Contact_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Contact" ALTER COLUMN "organizationId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Contact" ADD CONSTRAINT "Contact_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250528021650_/migration.sql b/prisma/migrations/20250528021650_/migration.sql new file mode 100644 index 0000000..5562f30 --- /dev/null +++ b/prisma/migrations/20250528021650_/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "OpportunityStatus" AS ENUM ('SUCCESS', 'FAILED', 'IN_PROGRESS'); + +-- AlterTable +ALTER TABLE "Opportunity" ADD COLUMN "status" "OpportunityStatus"; diff --git a/prisma/migrations/20250528040308_/migration.sql b/prisma/migrations/20250528040308_/migration.sql new file mode 100644 index 0000000..8cec846 --- /dev/null +++ b/prisma/migrations/20250528040308_/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "ContactSubmission" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "message" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "referrer" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ContactSubmission_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20250618024526_/migration.sql b/prisma/migrations/20250618024526_/migration.sql new file mode 100644 index 0000000..0c1386e --- /dev/null +++ b/prisma/migrations/20250618024526_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `department` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "department"; diff --git a/prisma/migrations/20250730175647_add_jwt_token_model/migration.sql b/prisma/migrations/20250730175647_add_jwt_token_model/migration.sql new file mode 100644 index 0000000..0440d5a --- /dev/null +++ b/prisma/migrations/20250730175647_add_jwt_token_model/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "JwtToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "isRevoked" BOOLEAN NOT NULL DEFAULT false, + "deviceInfo" TEXT, + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + + CONSTRAINT "JwtToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "JwtToken_token_key" ON "JwtToken"("token"); + +-- CreateIndex +CREATE INDEX "JwtToken_userId_idx" ON "JwtToken"("userId"); + +-- CreateIndex +CREATE INDEX "JwtToken_expiresAt_idx" ON "JwtToken"("expiresAt"); + +-- CreateIndex +CREATE INDEX "JwtToken_isRevoked_idx" ON "JwtToken"("isRevoked"); + +-- AddForeignKey +ALTER TABLE "JwtToken" ADD CONSTRAINT "JwtToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 648c57f..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af7e68a..7b57a4e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,40 +9,59 @@ datasource db { enum UserRole { ADMIN - SALES_REP - SALES_MANAGER - SUPPORT_REP - MARKETING - READ_ONLY + USER } model User { - id String @id @default(uuid()) - user_id String @unique - email String @unique - name String? - session_id String? @unique - role UserRole @default(READ_ONLY) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - profilePhoto String? - phone String? - title String? - department String? - isActive Boolean @default(true) - lastLogin DateTime? - accounts Account[] - contacts Contact[] - leads Lead[] - opportunities Opportunity[] - tasks Task[] - events Event[] - ownedTasks Task[] @relation("TaskOwner") - ownedEvents Event[] @relation("EventOwner") - cases Case[] - comments Comment[] - preparedQuotes Quote[] @relation("QuotePreparer") - organizations UserOrganization[] + id String @id @default(uuid()) + user_id String @unique + email String @unique + name String? + session_id String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + profilePhoto String? + phone String? + isActive Boolean @default(true) + lastLogin DateTime? + accounts Account[] + deletedAccounts Account[] @relation("DeletedAccounts") + contacts Contact[] + leads Lead[] + opportunities Opportunity[] + tasks Task[] // Tasks created by user + events Event[] // Events created by user + ownedTasks Task[] @relation("TaskOwner") + ownedEvents Event[] @relation("EventOwner") + cases Case[] + comments Comment[] + preparedQuotes Quote[] @relation("QuotePreparer") + organizations UserOrganization[] + AuditLog AuditLog[] + Board Board[] + BoardMember BoardMember[] + BoardTask BoardTask[] + BoardTaskActivity BoardTaskActivity[] + BlogPost BlogPost[] + jwtTokens JwtToken[] +} + +model JwtToken { + id String @id @default(uuid()) + token String @unique + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiresAt DateTime + isRevoked Boolean @default(false) + deviceInfo String? // Optional: store device/client info + ipAddress String? // Optional: store IP address + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsedAt DateTime? + + @@index([userId]) + @@index([expiresAt]) + @@index([isRevoked]) } model Organization { @@ -70,13 +89,15 @@ model Organization { quotes Quote[] products Product[] solutions Solution[] + AuditLog AuditLog[] + Board Board[] } model UserOrganization { - id String @id @default(uuid()) - role UserRole @default(READ_ONLY) - isPrimary Boolean @default(false) - joinedAt DateTime @default(now()) + id String @id @default(uuid()) + role UserRole @default(USER) + // isPrimary Boolean @default(false) + joinedAt DateTime @default(now()) // Relationships user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -90,62 +111,78 @@ model UserOrganization { } model Account { - id String @id @default(uuid()) + id String @id @default(uuid()) name String type String? // customer, partner, prospect industry String? website String? phone String? - address Address? @relation("AccountAddress", fields: [addressId], references: [id]) - addressId String? + street String? + city String? + state String? + postalCode String? + country String? + latitude Float? + longitude Float? annualRevenue Float? description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt numberOfEmployees Int? accountOwnership String? // Public, Private, Subsidiary, etc. tickerSymbol String? rating String? // Hot, Warm, Cold sicCode String? // Standard Industrial Classification code - owner User @relation(fields: [ownerId], references: [id]) + isActive Boolean @default(true) + isDeleted Boolean @default(false) + deletedAt DateTime? + deletedById String? + deletedBy User? @relation("DeletedAccounts", fields: [deletedById], references: [id]) + closedAt DateTime? + closureReason String? + owner User @relation(fields: [ownerId], references: [id]) ownerId String - organization Organization @relation(fields: [organizationId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id]) organizationId String - contacts Contact[] opportunities Opportunity[] tasks Task[] events Event[] cases Case[] comments Comment[] - quotes Quote[] @relation("AccountQuotes") + quotes Quote[] @relation("AccountQuotes") + relatedContacts AccountContactRelationship[] } model Contact { - id String @id @default(uuid()) - firstName String - lastName String - email String? - phone String? - title String? - department String? - mailingAddress Address? @relation(fields: [addressId], references: [id]) - description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - account Account? @relation(fields: [accountId], references: [id]) - accountId String? - owner User @relation(fields: [ownerId], references: [id]) - ownerId String - organization Organization @relation(fields: [organizationId], references: [id]) - organizationId String - tasks Task[] - events Event[] - opportunities Opportunity[] - cases Case[] - leads Lead[] - comments Comment[] - quotes Quote[] @relation("ContactQuotes") - addressId String? + id String @id @default(uuid()) + firstName String + lastName String + email String? + phone String? + title String? + department String? + street String? + city String? + state String? + postalCode String? + country String? + latitude Float? + longitude Float? + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + owner User @relation(fields: [ownerId], references: [id]) + ownerId String + organization Organization? @relation(fields: [organizationId], references: [id]) + organizationId String? + tasks Task[] + events Event[] + opportunities Opportunity[] + cases Case[] + leads Lead[] // Keep this relation for historical tracking only + comments Comment[] + quotes Quote[] @relation("ContactQuotes") + relatedAccounts AccountContactRelationship[] } enum LeadSource { @@ -198,11 +235,17 @@ enum LeadStatus { UNQUALIFIED CONVERTED } +enum OpportunityStatus { + SUCCESS + FAILED + IN_PROGRESS +} model Opportunity { id String @id @default(uuid()) name String amount Float? + status OpportunityStatus? closeDate DateTime? probability Float? // Percentage chance to close (0-100) type String? // New Business, Existing Business, etc. @@ -262,6 +305,7 @@ model Task { caseId String? organization Organization @relation(fields: [organizationId], references: [id]) organizationId String + comments Comment[] @relation("TaskComments") } model Event { @@ -329,6 +373,7 @@ model Case { origin String? // Email, Web, Phone type String? // Problem, Feature Request, Question reason String? // Example: "User didn't attend training, Complex functionality" + dueDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt closedAt DateTime? @@ -384,25 +429,39 @@ model Comment { accountId String? contact Contact? @relation(fields: [contactId], references: [id]) contactId String? + task Task? @relation("TaskComments", fields: [taskId], references: [id]) + taskId String? } model Quote { - id String @id @default(uuid()) - quoteNumber String @unique - name String - status QuoteStatus @default(DRAFT) - description String? @db.Text - expirationDate DateTime? - subtotal Decimal @default(0.00) @db.Decimal(18, 2) - discountAmount Decimal @default(0.00) @db.Decimal(18, 2) - taxAmount Decimal @default(0.00) @db.Decimal(18, 2) - grandTotal Decimal @default(0.00) @db.Decimal(18, 2) - billingAddressId String? - shippingAddressId String? - billingAddress Address? @relation("QuoteBillingAddress", fields: [billingAddressId], references: [id], map: "QuoteBillingAddress_fk") - shippingAddress Address? @relation("QuoteShippingAddress", fields: [shippingAddressId], references: [id], map: "QuoteShippingAddress_fk") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + quoteNumber String @unique + name String + status QuoteStatus @default(DRAFT) + description String? @db.Text + expirationDate DateTime? + subtotal Decimal @default(0.00) @db.Decimal(18, 2) + discountAmount Decimal @default(0.00) @db.Decimal(18, 2) + taxAmount Decimal @default(0.00) @db.Decimal(18, 2) + grandTotal Decimal @default(0.00) @db.Decimal(18, 2) + + // Billing address fields + billingStreet String? + billingCity String? + billingState String? + billingPostalCode String? + billingCountry String? + + // Shipping address fields + shippingStreet String? + shippingCity String? + shippingState String? + shippingPostalCode String? + shippingCountry String? + + // Remove billingAddressId, shippingAddressId and address relations + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relationships preparedById String @@ -454,18 +513,193 @@ enum QuoteStatus { ACCEPTED } -model Address { - id String @id @default(uuid()) - street String? - city String? - state String? - postalCode String? - country String? - latitude Float? - longitude Float? - - billingQuotes Quote[] @relation("QuoteBillingAddress") - shippingQuotes Quote[] @relation("QuoteShippingAddress") - accounts Account[] @relation("AccountAddress") - Contact Contact[] +model AccountContactRelationship { + id String @id @default(uuid()) + role String? // Decision Maker, Influencer, etc. + isPrimary Boolean @default(false) + startDate DateTime @default(now()) + endDate DateTime? + description String? + + // Relationships + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + accountId String + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contactId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([contactId, accountId]) + @@index([contactId]) + @@index([accountId]) +} + +enum AuditAction { + CREATE + UPDATE + DELETE + LOGIN + LOGOUT + EXPORT + IMPORT + VIEW + OTHER +} + +model AuditLog { + id String @id @default(uuid()) + timestamp DateTime @default(now()) + action AuditAction + entityType String // Account, Contact, Opportunity, etc. + entityId String? // The ID of the affected entity + description String? // Human-readable description of the action + oldValues Json? // Previous state (for updates/deletes) + newValues Json? // New state (for creates/updates) + ipAddress String? // IP address of the user + userAgent String? // Browser/client information + + // Relationships + user User @relation(fields: [userId], references: [id]) + userId String + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId String + + @@index([timestamp]) + @@index([entityType, entityId]) + @@index([userId]) + @@index([organizationId]) + @@index([action]) +} + +// --- Trello-like Board Models --- +model Board { + id String @id @default(uuid()) + name String + description String? + owner User @relation(fields: [ownerId], references: [id]) + ownerId String + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId String + members BoardMember[] + columns BoardColumn[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model BoardMember { + id String @id @default(uuid()) + board Board @relation(fields: [boardId], references: [id]) + boardId String + user User @relation(fields: [userId], references: [id]) + userId String + role String // e.g. "admin", "member" + createdAt DateTime @default(now()) + + @@unique([boardId, userId]) +} + +model BoardColumn { + id String @id @default(uuid()) + name String + order Int + board Board @relation(fields: [boardId], references: [id]) + boardId String + tasks BoardTask[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model BoardTask { + id String @id @default(uuid()) + title String + description String? + order Int + column BoardColumn @relation(fields: [columnId], references: [id]) + columnId String + assignee User? @relation(fields: [assigneeId], references: [id]) + assigneeId String? + dueDate DateTime? + completed Boolean @default(false) + activities BoardTaskActivity[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model BoardTaskActivity { + id String @id @default(uuid()) + task BoardTask @relation(fields: [taskId], references: [id]) + taskId String + author User @relation(fields: [authorId], references: [id]) + authorId String + type String // e.g. "comment", "status_change" + content String + createdAt DateTime @default(now()) +} + +model BlogPost { + id String @id @default(cuid()) + title String + seoTitle String + seoDescription String + excerpt String + slug String @unique + draft Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + contentBlocks BlogContentBlock[] + User User? @relation(fields: [userId], references: [id]) + userId String? +} + +model BlogContentBlock { + id String @id @default(cuid()) + blog BlogPost @relation(fields: [blogId], references: [id]) + blogId String + type ContentBlockType + content String + displayOrder Int + draft Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum ContentBlockType { + MARKDOWN + CODE + IMAGE +} + +model NewsletterSubscriber { + id String @id @default(uuid()) + email String @unique + isActive Boolean @default(true) + subscribedAt DateTime @default(now()) + unsubscribedAt DateTime? + confirmationToken String? @unique + isConfirmed Boolean @default(false) + confirmedAt DateTime? + ipAddress String? + userAgent String? + + @@index([email]) + @@index([isActive]) + @@index([subscribedAt]) +} + + + +model ContactSubmission { + id String @id @default(uuid()) + name String + email String + message String + reason String + + // Tracking fields + ipAddress String? + userAgent String? + referrer String? + + createdAt DateTime @default(now()) } diff --git a/server.js b/server.js new file mode 100644 index 0000000..b7150da --- /dev/null +++ b/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/src/app.css b/src/app.css index e9f78c8..a596c08 100644 --- a/src/app.css +++ b/src/app.css @@ -1,24 +1,3 @@ @import 'tailwindcss'; @plugin '@tailwindcss/typography'; -@plugin 'flowbite/plugin'; - -@custom-variant dark (&:where(.dark, .dark *)); -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --color-primary-50: #fff5f2; - --color-primary-100: #fff1ee; - --color-primary-200: #ffe4de; - --color-primary-300: #ffd5cc; - --color-primary-400: #ffbcad; - --color-primary-500: #fe795d; - --color-primary-600: #ef562f; - --color-primary-700: #eb4f27; - --color-primary-800: #cc4522; - --color-primary-900: #a5371b; -} - -@source "../node_modules/flowbite-svelte/dist"; \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..e4ad273 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,7 +3,11 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + user?: any; // You might want to replace 'any' with a more specific type for user + org?: any; // You might want to replace 'any' with a more specific type for org + org_name?: string; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/app.html b/src/app.html index 77a5ff5..c09a9ae 100644 --- a/src/app.html +++ b/src/app.html @@ -1,8 +1,17 @@ + + + - + %sveltekit.head% diff --git a/src/hooks.server.js b/src/hooks.server.js index 334b772..e9df2f5 100644 --- a/src/hooks.server.js +++ b/src/hooks.server.js @@ -5,7 +5,7 @@ import { redirect } from '@sveltejs/kit'; export async function handle({ event, resolve }) { const sessionId = await event.cookies.get('session'); // console.log(sessionId, '-----------------sessionid') - let user = false + let user = null; if(sessionId && sessionId!=''){ user = await prisma.user.findFirst({ where: { @@ -31,13 +31,29 @@ export async function handle({ event, resolve }) { } }); - if (userOrg) { - // User has access to this organization, set it in locals - event.locals.org = userOrg.organization; - } else { - // User doesn't have access to this organization, redirect to logout - throw redirect(307, '/logout'); - } + if (userOrg) { + // User has access to this organization, set it in locals + event.locals.org = userOrg.organization; + // Also set org_name from cookie if available + const orgName = event.cookies.get('org_name'); + if (orgName) { + try { + event.locals.org_name = decodeURIComponent(orgName); + } catch (e) { + event.locals.org_name = orgName; + } + } else { + event.locals.org_name = userOrg.organization.name; + } + } else { + // User doesn't have access to this organization or orgId is stale. + // Clear the invalid org cookies. + event.cookies.delete('org', { path: '/' }); + event.cookies.delete('org_name', { path: '/' }); + // Redirect to the organization selection page. + // The user is still authenticated. + throw redirect(307, '/org'); + } } } @@ -56,6 +72,17 @@ export async function handle({ event, resolve }) { // We already verified above that if org exists in locals, the user is part of it } + // Handle admin routes - only allow micropyramid.com domain users + else if (event.url.pathname.startsWith('/admin')) { + if (!user) { + throw redirect(307, '/login'); + } + + // Check if user's email domain is micropyramid.com + if (!user.email || !user.email.endsWith('@micropyramid.com')) { + throw redirect(307, '/app'); + } + } // Handle other protected routes else if (event.url.pathname.startsWith('/org')) { if (!user) { diff --git a/src/lib/assets/images/banner.png b/src/lib/assets/images/banner.png new file mode 100644 index 0000000..641cc18 Binary files /dev/null and b/src/lib/assets/images/banner.png differ diff --git a/src/lib/assets/images/logo.png b/src/lib/assets/images/logo.png index aaa1f1c..1433123 100644 Binary files a/src/lib/assets/images/logo.png and b/src/lib/assets/images/logo.png differ diff --git a/src/lib/components/LeadCard.svelte b/src/lib/components/LeadCard.svelte deleted file mode 100644 index db578f6..0000000 --- a/src/lib/components/LeadCard.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - -
-
-
selectLeadList(item.id)}>{item.title}
- -
- -
- {item.country} - source {item.source} - status {item.status} -
- -
- {#each item.tags as tag} - {tag} - {/each} -
- -
- Created {FormateTime(item.created_at)} by - {item.first_name} - {item.first_name} {item.last_name} -
-
- \ No newline at end of file diff --git a/src/lib/data/accountOwnership.js b/src/lib/data/accountOwnership.js new file mode 100644 index 0000000..203fbcf --- /dev/null +++ b/src/lib/data/accountOwnership.js @@ -0,0 +1,9 @@ +export const accountOwnership = [ + ['', 'Select Ownership'], + ['PUBLIC', 'Public'], + ['PRIVATE', 'Private'], + ['SUBSIDIARY', 'Subsidiary'], + ['NON_PROFIT', 'Non-Profit'], + ['GOVERNMENT', 'Government'], + ['OTHER', 'Other'] +]; diff --git a/src/lib/data/accountTypes.js b/src/lib/data/accountTypes.js new file mode 100644 index 0000000..6cbaeee --- /dev/null +++ b/src/lib/data/accountTypes.js @@ -0,0 +1,9 @@ +export const accountTypes = [ + ['', 'Select Type'], + ['CUSTOMER', 'Customer'], + ['PARTNER', 'Partner'], + ['PROSPECT', 'Prospect'], + ['VENDOR', 'Vendor'], + ['COMPETITOR', 'Competitor'], + ['OTHER', 'Other'] +]; diff --git a/src/lib/data/countries.js b/src/lib/data/countries.js new file mode 100644 index 0000000..a38992a --- /dev/null +++ b/src/lib/data/countries.js @@ -0,0 +1,44 @@ +export const countries = [ + ['', 'Select Country'], + ['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'] +]; diff --git a/src/lib/data/index.js b/src/lib/data/index.js new file mode 100644 index 0000000..f5efe2f --- /dev/null +++ b/src/lib/data/index.js @@ -0,0 +1,8 @@ +// Re-export all data constants for easy importing +export { industries } from './industries.js'; +export { accountTypes } from './accountTypes.js'; +export { accountOwnership } from './accountOwnership.js'; +export { ratings } from './ratings.js'; +export { countries } from './countries.js'; +export { leadSources } from './leadSources.js'; +export { leadStatuses } from './leadStatuses.js'; diff --git a/src/lib/data/industries.js b/src/lib/data/industries.js new file mode 100644 index 0000000..aa3dc88 --- /dev/null +++ b/src/lib/data/industries.js @@ -0,0 +1,16 @@ +export const industries = [ + ['', 'Select Industry'], + ['TECHNOLOGY', 'Technology'], + ['HEALTHCARE', 'Healthcare'], + ['FINANCE', 'Finance'], + ['EDUCATION', 'Education'], + ['RETAIL', 'Retail'], + ['MANUFACTURING', 'Manufacturing'], + ['ENERGY', 'Energy'], + ['REAL_ESTATE', 'Real Estate'], + ['CONSTRUCTION', 'Construction'], + ['TRANSPORTATION', 'Transportation'], + ['HOSPITALITY', 'Hospitality'], + ['AGRICULTURE', 'Agriculture'], + ['OTHER', 'Other'] +]; diff --git a/src/lib/data/leadSources.js b/src/lib/data/leadSources.js new file mode 100644 index 0000000..bb3fb67 --- /dev/null +++ b/src/lib/data/leadSources.js @@ -0,0 +1,18 @@ +export const leadSources = [ + ['', 'Select Source'], + ['WEB', 'Website'], + ['PHONE_INQUIRY', 'Phone Inquiry'], + ['PARTNER_REFERRAL', 'Partner Referral'], + ['COLD_CALL', 'Cold Call'], + ['TRADE_SHOW', 'Trade Show'], + ['EMPLOYEE_REFERRAL', 'Employee Referral'], + ['ADVERTISEMENT', 'Advertisement'], + ['SOCIAL_MEDIA', 'Social Media'], + ['EMAIL_CAMPAIGN', 'Email Campaign'], + ['WEBINAR', 'Webinar'], + ['CONTENT_MARKETING', 'Content Marketing'], + ['SEO', 'SEO/Organic Search'], + ['PPC', 'Pay-Per-Click Advertising'], + ['DIRECT_MAIL', 'Direct Mail'], + ['OTHER', 'Other'] +]; diff --git a/src/lib/data/leadStatuses.js b/src/lib/data/leadStatuses.js new file mode 100644 index 0000000..9653611 --- /dev/null +++ b/src/lib/data/leadStatuses.js @@ -0,0 +1,7 @@ +export const leadStatuses = [ + ['NEW', 'New'], + ['PENDING', 'Pending'], + ['CONTACTED', 'Contacted'], + ['QUALIFIED', 'Qualified'], + ['UNQUALIFIED', 'Unqualified'] +]; diff --git a/src/lib/data/ratings.js b/src/lib/data/ratings.js new file mode 100644 index 0000000..b93a611 --- /dev/null +++ b/src/lib/data/ratings.js @@ -0,0 +1,6 @@ +export const ratings = [ + ['', 'Select Rating'], + ['HOT', '🔥 Hot'], + ['WARM', '🟡 Warm'], + ['COLD', '🟦 Cold'] +]; diff --git a/src/lib/newsletter.js b/src/lib/newsletter.js new file mode 100644 index 0000000..6dd850d --- /dev/null +++ b/src/lib/newsletter.js @@ -0,0 +1,151 @@ +// Newsletter utility functions for email confirmation and management + +/** + * Generate unsubscribe link + * @param {string} token - Confirmation token + * @param {string} baseUrl - Base URL of the application + * @returns {string} Unsubscribe URL + */ +export function generateUnsubscribeLink(token, baseUrl = 'https://bottlecrm.io') { + return `${baseUrl}/unsubscribe?token=${token}`; +} + +/** + * Validate email format + * @param {string} email - Email to validate + * @returns {boolean} True if email is valid + */ +export function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +/** + * Generate email confirmation template + * @param {string} email - Subscriber email + * @param {string} unsubscribeLink - Unsubscribe link + * @returns {object} Email template with subject and body + */ +export function generateWelcomeEmail(email, unsubscribeLink) { + return { + subject: 'Welcome to BottleCRM Newsletter!', + html: ` + + + + + + Welcome to BottleCRM Newsletter + + +
+

Welcome to BottleCRM!

+

Thank you for subscribing to our newsletter

+
+ +
+

What to expect:

+
    +
  • 🚀 Product Updates: Be the first to know about new features and improvements
  • +
  • 💡 CRM Tips: Best practices to maximize your customer relationship management
  • +
  • 📊 Industry Insights: Stay ahead with the latest CRM trends and strategies
  • +
  • 🎯 Exclusive Content: Guides and resources available only to subscribers
  • +
+
+ +
+ Try BottleCRM Free +
+ +
+

You're receiving this email because you subscribed to BottleCRM newsletter.

+

+ Unsubscribe | + Visit Website +

+

+ BottleCRM by MicroPyramid
+ The free, open-source CRM for startups +

+
+ + + `, + text: ` + Welcome to BottleCRM Newsletter! + + Thank you for subscribing to our newsletter. Here's what you can expect: + + • Product Updates: Be the first to know about new features and improvements + • CRM Tips: Best practices to maximize your customer relationship management + • Industry Insights: Stay ahead with the latest CRM trends and strategies + • Exclusive Content: Guides and resources available only to subscribers + + Try BottleCRM Free: https://bottlecrm.io/demo + + You're receiving this email because you subscribed to BottleCRM newsletter. + To unsubscribe, visit: ${unsubscribeLink} + + BottleCRM by MicroPyramid + The free, open-source CRM for startups + https://bottlecrm.io + ` + }; +} + +/** + * Generate newsletter template for regular updates + * @param {any} content - Newsletter content + * @param {string} unsubscribeLink - Unsubscribe link + * @returns {object} Newsletter template with subject and body + */ +export function generateNewsletterTemplate(content, unsubscribeLink) { + const { subject, headline, articles = [], ctaText = 'Learn More', ctaLink = 'https://bottlecrm.io' } = content; + + const articlesHtml = articles.map(/** @param {any} article */ article => ` +
+

${article.title}

+

${article.excerpt}

+ Read more → +
+ `).join(''); + + return { + subject, + html: ` + + + + + + ${subject} + + +
+

${headline}

+
+ +
+ ${articlesHtml} +
+ +
+ ${ctaText} +
+ +
+

You're receiving this email because you subscribed to BottleCRM newsletter.

+

+ Unsubscribe | + Visit Website +

+

+ BottleCRM by MicroPyramid
+ The free, open-source CRM for startups +

+
+ + + ` + }; +} diff --git a/src/lib/stores/auth.js b/src/lib/stores/auth.js index 87f87d3..46e5903 100644 --- a/src/lib/stores/auth.js +++ b/src/lib/stores/auth.js @@ -1,7 +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/src/lib/utils/phone.js b/src/lib/utils/phone.js new file mode 100644 index 0000000..97ebd1f --- /dev/null +++ b/src/lib/utils/phone.js @@ -0,0 +1,74 @@ +import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js'; + +/** + * Validates a phone number and returns validation result + * @param {string} phoneNumber - The phone number to validate + * @param {string} defaultCountry - Default country code (e.g., 'US') + * @returns {{ isValid: boolean, formatted?: string, error?: string }} + */ +export function validatePhoneNumber(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber || phoneNumber.trim() === '') { + return { isValid: true }; // Allow empty phone numbers + } + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const isValid = isValidPhoneNumber(phoneNumber, { defaultCountry }); + + if (!isValid) { + return { + isValid: false, + error: 'Please enter a valid phone number' + }; + } + + // Parse and format the phone number + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return { + isValid: true, + formatted: parsed.formatInternational() + }; + } catch (error) { + return { + isValid: false, + error: 'Please enter a valid phone number' + }; + } +} + +/** + * Formats a phone number for display + * @param {string} phoneNumber - The phone number to format + * @param {string} defaultCountry - Default country code + * @returns {string} Formatted phone number or original if invalid + */ +export function formatPhoneNumber(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber) return ''; + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return parsed.formatInternational(); + } catch { + return phoneNumber; // Return original if parsing fails + } +} + +/** + * Formats a phone number for storage (E.164 format) + * @param {string} phoneNumber - The phone number to format + * @param {string} defaultCountry - Default country code + * @returns {string} E.164 formatted phone number or original if invalid + */ +export function formatPhoneForStorage(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber) return ''; + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return parsed.format('E.164'); + } catch { + return phoneNumber; // Return original if parsing fails + } +} diff --git a/src/routes/(admin)/+layout.svelte b/src/routes/(admin)/+layout.svelte new file mode 100644 index 0000000..741d59d --- /dev/null +++ b/src/routes/(admin)/+layout.svelte @@ -0,0 +1,128 @@ + + +
+ +
+
+
+ + + + +
+ + + + + + + + + +
+
+
+ + + {#if mobileMenuOpen} + + {/if} +
+ + +
+ {@render children()} +
+
\ No newline at end of file diff --git a/src/routes/(admin)/admin/+page.server.js b/src/routes/(admin)/admin/+page.server.js new file mode 100644 index 0000000..8a8ff25 --- /dev/null +++ b/src/routes/(admin)/admin/+page.server.js @@ -0,0 +1,114 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +/** @type {import('./$types').PageServerLoad} */ +export async function load() { + try { + // Get basic counts + const [ + totalUsers, + totalOrganizations, + totalAccounts, + totalContacts, + totalLeads, + totalOpportunities, + totalTasks, + totalCases + ] = await Promise.all([ + prisma.user.count({ where: { isActive: true } }), + prisma.organization.count({ where: { isActive: true } }), + prisma.account.count({ where: { isActive: true, isDeleted: false } }), + prisma.contact.count(), + prisma.lead.count(), + prisma.opportunity.count(), + prisma.task.count(), + prisma.case.count() + ]); + + // Get opportunity metrics + const [ + wonOpportunities, + openOpportunities + ] = await Promise.all([ + prisma.opportunity.count({ where: { stage: 'CLOSED_WON' } }), + prisma.opportunity.count({ + where: { + stage: { + in: ['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([ + prisma.account.count({ + where: { + createdAt: { gte: thirtyDaysAgo }, + isActive: true, + isDeleted: false + } + }), + prisma.lead.count({ + where: { createdAt: { gte: thirtyDaysAgo } } + }), + prisma.opportunity.count({ + where: { createdAt: { gte: thirtyDaysAgo } } + }), + prisma.task.count({ + where: { + updatedAt: { gte: thirtyDaysAgo }, + status: 'Completed' + } + }) + ]); + + 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/src/routes/(admin)/admin/+page.svelte b/src/routes/(admin)/admin/+page.svelte new file mode 100644 index 0000000..ccf05e8 --- /dev/null +++ b/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)} +
+
+
+ + +
+
+

Support Cases

+ +
+
+
+ Total Cases + {formatNumber(metrics.totalCases)} +
+
+
+ + +
+
+

This Month

+ +
+
+
+ New Opportunities + {formatNumber(metrics.newOpportunitiesThisMonth)} +
+
+
+
+
\ No newline at end of file diff --git a/src/routes/(admin)/admin/blogs/+page.server.js b/src/routes/(admin)/admin/blogs/+page.server.js new file mode 100644 index 0000000..8329d18 --- /dev/null +++ b/src/routes/(admin)/admin/blogs/+page.server.js @@ -0,0 +1,18 @@ +import prisma from '$lib/prisma'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load() { + const blogs = await prisma.blogPost.findMany({ + select: { + id: true, + title: true, + createdAt: true, + updatedAt: true, + draft: true + }, + orderBy: { + updatedAt: 'desc' + } + }); + return { blogs }; +}; \ No newline at end of file diff --git a/src/routes/(admin)/admin/blogs/+page.svelte b/src/routes/(admin)/admin/blogs/+page.svelte new file mode 100644 index 0000000..1ffac95 --- /dev/null +++ b/src/routes/(admin)/admin/blogs/+page.svelte @@ -0,0 +1,41 @@ + + +
+

Blogs

+ + + + + + + + + + + + + {#each data.blogs as blog} + + + + + + + + {/each} + +
TitleCategoryDraftCreated AtUpdated At
{blog.title} - EditN/A + {#if blog.draft} + Draft + {:else} + Published + {/if} + {new Date(blog.createdAt).toLocaleString()}{new Date(blog.updatedAt).toLocaleString()}
+
diff --git a/src/routes/(admin)/admin/blogs/[id]/+page.server.js b/src/routes/(admin)/admin/blogs/[id]/+page.server.js new file mode 100644 index 0000000..610abed --- /dev/null +++ b/src/routes/(admin)/admin/blogs/[id]/+page.server.js @@ -0,0 +1,13 @@ +import prisma from '$lib/prisma'; +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params }) { + const blog = await prisma.blogPost.findUnique({ + where: { + id: params.id + }, + include: { + contentBlocks: true + } + }); + return { blog }; +}; \ No newline at end of file diff --git a/src/routes/(admin)/admin/blogs/[id]/+page.svelte b/src/routes/(admin)/admin/blogs/[id]/+page.svelte new file mode 100644 index 0000000..46ecdc2 --- /dev/null +++ b/src/routes/(admin)/admin/blogs/[id]/+page.svelte @@ -0,0 +1,160 @@ + + +
+ +
+
+
+ + + + Edit Blog + +
+
+
+ + +
+ +
+
+ + 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 +
+ +
+
+
+
+ + + +
+
+
+ + diff --git a/src/routes/(admin)/admin/blogs/[id]/edit/+page.server.js b/src/routes/(admin)/admin/blogs/[id]/edit/+page.server.js new file mode 100644 index 0000000..84a9338 --- /dev/null +++ b/src/routes/(admin)/admin/blogs/[id]/edit/+page.server.js @@ -0,0 +1,110 @@ +import prisma from '$lib/prisma'; +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params }) { + const blog = await prisma.blogPost.findUnique({ + where: { + id: params.id + }, + include: { + contentBlocks: { + orderBy: { displayOrder: 'asc' } + } + } + }); + return { + blog + }; +}; + +/** @type {import('./$types').Actions} */ +export const actions = { + + 'add-block': async ({ request, params }) => { + const form = await request.formData(); + const type = form.get('type')?.toString(); + const content = form.get('content')?.toString(); + const displayOrder = form.get('displayOrder')?.toString(); + + if (!type || !content || !displayOrder) { + return { success: false, error: 'Missing required fields' }; + } + + await prisma.blogContentBlock.create({ + data: { + blogId: params.id, + type: /** @type {import('@prisma/client').ContentBlockType} */ (type), + content: content, + displayOrder: Number(displayOrder), + draft: form.get('draft') === 'on' + } + }); + return { success: true }; + }, + 'edit-block': async ({ request }) => { + const form = await request.formData(); + const id = form.get('id')?.toString(); + const type = form.get('type')?.toString(); + const content = form.get('content')?.toString(); + + if (!id || !type || !content) { + return { success: false, error: 'Missing required fields' }; + } + + await prisma.blogContentBlock.update({ + where: { id: id }, + data: { + type: /** @type {import('@prisma/client').ContentBlockType} */ (type), + content: content, + draft: form.get('draft') === 'on' + } + }); + return { success: true }; + }, + 'delete-block': async ({ request }) => { + const form = await request.formData(); + const id = form.get('id')?.toString(); + + if (!id) { + return { success: false, error: 'Missing block ID' }; + } + + await prisma.blogContentBlock.delete({ + where: { id: id } + }); + return { success: true }; + }, + 'update-blog': async ({ request, params }) => { + 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 prisma.blogPost.update({ + where: { id: params.id }, + data + }); + return { success: true }; + } + , + 'reorder-blocks': async ({ request, params }) => { + 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) { + await prisma.blogContentBlock.update({ + where: { id }, + data: { displayOrder } + }); + } + return { success: true }; + } +}; \ No newline at end of file diff --git a/src/routes/(admin)/admin/blogs/[id]/edit/+page.svelte b/src/routes/(admin)/admin/blogs/[id]/edit/+page.svelte new file mode 100644 index 0000000..19d7b62 --- /dev/null +++ b/src/routes/(admin)/admin/blogs/[id]/edit/+page.svelte @@ -0,0 +1,351 @@ + + +
+

Edit Blog

+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + {#if !blog.draft} +

+ Slug can only be edited in draft mode. +

+ {/if} +
+
+
+ Category: {blog.category} +
+
+
+ +
+ +
+ +

Content Blocks

+
    + {#each contentBlocks as block (block.id)} +
  • + {#if editingBlockId === block.id} +
    + +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + {:else} +
    +
    + {block.type} + Order: {block.displayOrder} + {#if block.draft} + Draft + {/if} +
    +
    + +
    + + +
    +
    +
    +
    {block.content}
    + {/if} +
  • + {/each} +
+ +

Add Content Block

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + {#if message} +

{message}

+ {/if} +
diff --git a/src/routes/(admin)/admin/blogs/new/+page.server.js b/src/routes/(admin)/admin/blogs/new/+page.server.js new file mode 100644 index 0000000..3536c28 --- /dev/null +++ b/src/routes/(admin)/admin/blogs/new/+page.server.js @@ -0,0 +1,36 @@ +import prisma from '$lib/prisma'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load() { + return {}; +} + +// Handle POST request for creating a new blog +/** @type {import('./$types').Actions} */ +export const actions = { + default: async ({ request }) => { + 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' }; + } + try { + await prisma.blogPost.create({ + data: { + title: title, + excerpt: excerpt, + slug: slug, + seoTitle:"", + seoDescription: "", + draft: true + } + }); + return { success: true }; + } catch (e) { + return { error: /** @type {any} */ (e)?.message || 'Error creating blog' }; + } + } +}; \ No newline at end of file diff --git a/src/routes/(admin)/admin/blogs/new/+page.svelte b/src/routes/(admin)/admin/blogs/new/+page.svelte new file mode 100644 index 0000000..a2f8ea5 --- /dev/null +++ b/src/routes/(admin)/admin/blogs/new/+page.svelte @@ -0,0 +1,61 @@ + + +
+

Create New Blog

+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.success} +
+ Blog post created successfully! +
+ {/if} + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
diff --git a/src/routes/(admin)/admin/contacts/+page.server.js b/src/routes/(admin)/admin/contacts/+page.server.js new file mode 100644 index 0000000..ab92ff0 --- /dev/null +++ b/src/routes/(admin)/admin/contacts/+page.server.js @@ -0,0 +1,6 @@ +import prisma from '$lib/prisma'; +/** @type {import('./$types').PageServerLoad} */ +export async function load() { + const contacts = await prisma.contactSubmission.findMany(); + return { contacts }; +}; \ No newline at end of file diff --git a/src/routes/(admin)/admin/contacts/+page.svelte b/src/routes/(admin)/admin/contacts/+page.svelte new file mode 100644 index 0000000..3461051 --- /dev/null +++ b/src/routes/(admin)/admin/contacts/+page.svelte @@ -0,0 +1,98 @@ + + +
+
+

Contact Submissions

+
+ + {#if data.contacts && data.contacts.length > 0} +
+ + + + + + + + + + + + {#each data.contacts as contact} + + + + + + + + {/each} + +
+ Contact Info + + Reason + + Message + + Submitted + + Tracking +
+
+
{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} +
+
+
+ +
+ Total submissions: {data.contacts.length} +
+ {:else} +
+
+ + + +
+

No contact submissions

+

No contact form requests have been submitted yet.

+
+ {/if} +
\ No newline at end of file diff --git a/src/routes/(admin)/admin/newsletter/+page.server.js b/src/routes/(admin)/admin/newsletter/+page.server.js new file mode 100644 index 0000000..b45a05a --- /dev/null +++ b/src/routes/(admin)/admin/newsletter/+page.server.js @@ -0,0 +1,45 @@ +import prisma from '$lib/prisma.js'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load() { + try { + const subscribers = await prisma.newsletterSubscriber.findMany({ + orderBy: { subscribedAt: 'desc' }, + select: { + id: true, + email: true, + isActive: true, + isConfirmed: true, + subscribedAt: true, + unsubscribedAt: true, + confirmedAt: true, + ipAddress: true + } + }); + + const stats = await prisma.newsletterSubscriber.aggregate({ + _count: { + id: true + }, + where: { + isActive: true + } + }); + + const totalSubscribers = await prisma.newsletterSubscriber.count(); + + return { + subscribers, + activeCount: stats._count.id, + 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/src/routes/(admin)/admin/newsletter/+page.svelte b/src/routes/(admin)/admin/newsletter/+page.svelte new file mode 100644 index 0000000..0145475 --- /dev/null +++ b/src/routes/(admin)/admin/newsletter/+page.svelte @@ -0,0 +1,168 @@ + + + + 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} +
+

{data.error}

+
+ {/if} + + +
+
+

+ + Newsletter Subscribers +

+
+ + {#if data.subscribers.length === 0} +
+ +

No subscribers yet

+

Get started by promoting your newsletter.

+
+ {:else} +
+ + + + + + + + + + + + {#each data.subscribers as subscriber} + + + + + + + + {/each} + +
+ Email + + Status + + Subscribed + + Confirmed + + IP Address +
+
{subscriber.email}
+
+ + {getStatusText(subscriber.isActive, subscriber.isConfirmed)} + + +
+ + {formatDate(subscriber.subscribedAt)} +
+
+ {#if subscriber.confirmedAt} +
+ + {formatDate(subscriber.confirmedAt)} +
+ {:else} + Not confirmed + {/if} +
+ {subscriber.ipAddress || 'N/A'} +
+
+ {/if} +
+
+
diff --git a/src/routes/(app)/+layout.server.js b/src/routes/(app)/+layout.server.js index c84da02..9f89f03 100644 --- a/src/routes/(app)/+layout.server.js +++ b/src/routes/(app)/+layout.server.js @@ -2,7 +2,10 @@ /** @type {import('./$types').LayoutServerLoad} */ export async function load({locals}) { // console.log("locals", locals.user); - return { - user: locals.user - }; -} \ No newline at end of file + return { + user: locals.user, + org_name: locals.org_name || 'BottleCRM' + }; +} + +export const ssr = false; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 1889ff6..f473d8e 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,21 +1,31 @@ - -
- -
-
- + + + + + +
+ +
+
+ {@render children()}
-
+
diff --git a/src/routes/(app)/Navbar.svelte b/src/routes/(app)/Navbar.svelte deleted file mode 100644 index e767b0c..0000000 --- a/src/routes/(app)/Navbar.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - - - (drawerHidden = !drawerHidden)} - class="m-0 me-3 md:block lg:hidden" - /> - - BottleCRM - - BottleCRM - - - -
- - - - - - - - {name} - {email} - - Sign out - -
-
diff --git a/src/routes/(app)/Sidebar.svelte b/src/routes/(app)/Sidebar.svelte index da64e46..5ad4b3a 100644 --- a/src/routes/(app)/Sidebar.svelte +++ b/src/routes/(app)/Sidebar.svelte @@ -1,159 +1,333 @@ - -

Main menu

- - +
+ + +
+
+ User avatar +
+
{user.name}
+
{user.email}
+
+
+ + +
+ + + +
+ + + {#if userDropdownOpen} +
{ if (e.key === 'Enter' || e.key === ' ') handleDropdownClick(e); }} + tabindex="0" + role="menu" + > + + + +
+ +
+ {/if} +
+
+ diff --git a/src/routes/(app)/app/+page.server.js b/src/routes/(app)/app/+page.server.js new file mode 100644 index 0000000..c4e24d6 --- /dev/null +++ b/src/routes/(app)/app/+page.server.js @@ -0,0 +1,131 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function load({ locals }) { + // Get user and organization from locals (assuming auth is handled) + const userId = locals.user?.id; + const organizationId = locals.org?.id; + + if (!userId || !organizationId) { + return { + error: 'User not authenticated' + }; + } + + try { + // Fetch dashboard metrics + const [ + totalLeads, + totalOpportunities, + totalAccounts, + totalContacts, + pendingTasks, + recentLeads, + recentOpportunities, + upcomingTasks, + recentActivities + ] = await Promise.all([ + // Count metrics + prisma.lead.count({ + where: { organizationId, isConverted: false } + }), + prisma.opportunity.count({ + where: { organizationId, stage: { not: 'CLOSED_WON' } } + }), + prisma.account.count({ + where: { organizationId, isActive: true } + }), + prisma.contact.count({ + where: { organizationId } + }), + prisma.task.count({ + where: { + organizationId, + status: { not: 'Completed' }, + ownerId: userId + } + }), + + // Recent data + prisma.lead.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + firstName: true, + lastName: true, + company: true, + status: true, + createdAt: true + } + }), + prisma.opportunity.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + account: { + select: { name: true } + } + } + }), + prisma.task.findMany({ + where: { + organizationId, + ownerId: userId, + status: { not: 'Completed' }, + dueDate: { gte: new Date() } + }, + orderBy: { dueDate: 'asc' }, + take: 5, + select: { + id: true, + subject: true, + status: true, + priority: true, + dueDate: true + } + }), + prisma.auditLog.findMany({ + where: { organizationId }, + orderBy: { timestamp: 'desc' }, + take: 10, + include: { + user: { + select: { name: true } + } + } + }) + ]); + + // Calculate opportunity revenue + const opportunityRevenue = await prisma.opportunity.aggregate({ + where: { organizationId }, + _sum: { amount: true } + }); + + return { + metrics: { + totalLeads, + totalOpportunities, + totalAccounts, + totalContacts, + pendingTasks, + opportunityRevenue: opportunityRevenue._sum.amount || 0 + }, + recentData: { + leads: recentLeads, + opportunities: recentOpportunities, + tasks: upcomingTasks, + activities: recentActivities + } + }; + } catch (error) { + console.error('Dashboard load error:', error); + return { + error: 'Failed to load dashboard data' + }; + } +} diff --git a/src/routes/(app)/app/+page.svelte b/src/routes/(app)/app/+page.svelte index e69de29..f8060c9 100644 --- a/src/routes/(app)/app/+page.svelte +++ b/src/routes/(app)/app/+page.svelte @@ -0,0 +1,278 @@ + + + + Dashboard - BottleCRM + + +
+ +
+
+

Dashboard

+

Welcome back! Here's what's happening with your CRM.

+
+ +
+ + {#if data.error} +
+ + {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)}

+
+
+ +
+
+
+
+ + +
+ +
+
+
+

Recent Leads

+ +
+
+
+ {#if recentData.leads?.length > 0} +
+ {#each recentData.leads as lead} +
+
+

{lead.firstName} {lead.lastName}

+

{lead.company || 'No company'}

+
+
+ + {lead.status} + +

{formatDate(lead.createdAt)}

+
+
+ {/each} +
+ {:else} +

No recent leads

+ {/if} +
+
+ + +
+
+
+

Recent Opportunities

+ +
+
+
+ {#if recentData.opportunities?.length > 0} +
+ {#each recentData.opportunities as opportunity} +
+
+

{opportunity.name}

+

{opportunity.account?.name || 'No account'}

+
+
+ {#if opportunity.amount} +

{formatCurrency(opportunity.amount)}

+ {/if} +

{formatDate(opportunity.createdAt)}

+
+
+ {/each} +
+ {:else} +

No recent opportunities

+ {/if} +
+
+ + +
+
+
+

Upcoming Tasks

+ +
+
+
+ {#if recentData.tasks?.length > 0} +
+ {#each recentData.tasks as task} +
+
+

{task.subject}

+

{task.status}

+
+
+ + {task.priority} + + {#if task.dueDate} +

{formatDate(task.dueDate)}

+ {/if} +
+
+ {/each} +
+ {:else} +

No upcoming tasks

+ {/if} +
+
+
+ + +
+
+
+

Recent Activities

+ +
+
+
+ {#if recentData.activities?.length > 0} +
+ {#each recentData.activities as activity} +
+
+ +
+
+

+ {activity.user?.name || 'Someone'} + {activity.description || `performed ${activity.action.toLowerCase()} on ${activity.entityType}`} +

+

{formatDate(activity.timestamp)}

+
+
+ {/each} +
+ {:else} +

No recent activities

+ {/if} +
+
+ {/if} +
diff --git a/src/routes/(app)/app/accounts/+page.server.js b/src/routes/(app)/app/accounts/+page.server.js new file mode 100644 index 0000000..39dadf1 --- /dev/null +++ b/src/routes/(app)/app/accounts/+page.server.js @@ -0,0 +1,105 @@ +import { error } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +export async function load({ locals, url }) { + const org = locals.org; + + 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 { + // Build the where clause for filtering + /** @type {import('@prisma/client').Prisma.AccountWhereInput} */ + const where = {organizationId: org.id}; + + // Add status filter + const status = url.searchParams.get('status'); + if (status === 'open') { + where.isActive = true; + } else if (status === 'closed') { + where.isActive = false; + } + + // Fetch accounts with pagination + const accounts = await prisma.account.findMany({ + where, + include: { + owner: { + select: { + id: true, + name: true, + email: true, + profilePhoto: true + } + }, + opportunities: { + select: { + id: true, + stage: true, + amount: true + } + }, + relatedContacts: { + select: { + contact: { + select: { + id: true, + firstName: true, + lastName: true + } + } + } + }, + tasks: { + select: { + id: true, + status: true + } + } + }, + orderBy: { + [sort]: order + }, + skip, + take: limit + }); + + // Get the total count for pagination + const total = await prisma.account.count({ where }); + + return { + accounts: accounts.map(account => ({ + ...account, + isActive: account.isActive, // Use only the active field, ignore closedAt for display purposes + opportunityCount: account.opportunities.length, + contactCount: account.relatedContacts.length, + taskCount: account.tasks.length, + openOpportunities: account.opportunities.filter(opp => + !['CLOSED_WON', 'CLOSED_LOST'].includes(opp.stage) + ).length, + totalOpportunityValue: account.opportunities.reduce((sum, opp) => sum + (opp.amount || 0), 0), + // Keep the arrays but transformed/simplified + topContacts: account.relatedContacts.slice(0, 3).map(rc => ({ + id: rc.contact.id, + name: `${rc.contact.firstName} ${rc.contact.lastName}` + })), + opportunities: undefined, + relatedContacts: undefined, + tasks: undefined + })), + 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/src/routes/(app)/app/accounts/+page.svelte b/src/routes/(app)/app/accounts/+page.svelte new file mode 100644 index 0000000..1187d8f --- /dev/null +++ b/src/routes/(app)/app/accounts/+page.svelte @@ -0,0 +1,431 @@ + + +
+ +
+
+
+

Accounts

+

+ Manage all your customer accounts and business relationships +

+
+ + +
+ +
+ + + debounceSearch(/** @type {HTMLInputElement} */ (e.target).value)} + /> +
+ + +
+ + + +
+ + + + + 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)}

+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + {#if isLoading} + + + + {:else if accounts.length === 0} + + + + {:else} + {#each accounts as account (account.id)} + + + + + + + + + + + {/each} + {/if} + +
toggleSort('name')}> +
+ + Account Name + {#if sortField === 'name'} + {#if sortOrder === 'asc'} + + {:else} + + {/if} + {/if} +
+
Actions
+
+
+

Loading accounts...

+
+
+
+ +
+

No accounts found

+

Get started by creating your first account

+
+ + + Create Account + +
+
+ + + +
+
+
+ + + {#if pagination.totalPages > 1} +
+
+ Showing {(pagination.page - 1) * pagination.limit + 1} to + {Math.min(pagination.page * pagination.limit, pagination.total)} of + {pagination.total} accounts +
+
+ + + + {pagination.page} of {pagination.totalPages} + + + +
+
+ {/if} +
diff --git a/src/routes/(app)/app/accounts/[accountId]/+page.server.js b/src/routes/(app)/app/accounts/[accountId]/+page.server.js new file mode 100644 index 0000000..f86bd13 --- /dev/null +++ b/src/routes/(app)/app/accounts/[accountId]/+page.server.js @@ -0,0 +1,319 @@ +import { error, fail } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params, url, locals }) { + const org = locals.org; + try { + const accountId = params.accountId; + + // Fetch account details + const account = await prisma.account.findUnique({ + where: { + id: accountId, + organizationId: org.id + } + }); + + if (!account) { + throw error(404, 'Account not found'); + } + + // Fetch account contacts + const contactRelationships = await prisma.accountContactRelationship.findMany({ + where: { + accountId: accountId + }, + include: { + contact: true + } + }); + + // Format contacts with isPrimary flag + const contacts = contactRelationships.map(rel => ({ + ...rel.contact, + isPrimary: rel.isPrimary, + role: rel.role + })); + + // Fetch account opportunities + const opportunities = await prisma.opportunity.findMany({ + where: { + accountId: accountId + } + }); + + // Fetch account comments/notes + const comments = await prisma.comment.findMany({ + where: { + accountId: accountId + }, + include: { + author: { + select: { + name: true, + id: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + // If ?commentsOnly=1, return just comments as JSON + if (url.searchParams.get('commentsOnly') === '1') { + return new Response(JSON.stringify({ comments }), { headers: { 'Content-Type': 'application/json' } }); + } + + // Fetch account quotes + const quotes = await prisma.quote.findMany({ + where: { + accountId: accountId + } + }); + + // Fetch account tasks + const tasks = await prisma.task.findMany({ + where: { + accountId: accountId + }, + include: { + owner: { + select: { + name: true, + id: true + } + } + } + }); + + // Fetch account cases + const cases = await prisma.case.findMany({ + where: { + accountId: accountId + } + }); + + // Load users in the same organization for assignment + const users = await prisma.user.findMany({ + where: { + organizations: { + some: { organizationId: account.organizationId } + } + }, + select: { id: true, name: true, email: true } + }); + + 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'; + const statusCode = err && typeof err === 'object' && 'status' in err ? + (typeof err.status === 'number' ? err.status : 500) : 500; + throw error(statusCode, errorMessage); + } +} + +/** @type {import('./$types').Actions} */ +export const actions = { + closeAccount: async ({ request, locals, params }) => { + try { + const user = locals.user; + const org = locals.org; + + 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' }); + } + + // Fetch the account to verify it exists + const account = await prisma.account.findUnique({ + where: { id: accountId, organizationId: org.id }, + select: { + id: true, + closedAt: true, + organizationId: true, + ownerId: true + } + }); + + if (!account) { + return fail(404, { success: false, message: 'Account not found' }); + } + + if (account.closedAt) { + return fail(400, { success: false, message: 'Account is already closed' }); + } + + // Check user permissions (must be the owner, a sales manager, or admin) + const userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: user.id, + organizationId: account.organizationId + } + }); + + const hasPermission = + user.id === account.ownerId || + userOrg?.role === 'ADMIN'; + + if (!hasPermission) { + return fail(403, { success: false, message: 'Permission denied. Only account owners, sales managers, or admins can close accounts.' }); + } + + // Update the account to mark it as closed + const updatedAccount = await prisma.account.update({ + where: { id: accountId }, + data: { + closedAt: new Date(), + isActive: false, + closureReason + } + }); + + // Log this action in the audit log + await prisma.auditLog.create({ + data: { + action: 'UPDATE', + entityType: 'Account', + entityId: accountId, + description: `Account closed: ${closureReason}`, + oldValues: { closedAt: null, closureReason: null }, + newValues: { closedAt: updatedAccount.closedAt, closureReason }, + 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 { accountId } = params; + + // Fetch the account to verify it exists + const account = await prisma.account.findUnique({ + where: { id: accountId, organizationId: org.id }, + select: { + id: true, + closedAt: true, + closureReason: true, + organizationId: true, + ownerId: true + } + }); + + if (!account) { + return fail(404, { success: false, message: 'Account not found' }); + } + + if (!account.closedAt) { + return fail(400, { success: false, message: 'Account is not closed' }); + } + + // Check user permissions (must be the owner, a sales manager, or admin) + const userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: user.id, + organizationId: account.organizationId + } + }); + + const hasPermission = + user.id === account.ownerId || + userOrg?.role === 'ADMIN'; + + if (!hasPermission) { + return fail(403, { success: false, message: 'Permission denied. Only account owners, sales managers, or admins can reopen accounts.' }); + } + + // Save the old values for the audit log + const oldValues = { + closedAt: account.closedAt, + closureReason: account.closureReason + }; + + // Update the account to mark it as reopened + await prisma.account.update({ + where: { id: accountId }, + data: { + closedAt: null, + isActive: true, + closureReason: null + } + }); + + // Log this action in the audit log + await prisma.auditLog.create({ + data: { + action: 'UPDATE', + entityType: 'Account', + entityId: accountId, + description: `Account reopened`, + oldValues: oldValues, + newValues: { closedAt: null, closureReason: null }, + 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; + // Fallback: fetch account to get organizationId + const account = await prisma.account.findUnique({ + where: { id: params.accountId, organizationId: org.id }, + select: { organizationId: true, ownerId: true } + }); + if (!account) { + return fail(404, { error: 'Account not found.' }); + } + // Use the account owner as the author if no user is available (for demo/dev only) + 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 prisma.comment.create({ + data: { + body, + authorId, + organizationId, + accountId: params.accountId + } + }); + return { success: true }; + } +}; diff --git a/src/routes/(app)/app/accounts/[accountId]/+page.svelte b/src/routes/(app)/app/accounts/[accountId]/+page.svelte new file mode 100644 index 0000000..a8e40f8 --- /dev/null +++ b/src/routes/(app)/app/accounts/[accountId]/+page.svelte @@ -0,0 +1,858 @@ + + +
+ +
+
+
+
+ + + Back to Accounts + +
+

{account.name}

+
+ + {account.isActive ? 'Active' : 'Inactive'} + + {#if account.type} + + {account.type} + + {/if} +
+
+
+ +
+ {#if account.closedAt} +
+ +
+ {:else} + + + Edit + + + {/if} +
+
+
+
+ +
+
+ +
+ +
+
+

Account Information

+
+
+
+
+
+ Name +

{account.name || 'N/A'}

+
+
+ Industry +

{account.industry || 'N/A'}

+
+
+ Website + {#if account.website} + + + {account.website} + + + {:else} +

N/A

+ {/if} +
+
+ Phone + {#if account.phone} + + + {account.phone} + + {:else} +

N/A

+ {/if} +
+
+ Email + {#if account.email} + + + {account.email} + + {:else} +

N/A

+ {/if} +
+
+ +
+
+ Annual Revenue +

+ {account.annualRevenue ? formatCurrency(account.annualRevenue) : 'N/A'} +

+
+
+ Employees +

+ {account.numberOfEmployees ? account.numberOfEmployees.toLocaleString() : 'N/A'} +

+
+
+ Ownership +

{account.accountOwnership || 'N/A'}

+
+
+ Rating +

{account.rating || '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)}

+
+
+
+
+ + +
+ +
+ +
+ + +
+ {#if activeTab === 'contacts'} + {#if contacts.length === 0} +
+ +

No contacts found for this account

+ + + Add Contact + +
+ {:else} +
+ + + + + + + + + + + + + {#each contacts as contact (contact.id)} + + + + + + + + + {/each} + +
NameTitleRoleActions
+ + {contact.firstName} {contact.lastName} + + {#if contact.isPrimary} + + Primary + + {/if} + {contact.title || 'N/A'}{contact.role || 'N/A'} + View +
+
+ {/if} + {/if} + + {#if activeTab === 'opportunities'} + {#if opportunities.length === 0} +
+ +

No opportunities found for this account

+ + + Add Opportunity + +
+ {:else} +
+ + + + + + + + + + + + + {#each opportunities as opportunity (opportunity.id)} + + + + + + + + + {/each} + +
NameValueStageActions
+ + {opportunity.name} + + {formatCurrency(opportunity.amount)} + + {opportunity.stage || 'Unknown'} + + + View +
+
+ {/if} + {/if} + + {#if activeTab === 'tasks'} + {#if tasks.length === 0} +
+ +

No tasks found for this account

+ + + Add Task + +
+ {:else} +
+ + + + + + + + + + + + + {#each tasks as task (task.id)} + + + + + + + + + {/each} + +
SubjectStatusPriorityActions
+ + {task.subject} + + + + {task.status} + + + + {task.priority} + + + View +
+
+ {/if} + {/if} + + {#if activeTab === 'cases'} + {#if cases.length === 0} +
+ +

No cases found for this account

+ + + Open Case + +
+ {:else} +
+ + + + + + + + + + + + + {#each cases as caseItem (caseItem.id)} + + + + + + + + + {/each} + +
Case NumberSubjectStatusPriorityActions
+ + {caseItem.caseNumber} + + {caseItem.subject} + + {caseItem.status} + + + + {caseItem.priority} + + + View +
+
+ {/if} + {/if} + + {#if activeTab === 'notes'} +
+ +
+ + + {#if commentError} +

{commentError}

+ {/if} +
+ +
+
+ + + {#if comments.length === 0} +
+ +

No notes found for this account

+
+ {:else} +
+ {#each comments as comment (comment.id)} +
+
+
+ {comment.author?.name || '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(/** @param {number} sum @param {any} opp */ (sum, opp) => sum + (opp.amount || 0), 0))} +

+
+ +
+ +
+
+

Open Cases

+

+ {cases.filter(/** @param {any} c */ (c) => 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. +

+ +
+ + + {#if closeError} +

{closeError}

+ {/if} +
+
+
+
+
+
+ + +
+
+
+
+
+ {/if} +
diff --git a/src/routes/(app)/app/accounts/[accountId]/edit/+page.server.js b/src/routes/(app)/app/accounts/[accountId]/edit/+page.server.js new file mode 100644 index 0000000..6f3fb86 --- /dev/null +++ b/src/routes/(app)/app/accounts/[accountId]/edit/+page.server.js @@ -0,0 +1,34 @@ +import { fail, redirect, error } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params, locals }) { + const account = await prisma.account.findUnique({ + where: { id: params.accountId, organizationId: locals.org.id } + }); + if (!account) throw error(404, 'Account not found'); + return { account }; +} + +/** @type {import('./$types').Actions} */ +export const 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 }); + } + + await prisma.account.update({ + where: { id: params.accountId, organizationId: org.id }, + data: { name, industry, type, website, phone } + }); + throw redirect(303, `/app/accounts/${params.accountId}`); + } +}; diff --git a/src/routes/(app)/app/accounts/[accountId]/edit/+page.svelte b/src/routes/(app)/app/accounts/[accountId]/edit/+page.svelte new file mode 100644 index 0000000..84b052b --- /dev/null +++ b/src/routes/(app)/app/accounts/[accountId]/edit/+page.svelte @@ -0,0 +1,39 @@ + + +
+

Edit Account

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
diff --git a/src/routes/(app)/app/accounts/new/+page.server.js b/src/routes/(app)/app/accounts/new/+page.server.js new file mode 100644 index 0000000..ebb8952 --- /dev/null +++ b/src/routes/(app)/app/accounts/new/+page.server.js @@ -0,0 +1,114 @@ +import { fail } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; +import { + industries, + accountTypes, + accountOwnership, + ratings, + countries +} from '$lib/data/index.js'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load() { + + // Get data for dropdowns + return { + data: { + industries, + accountTypes, + accountOwnership, + ratings, + countries + } + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + default: async ({ request, locals }) => { + // Get user and org from locals + const user = locals.user; + const org = locals.org; + + // 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: formData.get('type')?.toString() || null, + industry: formData.get('industry')?.toString() || null, + 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: formData.get('accountOwnership')?.toString() || null, + tickerSymbol: formData.get('tickerSymbol')?.toString() || null, + rating: formData.get('rating')?.toString() || null, + sicCode: formData.get('sicCode')?.toString() || null + }; + + try { + // Create new account in the database + const account = await prisma.account.create({ + data: { + ...accountData, + owner: { + connect: { + id: user.id + } + }, + organization: { + connect: { + id: org.id + } + } + } + }); + + // Return success instead of redirecting + return { + status: 'success', + message: 'Account created successfully', + account: { + id: account.id, + name: account.name + } + }; + + } 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/src/routes/(app)/app/accounts/new/+page.svelte b/src/routes/(app)/app/accounts/new/+page.svelte new file mode 100644 index 0000000..9f9e966 --- /dev/null +++ b/src/routes/(app)/app/accounts/new/+page.svelte @@ -0,0 +1,617 @@ + + + +
+ + {#if showToast} +
+
+
+ {#if toastType === 'success'} + + {:else} + + {/if} +
+
+

{toastMessage}

+
+ +
+
+ {/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 +

+
+
+
+ +
+ + + {#if errors.name} +

{errors.name}

+ {/if} +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+ + +
+
+

+ + Contact Information +

+
+
+
+ +
+ + + {#if errors.website} +

{errors.website}

+ {/if} +
+ + +
+ + + {#if errors.phone} +

{errors.phone}

+ {/if} +
+
+
+
+ + +
+
+

+ + Address Information +

+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+

+ + Company Details +

+
+
+
+ +
+ + + {#if errors.numberOfEmployees} +

{errors.numberOfEmployees}

+ {/if} +
+ + +
+ + + {#if errors.annualRevenue} +

{errors.annualRevenue}

+ {/if} +
+ + +
+ + +
+ + +
+ + +
+
+
+
+ + +
+
+

Additional Details

+
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+
\ No newline at end of file diff --git a/src/routes/(app)/app/cases/+page.server.js b/src/routes/(app)/app/cases/+page.server.js new file mode 100644 index 0000000..535d1fe --- /dev/null +++ b/src/routes/(app)/app/cases/+page.server.js @@ -0,0 +1,124 @@ +import { fail, redirect, error } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +export async function load({ url, locals }) { + const org = locals.org; + const user = locals.user; + // 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 where clause + const where = {}; + if (status) where.status = status; + if (assigned) where.owner = { name: assigned }; + if (account) where.account = { name: account }; + + // Fetch all possible filter options + const [allUsers, allAccounts] = await Promise.all([ + prisma.user.findMany({ select: { id: true, name: true } }), + prisma.account.findMany({ select: { id: true, name: true } }) + ]); + + // Optionally, define all possible statuses + const statusOptions = ['OPEN', 'IN_PROGRESS', 'CLOSED']; + + const cases = await prisma.case.findMany({ + where: { organizationId: org.id }, + include: { + owner: { select: { id: true, name: true } }, + account: { select: { id: true, name: true } }, + comments: { + include: { author: { select: { id: true, name: true } } }, + orderBy: { createdAt: 'desc' } + } + }, + orderBy: { createdAt: 'desc' } + }); + + return { cases, allUsers, allAccounts, statusOptions }; +} + +export const actions = { + create: async ({ request, locals }) => { + 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 newCase = await prisma.case.create({ + data: { + subject, + description, + accountId, + dueDate, + priority, + ownerId, + organizationId: locals.org.id + } + }); + throw redirect(303, `/app/cases/${newCase.id}`); + }, + update: async ({ request, params, locals }) => { + 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 prisma.case.update({ + where: { id: caseId, organizationId: locals.org.id }, + data: { + subject, + description, + accountId, + dueDate, + priority, + ownerId + } + }); + throw redirect(303, `/app/cases/${caseId}`); + }, + delete: async ({ request, locals }) => { + const form = await request.formData(); + const caseId = form.get('caseId')?.toString(); + if (!caseId) { + return fail(400, { error: 'Case ID is required.' }); + } + await prisma.case.delete({ + where: { + id: caseId, + organizationId: locals.org.id + } + }); + throw redirect(303, '/app/cases'); + }, + comment: async ({ request, locals }) => { + 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 prisma.comment.create({ + data: { + body, + authorId: locals.user.id, + organizationId: locals.org.id, + caseId: caseId + } + }); + return { success: true }; + } +}; diff --git a/src/routes/(app)/app/cases/+page.svelte b/src/routes/(app)/app/cases/+page.svelte new file mode 100644 index 0000000..e78170d --- /dev/null +++ b/src/routes/(app)/app/cases/+page.svelte @@ -0,0 +1,299 @@ + + +
+
+ +
+
+
+ +
+
+

Cases

+

Manage customer support cases and issues

+
+
+ + + New Case + +
+ + +
+
+ +

Filters

+ {#if hasActiveFilters} + + {[statusFilter, assignedFilter, accountFilter].filter(Boolean).length} active + + {/if} +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if hasActiveFilters} +
+ +
+ {/if} +
+
+ + +
+ {#if filteredCases.length} + + + + +
+ {#each filteredCases as c} +
+
+ + {c.subject} + + + {c.status.replace('_', ' ')} + +
+ + {#if c.description} +

{c.description}

+ {/if} + +
+
+ Account: + {c.account?.name || '-'} +
+
+ Priority: + + {c.priority} + +
+
+ Assigned: + {c.owner?.name || 'Unassigned'} +
+
+ Due: + + {c.dueDate ? new Date(c.dueDate).toLocaleDateString() : '-'} + +
+
+ + +
+ {/each} +
+ {:else} +
+
+ +
+

No cases found

+

+ {hasActiveFilters ? 'No cases match your current filters.' : 'Get started by creating your first case.'} +

+ {#if hasActiveFilters} + + {:else} + + + Create Case + + {/if} +
+ {/if} +
+
+
diff --git a/src/routes/(app)/app/cases/[caseId]/+page.server.js b/src/routes/(app)/app/cases/[caseId]/+page.server.js new file mode 100644 index 0000000..f422620 --- /dev/null +++ b/src/routes/(app)/app/cases/[caseId]/+page.server.js @@ -0,0 +1,46 @@ +import prisma from '$lib/prisma'; +import { error, fail, redirect } from '@sveltejs/kit'; + +export async function load({ params, locals }) { + const org = locals.org; + const caseId = params.caseId; + const caseItem = await prisma.case.findUnique({ + where: { id: caseId, organizationId: org.id }, + include: { + owner: { select: { id: true, name: true } }, + account: { select: { id: true, name: true } }, + comments: { + include: { author: { select: { id: true, name: true } } }, + orderBy: { createdAt: 'desc' } + } + } + }); + if (!caseItem) throw error(404, 'Case not found'); + return { caseItem }; +} + +export const actions = { + comment: async ({ request, params, locals }) => { + const org = locals.org; + + // check if the case is related to the organization + const caseExists = await prisma.case.findFirst({ + where: { id: params.caseId, organizationId: org.id } + }); + 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 prisma.comment.create({ + data: { + body, + authorId: locals.user.id, + organizationId: locals.org.id, + caseId: params.caseId + } + }); + return { success: true }; + } +}; diff --git a/src/routes/(app)/app/cases/[caseId]/+page.svelte b/src/routes/(app)/app/cases/[caseId]/+page.svelte new file mode 100644 index 0000000..88b5834 --- /dev/null +++ b/src/routes/(app)/app/cases/[caseId]/+page.svelte @@ -0,0 +1,244 @@ + + +
+
+ +
+
+
+
+ +
+
+

{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

+

{(new Date(data.caseItem.dueDate)).toLocaleDateString()}

+
+
+ {/if} + +
+ +
+

Created

+

{(new Date(data.caseItem.createdAt)).toLocaleDateString()}

+
+
+
+
+
+ + +
+
+ +

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'}

+ +

{(new Date(c.createdAt)).toLocaleDateString()}

+
+

{c.body}

+
+
+
+ {/each} + {:else} +
+ +

No comments yet. Be the first to add one!

+
+ {/if} +
+ + +
+
+ + +
+ {#if errorMsg} +

{errorMsg}

+ {/if} +
+
+
+ + +
+ +
+

Status & Priority

+ +
+
+

Status

+
+ + + {data.caseItem.status.replace('_', ' ')} + +
+
+ +
+

Priority

+ + {data.caseItem.priority} + +
+
+
+ + +
+

Activity Timeline

+ +
+
+
+
+

Case Created

+

{(new Date(data.caseItem.createdAt)).toLocaleDateString()}

+
+
+ + {#if data.caseItem.updatedAt && data.caseItem.updatedAt !== data.caseItem.createdAt} +
+
+
+

Last Updated

+

{(new Date(data.caseItem.updatedAt)).toLocaleDateString()}

+
+
+ {/if} + + {#if data.caseItem.closedAt} +
+
+
+

Case Closed

+

{(new Date(data.caseItem.closedAt)).toLocaleDateString()}

+
+
+ {/if} +
+
+
+
+
+
diff --git a/src/routes/(app)/app/cases/[caseId]/edit/+page.server.js b/src/routes/(app)/app/cases/[caseId]/edit/+page.server.js new file mode 100644 index 0000000..0019ac3 --- /dev/null +++ b/src/routes/(app)/app/cases/[caseId]/edit/+page.server.js @@ -0,0 +1,91 @@ +import prisma from '$lib/prisma'; +import { fail, redirect, error } from '@sveltejs/kit'; + +export async function load({ params, locals }) { + const org = locals.org; + const caseId = params.caseId; + const caseItem = await prisma.case.findUnique({ + where: { id: caseId, organizationId: org.id }, + include: { + owner: { select: { id: true, name: true } }, + account: { select: { id: true, name: true } } + } + }); + if (!caseItem) throw error(404, 'Case not found'); + // Fetch all users and accounts for dropdowns + const [users, accounts] = await Promise.all([ + prisma.user.findMany({ select: { id: true, name: true } }), + prisma.account.findMany({ select: { id: true, name: true } }) + ]); + return { caseItem, users, accounts }; +} + +export const actions = { + update: async ({ request, params, locals }) => { + const org = locals.org; + 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 prisma.case.findFirst({ + where: { id: params.caseId, organizationId: org.id } + }); + if (!caseExists) { + return fail(404, { error: 'Case not found or does not belong to this organization.' }); + } + + try { + await prisma.case.update({ + where: { id: params.caseId }, + data: { subject, description, accountId, dueDate, priority, ownerId } + }); + return { success: true }; + } catch (error) { + return fail(500, { error: 'Failed to update case.' }); + } + }, + close: async ({ params, locals }) => { + const org = locals.org; + + // Validate case is part of the organization + const caseExists = await prisma.case.findFirst({ + where: { id: params.caseId, organizationId: org.id } + }); + if (!caseExists) { + return fail(404, { error: 'Case not found or does not belong to this organization.' }); + } + + await prisma.case.update({ + where: { id: params.caseId }, + data: { status: 'CLOSED', closedAt: new Date() } + }); + throw redirect(303, `/app/cases/${params.caseId}`); + }, + reopen: async ({ params, locals }) => { + const org = locals.org; + + // Validate case is part of the organization + const caseExists = await prisma.case.findFirst({ + where: { id: params.caseId, organizationId: org.id } + }); + if (!caseExists) { + return fail(404, { error: 'Case not found or does not belong to this organization.' }); + } + + await prisma.case.update({ + where: { id: params.caseId }, + data: { status: 'OPEN', closedAt: null } + }); + throw redirect(303, `/app/cases/${params.caseId}`); + } +}; diff --git a/src/routes/(app)/app/cases/[caseId]/edit/+page.svelte b/src/routes/(app)/app/cases/[caseId]/edit/+page.svelte new file mode 100644 index 0000000..10b3f3a --- /dev/null +++ b/src/routes/(app)/app/cases/[caseId]/edit/+page.svelte @@ -0,0 +1,338 @@ + + +
+
+ +
+
+
+

Edit Case

+

Update case details and assignment

+
+
+ +
+
+
+ + +
+
{ + 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(); + }; + }}> +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + + {#if errorMsg} +
+ +
{errorMsg}
+
+ {/if} + + {#if successMsg} +
+
+
+
+
{successMsg}
+
+ {/if} +
+ + +
+ + + {#if data.caseItem.status === 'CLOSED'} + + {:else} + + {/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. +

+
+ + +
+
+
+
+ {/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. +

+
+ + +
+
+
+
+ {/if} +
+
diff --git a/src/routes/(app)/app/cases/new/+page.server.js b/src/routes/(app)/app/cases/new/+page.server.js new file mode 100644 index 0000000..3feb96a --- /dev/null +++ b/src/routes/(app)/app/cases/new/+page.server.js @@ -0,0 +1,68 @@ +import prisma from '$lib/prisma'; +import { fail, redirect } from '@sveltejs/kit'; + +export async function load({ locals, url }) { + const org = locals.org; + const preSelectedAccountId = url.searchParams.get('accountId'); + + const accounts = await prisma.account.findMany( + { + where: { organizationId: org.id }, + select: { id: true, name: true } + } + ); + const users = await prisma.userOrganization.findMany({ + where: { organizationId: org.id }, + select: { + user: { + select: { + id: true, + name: true + } + } + } + }); + + return { accounts, users, preSelectedAccountId }; +} + +export const actions = { + create: async ({ request, locals }) => { + const org = locals.org; + 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.' }); + } + + // check if the ownerId is valid and related to the organization + const isValidOwner = await prisma.userOrganization.findFirst({ + where: { + userId: ownerId, + organizationId: org.id + } + }); + if (!isValidOwner) { + return fail(400, { error: 'Invalid owner ID.' }); + } + + const newCase = await prisma.case.create({ + data: { + subject, + description, + accountId, + dueDate, + priority, + ownerId, + organizationId: locals.org.id + } + }); + throw redirect(303, `/app/cases/${newCase.id}`); + } +}; diff --git a/src/routes/(app)/app/cases/new/+page.svelte b/src/routes/(app)/app/cases/new/+page.svelte new file mode 100644 index 0000000..198004b --- /dev/null +++ b/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

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+

Assignment & Priority

+

Set ownership and urgency level

+
+ +
+ +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+ + + {#if errorMsg} +
+

{errorMsg}

+
+ {/if} + + +
+ + + Cancel + +
+
+
+
+
diff --git a/src/routes/(app)/app/contacts/+page.server.js b/src/routes/(app)/app/contacts/+page.server.js new file mode 100644 index 0000000..bcf40fd --- /dev/null +++ b/src/routes/(app)/app/contacts/+page.server.js @@ -0,0 +1,127 @@ +import prisma from '$lib/prisma'; +import { error } from '@sveltejs/kit'; +import { Prisma } from '@prisma/client'; + + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ url, locals }) { + 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; + + // Build where clause + const where = { + organizationId: locals.org.id, + ...(search && { + OR: [ + { firstName: { contains: search, mode: Prisma.QueryMode.insensitive } }, + { lastName: { contains: search, mode: Prisma.QueryMode.insensitive } }, + { email: { contains: search, mode: Prisma.QueryMode.insensitive } }, + { phone: { contains: search, mode: Prisma.QueryMode.insensitive } }, + { title: { contains: search, mode: Prisma.QueryMode.insensitive } }, + { department: { contains: search, mode: Prisma.QueryMode.insensitive } } + ] + }), + ...(ownerId && { ownerId }) + }; + + // Fetch contacts with owner info + const [contacts, totalCount, owners] = await Promise.all([ + prisma.contact.findMany({ + where, + include: { + owner: { + select: { + id: true, + name: true, + email: true + } + }, + relatedAccounts: { + include: { + account: { + select: { + id: true, + name: true + } + } + } + }, + _count: { + select: { + tasks: true, + events: true, + opportunities: true, + cases: true + } + } + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit + }), + prisma.contact.count({ where }), + prisma.user.findMany({ + where: { + organizations: { + some: { + organizationId: locals.org.id + } + } + }, + select: { + id: true, + name: true, + email: true + }, + orderBy: { name: 'asc' } + }) + ]); + + return { + contacts, + totalCount, + currentPage: page, + totalPages: Math.ceil(totalCount / limit), + limit, + search, + ownerId, + owners + }; + } catch (err) { + console.error('Error loading contacts:', err); + throw error(500, 'Failed to load contacts'); + } +} + +/** @type {import('./$types').Actions} */ +export const actions = { + delete: async ({ request, locals }) => { + try { + + const data = await request.formData(); + const contactId = data.get('contactId')?.toString(); + + if (!contactId) { + throw error(400, 'Contact ID is required'); + } + + await prisma.contact.delete({ + where: { + id: contactId, + organizationId: locals.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/src/routes/(app)/app/contacts/+page.svelte b/src/routes/(app)/app/contacts/+page.svelte new file mode 100644 index 0000000..4ba09a7 --- /dev/null +++ b/src/routes/(app)/app/contacts/+page.svelte @@ -0,0 +1,467 @@ + + + + Contacts - BottleCRM + + +
+ +
+
+
+
+
+ +
+
+

Contacts

+

+ {data.totalCount} total contacts +

+
+
+
+ + + + 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} + + {/if} +
+
+ + + {#if showFilters} +
+
+
+ + +
+
+
+ + +
+
+ {/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} + + + + +
+ {#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} + + {/if} + {/each} + + +
+
+ {/if} + {/if} +
+
\ No newline at end of file diff --git a/src/routes/(app)/app/contacts/[contactId]/+page.server.js b/src/routes/(app)/app/contacts/[contactId]/+page.server.js new file mode 100644 index 0000000..a2d5d7b --- /dev/null +++ b/src/routes/(app)/app/contacts/[contactId]/+page.server.js @@ -0,0 +1,71 @@ +import prisma from '$lib/prisma'; + +export async function load({ params, locals }) { + const org = locals.org; + const contact = await prisma.contact.findUnique({ + where: { id: params.contactId, organizationId: org.id }, + include: { + owner: { + select: { + id: true, + name: true, + email: true + } + } + } + }); + + if (!contact) { + return { + status: 404, + error: new Error('Contact not found') + }; + } + + // Get all related accounts via AccountContactRelationship + const accountRels = await prisma.accountContactRelationship.findMany({ + where: { contactId: params.contactId }, + include: { account: true }, + orderBy: [ + { isPrimary: 'desc' }, // Primary relationships first + { startDate: 'desc' } // Then by most recent + ] + }); + + // Fetch related opportunities + const opportunities = await prisma.opportunity.findMany({ + where: { + contacts: { some: { id: params.contactId } }, + organization: { id: org.id } + }, + include: { account: true }, + orderBy: { createdAt: 'desc' }, + take: 5 + }); + + // Fetch related tasks + const tasks = await prisma.task.findMany({ + where: { contactId: params.contactId, organizationId: org.id }, + include: { owner: true, createdBy: true }, + orderBy: { createdAt: 'desc' }, + take: 5 + }); + + // Fetch related events + const events = await prisma.event.findMany({ + where: { contactId: params.contactId, organizationId: org.id }, + include: { owner: true, createdBy: true }, + orderBy: { startDate: 'desc' }, + take: 5 + }); + + return { + contact: { + ...contact, + accountRelationships: accountRels, + opportunities, + tasks, + events + } + }; +} diff --git a/src/routes/(app)/app/contacts/[contactId]/+page.svelte b/src/routes/(app)/app/contacts/[contactId]/+page.svelte new file mode 100644 index 0000000..e19c454 --- /dev/null +++ b/src/routes/(app)/app/contacts/[contactId]/+page.svelte @@ -0,0 +1,357 @@ + + +
+ +
+
+
+
+ {#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 +

+
+
+
+ Email + {#if contact.email} + + + {contact.email} + + {:else} +

N/A

+ {/if} +
+
+ Phone + {#if contact.phone} + + + {contact.phone} + + {:else} +

N/A

+ {/if} +
+
+ Department +

{contact.department || 'N/A'}

+
+
+
+
+ Title +

{contact.title || 'N/A'}

+
+
+ Owner +

{contact.owner?.name || 'N/A'}

+
+
+ Created +

+ + {formatDate(contact.createdAt)} +

+
+
+
+ {#if contact.description} +
+ Description +

{contact.description}

+
+ {/if} +
+ + + {#if contact.accountRelationships && contact.accountRelationships.length > 0} +
+

+ + Account Relationships + ({contact.accountRelationships.length}) +

+
+ {#each contact.accountRelationships as relationship} +
+
+
+ + + {relationship.account.name} + + {#if relationship.isPrimary} + + + Primary + + {/if} +
+
+ {#if relationship.role} + + + {relationship.role} + + {/if} + + + Since {formatDate(relationship.startDate)} + +
+ {#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} +
+
+

+ + Opportunities +

+ + View all + + +
+
+ {#each contact.opportunities as opp} +
+
+ + {opp.name} + +

{opp.account?.name}

+
+
+

+ + {formatCurrency(opp.amount || 0)} +

+ + {opp.stage.replace('_', ' ')} + +
+
+ {/each} +
+
+ {/if} +
+ + +
+ + {#if contact.tasks && contact.tasks.length > 0} +
+
+

+ + Recent Tasks +

+ View all +
+
+ {#each contact.tasks as task} +
+ {#if task.status === 'Completed'} + + {:else} + + {/if} +
+

{task.subject}

+
+ + {task.priority} + + {#if task.dueDate} + + + {formatDate(task.dueDate)} + + {/if} +
+
+
+ {/each} +
+
+ {/if} + + + {#if contact.events && contact.events.length > 0} +
+
+

+ + Recent Events +

+ View all +
+
+ {#each contact.events as event} +
+

{event.subject}

+

+ + {formatDateTime(event.startDate)} +

+ {#if event.location} +

+ + {event.location} +

+ {/if} +
+ {/each} +
+
+ {/if} + +
+
+
+
diff --git a/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.js b/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.js new file mode 100644 index 0000000..dd0b513 --- /dev/null +++ b/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.js @@ -0,0 +1,89 @@ +import prisma from '$lib/prisma'; +import { fail, redirect } from '@sveltejs/kit'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; + +export async function load({ params, locals }) { + const org = locals.org; + const user = locals.user; + + const contact = await prisma.contact.findUnique({ + where: { id: params.contactId, organizationId: org.id } + }); + if (!contact) { + return fail(404, { message: 'Contact not found' }); + } + // Get related account info + const accountRel = await prisma.accountContactRelationship.findFirst({ + where: { contactId: params.contactId }, + include: { account: true } + }); + return { + contact, + account: accountRel?.account || null, + isPrimary: accountRel?.isPrimary || false, + role: accountRel?.role || '' + }; +} + +export const actions = { + default: async ({ request, params, locals }) => { + const org = locals.org; + const user = locals.user; + + 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 prisma.contact.findUnique({ + where: { id: params.contactId, organizationId: org.id } + }); + if (!contact) { + return fail(404, { message: 'Contact not found' }); + } + + // Update contact + await prisma.contact.update({ + where: { id: params.contactId }, + data: { + firstName, + lastName, + email, + phone: formattedPhone, + title, + department, + street, + city, + state, + postalCode, + country, + description + } + }); + + return { success: true }; + } +}; diff --git a/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte b/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte new file mode 100644 index 0000000..c52a2d5 --- /dev/null +++ b/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte @@ -0,0 +1,393 @@ + + +
+
+ +
+
+ +
+

Edit Contact

+

Update contact information and details

+
+
+
+ + +
+ +
+
+
+
+ +
+

Basic Information

+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+

Contact Information

+
+
+
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+ {#if phoneError} +

{phoneError}

+ {/if} +
+
+
+
+ + +
+
+
+
+ +
+

Address Information

+
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + + {#if account} +
+
+
+
+ +
+

Account Relationship

+
+
+
+
+
+
Account
+
{account.name}
+
+ {#if role} +
+
Role
+
{role}
+
+ {/if} + {#if isPrimary} +
+
+ + Primary Contact +
+
+ {/if} +
+
+
+ {/if} + + +
+
+
+
+ +
+

Additional Information

+
+
+
+
+ + +
+
+
+ + + {#if errorMsg} +
+
+
+ +
+
+

{errorMsg}

+
+
+
+ {/if} + + +
+ + +
+
+
+
diff --git a/src/routes/(app)/app/contacts/new/+page.server.js b/src/routes/(app)/app/contacts/new/+page.server.js new file mode 100644 index 0000000..a752fdc --- /dev/null +++ b/src/routes/(app)/app/contacts/new/+page.server.js @@ -0,0 +1,270 @@ +import { redirect, fail } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ locals, url }) { + // Ensure user is authenticated + if (!locals.user) { + throw redirect(302, '/auth/login'); + } + + // Get user's organizations for the dropdown + const userOrganizations = await prisma.userOrganization.findMany({ + where: { userId: locals.user.id }, + include: { organization: true } + }); + + // Get accounts for the account dropdown (if no specific accountId is provided) + const accountId = url.searchParams.get('accountId'); + /** @type {Array<{id: string, name: string, organizationId: string}>} */ + let accounts = []; + + if (!accountId) { + // Load accounts from user's organizations + const organizationIds = userOrganizations.map(uo => uo.organizationId); + accounts = await prisma.account.findMany({ + where: { + organizationId: { in: organizationIds }, + isDeleted: false + }, + select: { + id: true, + name: true, + organizationId: true + }, + orderBy: { name: 'asc' } + }); + } else { + // Load the specific account to validate access and show in UI + const account = await prisma.account.findFirst({ + where: { + id: accountId, + organizationId: { in: userOrganizations.map(uo => uo.organizationId) }, + isDeleted: false + }, + select: { + id: true, + name: true, + organizationId: true + } + }); + + if (account) { + accounts = [account]; + } + } + + return { + organizations: userOrganizations.map(uo => uo.organization), + accounts + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + create: async ({ request, locals, url }) => { + if (!locals.user) { + throw redirect(302, '/auth/login'); + } + + 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 = {}; + + 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; + + // If accountId is provided, validate it and get its organizationId + if (accountId) { + const account = await prisma.account.findFirst({ + where: { + id: accountId, + isDeleted: false + }, + include: { + organization: { + include: { + users: { + where: { userId: locals.user.id } + } + } + } + } + }); + + 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 + } + }); + } + + if (account.organization.users.length === 0) { + 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 prisma.userOrganization.findFirst({ + where: { + userId: locals.user.id, + 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 prisma.contact.findFirst({ + where: { + email: email, + 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 prisma.contact.create({ + data: { + 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: locals.user.id, + organizationId: validatedOrganizationId || null + } + }); + + // Create account-contact relationship if accountId is provided + if (accountId) { + await prisma.accountContactRelationship.create({ + data: { + accountId: accountId, + contactId: contact.id, + role: role || null, + isPrimary: isPrimary + } + }); + } + + // Create audit log + await prisma.auditLog.create({ + data: { + 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 }, + userId: locals.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/src/routes/(app)/app/contacts/new/+page.svelte b/src/routes/(app)/app/contacts/new/+page.svelte new file mode 100644 index 0000000..708ea6f --- /dev/null +++ b/src/routes/(app)/app/contacts/new/+page.svelte @@ -0,0 +1,467 @@ + + + + New Contact - BottleCRM + + +
+ +
+
+
+
+ + + {accountId ? 'Back to Account' : 'Back to Contacts'} + +
+

New Contact

+

+ {#if selectedAccount} + Add a new contact to {selectedAccount.name} + {:else} + Add a new contact to your CRM + {/if} +

+
+
+
+
+
+ + +
+ + {#if errors.general} +
+

{errors.general}

+
+ {/if} + + + {#if selectedAccount} +
+
+ +

+ This contact will be added to {selectedAccount.name} +

+
+
+ {/if} + +
+ + {#if accountId} + + {/if} + + +
+
+
+ +

Basic Information

+
+
+
+ + {#if !accountId} +
+ + +
+ {/if} + + + {#if !accountId && data.accounts?.length > 0} +
+ + +
+ {/if} + + +
+
+ + + {#if errors.firstName} +

{errors.firstName}

+ {/if} +
+
+ + + {#if errors.lastName} +

{errors.lastName}

+ {/if} +
+
+ + +
+
+ + + {#if errors.email} +

{errors.email}

+ {/if} +
+
+ + + {#if errors.phone} +

{errors.phone}

+ {/if} + {#if phoneError} +

{phoneError}

+ {/if} +
+
+
+
+ + + {#if selectedAccount || formValues.accountId} +
+
+
+ +

Account Relationship

+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ {/if} + + +
+
+
+ +

Professional Information

+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +

Address Information

+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +

Additional Information

+
+
+
+
+ + +
+
+
+ + +
+ + Cancel + + +
+
+
+
\ No newline at end of file diff --git a/src/routes/(app)/app/invoices/+page.server.js b/src/routes/(app)/app/invoices/+page.server.js new file mode 100644 index 0000000..3833ac1 --- /dev/null +++ b/src/routes/(app)/app/invoices/+page.server.js @@ -0,0 +1,40 @@ +import { redirect } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ locals }) { + if (!locals.user || !locals.org) { + throw redirect(302, '/login'); + } + + // Get quotes that serve as invoices for this organization + const invoices = await prisma.quote.findMany({ + where: { + organizationId: locals.org.id + }, + include: { + account: { + select: { + id: true, + name: true + } + }, + lineItems: { + include: { + product: { + select: { + name: true + } + } + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + + return { + invoices + }; +}; diff --git a/src/routes/(app)/app/invoices/+page.svelte b/src/routes/(app)/app/invoices/+page.svelte new file mode 100644 index 0000000..eb6afdd --- /dev/null +++ b/src/routes/(app)/app/invoices/+page.svelte @@ -0,0 +1,134 @@ + + + +
+
+
+

Invoices

+ + New Invoice +
+ + +
+ +
+ + + + + + +
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+
+ + +
+ {#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} +
+
+
+
+
{formatCurrency(Number(invoice.grandTotal))}
+
+ View + Edit +
+
+ +
+
+ {/each} + + + {#if data.invoices.length === 0} +
+
📄
+

No invoices yet

+

Create your first invoice to get started

+ + Create Invoice + +
+ {/if} +
+
+
diff --git a/src/routes/(app)/app/invoices/[invoiceId]/+page.server.js b/src/routes/(app)/app/invoices/[invoiceId]/+page.server.js new file mode 100644 index 0000000..6535ac4 --- /dev/null +++ b/src/routes/(app)/app/invoices/[invoiceId]/+page.server.js @@ -0,0 +1,63 @@ +import { error, redirect } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params, locals }) { + if (!locals.user || !locals.org) { + throw redirect(302, '/login'); + } + + const invoice = await prisma.quote.findFirst({ + where: { + id: params.invoiceId, + organizationId: locals.org.id + }, + include: { + account: { + select: { + id: true, + name: true, + street: true, + city: true, + state: true, + postalCode: true, + country: true + } + }, + contact: { + select: { + firstName: true, + lastName: true, + email: true + } + }, + lineItems: { + include: { + product: { + select: { + name: true, + code: true + } + } + }, + orderBy: { + id: 'asc' + } + }, + preparedBy: { + select: { + name: true, + email: true + } + } + } + }); + + if (!invoice) { + throw error(404, 'Invoice not found'); + } + + return { + invoice + }; +}; diff --git a/src/routes/(app)/app/invoices/[invoiceId]/+page.svelte b/src/routes/(app)/app/invoices/[invoiceId]/+page.svelte new file mode 100644 index 0000000..bdf3d98 --- /dev/null +++ b/src/routes/(app)/app/invoices/[invoiceId]/+page.svelte @@ -0,0 +1,160 @@ + + + +
+
+
+
+

Invoice

+
{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: + + {invoice.status.toLowerCase()} + +
+
+ Created:{new Date(invoice.createdAt).toLocaleDateString()} +
+
+ Due Date:{invoice.expirationDate ? new Date(invoice.expirationDate).toLocaleDateString() : 'N/A'} +
+
+
+ +
+ + + + + + + + + + + {#each invoice.lineItems as item} + + + + + + + {/each} + + + + + + + + + + + +
DescriptionQuantityRateTotal
{item.description || item.product?.name || 'N/A'}{item.quantity}{formatCurrency(Number(item.unitPrice))}{formatCurrency(Number(item.totalPrice))}
Subtotal:{formatCurrency(Number(invoice.subtotal))}
Total:{formatCurrency(Number(invoice.grandTotal))}
+
+ + {#if invoice.description} +
+
Notes:
+
+ {invoice.description} +
+
+ {/if} + + + + +
+
+
+
diff --git a/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.server.js b/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.server.js new file mode 100644 index 0000000..3689bde --- /dev/null +++ b/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.server.js @@ -0,0 +1,127 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params, locals }) { + if (!locals.user || !locals.org) { + throw redirect(302, '/login'); + } + + const invoice = await prisma.quote.findFirst({ + where: { + id: params.invoiceId, + organizationId: locals.org.id + }, + include: { + account: { + select: { + id: true, + name: true + } + }, + lineItems: { + include: { + product: { + select: { + id: true, + name: true, + code: true + } + } + }, + orderBy: { + id: 'asc' + } + } + } + }); + + if (!invoice) { + throw error(404, 'Invoice not found'); + } + + // Get accounts for the dropdown + const accounts = await prisma.account.findMany({ + where: { + organizationId: locals.org.id, + isActive: true, + isDeleted: false + }, + select: { + id: true, + name: true + }, + orderBy: { + name: 'asc' + } + }); + + return { + invoice, + accounts + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + default: async ({ request, params, locals }) => { + if (!locals.user || !locals.org) { + return fail(401, { error: 'Unauthorized' }); + } + + 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 prisma.quote.findFirst({ + where: { + id: params.invoiceId, + organizationId: locals.org.id + } + }); + + 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 prisma.quote.update({ + where: { + id: params.invoiceId + }, + data: { + accountId, + status: quoteStatus, + description: notes, + expirationDate: new Date(dueDate), + updatedAt: new Date() + } + }); + + 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/invoices/[invoiceId]/edit/+page.svelte b/src/routes/(app)/app/invoices/[invoiceId]/edit/+page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/(app)/app/invoices/new/+page.server.js b/src/routes/(app)/app/invoices/new/+page.server.js new file mode 100644 index 0000000..e58025c --- /dev/null +++ b/src/routes/(app)/app/invoices/new/+page.server.js @@ -0,0 +1,87 @@ +import { fail, redirect } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ locals }) { + if (!locals.user || !locals.org) { + throw redirect(302, '/login'); + } + + // Get accounts for the dropdown + const accounts = await prisma.account.findMany({ + where: { + organizationId: locals.org.id, + isActive: true, + isDeleted: false + }, + select: { + id: true, + name: true + }, + orderBy: { + name: 'asc' + } + }); + + return { + accounts + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + default: async ({ request, locals }) => { + if (!locals.user || !locals.org) { + return fail(401, { error: 'Unauthorized' }); + } + + 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') || ''); + const status = String(formData.get('status') || '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 { + // For now, we'll create a Quote since that's what exists in the schema + // In a real implementation, you might want to add an Invoice model + // or extend the Quote model to handle invoices + + // Generate unique quote number (since we're using Quote model) + const quoteNumber = `INV-${Date.now()}`; + + const quote = await prisma.quote.create({ + data: { + quoteNumber, + name: `Invoice ${invoiceNumber}`, + status: status === 'DRAFT' ? 'DRAFT' : + status === 'SENT' ? 'PRESENTED' : + status === 'PAID' ? 'ACCEPTED' : 'DRAFT', + description: notes, + expirationDate: new Date(dueDate), + subtotal: 0, // Will be updated when line items are added + grandTotal: 0, + preparedById: locals.user.id, + accountId: accountId, + organizationId: locals.org.id + } + }); + + 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/src/routes/(app)/app/invoices/new/+page.svelte b/src/routes/(app)/app/invoices/new/+page.svelte new file mode 100644 index 0000000..b0cc179 --- /dev/null +++ b/src/routes/(app)/app/invoices/new/+page.svelte @@ -0,0 +1,258 @@ + + + +
+
+
+
+

New Invoice

+

Create a professional invoice for your client

+
+
+ DRAFT +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

Line Items

+ +
+ +
+ + + + + + + + + + + + {#each lineItems as item, index (index)} + + + + + + + + {/each} + + + + + + + + + + + + + +
DescriptionQuantityRateTotal
+ { + const target = e.target; + if (target instanceof HTMLInputElement) { + updateLineItem(index, 'description', target.value); + } + }} + class="w-full border-none bg-transparent focus:ring-2 focus:ring-blue-500 rounded px-2 py-1" + placeholder="Description" /> + + { + const target = e.target; + if (target instanceof HTMLInputElement) { + updateLineItem(index, 'quantity', Number(target.value)); + } + }} + class="w-20 border-none bg-transparent focus:ring-2 focus:ring-blue-500 rounded px-2 py-1 text-right" + min="1" /> + + { + const target = e.target; + if (target instanceof HTMLInputElement) { + updateLineItem(index, 'rate', Number(target.value)); + } + }} + class="w-24 border-none bg-transparent focus:ring-2 focus:ring-blue-500 rounded px-2 py-1 text-right" + min="0" + step="0.01" /> + + ${item.total.toFixed(2)} + + +
Subtotal:${subtotal.toFixed(2)}
Total:${grandTotal.toFixed(2)}
+
+
+ + +
+ + +
+ + +
+ + +
+
+
+
diff --git a/src/routes/(app)/app/leads/[lead_id]/+page.server.js b/src/routes/(app)/app/leads/[lead_id]/+page.server.js index 8248c6d..c76aedb 100644 --- a/src/routes/(app)/app/leads/[lead_id]/+page.server.js +++ b/src/routes/(app)/app/leads/[lead_id]/+page.server.js @@ -1,41 +1,247 @@ -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { PrismaClient } from '@prisma/client'; +import { z } from 'zod'; // For input validation const prisma = new PrismaClient(); -export async function load({ params }) { +// Input validation schemas +const commentSchema = z.object({ + comment: z.string().min(1, 'Comment cannot be empty').max(1000, 'Comment too long').trim() +}); + +export async function load({ params, locals }) { const lead_id = params.lead_id; - - try { - // Fetch lead with owner information - const lead = await prisma.lead.findUnique({ - where: { id: lead_id }, - include: { - owner: true, - tasks: { - orderBy: { createdAt: 'desc' } - }, - events: { - orderBy: { startDate: 'asc' } + const org = locals.org; + + const lead = await prisma.lead.findUnique({ + where: { id: lead_id, organizationId: org.id }, + include: { + owner: true, + tasks: { + orderBy: { createdAt: 'desc' } + }, + events: { + orderBy: { startDate: 'asc' } + }, + comments: { + include: { + author: true }, - comments: { - include: { - author: true - }, - orderBy: { createdAt: 'desc' } + orderBy: { createdAt: 'desc' } + }, + contact: true + } + }); + + if (!lead) { + throw error(404, 'Lead not found'); + } + + return { + lead + }; +} + +export const actions = { + convert: async ({ params, locals }) => { + const lead_id = params.lead_id; + const user = locals.user; + const org = locals.org; + + try { + console.log('Starting lead conversion for lead:', lead_id); + + const lead = await prisma.lead.findUnique({ + where: { id: lead_id, organizationId: org.id }, + include: { + organization: true, + owner: true } + }); + + if (!lead) { + return fail(404, { status: 'error', message: 'Lead not found' }); } - }); - - if (!lead) { - throw error(404, 'Lead not found'); + + if (lead.status === 'CONVERTED') { + return { status: 'success', message: 'Lead already converted' }; + } + + console.log('Creating contact...'); + const contact = await prisma.contact.create({ + data: { + firstName: lead.firstName, + lastName: lead.lastName, + email: lead.email, + phone: lead.phone, + title: lead.title, + description: lead.description, + owner: { connect: { id: lead.ownerId } }, + organization: { connect: { id: lead.organizationId } } + } + }); + console.log('Contact created with ID:', contact.id); + + let accountId = null; + let account = null; + if (lead.company) { + console.log('Creating account for company:', lead.company); + account = await prisma.account.create({ + data: { + name: lead.company, + industry: lead.industry, + owner: { connect: { id: lead.ownerId } }, + organization: { connect: { id: lead.organizationId } } + } + }); + accountId = account.id; + console.log('Account created with ID:', accountId); + + console.log('Creating account-contact relationship...'); + await prisma.accountContactRelationship.create({ + data: { + account: { connect: { id: account.id } }, + contact: { connect: { id: contact.id } }, + isPrimary: true, + role: 'Primary Contact' + } + }); + console.log('Account-contact relationship created'); + } else { + console.log('Creating placeholder account...'); + // Create a placeholder account if no company + account = await prisma.account.create({ + data: { + name: `${lead.firstName} ${lead.lastName} Account`, + owner: { connect: { id: lead.ownerId } }, + organization: { connect: { id: lead.organizationId } } + } + }); + accountId = account.id; + console.log('Placeholder account created with ID:', accountId); + + console.log('Creating account-contact relationship...'); + await prisma.accountContactRelationship.create({ + data: { + account: { connect: { id: account.id } }, + contact: { connect: { id: contact.id } }, + isPrimary: true, + role: 'Primary Contact' + } + }); + console.log('Account-contact relationship created'); + } + + console.log('Creating opportunity...'); + const opportunity = await prisma.opportunity.create({ + data: { + name: `${lead.company || lead.firstName + ' ' + lead.lastName} Opportunity`, + stage: 'PROSPECTING', + amount: 0, + closeDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + contacts: { connect: { id: contact.id } }, + owner: { connect: { id: lead.ownerId } }, + organization: { connect: { id: lead.organizationId } }, + account: { connect: { id: accountId } } + } + }); + console.log('Opportunity created with ID:', opportunity.id); + + console.log('Updating lead status...'); + await prisma.lead.update({ + where: { id: lead_id }, + data: { + status: 'CONVERTED', + isConverted: true, + convertedAt: new Date(), + convertedContactId: contact.id, + convertedAccountId: accountId, + convertedOpportunityId: opportunity.id, + contact: { connect: { id: contact.id } } + } + }); + console.log('Lead status updated successfully'); + + console.log('Lead conversion completed, account created:', accountId); + + return { + status: 'success', + message: 'Lead successfully converted', + redirectTo: `/app/accounts/${accountId}`, + contact, + account, + opportunity + }; + } catch (err) { + console.error('Error converting lead:', err); + + // Extract meaningful error message + let errorMessage = 'Failed to convert lead'; + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'string') { + errorMessage = err; + } else if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { + errorMessage = err.message; + } + + return fail(500, { + status: 'error', + message: `Error converting lead: ${errorMessage}` + }); } + }, + + addComment: async ({ params, request, locals }) => { + const lead_id = params.lead_id; + const user = locals.user; + const org = locals.org; + + + // Validate form data + const data = await request.formData(); + const comment = data.get('comment'); - return { - lead - }; - } catch (err) { - console.error('Error fetching lead:', err); - throw error(500, 'Failed to load lead details'); + try { + const validatedComment = commentSchema.parse({ comment }); + + const lead = await prisma.lead.findUnique({ + where: { id: lead_id, organizationId: org.id }, + select: { organizationId: true } + }); + + if (!lead) { + return fail(404, { status: 'error', message: 'Lead not found' }); + } + + await prisma.comment.create({ + data: { + body: validatedComment.comment, + lead: { connect: { id: lead_id } }, + author: { connect: { id: user.id } }, + organization: { connect: { id: lead.organizationId } } + } + }); + + const updatedLead = await prisma.lead.findUnique({ + where: { id: lead_id }, + include: { + comments: { include: { author: true }, orderBy: { createdAt: 'desc' } } + } + }); + + return { + status: 'success', + message: 'Comment added successfully', + commentAdded: true, + comments: updatedLead?.comments || [] + }; + } catch (err) { + console.error('Error adding comment:', err instanceof Error ? err.message : String(err)); + if (err instanceof z.ZodError) { + return fail(400, { status: 'error', message: err.issues[0].message }); + } + return fail(500, { status: 'error', message: 'Failed to add comment' }); + } } -} +}; \ No newline at end of file diff --git a/src/routes/(app)/app/leads/[lead_id]/+page.svelte b/src/routes/(app)/app/leads/[lead_id]/+page.svelte index ae0f5d0..f616ce3 100644 --- a/src/routes/(app)/app/leads/[lead_id]/+page.svelte +++ b/src/routes/(app)/app/leads/[lead_id]/+page.svelte @@ -1,393 +1,863 @@ -
- -
-
-
- - - - - -

{getFullName(lead)}

- {lead.status} -
-
- {#if lead.status !== 'CONVERTED'} - - {/if} - -
-
-
- -
- - - - -
- - -
-
-

Full Name

-
{getFullName(lead)}
-
- {#if lead.company} -
-

Company

-
{lead.company}
-
- {/if} - {#if lead.email} -
-

Email

- {lead.email} -
- {/if} - {#if lead.phone} -
-

Phone

- {lead.phone} -
- {/if} - {#if lead.title} -
-

Title

-
{lead.title}
-
- {/if} - {#if lead.leadSource} -
-

Lead Source

-
{lead.leadSource.replace('_', ' ').toLowerCase()}
-
- {/if} - {#if lead.industry} -
-

Industry

-
{lead.industry}
-
- {/if} - {#if lead.rating} -
-

Rating

-
{lead.rating}
-
- {/if} -
- {#if lead.description} -
-

Description

-
{lead.description}
-
- {/if} -
- - - - - -
-
- +
+ +
+
+ + + +
+ {#if lead.comments && lead.comments.length > 0} + {#each lead.comments as comment, i (comment.id || i)} +
+
+
+ +
+
+
+
+

+ {comment.author?.name || '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/src/routes/(app)/app/leads/[lead_id]/edit/+page.server.js b/src/routes/(app)/app/leads/[lead_id]/edit/+page.server.js index 6f20b38..f83fa62 100644 --- a/src/routes/(app)/app/leads/[lead_id]/edit/+page.server.js +++ b/src/routes/(app)/app/leads/[lead_id]/edit/+page.server.js @@ -1,14 +1,14 @@ import { error, redirect } from '@sveltejs/kit'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, LeadStatus, LeadSource } from '@prisma/client'; const prisma = new PrismaClient(); -export async function load({ params }) { +export async function load({ params, locals }) { const { lead_id } = params; - - try { + const org = locals.org; + const lead = await prisma.lead.findUnique({ - where: { id: lead_id }, + where: { id: lead_id, organizationId: org.id }, include: { owner: true, contact: true @@ -19,45 +19,149 @@ export async function load({ params }) { throw error(404, 'Lead not found'); } - const users = await prisma.user.findMany({ - where: { isActive: true }, - orderBy: { name: 'asc' } + const users = await prisma.userOrganization.findMany({ + where: { organizationId: org.id }, + include: { + user: true + } }); return { lead, users }; - } catch (err) { - console.error('Error loading lead:', err); - throw error(500, 'Failed to load lead data'); - } + } export const actions = { - default: async ({ request, params }) => { + default: async ({ request, params, locals }) => { const { lead_id } = params; const formData = await request.formData(); + const org = locals.org; + + const leadEmail = formData.get('email'); + const ownerId = formData.get('ownerId'); + const firstName = formData.get('firstName'); + const lastName = formData.get('lastName'); + + // Validate required fields + 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.' + }; + } + + // Validate owner ID - ensure the user belongs to the organization + const ownerValidation = await prisma.userOrganization.findUnique({ + where: { + userId_organizationId: { + userId: ownerId, + organizationId: org.id, + }, + }, + select: { id: true } + }); + + if (!ownerValidation) { + return { + success: false, + error: 'Invalid owner selected. User is not part of this organization.' + }; + } + + // Check if leadEmail is a non-empty string before proceeding + if (typeof leadEmail === 'string' && leadEmail.trim() !== '') { + // Step 1: Find the user by email + const user = await prisma.user.findUnique({ + where: { email: leadEmail }, + select: { id: true }, // Select only the user ID + }); + + if (user) { + // Step 2: Find the UserOrganization record using the user's ID and organization ID + // This uses the compound unique key @@unique([userId, organizationId]) + const userOrgMembership = await prisma.userOrganization.findUnique({ + where: { + userId_organizationId: { + userId: user.id, + organizationId: org.id, + }, + }, + select: { id: true } // Fetch only id to confirm existence + }); + if (!userOrgMembership) { + return { + success: false, + error: 'User is not part of this organization.' + }; + } + // If userOrgMembership exists, validation passes. + } else { + return { + success: false, + error: 'User with this email does not exist.' + }; + } + } + + // Get and validate form data + const statusValue = formData.get('status')?.toString() || 'NEW'; + const leadSourceValue = formData.get('leadSource')?.toString(); + + // Simple string validation - Prisma will validate the enum at runtime + const validStatuses = ['NEW', 'PENDING', 'CONTACTED', 'QUALIFIED', 'UNQUALIFIED', 'CONVERTED']; + const validSources = ['WEB', 'PHONE_INQUIRY', 'PARTNER_REFERRAL', 'COLD_CALL', 'TRADE_SHOW', 'EMPLOYEE_REFERRAL', 'ADVERTISEMENT', 'OTHER']; - const updatedLead = { - firstName: formData.get('firstName'), - lastName: formData.get('lastName'), - email: formData.get('email'), - phone: formData.get('phone'), - company: formData.get('company'), - title: formData.get('title'), - status: formData.get('status'), - leadSource: formData.get('leadSource') || null, - industry: formData.get('industry') || null, - rating: formData.get('rating') || null, - description: formData.get('description') || null, - ownerId: formData.get('ownerId') - }; - + if (!validStatuses.includes(statusValue)) { + return { + success: false, + error: 'Invalid lead status provided.' + }; + } + + if (leadSourceValue && !validSources.includes(leadSourceValue)) { + return { + success: false, + error: 'Invalid lead source provided.' + }; + } + try { + // Use the correct Prisma update method with proper typing await prisma.lead.update({ where: { id: lead_id }, - data: updatedLead + data: { + firstName: firstName.trim(), + lastName: lastName.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: formData.get('industry')?.toString() || null, + rating: formData.get('rating')?.toString() || null, + description: formData.get('description')?.toString() || null, + ownerId: ownerId, + organizationId: org.id, + // @ts-ignore - Bypassing TypeScript enum checking for validated enum values + status: statusValue, + // @ts-ignore - Bypassing TypeScript enum checking for validated enum values + leadSource: leadSourceValue || null + } }); return { success: true }; diff --git a/src/routes/(app)/app/leads/[lead_id]/edit/+page.svelte b/src/routes/(app)/app/leads/[lead_id]/edit/+page.svelte index 4099602..313d225 100644 --- a/src/routes/(app)/app/leads/[lead_id]/edit/+page.svelte +++ b/src/routes/(app)/app/leads/[lead_id]/edit/+page.svelte @@ -1,41 +1,31 @@ - -
- -
-
-
- - - - - -

Edit Lead

+
+ +
+
+
+
+ +
+

Edit Lead

+

Editing {lead.firstName} {lead.lastName}

+
+
-
+
+ {#if formSubmitted && !errorMessage} -
- - Success! Lead updated successfully. - +
+
+
+ + + +
+
+

Lead updated successfully!

+
+
{/if} {#if errorMessage} -
- - Error: {errorMessage} - +
+
+ +
+

{errorMessage}

+
+
{/if} - -
{ - const isValid = validateForm(formData); - if (!isValid) return; + + { + const isValid = validateForm(formData); + if (!isValid) return; + + isSubmitting = true; + return async ({ result, update }) => { + isSubmitting = false; + formSubmitted = true; - 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; - } - } else { - errorMessage = 'An unexpected error occurred'; + 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; } - }; - }} - > -
- -
-

Basic Information

- + } else { + errorMessage = 'An unexpected error occurred'; + } + }; + }} + class="space-y-8" + > + +
+
+
+ +

Personal Information

+
+
+
+
- - + First Name * + + {#if errors.firstName} -

{errors.firstName}

+

{errors.firstName}

{/if}
- - + Last Name * + + {#if errors.lastName} -

{errors.lastName}

+

{errors.lastName}

{/if}
- - + + Email Address + + {#if errors.email} -

{errors.email}

+

{errors.email}

{/if}
- - + + Phone Number + +
- - -
-

Company Information

- +
+
+ + +
+
+
+ +

Company Information

+
+
+
+
- - Company Name +
- - Job Title +
-
- - {#each industryOptions as option} - + {/each} - +
+
+
- -
-

Lead Details

+ +
+
+
+ +

Lead Details

+
+
+
+
+
+ + +
-
-
- - -
- -
- - -
- -
- - -
+
+ +
- - + + {#each ratingOptions as option} + {/each} - +
- +
+ +
- - +
+
- -
- - -
- - + +
+ + +
+
diff --git a/src/routes/(app)/app/leads/new/+page.server.js b/src/routes/(app)/app/leads/new/+page.server.js index ecfe5f3..6c379c0 100644 --- a/src/routes/(app)/app/leads/new/+page.server.js +++ b/src/routes/(app)/app/leads/new/+page.server.js @@ -2,64 +2,26 @@ import { env } from '$env/dynamic/private'; import { redirect } from '@sveltejs/kit'; import prisma from '$lib/prisma'; import { fail } from '@sveltejs/kit'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; +import { + industries, + leadSources, + leadStatuses, + countries +} from '$lib/data/index.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ locals }) { const user = locals.user; const org = locals.org; - if (!user || !org) { - throw redirect(307, '/login'); - } - // Get data for dropdowns return { data: { - industries: [ - ['', 'Select Industry'], - ['TECHNOLOGY', 'Technology'], - ['HEALTHCARE', 'Healthcare'], - ['FINANCE', 'Finance'], - ['EDUCATION', 'Education'], - ['RETAIL', 'Retail'], - ['MANUFACTURING', 'Manufacturing'], - ['ENERGY', 'Energy'], - ['REAL_ESTATE', 'Real Estate'], - ['CONSTRUCTION', 'Construction'], - ['TRANSPORTATION', 'Transportation'], - ['HOSPITALITY', 'Hospitality'], - ['AGRICULTURE', 'Agriculture'], - ['OTHER', 'Other'] - ], - status: Object.entries({ - 'NEW': 'New', - 'PENDING': 'Pending', - 'CONTACTED': 'Contacted', - 'QUALIFIED': 'Qualified', - 'UNQUALIFIED': 'Unqualified' - }), - source: Object.entries({ - 'WEB': 'Website', - 'PHONE_INQUIRY': 'Phone Inquiry', - 'PARTNER_REFERRAL': 'Partner Referral', - 'COLD_CALL': 'Cold Call', - 'TRADE_SHOW': 'Trade Show', - 'EMPLOYEE_REFERRAL': 'Employee Referral', - 'ADVERTISEMENT': 'Advertisement', - 'OTHER': 'Other' - }), - countries: [ - ['', 'Select Country'], - ['US', 'United States'], - ['UK', 'United Kingdom'], - ['CA', 'Canada'], - ['AU', 'Australia'], - ['IN', 'India'], - ['DE', 'Germany'], - ['FR', 'France'], - ['JP', 'Japan'], - ['OTHER', 'Other'] - ] + industries, + status: leadStatuses, + source: leadSources, + countries } }; } @@ -71,9 +33,6 @@ export const actions = { const user = locals.user; const org = locals.org; - if (!user || !org) { - return fail(401, { error: 'Unauthorized' }); - } // Get the submitted form data const formData = await request.formData(); @@ -82,69 +41,113 @@ export const actions = { 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()); + } // Extract all form fields const leadData = { firstName, lastName, + title: leadTitle, email: email || null, - phone: formData.get('phone')?.toString() || null, + phone: formattedPhone, company: formData.get('company')?.toString() || null, - title: formData.get('title')?.toString() || null, - status: formData.get('status')?.toString() || 'PENDING', + status: (formData.get('status')?.toString() || 'PENDING'), leadSource: formData.get('source')?.toString() || null, industry: formData.get('industry')?.toString() || null, - rating: null, // Can be set based on a rating field if added 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 { - // Create new lead in the database - const lead = await prisma.lead.create({ - data: { - ...leadData, - owner: { - connect: { - id: user.id - } - }, - organization: { - connect: { - id: org.id - } + // Prepare basic lead data that matches the Prisma schema + const leadCreateData = { + firstName: leadData.firstName, + lastName: leadData.lastName, + email: leadData.email, + phone: leadData.phone, + company: leadData.company, + title: leadData.title, + status: leadData.status, + leadSource: leadData.leadSource || null, + industry: leadData.industry, + description: leadData.description || '', + rating: null, // This is in the schema + owner: { + connect: { + id: user.id + } + }, + organization: { + connect: { + id: org.id } } + }; + + // Remove logic that appends extra info to description + // All extra fields are now ignored if not in schema + + // Create new lead in the database + const lead = await prisma.lead.create({ + // @ts-ignore - status is a valid LeadStatus enum value + data: leadCreateData }); - // Optionally create an address if provided - const street = formData.get('street')?.toString() || ''; - const city = formData.get('city')?.toString() || ''; - const state = formData.get('state')?.toString() || ''; - const postalCode = formData.get('postcode')?.toString() || ''; - const country = formData.get('country')?.toString() || ''; - - if (street || city || state || postalCode || country) { - // Create address (this would require extending the Prisma schema to link address to lead) - // For now, we'll just add these fields to lead description - await prisma.lead.update({ - where: { id: lead.id }, - data: { - description: `${leadData.description || ''}\n\nAddress:\n${street} ${city} ${state} ${postalCode} ${country}`.trim() - } - }); - } - - // Redirect to the newly created lead - throw redirect(303, '/app/leads/'); + // Return success instead of redirecting + 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', + error: 'Failed to create lead: ' + (err instanceof Error ? err.message : 'Unknown error'), values: leadData // Return entered values so the form can be repopulated }); } diff --git a/src/routes/(app)/app/leads/new/+page.svelte b/src/routes/(app)/app/leads/new/+page.svelte index d7ae642..dffc13f 100644 --- a/src/routes/(app)/app/leads/new/+page.svelte +++ b/src/routes/(app)/app/leads/new/+page.svelte @@ -1,31 +1,54 @@ -
- +
+ + {#if showToast} +
+
+
+ {#if toastType === 'success'} + + {:else} + + {/if} +
+
+

{toastMessage}

+
+ +
+
+ {/if} + +
+ +
+
+
+
+

+ + Create New Lead +

+

Capture lead information and start building relationships

+
+
+
+
+ {#if form?.error} - - Error: {form.error} - +
+
+ + Error: + {form.error} +
+
{/if} - -
- -
-

Lead Information

-
- -
- - - {#if errors.title} - {errors.title} + + { + 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/open'), 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 +

+
+
+
+ +
+ + + {#if errors.lead_title} +

{errors.lead_title}

{/if}
- + +
- - + +
- + +
- - + +
+
- - + {#each data.data.industries as [value, label]} {/each} - +
+
- - {#each data.data.status as [value, label]} {/each} - +
- + +
- - + +
- + +
- - + + + {#if errors.website} +

{errors.website}

+ {/if} +
+ + +
+ +
- + +
- -
- -
- -
-
+ + + {#if errors.probability} +

{errors.probability}

+ {/if} +
+ + +
+ + +
+ + +
+ +
+
- -
-

Contact Information

-
+ +
+
+

+ + Contact Information +

+
+
+
- - + First Name * + + + oninput={handleChange} + placeholder="First name" + required + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.first_name ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> + {#if errors.first_name} +

{errors.first_name}

+ {/if}
+
- - + Last Name * + + + oninput={handleChange} + placeholder="Last name" + required + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.last_name ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> + {#if errors.last_name} +

{errors.last_name}

+ {/if}
+
- - + Job Title + + + oninput={handleChange} + placeholder="Job title" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
+
- - + + Phone + + + oninput={handleChange} + onblur={validatePhone} + placeholder="+1 (555) 123-4567" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.phone || phoneError ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> + {#if errors.phone} +

{errors.phone}

+ {/if} + {#if phoneError} +

{phoneError}

+ {/if}
+
- - + + Email * + + + oninput={handleChange} + placeholder="email@company.com" + required + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.email ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.email} - {errors.email} +

{errors.email}

+ {/if} +
+ + +
+ + + {#if errors.linkedin_url} +

{errors.linkedin_url}

{/if}
+
- -
-

Address

-
+ +
+
+

+ + Address Information +

+
+
+
- - + Address Line + + + oninput={handleChange} + placeholder="Street address" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
- +
- - City + -
- -
- - + oninput={handleChange} + placeholder="City" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
- +
- - State + + oninput={handleChange} + placeholder="State" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
- +
- - Postal Code + + oninput={handleChange} + placeholder="Postal code" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
- +
- - + +
+
- -
-

Description

+ +
+
+

Additional Details

+
+
+
+ + oninput={handleChange} + placeholder="Additional notes about this lead..." + rows="3" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-vertical"> +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+
- -
-
- + +
+
+
+ +
+
- +
diff --git a/src/routes/(app)/app/leads/open/+page.server.js b/src/routes/(app)/app/leads/open/+page.server.js index d1741b6..f47c5b9 100644 --- a/src/routes/(app)/app/leads/open/+page.server.js +++ b/src/routes/(app)/app/leads/open/+page.server.js @@ -8,9 +8,6 @@ export async function load({ cookies, locals }) { const user = locals.user; const org = locals.org; - if (!user || !org) { - throw redirect(307, '/login'); - } // Fetch open leads for the user's organization // We're considering NEW, PENDING, CONTACTED, and QUALIFIED statuses as "open" diff --git a/src/routes/(app)/app/leads/open/+page.svelte b/src/routes/(app)/app/leads/open/+page.svelte index e501b31..2825d26 100644 --- a/src/routes/(app)/app/leads/open/+page.svelte +++ b/src/routes/(app)/app/leads/open/+page.svelte @@ -1,152 +1,553 @@ -
+
-
+
-

- 📋 Open Leads -

- +
+ +
+
+ +
+
+ + + +
+ +
+ + + {#if showFilters} +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {/if} +
+
+ -
+
{#if isLoading}
- +
- {:else if leads.length === 0} -
-
📭
-

No open leads found

- + {:else if filteredLeads.length === 0} +
+
📭
+

No leads found

+

Try adjusting your search criteria or create a new lead.

+ + + Create New Lead +
{:else} -
+
-
-
- {#each leads as lead, i} +
+ {#each filteredLeads as lead, i} + {@const statusConfig = getStatusConfig(lead.status)} + {@const ratingConfig = getRatingConfig(lead.rating || '')}
-
- - {getFullName(lead)} - - {lead.status} + +
+
+
+ {lead.firstName.charAt(0)}{lead.lastName.charAt(0)} +
+
+ + {getFullName(lead)} + + {#if lead.title} +

{lead.title}

+ {/if} +
+
+
+ {#snippet statusIcon(/** @type {any} */ config)} + {@const StatusIcon = config.icon} + + {/snippet} + {@render statusIcon(statusConfig)} + + {lead.status} + +
-
- {#if lead.email} -
- Email: - {lead.email} + +
+ {#if lead.company} +
+ + {lead.company}
{/if} - {#if lead.phone} -
- Phone: - {lead.phone} -
+ {#if lead.email} + + + {lead.email} + {/if} - {#if lead.company} -
- Company: - {lead.company} -
+ {#if lead.phone} + + + {lead.phone} + {/if} - + +
+
+ + {formatDate(lead.createdAt)} +
+ + {#if lead.rating} +
+ {#each Array(ratingConfig.dots) as _, i} +
+ {/each} + {lead.rating} +
+ {/if} +
+ {#if lead.owner?.name} -
- Owner: - {lead.owner.name} +
+ + Owned by {lead.owner.name}
{/if}
+ + +
{/each}
diff --git a/src/routes/(app)/app/opportunities/+page.server.js b/src/routes/(app)/app/opportunities/+page.server.js new file mode 100644 index 0000000..c329ff3 --- /dev/null +++ b/src/routes/(app)/app/opportunities/+page.server.js @@ -0,0 +1,163 @@ +import { PrismaClient } from '@prisma/client'; +import { fail } from '@sveltejs/kit'; + +const prisma = new PrismaClient(); + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ locals }) { + try { + // Get user's organization (assuming it's available in locals) + const userId = locals.user?.id; + const organizationId = locals.org?.id; + + if (!userId) { + return { + opportunities: [], + stats: { + total: 0, + totalValue: 0, + wonValue: 0, + pipeline: 0 + } + }; + } + + // If no organizationId in user object, try to get it from UserOrganization + let finalOrganizationId = organizationId; + if (!finalOrganizationId) { + const userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: userId + }, + select: { + organizationId: true + } + }); + finalOrganizationId = userOrg?.organizationId; + } + + if (!finalOrganizationId) { + return { + opportunities: [], + stats: { + total: 0, + totalValue: 0, + wonValue: 0, + pipeline: 0 + } + }; + } + + // Fetch opportunities with related data + const opportunities = await prisma.opportunity.findMany({ + where: { + organizationId: finalOrganizationId + }, + include: { + account: { + select: { + id: true, + name: true, + type: true + } + }, + owner: { + select: { + id: true, + name: true, + email: true + } + }, + contacts: { + select: { + id: true, + firstName: true, + lastName: true, + email: true + } + }, + _count: { + select: { + tasks: true, + events: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + + // Calculate stats + const stats = { + total: opportunities.length, + totalValue: opportunities.reduce((sum, opp) => sum + (opp.amount || 0), 0), + wonValue: opportunities + .filter(opp => opp.stage === 'CLOSED_WON') + .reduce((sum, opp) => sum + (opp.amount || 0), 0), + pipeline: opportunities + .filter(opp => !['CLOSED_WON', 'CLOSED_LOST'].includes(opp.stage)) + .reduce((sum, opp) => sum + (opp.amount || 0), 0) + }; + + return { + opportunities: opportunities.map(opp => ({ + ...opp, + amount: opp.amount ? Number(opp.amount) : null, + expectedRevenue: opp.expectedRevenue ? Number(opp.expectedRevenue) : null + })), + stats + }; + } catch (error) { + console.error('Error loading opportunities:', error); + return { + opportunities: [], + stats: { + total: 0, + totalValue: 0, + wonValue: 0, + pipeline: 0 + } + }; + } +}; + +/** @type {import('./$types').Actions} */ +export const actions = { + delete: async ({ request, locals }) => { + try { + const formData = await request.formData(); + const opportunityId = formData.get('opportunityId')?.toString(); + const userId = locals.user?.id; + const organizationId = locals.org?.id; + + if (!opportunityId || !userId || !organizationId) { + return fail(400, { message: 'Missing required data' }); + } + + // Check if the opportunity exists and belongs to the user's organization + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: opportunityId, + organizationId: organizationId + } + }); + + if (!opportunity) { + return fail(404, { message: 'Opportunity not found' }); + } + + // Delete the opportunity + await prisma.opportunity.delete({ + where: { + 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/src/routes/(app)/app/opportunities/+page.svelte b/src/routes/(app)/app/opportunities/+page.svelte new file mode 100644 index 0000000..b53bab6 --- /dev/null +++ b/src/routes/(app)/app/opportunities/+page.svelte @@ -0,0 +1,589 @@ + + + + Opportunities - BottleCRM + + +
+ + {#if form?.success} +
+ +
+ {/if} + + {#if form?.message && !form?.success} +
+ +
+ {/if} + + +
+
+
+
+

Opportunities

+
+ +
+
+
+ + +
+
+
+
+
+
+ +
+
+
+
Total Opportunities
+
{data.stats.total}
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
Total Value
+
{formatCurrency(data.stats.totalValue)}
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
Pipeline Value
+
{formatCurrency(data.stats.pipeline)}
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
Won Value
+
{formatCurrency(data.stats.wonValue)}
+
+
+
+
+
+
+ + +
+
+
+ +
+
+ +
+ +
+ +
+
+ + +
+ + +
+ + + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + {#each filteredOpportunities as opportunity (opportunity.id)} + {@const config = stageConfig[opportunity.stage] || stageConfig.PROSPECTING} + + + + + + + + + + + {/each} + +
toggleSort('name')} + > +
+ Opportunity + +
+
toggleSort('account.name')} + > +
+ Account + +
+
+ Stage + toggleSort('amount')} + > +
+ Amount + +
+
toggleSort('closeDate')} + > +
+ Close Date + +
+
toggleSort('owner.name')} + > +
+ Owner + +
+
+ Activities + + Actions +
+
+
+
+ {opportunity.name || 'Unnamed Opportunity'} +
+ {#if opportunity.type} +
+ {opportunity.type} +
+ {/if} +
+
+
+
+ +
+
+ {opportunity.account?.name || 'No Account'} +
+ {#if opportunity.account?.type} +
+ {opportunity.account.type} +
+ {/if} +
+
+
+ + {#if config.icon} + {@const IconComponent = config.icon} + + {/if} + {config.label} + + + {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} +
+
+
+ + + + + + + +
+
+
+ + {#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} + +{/if} \ No newline at end of file diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/+page.server.js b/src/routes/(app)/app/opportunities/[opportunityId]/+page.server.js new file mode 100644 index 0000000..81aa64c --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/+page.server.js @@ -0,0 +1,20 @@ +import { error } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +export async function load({ params }) { + const opportunity = await prisma.opportunity.findUnique({ + where: { id: params.opportunityId }, + include: { + account: true, + owner: true + } + }); + if (!opportunity) { + throw error(404, 'Opportunity not found'); + } + return { + opportunity, + account: opportunity.account, + owner: opportunity.owner + }; +} diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/+page.svelte b/src/routes/(app)/app/opportunities/[opportunityId]/+page.svelte new file mode 100644 index 0000000..6cd53a2 --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/+page.svelte @@ -0,0 +1,297 @@ + + +
+
+ +
+ + +
+

{opportunity.name}

+
+ + {opportunity.stage.replace('_', ' ')} + + {#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
+
{opportunity.type || 'Not specified'}
+
+
+
Lead Source
+
{opportunity.leadSource || 'Not specified'}
+
+
+
Forecast Category
+
{opportunity.forecastCategory || 'Not specified'}
+
+
+
Close Date
+
+ + {formatDate(opportunity.closeDate)} +
+
+
+
+ + + {#if opportunity.nextStep} +
+
+ +

Next Steps

+
+
{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

+
+
+
+
Account
+ {#if account} + + {account.name} + + {:else} + No account + {/if} +
+
+
Owner
+
+ + {owner?.name ?? 'Unassigned'} +
+
+
+
+ + +
+
+ +

System Information

+
+
+
+
Created
+
{formatDateTime(opportunity.createdAt)}
+
+
+
Last Updated
+
{formatDateTime(opportunity.updatedAt)}
+
+
+
+
+
+
+
+ + diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.server.js b/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.server.js new file mode 100644 index 0000000..208fc04 --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.server.js @@ -0,0 +1,120 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +/** + * @param {Object} options + * @param {Record} options.params + * @param {App.Locals} options.locals + */ +export async function load({ params, locals }) { + if (!locals.org?.id) { + throw error(403, 'Organization access required'); + } + + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: params.opportunityId, + organizationId: locals.org.id + }, + include: { + account: { + select: { id: true, name: true } + } + } + }); + + if (!opportunity) { + throw error(404, 'Opportunity not found'); + } + + return { opportunity }; +} + +export const actions = { + /** + * @param {Object} options + * @param {Request} options.request + * @param {Record} options.params + * @param {App.Locals} options.locals + */ + default: async ({ request, params, locals }) => { + if (!locals.org?.id) { + return fail(403, { error: 'Organization access required' }); + } + + const formData = await request.formData(); + const status = formData.get('status')?.toString(); + const closeDate = formData.get('closeDate')?.toString(); + const closeReason = formData.get('closeReason')?.toString(); + + // Validate required fields + if (!status || !closeDate) { + return fail(400, { error: 'Status and close date are required' }); + } + + // Validate status + const validCloseStatuses = ['CLOSED_WON', 'CLOSED_LOST']; + if (!status || !validCloseStatuses.includes(status)) { + return fail(400, { error: 'Invalid status selected' }); + } + + try { + // First, verify the opportunity exists and belongs to the organization + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: params.opportunityId, + organizationId: locals.org.id + }, + include: { + account: { select: { id: true } } + } + }); + + if (!opportunity) { + return fail(404, { error: 'Opportunity not found' }); + } + + // Update the opportunity with closing details + const opportunityStage = /** @type {import('@prisma/client').OpportunityStage} */ (status); + + await prisma.opportunity.update({ + where: { id: params.opportunityId }, + data: { + stage: opportunityStage, // CLOSED_WON or CLOSED_LOST + status: status === 'CLOSED_WON' ? 'SUCCESS' : 'FAILED', + closeDate: closeDate ? new Date(closeDate) : null, + description: closeReason ? + (opportunity.description ? `${opportunity.description}\n\nClose Reason: ${closeReason}` : `Close Reason: ${closeReason}`) + : opportunity.description, + updatedAt: new Date() + } + }); + + // Create audit log entry + await prisma.auditLog.create({ + data: { + action: 'UPDATE', + entityType: 'Opportunity', + entityId: opportunity.id, + description: `Opportunity closed with status: ${status}`, + newValues: { + stage: status, + status: status === 'CLOSED_WON' ? 'SUCCESS' : 'FAILED', + closeDate: closeDate, + closeReason: closeReason + }, + userId: locals.user.id, + organizationId: locals.org.id + } + }); + + throw redirect(303, `/app/opportunities/${opportunity.id}`); + } catch (err) { + console.error('Error closing opportunity:', err); + if (err && typeof err === 'object' && 'status' in err && err.status === 303) { + throw err; // Re-throw redirect + } + return fail(500, { error: 'Failed to close opportunity. Please try again.' }); + } + } +}; diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.svelte b/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.svelte new file mode 100644 index 0000000..354550a --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/close/+page.svelte @@ -0,0 +1,196 @@ + + +
+
+ +
+
+

+
+ +
+ 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

+

{opportunity.stage.replace('_', ' ')}

+
+
+

Probability

+

+ {opportunity.probability ? `${opportunity.probability}%` : 'Not specified'} +

+
+
+
+
+ + +
+
+

Close Opportunity

+
+ +
{ + return async ({ update }) => { + isSubmitting = true; + await update(); + isSubmitting = false; + }; + }} class="p-6 space-y-6"> + {#if form?.error} +
+
+ + Error +
+

{form.error}

+
+ {/if} + + +
+ +
+ {#each statusOptions as option} + + {/each} +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + Cancel + +
+
+
+
+
+ + diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.js b/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.js new file mode 100644 index 0000000..f3c8c3b --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.js @@ -0,0 +1,81 @@ +import { error } from '@sveltejs/kit'; +import { fail } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +export async function load({ params, locals }) { + const userId = locals.user?.id; + const organizationId = locals.org?.id; + + if (!userId || !organizationId) { + throw error(401, 'Unauthorized'); + } + + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: params.opportunityId, + organizationId: organizationId + }, + include: { + account: { + select: { + id: true, + name: true + } + }, + owner: { + select: { + id: true, + name: true, + email: true + } + } + } + }); + + if (!opportunity) { + throw error(404, 'Opportunity not found'); + } + + return { + opportunity + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + default: async ({ params, locals }) => { + try { + const userId = locals.user?.id; + const organizationId = locals.org?.id; + + if (!userId || !organizationId) { + return fail(401, { message: 'Unauthorized' }); + } + + // Check if the opportunity exists and belongs to the user's organization + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: params.opportunityId, + organizationId: organizationId + } + }); + + if (!opportunity) { + return fail(404, { message: 'Opportunity not found' }); + } + + // Delete the opportunity (this will cascade delete related records) + await prisma.opportunity.delete({ + where: { + 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/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte b/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte new file mode 100644 index 0000000..c7e51c4 --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte @@ -0,0 +1,112 @@ + + + + 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: {data.opportunity.stage}
+
+
+ +
+

+ 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'); + } + }; + }}> + +
+
+
+
+
+
+
diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.server.js b/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.server.js new file mode 100644 index 0000000..e246400 --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.server.js @@ -0,0 +1,135 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +/** + * @param {Object} options + * @param {Record} options.params + * @param {App.Locals} options.locals + */ +export async function load({ params, locals }) { + if (!locals.org?.id) { + throw error(403, 'Organization access required'); + } + + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: params.opportunityId, + organizationId: locals.org.id + }, + include: { + account: { + select: { + id: true, + name: true + } + }, + owner: { + select: { + id: true, + name: true + } + } + } + }); + if (!opportunity) throw error(404, 'Opportunity not found'); + return { + opportunity, + account: opportunity.account, + owner: opportunity.owner + }; +} + +export const actions = { + /** + * @param {Object} options + * @param {Request} options.request + * @param {Record} options.params + * @param {App.Locals} options.locals + */ + default: async ({ request, params, locals }) => { + if (!locals.org?.id) { + return fail(403, { error: 'Organization access required' }); + } + + 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 stage = 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 = form.get('leadSource')?.toString() || null; + const forecastCategory = form.get('forecastCategory')?.toString() || null; + const type = form.get('type')?.toString() || null; + 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 (!stage) { + return fail(400, { message: 'Stage is required.' }); + } + + // Validate stage is a valid enum value + const validStages = ['PROSPECTING', 'QUALIFICATION', 'PROPOSAL', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST']; + if (!validStages.includes(stage)) { + return fail(400, { message: 'Invalid stage selected.' }); + } + + // Validate probability range + if (probability !== null && (probability < 0 || probability > 100)) { + return fail(400, { message: 'Probability must be between 0 and 100.' }); + } + + // Validate amounts are not negative + 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 { + // Verify the opportunity exists and belongs to the organization + const existingOpportunity = await prisma.opportunity.findFirst({ + where: { + id: params.opportunityId, + organizationId: locals.org.id + } + }); + + if (!existingOpportunity) { + return fail(404, { message: 'Opportunity not found' }); + } + + const opportunityStage = /** @type {import('@prisma/client').OpportunityStage} */ (stage); + + await prisma.opportunity.update({ + where: { id: params.opportunityId }, + data: { + name, + amount, + expectedRevenue, + stage: opportunityStage, + probability, + closeDate, + leadSource, + forecastCategory, + type, + nextStep, + description + } + }); + + 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/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte b/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte new file mode 100644 index 0000000..c8cb2a2 --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte @@ -0,0 +1,319 @@ + + +
+
+ +
+
+
+ + + +
+

Edit Opportunity

+

Update opportunity details and track progress

+
+
+
+
+ + +
+
+ +
+

+ + Basic Information +

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ + + {#if error} +
+
+ +

{error}

+
+
+ {/if} + + +
+ + Cancel + + +
+
+
+
+
+ diff --git a/src/routes/(app)/app/opportunities/new/+page.server.js b/src/routes/(app)/app/opportunities/new/+page.server.js new file mode 100644 index 0000000..b346569 --- /dev/null +++ b/src/routes/(app)/app/opportunities/new/+page.server.js @@ -0,0 +1,229 @@ +import prisma from '$lib/prisma'; +import { redirect, fail } from '@sveltejs/kit'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ locals, url }) { + if (!locals.org) { + throw redirect(302, '/org'); + } + + const preSelectedAccountId = url.searchParams.get('accountId'); + + try { + const [accounts, contacts, users] = await Promise.all([ + // Get active accounts for the organization + prisma.account.findMany({ + where: { + organizationId: locals.org.id, + isActive: true, + isDeleted: false + }, + select: { + id: true, + name: true, + type: true + }, + orderBy: { name: 'asc' } + }), + + // Get contacts for the organization or specific account + prisma.contact.findMany({ + where: { + organizationId: locals.org.id, + ...(preSelectedAccountId && { + relatedAccounts: { + some: { + accountId: preSelectedAccountId + } + } + }) + }, + select: { + id: true, + firstName: true, + lastName: true, + email: true + }, + orderBy: [{ firstName: 'asc' }, { lastName: 'asc' }] + }), + + // Get users in the organization for assignment + prisma.user.findMany({ + where: { + organizations: { + some: { + organizationId: locals.org.id + } + }, + isActive: true + }, + select: { + id: true, + name: true, + email: true + }, + orderBy: { name: 'asc' } + }) + ]); + + // Get pre-selected account details if provided + let preSelectedAccount = null; + if (preSelectedAccountId) { + preSelectedAccount = accounts.find(account => account.id === preSelectedAccountId); + } + + return { + accounts, + contacts: [], // Keep for backward compatibility + accountContacts: contacts, // Renamed for clarity + 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 + }; + } +} + +/** @type {import('./$types').Actions} */ +export const actions = { + create: async ({ request, locals }) => { + if (!locals.user || !locals.org) { + return fail(401, { error: 'Unauthorized' }); + } + + 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()) + }; + + // Validation + const errors = {}; + + 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'; + } + + // Validate stage is a valid enum value + 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 { + // Verify account belongs to organization + const account = await prisma.account.findFirst({ + where: { + id: data.accountId, + organizationId: locals.org.id, + isActive: true, + isDeleted: false + } + }); + + if (!account) { + return fail(400, { error: 'Invalid account selected' }); + } + + // At this point validation has passed so these values are guaranteed to exist + if (!data.name || !data.accountId || !data.stage) { + return fail(400, { error: 'Missing required fields after validation' }); + } + + const name = data.name; + const accountId = data.accountId; + const stage = /** @type {import('@prisma/client').OpportunityStage} */ (data.stage); + + // Create opportunity + const opportunity = await prisma.opportunity.create({ + data: { + name, + accountId, + stage, + amount: data.amount ? parseFloat(data.amount) : null, + closeDate: data.closeDate ? new Date(data.closeDate) : null, + probability: data.probability ? parseFloat(data.probability) : null, + 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: data.amount && data.probability + ? (parseFloat(data.amount) * parseFloat(data.probability)) / 100 + : null, + // Connect contacts if any selected + ...(data.contactIds.length > 0 && { + contacts: { + connect: data.contactIds.map(id => ({ id })) + } + }) + } + }); + + // Create audit log + await prisma.auditLog.create({ + data: { + action: 'CREATE', + entityType: 'Opportunity', + entityId: opportunity.id, + description: `Created opportunity: ${opportunity.name}`, + newValues: { + name: opportunity.name, + stage: opportunity.stage, + amount: opportunity.amount + }, + 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 && error.status === 302) { + throw error; // Re-throw redirect + } + console.error('Error creating opportunity:', error); + return fail(500, { error: 'Failed to create opportunity' }); + } + } +}; \ No newline at end of file diff --git a/src/routes/(app)/app/opportunities/new/+page.svelte b/src/routes/(app)/app/opportunities/new/+page.svelte new file mode 100644 index 0000000..14e8a6f --- /dev/null +++ b/src/routes/(app)/app/opportunities/new/+page.svelte @@ -0,0 +1,431 @@ + + + + New Opportunity - BottleCRM + + +
+ +
+
+
+
+ +
+
+ +
+
+

New Opportunity

+

Create a new sales opportunity

+
+
+
+
+
+
+ + +
+ {#if form?.error} +
+

{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 +

+ +
+ +
+ + + {#if form?.errors?.name} +

{form.errors.name}

+ {/if} +
+ + +
+ + + {#if data.preSelectedAccountId} +

+ Account pre-selected from {data.preSelectedAccountName} +

+ {/if} + {#if form?.errors?.accountId} +

{form.errors.accountId}

+ {/if} +
+ + +
+ + + {#if form?.errors?.stage} +

{form.errors.stage}

+ {/if} +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+

+ + Financial Information +

+ +
+ +
+ + + {#if form?.errors?.amount} +

{form.errors.amount}

+ {/if} +
+ + +
+ + + {#if form?.errors?.probability} +

{form.errors.probability}

+ {/if} +
+ + +
+
+ Expected Revenue +
+
+ ${calculateExpectedRevenue()} +
+
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+

Additional Information

+ +
+ + {#if data.accountContacts.length > 0} +
+
+ Associated Contacts {data.preSelectedAccountId ? `from ${data.preSelectedAccountName}` : ''} +
+
+ {#each data.accountContacts as contact} + + {/each} +
+
+ {/if} + + +
+ + +
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+
\ No newline at end of file diff --git a/src/routes/(app)/app/profile/+page.server.js b/src/routes/(app)/app/profile/+page.server.js new file mode 100644 index 0000000..67a0bf2 --- /dev/null +++ b/src/routes/(app)/app/profile/+page.server.js @@ -0,0 +1,108 @@ +import prisma from '$lib/prisma'; +import { fail, redirect } from '@sveltejs/kit'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ locals }) { + if (!locals.user) { + throw redirect(307, '/login'); + } + + // Get user with their organization memberships + const user = await prisma.user.findUnique({ + where: { + id: locals.user.id + }, + include: { + organizations: { + include: { + organization: true + } + } + } + }); + + if (!user) { + throw redirect(307, '/login'); + } + + return { + user: { + id: user.id, + user_id: user.user_id, + email: user.email, + name: user.name, + profilePhoto: user.profilePhoto, + phone: user.phone, + isActive: user.isActive, + lastLogin: user.lastLogin, + createdAt: user.createdAt, + organizations: user.organizations + } + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + updateProfile: async ({ request, locals }) => { + if (!locals.user) { + throw redirect(307, '/login'); + } + + 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 prisma.user.update({ + where: { + id: locals.user.id + }, + data: { + name: name.trim(), + phone: formattedPhone, + updatedAt: new Date() + } + }); + + 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/src/routes/(app)/app/profile/+page.svelte b/src/routes/(app)/app/profile/+page.svelte new file mode 100644 index 0000000..53b5533 --- /dev/null +++ b/src/routes/(app)/app/profile/+page.svelte @@ -0,0 +1,353 @@ + + + + Profile - BottleCRM + + +
+ +
+
+
+

Profile

+

Manage your personal information and account settings

+
+ +
+
+ + + {#if form?.success} +
+
+
+ + + +
+
+

+ {form.message} +

+
+
+
+ {/if} + + {#if form?.error} +
+
+
+ + + +
+
+

+ {form.error} +

+
+
+
+ {/if} + +
+ +
+
+
+ +
+ {#if data.user.profilePhoto} + {data.user.name + {:else} +
+ + {getInitials(data.user.name)} + +
+ {/if} +
+ +

+ {data.user.name || 'Unnamed User'} +

+

{data.user.email}

+
+ + +
+ + {data.user.isActive ? 'Active' : 'Inactive'} + +
+
+
+ + +
+
+ {#if isEditing} + +
+
+

Edit Profile Information

+ +
+ +
+ + +
+ + +
+ + + {#if phoneError} +

+ {phoneError} +

+ {/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)} +
+
+ + +
+
+ + Member Since +
+
+ {formatDate(data.user.createdAt)} +
+
+
+
+ {/if} +
+ + + {#if data.user.organizations && data.user.organizations.length > 0} +
+

Organizations

+
+ {#each data.user.organizations as userOrg} +
+
+
+ +
+
+

+ {userOrg.organization.name} +

+

+ Joined {formatDate(userOrg.joinedAt)} +

+
+
+ + {userOrg.role} + +
+ {/each} +
+
+ {/if} +
+
+
\ No newline at end of file diff --git a/src/routes/(app)/app/support/+page.svelte b/src/routes/(app)/app/support/+page.svelte new file mode 100644 index 0000000..6f30627 --- /dev/null +++ b/src/routes/(app)/app/support/+page.svelte @@ -0,0 +1,172 @@ + + +
+
+ +
+

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 + +
+ + +
+
+
+ +
+

Professional Support

+
+

+ Get priority support, hosting assistance, and custom development services. +

+ + + Contact for Paid Support + +
+
+ + +
+
+
+ +
+

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 + +
+ + +
+
+
+ +
+

Bug Reports

+
+

+ 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. +

+ +
+
+
\ No newline at end of file diff --git a/src/routes/(app)/app/tasks/+page.server.js b/src/routes/(app)/app/tasks/+page.server.js new file mode 100644 index 0000000..5f51733 --- /dev/null +++ b/src/routes/(app)/app/tasks/+page.server.js @@ -0,0 +1,42 @@ +import prisma from '$lib/prisma'; + +export async function load({ locals }) { + const user = locals.user; + const org = locals.org; + const boards = await prisma.board.findMany({ + where: { ownerId: user.id, organizationId: org.id }, + select: { id: true, name: true }, + orderBy: { createdAt: 'desc' } + }); + return { boards }; +} + +export const actions = { + create: async ({ request, locals }) => { + const user = locals.user; + const org = locals.org; + const form = await request.formData(); + const name = form.get('name')?.toString(); + if (!name) return { status: 400 }; + const board = await prisma.board.create({ + data: { + name, + ownerId: user.id, + organizationId: org.id + } + }); + const defaultColumns = ["To Do", "In Progress", "Done"]; + await prisma.$transaction( + defaultColumns.map((col, idx) => + prisma.boardColumn.create({ + data: { + name: col, + boardId: board.id, + order: idx + 1 + } + }) + ) + ); + return { success: true }; + } +}; diff --git a/src/routes/(app)/app/tasks/+page.svelte b/src/routes/(app)/app/tasks/+page.svelte new file mode 100644 index 0000000..5d5fd5b --- /dev/null +++ b/src/routes/(app)/app/tasks/+page.svelte @@ -0,0 +1,134 @@ + + +
+

Boards

+
+ {#if data?.boards?.length} + {#each data.boards as board} + +
{board.name}
+
+ {/each} + {:else} +

No boards found.

+ {/if} +
+
+
+ + + +
+
+
+ + diff --git a/src/routes/(app)/app/tasks/[task_id]/+page.server.js b/src/routes/(app)/app/tasks/[task_id]/+page.server.js new file mode 100644 index 0000000..96a718d --- /dev/null +++ b/src/routes/(app)/app/tasks/[task_id]/+page.server.js @@ -0,0 +1,165 @@ +import prisma from '$lib/prisma'; +import { fail } from '@sveltejs/kit'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params, locals }) { + const { task_id } = params; + const org = locals.org; + const user = locals.user; + + // check if the task is related to org.id and the user is related to the org + const taskToUpdate = await prisma.task.findUnique({ + where: { + id: params.task_id, + organizationId: org.id + } + }); + + if (!taskToUpdate) { + return fail(404, { message: 'Task not found or you do not have permission to view it.' }); + } + + const userExistsInOrg = await prisma.userOrganization.findUnique({ + where: { + userId_organizationId: { + userId: user.id, + organizationId: org.id + } + } + }); + if (!userExistsInOrg) { + return fail(400, { fieldError: ['ownerId', 'User is not part of this organization.'] }); + } + + + + const task = await prisma.task.findUniqueOrThrow({ + where: { id: task_id }, + include: { + owner: { + select: { id: true, name: true, profilePhoto: true } + }, + account: { + select: { id: true, name: true } + }, + // You can include other relations like contact, lead, opportunity, case if needed + // contact: { select: { id: true, firstName: true, lastName: true } }, + comments: { + include: { + author: { + select: { id: true, name: true, profilePhoto: true } + } + }, + orderBy: { + createdAt: 'asc' + } + } + } + }); + + const users = await prisma.user.findMany({ + select: { id: true, name: true, profilePhoto: true } // Added profilePhoto for comment author + }); + + const accounts = await prisma.account.findMany({ + select: { id: true, name: true } + }); + + // Format dueDate for input[type=date] if it exists, otherwise it might cause issues + // Also ensure it's in YYYY-MM-DD format for display if not using toLocaleDateString() + const formattedTask = { + ...task, + dueDate: task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : task.dueDate + }; + + // Assuming locals.user is populated by your auth setup + const loggedInUser = locals.user ? { + id: locals.user.id, + name: locals.user.name, + profilePhoto: locals.user.profilePhoto + // organizationId: locals.user.organizationId // If available and needed directly + } : null; + + return { + task: formattedTask, + users, + accounts, + loggedInUser + }; +}; + +/** @type {import('./$types').Actions} */ +export const actions = { + addComment: async ({ request, params, locals }) => { + const org = locals.org; + + const formData = await request.formData(); + const commentBody = formData.get('commentBody')?.toString(); + const { task_id } = params; + + const userId = locals.user.id; + + + + // check if the task is related to org.id and the user is related to the org + const taskToUpdate = await prisma.task.findUnique({ + where: { + id: params.task_id, + organizationId: org.id + } + }); + + if (!taskToUpdate) { + return fail(404, { message: 'Task not found or you do not have permission to edit it.' }); + } + + const userExistsInOrg = await prisma.userOrganization.findUnique({ + where: { + userId_organizationId: { + userId: userId, + 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 task = await prisma.task.findUnique({ + where: { id: task_id }, + select: { organizationId: true } + }); + + 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 prisma.comment.create({ + data: { + body: commentBody, + authorId: userId, + taskId: task_id, + organizationId: task.organizationId, // Use task's 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/src/routes/(app)/app/tasks/[task_id]/+page.svelte b/src/routes/(app)/app/tasks/[task_id]/+page.svelte new file mode 100644 index 0000000..baad132 --- /dev/null +++ b/src/routes/(app)/app/tasks/[task_id]/+page.svelte @@ -0,0 +1,241 @@ + + +
+
+ +
+
+
+ +
+

Task Details

+

View and manage task information

+
+
+ +
+
+ + + {#if task} +
+ +
+
+ + {task.status} + + + {task.priority} + +
+ + Due {formatDate(task.dueDate)} +
+
+ +

{task.subject}

+ + {#if task.description} +

{task.description}

+ {:else} +

No description provided

+ {/if} +
+ + +
+
+ +
+
+ + Task Owner +
+
+ {#if task.owner?.profilePhoto} + {task.owner.name} + {: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} +
+

{form.message}

+
+ {/if} + + {#if form?.fieldError && Array.isArray(form.fieldError) && form.fieldError.includes('commentBody')} + {@const formData = /** @type {any} */ (form)} + {#if 'commentBody' in formData} + {@const _ = newComment = /** @type {string} */ (formData.commentBody || '')} + {/if} + {/if} + + +
+ {#if task.comments && task.comments.length > 0} + {#each task.comments as c (c.id || c.createdAt)} +
+ {#if c.author.profilePhoto} + {c.author.name} + {:else} +
+ + {c.author?.name?.charAt(0) || 'U'} + +
+ {/if} +
+
+ {c.author.name} + {new Date(c.createdAt).toLocaleString()} +
+
{c.body}
+
+
+ {/each} + {:else} +
+ +

No comments yet

+

Be the first to add a comment

+
+ {/if} +
+ + +
+
+ + +
+
+ +
+
+
+
+ {/if} +
+
\ No newline at end of file diff --git a/src/routes/(app)/app/tasks/[task_id]/edit/+page.server.js b/src/routes/(app)/app/tasks/[task_id]/edit/+page.server.js new file mode 100644 index 0000000..41575f3 --- /dev/null +++ b/src/routes/(app)/app/tasks/[task_id]/edit/+page.server.js @@ -0,0 +1,125 @@ +import prisma from '$lib/prisma'; +import { fail, redirect } from '@sveltejs/kit'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params, locals }) { + const user = locals.user; + const org = locals.org; + + const task = await prisma.task.findUnique({ + where: { + id: params.task_id, + organizationId: org.id + } + }); + + if (!task) { + throw redirect(303, '/app/tasks'); // Or a 404 page + } + + // Format dueDate for input[type=date] + const formattedTask = { + ...task, + dueDate: task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : task.dueDate + }; + + const users = await prisma.user.findMany({ + where: { + organizations: { + some: { + organizationId: org.id + } + } + }, + select: { id: true, name: true } + }); + + const accounts = await prisma.account.findMany({ + where: { organizationId: org.id }, + select: { id: true, name: true } + }); + + return { + task: formattedTask, + users, + accounts + }; +}; + +/** @type {import('./$types').Actions} */ +export const actions = { + update: async ({ request, params, locals }) => { + const formData = await request.formData(); + const org = locals.org; + const user = locals.user; + + const subject = formData.get('subject'); + const description = formData.get('description'); + const status = formData.get('status'); + const priority = formData.get('priority'); + let dueDate = 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.'] }); + } + + // check if the task is related to org.id and the user is related to the org + const taskToUpdate = await prisma.task.findUnique({ + where: { + id: params.task_id, + organizationId: org.id + } + }); + + if (!taskToUpdate) { + return fail(404, { message: 'Task not found or you do not have permission to edit it.' }); + } + + const userExistsInOrg = await prisma.userOrganization.findUnique({ + where: { + userId_organizationId: { + userId: user.id, + 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 ISOString or null if empty + dueDate = dueDate && typeof dueDate === 'string' && dueDate.trim() !== '' ? new Date(dueDate).toISOString() : null; + + try { + await prisma.task.update({ + where: { + id: params.task_id, + organizationId: org.id + }, + data: { + subject: subject.trim(), + description: description ? description.toString().trim() : null, + status: status ? status.toString() : 'Not Started', + priority: priority ? priority.toString() : 'Normal', + dueDate, + ownerId: ownerId.toString(), + accountId: accountId ? accountId.toString() : null, + } + }); + } 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/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte b/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte new file mode 100644 index 0000000..ffe52f0 --- /dev/null +++ b/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte @@ -0,0 +1,241 @@ + + +
+
+ +
+
+
+
+
+ +
+
+

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} + + +
+
+ +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+
\ No newline at end of file diff --git a/src/routes/(app)/app/tasks/calendar/+page.server.js b/src/routes/(app)/app/tasks/calendar/+page.server.js new file mode 100644 index 0000000..c41b8bd --- /dev/null +++ b/src/routes/(app)/app/tasks/calendar/+page.server.js @@ -0,0 +1,28 @@ +import prisma from '$lib/prisma.js'; + +export async function load({ locals }) { + const user = locals.user; + const org = locals.org; + + // Fetch classic CRM tasks + const tasks = await prisma.task.findMany({ + where: { + ownerId: user.id, + organizationId: org.id, + dueDate: { not: null } + }, + select: { + id: true, + subject: true, + description: true, + dueDate: true, + status: true, + priority: true, + createdAt: true, + updatedAt: true + }, + orderBy: { dueDate: 'asc' } + }); + + return { tasks }; +} diff --git a/src/routes/(app)/app/tasks/calendar/+page.svelte b/src/routes/(app)/app/tasks/calendar/+page.svelte new file mode 100644 index 0000000..7702b13 --- /dev/null +++ b/src/routes/(app)/app/tasks/calendar/+page.svelte @@ -0,0 +1,310 @@ + + +
+
+ +
+
+ +

Task Calendar

+
+

Manage and track your tasks with ease

+
+ +
+ +
+
+ +
+
+
+ +

+ {monthNames[month]} {year} +

+ +
+ +
+
+ + +
+ {#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day} +
+ {day} +
+ {/each} +
+ + +
+ {#each calendar as date, i} + {#if date} + + {: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' : ''} +
+
+ + +
+ + +
+

This Month

+
+
+ Total Tasks + + {totalMonthlyTasks} + +
+
+ Days with Tasks + {monthlyTasks.length} +
+
+
+
+
+
+
diff --git a/src/routes/(app)/app/tasks/list/+page.server.js b/src/routes/(app)/app/tasks/list/+page.server.js new file mode 100644 index 0000000..396f9c3 --- /dev/null +++ b/src/routes/(app)/app/tasks/list/+page.server.js @@ -0,0 +1,24 @@ +import prisma from '$lib/prisma.js'; + +export async function load({ locals }) { + const user = locals.user; // Ensure user is used if needed for filtering or logging + const org = locals.org; + + const tasks = await prisma.task.findMany({ + where: { + organizationId: org.id + }, + include: { + owner: { + select: { name: true, id: true } // Select only necessary fields from owner + }, + account: { + select: { name: true, id: true } // Select only necessary fields from account + } + }, + orderBy: { + createdAt: 'desc' // Optional: order tasks, e.g., by creation date + } + }); + return { tasks }; +} \ No newline at end of file diff --git a/src/routes/(app)/app/tasks/list/+page.svelte b/src/routes/(app)/app/tasks/list/+page.svelte new file mode 100644 index 0000000..5887416 --- /dev/null +++ b/src/routes/(app)/app/tasks/list/+page.svelte @@ -0,0 +1,340 @@ + + +
+
+ +
+
+
+

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} + + + + +
+ {#each data.tasks as task (task.id)} +
+
+
+ + {task.subject} + + {#if task.description} +

+ {task.description} +

+ {/if} +
+
+ + + + +
+
+ +
+
+ {#snippet statusIconCard(/** @type {string} */ status)} + {@const StatusIcon = getStatusIcon(status)} + + {/snippet} + {@render statusIconCard(task.status)} + + {task.status || 'N/A'} + +
+ +
+ {#snippet priorityIconCard(/** @type {string} */ priority)} + {@const PriorityIcon = getPriorityIcon(priority)} + + {/snippet} + {@render priorityIconCard(task.priority)} + + {task.priority || 'Normal'} + +
+
+ +
+
+ + Due: {formatDate(task.dueDate)} +
+
+ + {task.owner?.name || 'Unassigned'} +
+ {#if task.account?.name} +
+ + {task.account.name} +
+ {/if} +
+
+ {/each} +
+ {/if} +
+
+
+ + \ No newline at end of file diff --git a/src/routes/(app)/app/tasks/new/+page.server.js b/src/routes/(app)/app/tasks/new/+page.server.js new file mode 100644 index 0000000..d618841 --- /dev/null +++ b/src/routes/(app)/app/tasks/new/+page.server.js @@ -0,0 +1,149 @@ +import prisma from '$lib/prisma'; +import { fail, redirect } from '@sveltejs/kit'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ locals, url }) { + const user = locals.user; + const org = locals.org; + + // Get accountId from URL parameters for validation + const urlAccountId = url.searchParams.get('accountId'); + + const users = await prisma.user.findMany({ + where: { + organizations: { + some: { + organizationId: org.id + } + } + } + }); + + const accounts = await prisma.account.findMany({ + where: { + organizationId: org.id + } + }); + + // If accountId is provided in URL, validate it exists + if (urlAccountId) { + const accountExists = accounts.some(account => account.id === urlAccountId); + if (!accountExists) { + throw redirect(303, '/app/tasks/new'); + } + } + + return { users, accounts }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + default: async ({ request, locals }) => { + const { user, org } = locals; + + const formData = await request.formData(); + + const subject = formData.get('subject')?.toString(); + const status = formData.get('status')?.toString() || 'Not Started'; + const priority = formData.get('priority')?.toString() || '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 prisma.user.findFirst({ + where: { + id: ownerId, + organizations: { + some: { 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 prisma.account.findFirst({ + where: { + id: accountId, + 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 { + // Prepare task data + const taskData = { + 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 prisma.task.create({ + data: taskData + }); + + console.log('Task created successfully:', task); + + // Redirect based on where the task was created from + if (accountId) { + // If task was created from an account page, redirect back to that account + throw redirect(303, `/app/accounts/${accountId}`); + } else { + // Otherwise, redirect to the tasks list + throw redirect(303, '/app/tasks/list'); + } + + } catch (e) { + // If it's a redirect, let it pass through + 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/src/routes/(app)/app/tasks/new/+page.svelte b/src/routes/(app)/app/tasks/new/+page.svelte new file mode 100644 index 0000000..c59ffcb --- /dev/null +++ b/src/routes/(app)/app/tasks/new/+page.svelte @@ -0,0 +1,344 @@ + + +
+
+ +
+
+
+

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} +
+
+
+ + + +
+
+

{form.error}

+
+
+
+ {/if} + +
{ + isSubmitting = true; + return async ({ update }) => { + await update(); + isSubmitting = false; + }; + }} + class="p-6" + > + + {#if urlAccountId} + + {/if} + +
+ +
+

+ + Task Details +

+ +
+ +
+ + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + +
+

+ + Assignment +

+ +
+ +
+ + +
+ + +
+ + {#if urlAccountId} + + +

+ Account pre-selected from URL +

+ {:else} + + + {/if} +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ + diff --git a/src/routes/(app)/app/users/+page.server.js b/src/routes/(app)/app/users/+page.server.js new file mode 100644 index 0000000..707b29c --- /dev/null +++ b/src/routes/(app)/app/users/+page.server.js @@ -0,0 +1,222 @@ +import prisma from '$lib/prisma' +import { fail, redirect } from '@sveltejs/kit'; +import { UserRole } from '@prisma/client'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params, locals }) { + const org_id = locals.org.id; // Changed from params.org_id + const user = locals.user; + + // Check if user is admin of the organization + const userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: user.id, + organizationId: org_id, + role: 'ADMIN' + } + }); + console.log('User Organization:', userOrg); + if (!userOrg) { + return { + error: { + name: 'You do not have permission to access this organization' + } + }; + } + // Fetch organization details + const organization = await prisma.organization.findUnique({ + where: { + id: org_id // Changed from params.org_id + } + }); + + // fetch all users in the organization + const users = await prisma.userOrganization.findMany({ + where: { + organizationId: org_id + }, + include: { + user: true + } + }); + // Pass logged-in user id to page for UI logic + return { organization, users, user: { id: user.id } }; +}; + +/** @type {import('./$types').Actions} */ +export const actions = { + update: async ({ request, params, locals }) => { + const org_id = locals.org.id; // Changed from params.org_id + const user = locals.user; + + // Only ADMIN can update + const userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: user.id, + organizationId: org_id, + role: 'ADMIN' + } + }); + if (!userOrg) return fail(403, { error: 'Forbidden' }); + + const formData = await request.formData(); + const name = formData.get('name')?.toString().trim(); + const domain = formData.get('domain')?.toString().trim(); + const description = formData.get('description')?.toString().trim(); + + if (!name) return fail(400, { error: 'Name is required' }); + + try { + await prisma.organization.update({ + where: { id: org_id }, + data: { + name, + domain, + description + } + }); + // Update locals for the current request so layout reloads with new name + if (name) { + if (locals.org) { + locals.org.name = name; + } + locals.org_name = name; + } + return { success: true }; + } catch (err) { + return fail(500, { error: 'Failed to update organization' }); + } + }, + + add_user: async ({ request, params, locals }) => { + const org_id = locals.org.id; // Changed from params.org_id + const user = locals.user; + + // Only ADMIN can add + const userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: user.id, + organizationId: org_id, + role: 'ADMIN' + } + }); + if (!userOrg) return fail(403, { error: 'Forbidden' }); + + const formData = await request.formData(); + const email = formData.get('email')?.toString().trim().toLowerCase(); + const roleString = formData.get('role')?.toString(); + const role = /** @type {UserRole} */ (roleString); + if (!email || !role) return fail(400, { error: 'Email and role are required' }); + + // Find user by email + const foundUser = await prisma.user.findUnique({ where: { email } }); + if (!foundUser) return fail(404, { error: 'No user found with that email' }); + + // Check if already in org + const already = await prisma.userOrganization.findFirst({ + where: { userId: foundUser.id, organizationId: org_id } + }); + if (already) return fail(400, { error: 'User already in organization' }); + + // Add user to org + await prisma.userOrganization.create({ + data: { + userId: foundUser.id, + organizationId: org_id, + role + } + }); + return { success: true }; + }, + + edit_role: async ({ request, params, locals }) => { + const org_id = locals.org.id; // Changed from params.org_id + const user = locals.user; + + // Only ADMIN can edit + const userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: user.id, + organizationId: org_id, + role: 'ADMIN' + } + }); + if (!userOrg) return fail(403, { error: 'Forbidden' }); + + const formData = await request.formData(); + const user_id = formData.get('user_id')?.toString(); + const roleString = formData.get('role')?.toString(); + const role = /** @type {UserRole} */ (roleString); + if (!user_id || !role) return fail(400, { error: 'User and role are required' }); + + // Don't allow editing own role (prevent lockout) + if (user_id === user.id) return fail(400, { error: 'You cannot change your own role' }); + + // Don't allow editing role of the only remaining admin + if (role !== 'ADMIN') { + // Count number of admins in org + const adminCount = await prisma.userOrganization.count({ + where: { + organizationId: org_id, + role: 'ADMIN' + } + }); + // If target user is admin and only one admin left, prevent demotion + const target = await prisma.userOrganization.findUnique({ + where: { userId_organizationId: { userId: user_id, organizationId: org_id } } + }); + if (target && target.role === 'ADMIN' && adminCount === 1) { + return fail(400, { error: 'Organization must have at least one admin' }); + } + } + + await prisma.userOrganization.update({ + where: { userId_organizationId: { userId: user_id, organizationId: org_id } }, + data: { role } + }); + return { success: true }; + }, + + remove_user: async ({ request, params, locals }) => { + const org_id = locals.org.id; // Changed from params.org_id + const user = locals.user; + + // Only ADMIN can remove + const userOrg = await prisma.userOrganization.findFirst({ + where: { + userId: user.id, + organizationId: org_id, + role: 'ADMIN' + } + }); + if (!userOrg) return fail(403, { error: 'Forbidden' }); + + const formData = await request.formData(); + const user_id = formData.get('user_id')?.toString(); + if (!user_id) return fail(400, { error: 'User is required' }); + + // Don't allow removing self (prevent lockout) + if (user_id === user.id) return fail(400, { error: 'You cannot remove yourself' }); + + // Don't allow removing the only remaining admin + const target = await prisma.userOrganization.findUnique({ + where: { userId_organizationId: { userId: user_id, organizationId: org_id } } + }); + if (target && target.role === 'ADMIN') { + const adminCount = await prisma.userOrganization.count({ + where: { + organizationId: org_id, + role: 'ADMIN' + } + }); + if (adminCount === 1) { + return fail(400, { error: 'Organization must have at least one admin' }); + } + } + + await prisma.userOrganization.delete({ + where: { userId_organizationId: { userId: user_id, organizationId: org_id } } + }); + return { success: true }; + } +}; diff --git a/src/routes/(app)/app/users/+page.svelte b/src/routes/(app)/app/users/+page.svelte new file mode 100644 index 0000000..a51cda1 --- /dev/null +++ b/src/routes/(app)/app/users/+page.svelte @@ -0,0 +1,398 @@ + + +
+
+ +
+
+

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} +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + +
+
+ {/if} +
+
+ + +
+
+
+
+
+ +
+

Team Members

+
+
+ + +
+

+ + Add New Member +

+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + + + + + + + + + + {#each users as user, i} + + + + + + + {/each} + +
+ Member + + Role + + Joined + + Actions +
+
+ {#if user.avatar} + {user.name} + {:else} +
+ +
+ {/if} +
+
+ {user.name} + {#if user.isSelf} + + You + + {/if} +
+
{user.email}
+
+
+
+ {#if user.isSelf} + + {#snippet roleIcon(/** @type {string} */ role)} + {@const RoleIcon = roleIcons[role] || User} + + {/snippet} + {@render roleIcon(user.role)} + {user.role} + + {:else} + {#if user.editingRole} +
+ + + + + +
+ {:else} + + {/if} + {/if} +
+ {user.joined} + + {#if user.isSelf} + + {:else} +
{ + if (!confirm('Remove this user from the organization?')) { + e.preventDefault(); + } + }} + > + + +
+ {/if} +
+
+
+
+
+
+
diff --git a/src/routes/(no-layout)/login/+page.server.js b/src/routes/(no-layout)/login/+page.server.js index 36617a3..0c00319 100644 --- a/src/routes/(no-layout)/login/+page.server.js +++ b/src/routes/(no-layout)/login/+page.server.js @@ -3,12 +3,15 @@ import prisma from '$lib/prisma' import { redirect } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; -import { auth } from '$lib/stores/auth'; // import bcrypt from "bcryptjs"; import { v4 as uuidv4 } from 'uuid'; +/** + * @param {Object} params - OAuth parameters + * @param {string} params.access_token - Access token + */ async function fetchUserData(params) { const url = 'https://www.googleapis.com/oauth2/v1/userinfo' @@ -26,14 +29,13 @@ async function fetchUserData(params) { } /** @type {import('@sveltejs/kit').ServerLoad} */ -export async function load({ params, url, cookies }) { +export async function load({ url, cookies }) { const code = url.searchParams.get('code') const redirect_uri = env.GOOGLE_LOGIN_DOMAIN + '/login' - console.log('code', code) // Check if the user is already authenticated if (code != null) { - params = { + const tokenParams = { grant_type: 'authorization_code', code, redirect_uri, @@ -43,7 +45,7 @@ export async function load({ params, url, cookies }) { let info try { - const response = await axios.post('https://accounts.google.com/o/oauth2/token', params) + const response = await axios.post('https://accounts.google.com/o/oauth2/token', tokenParams) info = response.data } catch (error) { console.error('Error:', error) @@ -53,8 +55,7 @@ export async function load({ params, url, cookies }) { const session_id = uuidv4() - console.log('user_info', user_info) - const user = await prisma.user.upsert({ + await prisma.user.upsert({ where: { email: user_info.email }, update: { session_id: session_id, diff --git a/src/routes/(no-layout)/login/+page.svelte b/src/routes/(no-layout)/login/+page.svelte index 6c51733..dedb030 100644 --- a/src/routes/(no-layout)/login/+page.svelte +++ b/src/routes/(no-layout)/login/+page.svelte @@ -1,25 +1,274 @@ -
-
+ $effect(() => { + mounted = true; + }); + + function handleGoogleLogin() { + isLoading = true; + // The actual navigation will happen via the href + setTimeout(() => { + isLoading = false; + }, 3000); + } + + + + Login | BottleCRM - Free Open-Source CRM for Startups + + + + +
+ +
+
+ + +
+
- Product Logo +
+
+
+ + + + {/if} +
+ + +
+ {#if mounted} +
+ +
+ + +
+ + +
+ +
+
+ BottleCRM Logo +
+ +
+

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 +
  • +
+
+
+ + + + + +
+
+ + Your data is secure and private +
+ + + +
+
+
+ + +
+

+ New to BottleCRM? + + Learn more about our free CRM + +

+
+
+ {/if} +
+
+
+
- \ No newline at end of file + + diff --git a/src/routes/(no-layout)/logout/+page.server.js b/src/routes/(no-layout)/logout/+page.server.js index 2fb3ac7..d0272ef 100644 --- a/src/routes/(no-layout)/logout/+page.server.js +++ b/src/routes/(no-layout)/logout/+page.server.js @@ -10,7 +10,8 @@ export async function load({ locals, cookies }) { delete locals.user await cookies.delete('session', { path: '/' }); + await cookies.delete('org', { path: '/' }); } - throw redirect(303, '/bounce'); + throw redirect(303, '/login'); } diff --git a/src/routes/(no-layout)/org/+page.server.js b/src/routes/(no-layout)/org/+page.server.js index 43ddc3c..c23817c 100644 --- a/src/routes/(no-layout)/org/+page.server.js +++ b/src/routes/(no-layout)/org/+page.server.js @@ -23,8 +23,7 @@ export async function load({ cookies, locals }) { id: userOrg.organization.id, name: userOrg.organization.name, logo: userOrg.organization.logo, - role: userOrg.role, - isPrimary: userOrg.isPrimary + role: userOrg.role })); return { orgs }; diff --git a/src/routes/(no-layout)/org/+page.svelte b/src/routes/(no-layout)/org/+page.svelte index db83a6d..b7ff6c8 100644 --- a/src/routes/(no-layout)/org/+page.svelte +++ b/src/routes/(no-layout)/org/+page.svelte @@ -1,18 +1,24 @@ -
-

Select an Organization

- -
- {#each orgs as org} - selectOrg(org)}> -
-
- -
-
-
{org.name}
-
-
- -
- {/each} -
- - {#if orgs.length === 0} -
-

No organizations found. Create a new one!

+
+
+ +
+
+

Select Organization

+

Choose an organization to continue

+
+ + + +
- {/if} -
- - - \ No newline at end of file + + {#if orgs.length > 0} +
+ {#each orgs as org} + + {/each} +
+ {:else} +
+
+ +
+

No organizations found

+

Create your first organization to get started

+ + + Create Organization + +
+ {/if} + + + {#if orgs.length > 0} + + + + {/if} +
+
\ No newline at end of file diff --git a/src/routes/(no-layout)/org/new/+page.server.js b/src/routes/(no-layout)/org/new/+page.server.js index f996776..7598b2a 100644 --- a/src/routes/(no-layout)/org/new/+page.server.js +++ b/src/routes/(no-layout)/org/new/+page.server.js @@ -23,7 +23,15 @@ export const actions = { // Get the submitted form data const formData = await request.formData(); - const orgName = formData.get('org_name'); + 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 @@ -55,8 +63,7 @@ export const actions = { data: { userId: user.id, organizationId: newOrg.id, - role: 'ADMIN', - isPrimary: true + role: 'ADMIN' } }); diff --git a/src/routes/(no-layout)/org/new/+page.svelte b/src/routes/(no-layout)/org/new/+page.svelte index fb9f59e..137ce2f 100644 --- a/src/routes/(no-layout)/org/new/+page.svelte +++ b/src/routes/(no-layout)/org/new/+page.svelte @@ -1,44 +1,88 @@ - -
-
-
- - +
+
+ +
+
+
+

Create Organization

+

Set up your new organization to get started

+
+ + +
+ + +
+ + +
+ + + {#if form?.error} +
+ + {form.error.name} +
+ {/if} + + + {#if form?.data} +
+ + + Organization "{form.data.name}" created successfully! + +
+ {/if} + + + + + + +
- - {#if form?.error} -
{form.error.name}
- {/if} - - {#if form?.data} -
Organization "{form.data.name}" created successfully!
- {/if} - - - Back to Organizations - - - +
+
diff --git a/src/routes/(site)/+layout.svelte b/src/routes/(site)/+layout.svelte index f091e3a..ad0dd7b 100644 --- a/src/routes/(site)/+layout.svelte +++ b/src/routes/(site)/+layout.svelte @@ -1,182 +1,402 @@ -
- -