diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 886d7ec..0000000 --- a/.cursorrules +++ /dev/null @@ -1,579 +0,0 @@ -# Cursor Rules for CV Generator Project - -## Code Style & Preferences - -### Function Declarations -- **Prefer arrow functions** where possible for consistency and modern JavaScript practices -- Use arrow functions for callbacks, event handlers, and short utility functions -- Reserve `function` declarations for methods that need `this` binding or when hoisting is beneficial - -### Control Flow -- **Prefer early returns** to reduce nesting and improve readability -- Use guard clauses at the beginning of functions to handle edge cases -- **Prefer ternary operations** over if/else for simple conditional assignments -- Use ternary for inline conditionals: `condition ? valueIfTrue : valueIfFalse` -- **If statements should have proper formatting**: curly brackets and whitespace -- **Always use curly braces for if/else statements** - never omit braces even for single-line statements -- **Always add a newline after if/else keywords** - format as: `if (condition) {\n // code\n}` not `if (condition) { // code }` -- **Prefer ternary expressions** over if statements when possible for cleaner code - -### Destructuring & Assignment -- **Prefer destructuring** for object and array access -- Use destructuring in function parameters: `({ id, name }) => ...` -- Use destructuring for imports: `import { Component } from 'library'` -- Prefer object spread over Object.assign: `{ ...obj, newProp: value }` -- **Use object property shorthand** when variable names match object keys -- Example: Use `{ where }` instead of `{ where: whereClause }` -- Name variables appropriately to enable shorthand syntax (e.g., `where` not `whereClause`) - -### Exports -- **Prefer named exports** over default exports for consistency and better refactoring support -- Use `export const Component = () => {}` instead of `export default function Component() {}` -- Named exports make imports explicit and enable better IDE support -- Exception: Only use default exports when required by frameworks (e.g., Next.js pages) - -### Variable Declarations -- **Prefer `const`** over `let` where possible -- Use `let` only when reassignment is necessary -- Avoid `var` entirely - -### TypeScript -- Use explicit types for function parameters and return values -- Prefer interface over type for object shapes -- Use type assertions sparingly and with proper type guards -- Leverage TypeScript's strict mode features -- **NEVER use non-null assertion operator (`!`)** -- Use proper null checks, optional chaining, and type guards instead -- All properties should be properly typed without assertions - -### React/JSX -- Use functional components with hooks -- Prefer arrow function components for consistency -- Use destructuring for props: `const Component = ({ prop1, prop2 }) => ...` -- Prefer early returns in render methods to avoid deep nesting -- **Prefer Button/IconButton components over native HTML buttons** -- Use composition for icons with `leftIcon`/`rightIcon` props -- Extract inline SVGs to separate icon components -- **Prefer CVA (Class Variance Authority) for dynamic styling configurations** -- Use CVA for conditional styling patterns instead of manual className concatenation -- **Prefer single state object over multiple useState calls** -- Use `useState` with object state when managing multiple related values -- Example: `const [state, setState] = useState({ items: [], loading: false, error: null })` - -### NestJS/Backend -- Use dependency injection properly with regular imports (not `import type`) -- Prefer arrow functions for service methods -- Use early returns in validation and error handling -- Leverage decorators for clean, declarative code -- **Use `getOrThrow()` for configuration values instead of manual error throwing** -- Inject ConfigService where needed for environment variable access -- Use proper type annotations with `getOrThrow("VARIABLE_NAME")` -- **One injectable service/class per file** - keep services focused and maintainable -- **Extract domain mapping logic to separate mapper files** - services should not contain `toDomain()` methods -- **Services should contain pure domain logic** - no GraphQL concerns (connections, edges, pageInfo) -- **GraphQL resolvers handle connection composition** - services only provide `findMany()` and `count()` methods - -### GraphQL -- **Prefer `.graphql` files for queries and mutations** instead of inline strings -- Extract GraphQL queries to separate files for better syntax highlighting and maintainability -- Use one query/mutation per file for clarity and easier maintenance -- Load GraphQL files using simple `fs.readFileSync()` utilities - -### Error Handling -- Use early returns for error conditions -- Prefer specific error types over generic Error -- Use optional chaining (`?.`) for safe property access -- Handle errors at the appropriate level of abstraction -- **Use `notFound()` utility for consistent 404 errors** -- **Include property and value in error messages**: `notFound("Entity", "property", value)` -- **Prefer ternary expressions with `notFound()` for early returns**: `return entity ?? notFound("Entity", "id", id)` - -### Code Organization -- Group related functionality together -- Use barrel exports (`index.ts`) for clean imports -- Prefer composition over inheritance -- Keep functions small and focused on single responsibility - -### Comments -- **Avoid inline comments** - code should be self-documenting through clear naming and structure -- Use descriptive variable and function names instead of comments -- Only add comments when absolutely necessary to explain complex business logic or non-obvious behavior -- Prefer JSDoc comments for public APIs and complex functions when documentation is needed - -### Import Management -- **Prefer TypeScript path aliases over relative directory traversal** -- Use `@/` alias for all imports within the project -- Avoid relative imports like `../`, `../../`, `../../../` -- Use absolute paths with aliases: `@/modules/entity/entity.service` -- This improves maintainability and reduces import path complexity - -### Performance -- Use `useMemo` and `useCallback` judiciously in React -- Prefer `const` assertions for immutable data -- Use object spread efficiently -- Leverage TypeScript's type system for compile-time optimizations - -## Examples - -### Good ✅ -```typescript -// Arrow function with early return -const processUser = (user: User | null) => { - if (!user) return null; - - return { - id: user.id, - name: user.name, - isActive: user.status === 'active' - }; -}; - -// Destructuring in parameters -const UserCard = ({ user, onEdit }: { user: User; onEdit: (id: string) => void }) => { - const { id, name, email } = user; - - return ( -
onEdit(id)}> -

{name}

-

{email}

-
- ); -}; - -// Ternary for simple conditionals -const statusColor = isActive ? 'green' : 'red'; - -// Object spread -const updatedUser = { ...user, lastLogin: new Date() }; - -// Proper null handling -const userId = user?.id ?? ''; -const userName = user?.name ?? 'Unknown'; - -// Proper entity instantiation -class User { - id: string; - name: string; - - constructor(data: { id: string; name: string }) { - this.id = data.id; - this.name = data.name; - } -} - -// Proper configuration with getOrThrow -const secret = configService.getOrThrow("JWT_SECRET"); -const port = configService.getOrThrow("PORT"); - -// CVA for dynamic styling -const statusVariants = cva("text-sm cursor-help", { - variants: { - status: { - online: "text-ctp-green", - offline: "text-ctp-red", - checking: "text-ctp-yellow", - }, - animated: { - true: "animate-pulse", - false: "", - }, - }, - defaultVariants: { - status: "checking", - animated: false, - }, -}); - -// TypeScript path aliases for imports -import { CompanyService } from "@/modules/job-experience/company/company.service"; -import { BasePaginationArgs } from "@/modules/base/pagination.types"; -import { User } from "@/modules/auth/user.entity"; - -// Error handling with notFound utility -async findByIdOrFail(id: string): Promise { - const company = await this.findById(id); - return company ?? notFound("Company", "id", id); -} - -// Proper if statement formatting (when ternary isn't suitable) -if (user.isActive) { - return processUser(user); -} - -// If/else with proper formatting -if (user.isActive) { - return processUser(user); -} else { - return null; -} - -// Ternary expressions for simple conditionals -const statusColor = isActive ? 'green' : 'red'; -const message = user ? `Hello ${user.name}` : 'Hello Guest'; -``` - -### Avoid ❌ -```typescript -// Nested if statements -function processUser(user) { - if (user) { - if (user.status === 'active') { - return { - id: user.id, - name: user.name, - isActive: true - }; - } else { - return { - id: user.id, - name: user.name, - isActive: false - }; - } - } else { - return null; - } -} - -// Verbose conditional -let statusColor; -if (isActive) { - statusColor = 'green'; -} else { - statusColor = 'red'; -} - -// Object.assign -const updatedUser = Object.assign({}, user, { lastLogin: new Date() }); - -// Non-null assertions -const userId = user!.id; -const userName = user!.name!; - -// Improper entity instantiation -class User { - id!: string; - name!: string; - - constructor(partial: Partial) { - Object.assign(this, partial); - } -} - -// Manual error throwing for configuration -const secret = configService.get("JWT_SECRET"); -if (!secret) { - throw new Error("JWT_SECRET environment variable is required"); -} - -// Direct process.env access without proper validation -const port = process.env["PORT"] || "3000"; - -// Relative directory traversal imports -import { CompanyService } from "../company/company.service"; -import { BasePaginationArgs } from "../../../base/pagination.types"; -import { User } from "../../auth/user.entity"; - -// Verbose error handling -async findByIdOrFail(id: string): Promise { - const company = await this.findById(id); - if (!company) { - throw new NotFoundException(`Company with id ${id} not found`); - } - return company; -} - -// Poor if statement formatting -if(user.isActive)return processUser(user); - -// Missing curly braces -if (user.isActive) return processUser(user); - -// Missing newline after if -if (user.isActive) { return processUser(user); } - -// Verbose conditionals that could be ternary -let statusColor; -if (isActive) { - statusColor = 'green'; -} else { - statusColor = 'red'; -} -``` - -## Project-Specific Rules - -### Environment Variables -- Use bracket notation for environment variables: `process.env["VARIABLE_NAME"]` -- This is required due to TypeScript's `noUncheckedIndexedAccess` rule - -### Dependency Injection -- Use regular imports for NestJS services: `import { Service } from './service'` -- Avoid `import type` for classes that are injected at runtime - -### Prisma/Database -- Use bracket notation for Prisma client methods: `prisma["user"].findMany()` -- This is required due to TypeScript's strict index signature checking -- **Always run database operations (seeding, migrations) in Docker containers** -- Use `docker-compose exec` or `docker-compose run` for database operations -- This ensures consistent environment and proper database connectivity - -### GraphQL -- Use arrow functions for resolvers -- Prefer early returns for validation -- Use destructuring for resolver parameters -- **Prefer GraphQL Relay-style connections over arrays** for list queries -- Use `Connection` types with `edges`, `pageInfo`, and `totalCount` for paginated data -- Only return arrays for small, non-paginated lists (e.g., enum values, small reference data) -- Resolvers should compose connections from service `findMany()` and `count()` methods using `PaginationService` - -### Three-Layer Architecture -**Prefer GraphQL -> Domain Entity -> Prisma Entity separation** - -#### Domain Entity Rules -- **Domain entities should NOT contain foreign key fields** (userId, companyId, roleId, etc.) -- **Domain entities should contain full related entities** instead of IDs -- **Only include the user relation when it's essential** for the business logic -- **Use `findFor` methods** instead of `findBy` when working with related entities - -#### Layer 1: GraphQL Types -- GraphQL types should be separate from domain models -- Focus on API contract and client needs -- Use `fromDomain()` static methods to convert from domain entities -- **All GraphQL types MUST instantiate properties through constructors** -- **NEVER use non-null assertion operator (`!`) in GraphQL types** -- Use proper nullable types and constructor parameter validation -- Example: `UserGraphQL.fromDomain(domainUser)` - -#### Layer 2: Domain Entities -- Domain entities should be separate from Prisma models -- Contain business logic and domain rules -- Use `toDomain()` and `mapToDomain()` methods for conversion -- **All domain entities MUST instantiate properties through constructors** -- **NEVER use non-null assertion operator (`!`) in domain entities** -- Use proper nullable types and constructor parameter validation -- Example: `User` domain entity with business logic - -#### Layer 3: Prisma Models -- Prisma models are database representations -- Keep database operations in Prisma services -- Use `mapToDomain()` to convert to domain entities -- Example: `prisma.user.findMany()` -> `mapToDomain()` -> `User` domain - -#### Service Method Rules -- **Service methods should accept full entities** instead of IDs when possible -- **Use `findFor` methods** (e.g., `findForUser(user)`) instead of `findBy` methods with IDs -- **DTOs should contain full entities** instead of foreign key IDs -- **Only use IDs for Prisma operations** - extract IDs from entities at the service boundary -- **OrFail delegation**: Implement `findByXOrFail` methods by delegating to the corresponding non-throwing `findByX` method and only handling the error-throwing responsibility (prefer early return style). This avoids duplication and ensures consistent behavior. -- **Maximise mapper usage**: Services must use their injected mappers (`toDomain`, `mapToDomain`, and any specialized helpers) for all conversions from Prisma to domain, including joined/`include` cases. Avoid manual `new Entity(...)` in services. -- **Use PaginationService.buildQueryOptions()** for cursor-based pagination instead of manual cursor logic. This method handles all after/before/first/last logic generically. -- **Connection classes must handle their own edge management**: All GraphQL connection types must include a static `fromPaginationResult()` factory method that handles edge creation and domain mapping. This keeps edge management logic within the connection class and follows the established pattern used by other connections in the codebase. - -#### Mapping Functions -- **`fromDomain()`** - Domain entity to GraphQL type -- **`toDomain()`** - Prisma model to domain entity -- **`mapToDomain()`** - Array of Prisma models to domain entities -- **`fromDomain()` MUST always accept domain entities, never Prisma entities** -- GraphQL types should only work with domain entities, not Prisma models -- If relations are needed, domain entities should include them as optional properties -- Keep resolvers focused on GraphQL concerns -- Keep domain logic in domain entities -- Keep database operations in Prisma services - -#### Entity Instantiation Patterns -**All entities and GraphQL types MUST use constructor-based instantiation:** - -```typescript -// ✅ GOOD: Proper constructor with nullable types -@ObjectType() -export class Organization { - @Field(() => String) - id: string; - - @Field(() => String) - name: string; - - @Field(() => String, { nullable: true }) - description: string | null; - - @Field(() => Date) - createdAt: Date; - - @Field(() => Date) - updatedAt: Date; - - constructor(data: { - id: string; - name: string; - description?: string | null; - createdAt: Date; - updatedAt: Date; - }) { - this.id = data.id; - this.name = data.name; - this.description = data.description ?? null; - this.createdAt = data.createdAt; - this.updatedAt = data.updatedAt; - } -} -``` - -```typescript -// ❌ BAD: Non-null assertions and no proper constructor -@ObjectType() -export class Organization { - @Field(() => String) - id!: string; - - @Field(() => String) - name!: string; - - @Field(() => String, { nullable: true }) - description?: string | null; - - @Field(() => Date) - createdAt!: Date; - - @Field(() => Date) - updatedAt!: Date; - - constructor(partial: Partial) { - Object.assign(this, partial); - } -} -``` - -**Key Requirements:** -- **NEVER use `!` non-null assertion operator** -- **ALWAYS use explicit constructor parameters** -- **ALWAYS handle nullable types properly** -- **ALWAYS validate required properties in constructor** -- Use `??` nullish coalescing for default values -- Use proper TypeScript types without assertions - -## Biome Integration - -These rules are enforced by Biome with the following configuration: -- `useConst: "error"` - Prefer const over let -- `useShorthandAssign: "error"` - Prefer shorthand object assignment -- `useShorthandFunctionType: "error"` - Prefer arrow functions -- `useObjectSpread: "error"` - Prefer object spread -- `useCollapsedElseIf: "error"` - Prefer early returns -- `useCollapsedIf: "error"` - Prefer early returns -- `useOptionalChain: "error"` - Prefer optional chaining -- `useSimplifiedLogicExpression: "error"` - Prefer ternary operations -- `noNonNullAssertion: "error"` - Prohibit non-null assertion operator (`!`) -- `noExplicitAny: "error"` - Prohibit explicit `any` types -- `noUnsafeAssignment: "error"` - Prohibit unsafe assignments - -## File Organization - -### Frontend (`apps/client/`) -- Components in `src/components/` -- Features in `src/features/` -- Pages in `src/pages/` -- Utilities in `src/utils/` -- Use `@/` alias for imports - -### Backend (`apps/server/`) -- Modules in `src/modules/` -- Services in `src/modules/{module}/` -- Resolvers in `src/modules/{module}/` -- Use `@/` alias for imports - -### Shared -- Utilities in `packages/utils/` -- Types in `packages/types/` - -## JavaScript File Management - -### Build Artifacts -- **NEVER create JavaScript files** through TypeScript compilation or build commands -- **Always clean up JavaScript files** after any build/compilation operations -- Use `find . -name "*.js" -type f | grep -v node_modules | grep -v dist | xargs rm -f` to clean up -- **Prefer TypeScript-only codebase** - no compiled JavaScript artifacts should remain -- **After any build/test operations, immediately clean up generated JavaScript files** - -## Git & Commits - -### Commit Behavior -- **Only commit when explicitly requested by the user** -- **Only stage files when explicitly requested by the user** -- Do not automatically commit or stage changes -- Ask for permission before making any commits or staging files -- Wait for user approval to commit or stage files -- **When making commits, check if any roadmap items were completed and check them off** -- Review ROADMAP.md for relevant completed features and update checkboxes accordingly - -### Scripts and Automation -- **Avoid creating one-off scripts in a scripts directory unless explicitly requested** -- **Prefer running commands directly or using existing package.json scripts** -- **Only create package.json scripts for operations that will be used repeatedly** -- **For one-time operations, run commands directly rather than creating script files** - -### Monorepo Dependencies -- **Unless it's specifically monorepo tooling (like Lerna, Nx, Rush), install npm libraries in the sub-projects where they're used** -- **Avoid relying on root-level node_modules for sub-project dependencies** -- **Each package should have its own dependencies installed locally for better isolation and reliability** - -### Conventional Commits -- **Always use conventional commit format**: `type(scope): description` -- **Types**: `feat:`, `fix:`, `docs:`, `style:`, `refactor:`, `test:`, `chore:`, `perf:`, `ci:`, `build:` -- **Scope**: Optional, use for specific areas (e.g., `feat(auth):`, `fix(ui):`, `docs(api):`) -- **Description**: Clear, concise description of the change -- **Breaking changes**: Use `!` after type/scope (e.g., `feat!: breaking change`) -- **Examples**: - - `feat(toast): add configurable timing system` - - `fix(auth): resolve JWT token validation` - - `docs(api): update GraphQL schema documentation` - - `refactor(ui): extract toast icons to separate components` - - `chore(deps): update biome to latest version` - -### Docker & Service Health -- **NEVER use `sleep` commands to wait for Docker services to start** -- **Always rely on healthchecks and `depends_on` conditions** defined in `docker-compose.yml` -- Services are configured with `depends_on` and `condition: service_healthy` to ensure proper startup order -- If a service needs to be restarted, use `docker-compose restart ` and trust the healthchecks -- Check service status with `docker-compose ps` to see health status -- Only run commands on healthy services using `docker-compose exec` -- Example: All services have healthchecks (db, server, client) with proper intervals and timeouts - -### Post-Refactor Validation -- **After major refactors** (schema changes, service refactoring, entity changes), always: - 1. **Run codegen in Docker**: `docker-compose exec client sh -c "cd /app/apps/client && GRAPHQL_ENDPOINT=http://localhost:3000/graphql npm run codegen"` - 2. **Run TypeScript typecheck**: `docker-compose exec client sh -c "cd /app/apps/client && npx tsc --noEmit"` - 3. **Fix any type errors** before considering the refactor complete - 4. **Clean up generated JavaScript files**: `find apps/client -name "*.js" -type f | grep -v node_modules | xargs rm -f` -- This ensures the frontend remains in sync with backend schema changes and catches type issues early - -## Bug Tracking - -### Bug Report Format -When reporting bugs, use this standardized format in `apps/docs/content/KNOWN_BUGS.md`: - -```markdown -### [Bug Title] -- **File**: `path/to/file.ts` -- **Issue**: Brief description of the problem -- **Steps to Reproduce**: - 1. Step 1 - 2. Step 2 - 3. Step 3 -- **Expected Behavior**: What should happen -- **Actual Behavior**: What actually happens -- **Priority**: High/Medium/Low -- **Status**: Open/In Progress/Fixed -- **Commit**: `abc1234` (if applicable) -- **Ticket**: `#123` or `JIRA-456` (if applicable) -- **PR**: `#789` or `https://github.com/owner/repo/pull/789` (if applicable) -``` - -### Bug Management -- **Track all bugs** in `apps/docs/content/KNOWN_BUGS.md` -- **Organize by category** (Authentication, UI, Backend, etc.) -- **Update status** when bugs are fixed -- **Include file paths** for easy navigation -- **Use clear, descriptive titles** -- **Provide reproduction steps** for complex bugs -- **Mark fixed bugs** with ✅ and fix date diff --git a/.dockerignore b/.dockerignore index 30b60dc..fd555ee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -50,8 +50,8 @@ coverage/ .cache/ .npm/ -# Package manager lock files (keep package.json but ignore locks for faster builds) -package-lock.json +# Package manager lock files (keep these for deterministic builds) +# package-lock.json yarn.lock pnpm-lock.yaml diff --git a/.env.example b/.env.example index a93136d..9217f94 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,4 @@ VITE_CLIENT_URL=http://localhost:5173 # Prisma PRISMA_ENABLE_TRACING=false VITE_DOCS_URL=http://localhost:3001 +ENCRYPTION_KEY=94caadf1e9765adf9d89fc3c440f4b67651ec85b3bc0cf8fe3b0e1db2c585779 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3dc7b21..7f57c6d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,8 @@ packages/**/dist/ # Lerna lerna-debug.log + +# AI assistant rules (local preferences) +.claude/ +.cursorrules +CLAUDE.md diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000..1a89acc --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,25 @@ +# Base image with monorepo dependencies installed +FROM node:22-alpine AS base + +WORKDIR /app + +# Copy monorepo configuration +COPY package.json package-lock.json lerna.json ./ + +# Copy all package.json files +COPY packages/*/package.json packages/*/ +COPY apps/*/package.json apps/*/ + +# Copy package source code (needed for workspace resolution) +COPY packages/ ./packages/ + +# Install all dependencies +RUN npm ci + +# Development image with tools +FROM base AS development + +# Install system dependencies for dev tools +RUN apk add --no-cache curl openssl + +WORKDIR /app diff --git a/apps/client/Dockerfile b/apps/client/Dockerfile index d5bb817..8e2e415 100644 --- a/apps/client/Dockerfile +++ b/apps/client/Dockerfile @@ -1,83 +1,47 @@ -# ============================================================================= -# Development Stage -# ============================================================================= -FROM node:22-alpine AS development +FROM node:24-alpine AS development -# Install curl for health checks RUN apk add --no-cache curl WORKDIR /app -# ============================================================================= -# Layer 2: Package dependencies (changes when dependencies update) -# ============================================================================= -COPY package*.json ./ -COPY lerna.json ./ -COPY apps/client/package*.json ./apps/client/ -COPY apps/server/package*.json ./apps/server/ -COPY packages/utils/package.json ./packages/utils/ -COPY packages/tsconfig/package.json ./packages/tsconfig/ -COPY packages/biome-config/package.json ./packages/biome-config/ -COPY packages/ui/package.json ./packages/ui/ - -# Install all dependencies (monorepo) -RUN npm install --ignore-scripts - -# ============================================================================= -# Layer 3: Configuration files (changes less frequently than source code) -# ============================================================================= -COPY packages/tsconfig/ ./packages/tsconfig/ -COPY packages/biome-config/ ./packages/biome-config/ -COPY packages/utils/ ./packages/utils/ -COPY packages/ui/ ./packages/ui/ - -# ============================================================================= -# Layer 4: Client configuration (changes when config updates) -# ============================================================================= -COPY apps/client/tsconfig*.json ./apps/client/ -COPY apps/client/vite.config.ts ./apps/client/ -COPY apps/client/postcss.config.cjs ./apps/client/ -COPY apps/client/codegen.ts ./apps/client/ -COPY apps/client/index.html ./apps/client/ - -# ============================================================================= -# Layer 5: Source code (changes most frequently) -# ============================================================================= -COPY apps/client/src/ ./apps/client/src/ - -# Copy scripts for health checks -COPY scripts/ ./scripts/ +# Copy monorepo files +COPY package.json package-lock.json lerna.json ./ +COPY packages/*/package.json packages/*/ +COPY apps/*/package.json apps/*/ +COPY packages/ ./packages/ + +# Install dependencies +RUN npm ci + +# Copy client files +COPY apps/client/ ./apps/client/ -WORKDIR /app/apps/client +# Copy health check scripts +COPY scripts/ ./scripts/ EXPOSE 5173 -CMD ["npm", "run", "dev"] +CMD ["npm", "run", "dev", "--workspace=@cv/client"] -# ============================================================================= -# Build Stage -# ============================================================================= -FROM node:22-alpine AS builder +# Production build stage +FROM node:24-alpine AS builder WORKDIR /app -# Copy all files for build -COPY . . +COPY package.json package-lock.json lerna.json ./ +COPY packages/ ./packages/ +COPY apps/*/package.json apps/*/ -# Install dependencies and build -RUN npm ci && npm run build --workspace=@cv/client +RUN npm ci -# ============================================================================= -# Production Stage -# ============================================================================= -FROM nginx:alpine AS production +COPY apps/client/ ./apps/client/ -WORKDIR /usr/share/nginx/html +RUN npm run build --workspace=@cv/client -# Copy built assets from builder -COPY --from=builder /app/apps/client/dist . +# Production serve stage +FROM nginx:alpine AS production -# Copy nginx configuration +COPY --from=builder /app/apps/client/dist /usr/share/nginx/html COPY apps/client/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 diff --git a/apps/client/FRONTEND_STRUCTURE.md b/apps/client/FRONTEND_STRUCTURE.md deleted file mode 100644 index 7482dd7..0000000 --- a/apps/client/FRONTEND_STRUCTURE.md +++ /dev/null @@ -1,175 +0,0 @@ -# Frontend Application Structure - -This document reflects the current, up-to-date structure of the client app and the co-location pattern for GraphQL and validation. - -``` -apps/client/src/ -├── components/ # Reusable UI components -│ ├── ConfirmationModal.tsx # Modal for confirmations -│ ├── ErrorBoundary.tsx # Error boundary component -│ ├── Navbar.tsx # Navigation bar component -│ ├── ServerStatusIndicator/ # Server health indicator -│ │ ├── ServerTooltip.tsx # Tooltip for server info -│ │ ├── StatusDot.tsx # Status indicator dot -│ │ ├── constants.ts # Status constants -│ │ ├── index.tsx # Main component -│ │ ├── types.ts # Type definitions -│ │ └── utils.ts # Utility functions -│ ├── Toast.tsx # Toast notification component -│ ├── ToastContainer.tsx # Toast container -│ ├── icons/ # Icon components -│ │ ├── CloseIcon.tsx -│ │ ├── DeleteIcon.tsx -│ │ ├── DocumentIcon.tsx -│ │ ├── EditIcon.tsx -│ │ ├── ErrorIcon.tsx -│ │ ├── LinkIcon.tsx -│ │ ├── LoadingIcon.tsx -│ │ ├── ToastIcon.tsx -│ │ ├── UploadIcon.tsx -│ │ └── index.ts # Icon exports -│ └── navLinks.ts # Navigation links configuration -│ -├── constants/ # Application constants -│ └── auth.ts # Authentication constants -│ -├── contexts/ # React contexts -│ ├── ConfirmationModalContext.tsx # Confirmation modal context -│ └── ToastContext.tsx # Toast notifications context -│ -├── features/ # Feature-based modules -│ ├── app/ # App-level features -│ │ └── queries/ -│ │ └── app.graphql # App health query -│ │ -│ ├── auth/ # Authentication feature -│ │ ├── queries/ -│ │ │ └── auth.graphql # Authentication queries -│ │ ├── LoginForm.tsx # Login form -│ │ └── RegisterForm.tsx # Registration form -│ │ -│ ├── job-experience/ # Job experience feature -│ │ ├── queries/ # GraphQL queries & mutations -│ │ │ ├── companies-query.graphql -│ │ │ ├── create-job-experience.graphql -│ │ │ ├── delete-job-experience.graphql -│ │ │ ├── job-experience-form-data.graphql -│ │ │ ├── levels-query.graphql -│ │ │ ├── me-job-experience.graphql -│ │ │ ├── roles-query.graphql -│ │ │ └── skills-query.graphql -│ │ └── components/ -│ │ ├── JobExperienceCard.tsx -│ │ ├── JobExperienceCreationSelector.tsx -│ │ ├── JobExperienceEmpty.tsx -│ │ ├── JobExperienceForm.tsx -│ │ ├── JobExperienceHeader.tsx -│ │ ├── JobExperienceList.tsx -│ │ ├── JobExperienceLoading.tsx -│ │ ├── JobExperienceTable.tsx -│ │ ├── jobExperience.schema.ts # Zod schema co-located with form -│ │ └── index.ts -│ │ -│ ├── organizations/ # Organizations feature -│ │ └── components/ -│ │ ├── MembersTableBody.tsx -│ │ ├── MembersTableHeader.tsx -│ │ ├── OrganizationMemberRow.tsx -│ │ ├── OrganizationMembersTable.tsx -│ │ └── index.ts -│ │ -│ ├── user/ # Shared user queries -│ │ └── queries/ -│ │ ├── me-minimal.graphql -│ │ ├── me-with-organizations.graphql -│ │ └── me.graphql -│ │ -│ └── vacancies/ # Vacancies feature -│ ├── queries/ -│ │ ├── create-vacancy.graphql -│ │ ├── delete-vacancy.graphql -│ │ └── my-vacancies.graphql -│ └── components/ -│ ├── VacancyCard.tsx -│ ├── VacancyCreationSelector/ -│ │ ├── CreationMethodCard.tsx -│ │ ├── PlaceholderForm.tsx -│ │ ├── VacancyCreationSelector.tsx -│ │ ├── constants.ts -│ │ ├── index.ts -│ │ ├── types.ts -│ │ └── variants.ts -│ ├── VacancyForm.tsx -│ ├── VacancyList.tsx -│ ├── vacancy.schema.ts # Zod schema co-located with form -│ └── index.ts -│ -├── generated/ # Generated GraphQL types & hooks -│ └── graphql.ts -│ -├── hooks/ -│ ├── useAuth.ts -│ └── useServerHealth.ts -│ -├── layouts/ -│ └── AuthenticatedLayout.tsx -│ -├── lib/ -│ ├── apollo-client.ts -│ └── config.ts -│ -├── pages/ -│ ├── CreateJobExperiencePage.tsx -│ ├── CreateVacancyPage.tsx -│ ├── DashboardPage.tsx -│ ├── JobExperiencePage.tsx -│ ├── OrganizationsPage.tsx -│ ├── ProfilePage.tsx -│ └── VacanciesPage.tsx -│ -├── providers/ -│ └── TokenProvider.tsx -│ -├── router/ -│ └── AppRouter.tsx -│ -├── types/ -│ ├── auth.ts -│ └── graphql.d.ts -│ -├── ui/ -│ ├── Badge.tsx -│ ├── Button.tsx -│ ├── Checkbox.tsx -│ ├── IconButton.tsx -│ ├── Select.tsx -│ ├── StatusBadge.tsx -│ ├── Table.tsx -│ ├── TextInput.tsx -│ ├── Textarea.tsx -│ └── index.ts -│ -├── utils/ -│ ├── auth.ts -│ └── dateUtils.ts -│ -├── App.tsx -├── index.css -└── main.tsx -``` - -## Conventions -- GraphQL is co-located per feature under `features/*/queries/`. -- Zod validation is co-located with the form component using it (e.g., `*.schema.ts`). -- Reusable UI lives in `ui/`; global components in `components/`. - -## GraphQL Codegen -- Source glob: `src/**/*.graphql` -- Generated output: `src/generated/graphql.ts` -- Client: Apollo React hooks are generated for queries/mutations -- Typical import pattern: - - Operations: create `.graphql` files under the relevant `features/*/queries/` - - Types/hooks: `import { useMyQuery } from "@/generated/graphql"` -- To regenerate locally: - - Root script: `npm run codegen` (executes scoped client codegen) - - Ensure server schema is reachable via `VITE_SERVER_URL`/`GRAPHQL_SCHEMA_URL` in docker-compose or `.env.local` diff --git a/apps/client/package.json b/apps/client/package.json index 91ca137..6ea9319 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -10,10 +10,12 @@ "lint": "biome check .", "lint:fix": "biome check --write .", "start": "vite", + "typecheck": "tsc -b --noEmit", "codegen": "graphql-codegen --config codegen.ts", "codegen:client": "graphql-codegen --config codegen.ts" }, "dependencies": { + "@cv/routing": "*", "@cv/ui": "*", "@cv/utils": "*", "@tanstack/react-query": "^5.59.0", @@ -22,11 +24,11 @@ "@types/react-router-dom": "^5.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.0.0", - "graphql": "^16.8.1", + "graphql": "^16.12.0", "graphql-request": "^6.1.0", "graphql-type-json": "^0.3.2", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-router-dom": "^7.9.4", "tailwind-merge": "^2.0.0", "zod": "^3.25.76" @@ -35,14 +37,14 @@ "@biomejs/biome": "^2.2.6", "@cv/biome-config": "*", "@cv/tsconfig": "*", - "@graphql-codegen/cli": "^5.0.0", - "@graphql-codegen/typescript": "^4.0.1", - "@graphql-codegen/typescript-operations": "^4.0.1", + "@graphql-codegen/cli": "^6.1.0", + "@graphql-codegen/typescript": "^5.0.6", + "@graphql-codegen/typescript-operations": "^5.0.6", "@graphql-codegen/typescript-react-query": "^6.1.0", "@tailwindcss/postcss": "^4.1.15", "@tailwindcss/vite": "^4.0.0", - "@types/react": "^18.3.11", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.0.6", + "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "^5.6.3", diff --git a/apps/client/src/components/ActiveSessions.tsx b/apps/client/src/components/ActiveSessions.tsx new file mode 100644 index 0000000..79f44e7 --- /dev/null +++ b/apps/client/src/components/ActiveSessions.tsx @@ -0,0 +1,145 @@ +import { ConfirmationModal, DeleteIcon, IconButton, Placeholder } from "@cv/ui"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { + useActiveSessionsQuery, + useDeleteSessionMutation, +} from "@/generated/graphql"; +import { clearAuthCookies } from "@/utils/cookies"; + +export const ActiveSessions = () => { + const queryClient = useQueryClient(); + const { data, isLoading, error } = useActiveSessionsQuery(); + const deleteSessionMutation = useDeleteSessionMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: useActiveSessionsQuery.getKey(), + }); + }, + }); + + const [sessionToDelete, setSessionToDelete] = useState<{ + id: string; + isCurrent: boolean; + } | null>(null); + + if (isLoading) { + return ( +
+

Active Sessions

+ +
+ ); + } + + if (error) { + return ( +
+

Active Sessions

+ +
+ ); + } + + const sessions = data?.activeSessions ?? []; + + if (sessions.length === 0) { + return ( +
+

Active Sessions

+ +
+ ); + } + + return ( +
+

Active Sessions

+
+ {sessions.map((session) => ( +
+
+
+
+

+ {session.deviceName ?? "Unknown Device"} +

+ {session.isCurrentSession && ( + + Current Session + + )} + {session.deviceType && ( + + {session.deviceType} + + )} +
+
+ {session.city && session.country && ( +

+ {session.city}, {session.country} +

+ )} +

+ Last active: {new Date(session.createdAt).toLocaleString()} +

+

Expires: {new Date(session.expiresAt).toLocaleString()}

+
+
+ } + label="Delete session" + onClick={() => + setSessionToDelete({ + id: session.id, + isCurrent: session.isCurrentSession, + }) + } + /> +
+
+ ))} +
+ setSessionToDelete(null)} + onConfirm={() => { + if (sessionToDelete) { + deleteSessionMutation.mutate( + { sessionId: sessionToDelete.id }, + { + onSuccess: async () => { + setSessionToDelete(null); + if (sessionToDelete.isCurrent) { + await clearAuthCookies(); + queryClient.clear(); + window.location.href = "/auth/login"; + } + }, + }, + ); + } + }} + title="Delete Session" + message={ + sessionToDelete?.isCurrent + ? "Are you sure you want to delete your current session? You will be logged out and redirected to the login page." + : "Are you sure you want to delete this session? You will be logged out from this device." + } + confirmText="Delete" + cancelText="Cancel" + variant="danger" + /> +
+ ); +}; diff --git a/apps/client/src/components/ChangePasswordModal.tsx b/apps/client/src/components/ChangePasswordModal.tsx new file mode 100644 index 0000000..508fc56 --- /dev/null +++ b/apps/client/src/components/ChangePasswordModal.tsx @@ -0,0 +1,163 @@ +import { Button, TextInput } from "@cv/ui"; +import { Fragment, useState } from "react"; + +interface ChangePasswordModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (currentPassword: string, newPassword: string) => Promise; +} + +export const ChangePasswordModal = ({ + isOpen, + onClose, + onSubmit, +}: ChangePasswordModalProps) => { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + setError(null); + + const hasEmptyFields = + currentPassword === "" || newPassword === "" || confirmPassword === ""; + if (hasEmptyFields) { + setError("All fields are required"); + return; + } + + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + return; + } + + if (newPassword.length < 8) { + setError("New password must be at least 8 characters long"); + return; + } + + const passwordPattern = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$/; + if (!passwordPattern.test(newPassword)) { + setError( + "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character", + ); + return; + } + + setIsSubmitting(true); + try { + await onSubmit(currentPassword, newPassword); + setIsSubmitting(false); + setTimeout(() => { + handleClose(); + }, 300); + } catch (err) { + setIsSubmitting(false); + const errorMessage = + err instanceof Error + ? err.message + : "Failed to change password. Please try again."; + setError(errorMessage); + throw err; + } + }; + + const handleClose = () => { + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setError(null); + setIsSubmitting(false); + onClose(); + }; + + if (!isOpen) { + return null; + } + + return ( + + + + + + + + + ); +}; diff --git a/apps/client/src/components/DeleteAccountModal.tsx b/apps/client/src/components/DeleteAccountModal.tsx new file mode 100644 index 0000000..84afcea --- /dev/null +++ b/apps/client/src/components/DeleteAccountModal.tsx @@ -0,0 +1,136 @@ +import { Button, TextInput } from "@cv/ui"; +import { Fragment, useState } from "react"; + +interface DeleteAccountModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (password: string) => Promise; +} + +export const DeleteAccountModal = ({ + isOpen, + onClose, + onSubmit, +}: DeleteAccountModalProps) => { + const [password, setPassword] = useState(""); + const [confirmText, setConfirmText] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + setError(null); + + if (!password) { + setError("Password is required"); + return; + } + + if (confirmText.toLowerCase() !== "delete") { + setError('Please type "DELETE" to confirm'); + return; + } + + setIsSubmitting(true); + try { + await onSubmit(password); + handleClose(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Failed to delete account. Please try again.", + ); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + setPassword(""); + setConfirmText(""); + setError(null); + setIsSubmitting(false); + onClose(); + }; + + if (!isOpen) { + return null; + } + + return ( + + + + + + + + + ); +}; diff --git a/apps/client/src/components/ErrorBoundary.tsx b/apps/client/src/components/ErrorBoundary.tsx index c2cb3b9..5a16411 100644 --- a/apps/client/src/components/ErrorBoundary.tsx +++ b/apps/client/src/components/ErrorBoundary.tsx @@ -74,11 +74,7 @@ export function ErrorBoundary({ ); } - if (fallback) { - return <>{fallback}; - } - - return <>{children}; + return <>{fallback ?? children}; } interface ErrorDisplayProps { diff --git a/apps/client/src/components/Navbar.tsx b/apps/client/src/components/Navbar.tsx index 3ef8b16..55d101b 100644 --- a/apps/client/src/components/Navbar.tsx +++ b/apps/client/src/components/Navbar.tsx @@ -1,4 +1,5 @@ -import { Link, useLocation } from "react-router-dom"; +import { ViewTransitionLink } from "@cv/routing"; +import { useLocation } from "react-router-dom"; import { cn } from "@/utils/cn"; import type { NavLink } from "./navLinks"; import { UserProfileDrawer } from "./UserProfileDrawer"; @@ -14,23 +15,23 @@ type NavbarProps = { export const Navbar = ({ user, onLogout, links }: NavbarProps) => { const location = useLocation(); - const isActive = (path: string) => { - return ( - location.pathname === path || location.pathname.startsWith(`${path}/`) - ); - }; + const isActive = (path: string) => + location.pathname === path || location.pathname.startsWith(`${path}/`); return ( ); diff --git a/apps/docs/src/config/docs.config.ts b/apps/docs/src/config/docs.config.ts index bfd2334..ecd9d31 100644 --- a/apps/docs/src/config/docs.config.ts +++ b/apps/docs/src/config/docs.config.ts @@ -41,9 +41,10 @@ const extractMDXComponent = (module: unknown): ComponentType | null => { /** * Auto-discovered markdown and MDX files as MDX components * Both .md and .mdx files are processed by the MDX plugin + * Lazy loading enabled for better performance */ const mdxModules = import.meta.glob("../../content/**/*.{md,mdx}", { - eager: true, + eager: false, }); /** @@ -51,14 +52,17 @@ const mdxModules = import.meta.glob("../../content/**/*.{md,mdx}", { * * Normalizes the slug and searches for matching files in the content directory. * Handles README files as index files (e.g., "api/readme" matches slug "api"). + * Returns a promise that resolves to the component for lazy loading. * * @param slug - The documentation slug (e.g., "docs/architecture", "components/button") - * @returns The React component for the documentation page, or null if not found + * @returns Promise that resolves to the React component for the documentation page, or null if not found */ -export const getDocComponent = (slug: string): ComponentType | null => { +export const getDocComponent = async ( + slug: string, +): Promise => { const normalizedSlug = slug.toLowerCase().replace(/^\/+|\/+$/g, ""); - for (const [path, module] of Object.entries(mdxModules)) { + for (const [path, moduleLoader] of Object.entries(mdxModules)) { const normalizedPath = path .replace(/^\.\.\/\.\.\/content\//, "") .replace(/\.(md|mdx)$/, "") @@ -77,6 +81,7 @@ export const getDocComponent = (slug: string): ComponentType | null => { } if (matches) { + const module = await moduleLoader(); const component = extractMDXComponent(module); if (component) { diff --git a/apps/docs/src/config/env.config.ts b/apps/docs/src/config/env.config.ts index ce3455f..02dcf15 100644 --- a/apps/docs/src/config/env.config.ts +++ b/apps/docs/src/config/env.config.ts @@ -32,6 +32,7 @@ export const ENV_VARS: Record = { GRAPHQL_URL: getEnvVar("VITE_GRAPHQL_URL", "http://localhost:3000/graphql"), DB_HOST: getEnvVar("VITE_DB_HOST", "localhost"), DB_PORT: getEnvVar("VITE_DB_PORT", "5432"), + REPO_URL: getEnvVar("VITE_REPO_URL", ""), }; /** diff --git a/apps/docs/src/config/navigation.config.ts b/apps/docs/src/config/navigation.config.ts index bedf544..a17a01d 100644 --- a/apps/docs/src/config/navigation.config.ts +++ b/apps/docs/src/config/navigation.config.ts @@ -86,6 +86,7 @@ export const NAVIGATION: NavigationItem[] = [ { title: "Architecture Overview", slug: "docs/architecture" }, { title: "Frontend Structure", slug: "docs/frontend-structure" }, { title: "Docker Strategy", slug: "docs/docker-strategy" }, + { title: "Entity Policies", slug: "ENTITY_POLICIES" }, ], }, { diff --git a/apps/docs/src/index.css b/apps/docs/src/index.css index 78306e1..5c24028 100644 --- a/apps/docs/src/index.css +++ b/apps/docs/src/index.css @@ -1,237 +1,8 @@ -@import "tailwindcss"; @import "@cv/ui/styles"; +@import "tailwindcss"; +@import "highlight.js/styles/default.css"; +@import "./styles/base.css"; +@import "./styles/syntax-highlighting.css"; +@import "./styles/markdown.css"; +@import "./styles/view-transitions.css"; @source "../node_modules/@cv/ui"; - -body { - margin: 0; - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", - "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: var(--color-ctp-base); - color: var(--color-ctp-text); -} - -code { - font-family: - source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; -} - -/* Syntax Highlighting */ -.hljs { - background-color: var(--color-ctp-mantle); - color: var(--color-ctp-text); -} - -/* Highlight.js theme colors using Catppuccin palette */ -.hljs-keyword, -.hljs-selector-tag, -.hljs-title, -.hljs-section, -.hljs-doctag, -.hljs-name, -.hljs-strong { - color: var(--color-ctp-mauve); - font-weight: bold; -} - -.hljs-comment, -.hljs-quote { - color: var(--color-ctp-surface2); - font-style: italic; -} - -.hljs-string, -.hljs-literal, -.hljs-number, -.hljs-regexp, -.hljs-variable, -.hljs-template-variable { - color: var(--color-ctp-green); -} - -.hljs-type, -.hljs-class .hljs-title { - color: var(--color-ctp-yellow); -} - -.hljs-function .hljs-title { - color: var(--color-ctp-blue); -} - -.hljs-attr, -.hljs-attribute, -.hljs-tag, -.hljs-name { - color: var(--color-ctp-sapphire); -} - -.hljs-symbol, -.hljs-bullet, -.hljs-link { - color: var(--color-ctp-peach); -} - -.hljs-built_in, -.hljs-builtin-name { - color: var(--color-ctp-maroon); -} - -.hljs-meta, -.hljs-meta .hljs-keyword { - color: var(--color-ctp-subtext0); -} - -.hljs-deletion { - background-color: var(--color-ctp-red); - color: var(--color-ctp-base); -} - -.hljs-addition { - background-color: var(--color-ctp-green); - color: var(--color-ctp-base); -} - -.hljs-emphasis { - font-style: italic; -} - -.hljs-strong { - font-weight: bold; -} - -/* Markdown Styles */ -.markdown { - line-height: 1.7; -} - -.markdown h1 { - font-size: 2.5rem; - font-weight: bold; - margin-top: 2rem; - margin-bottom: 1rem; - color: var(--color-ctp-text); - border-bottom: 2px solid var(--color-ctp-surface1); - padding-bottom: 0.5rem; -} - -.markdown h2 { - font-size: 2rem; - font-weight: bold; - margin-top: 1.5rem; - margin-bottom: 0.75rem; - color: var(--color-ctp-text); - border-bottom: 1px solid var(--color-ctp-surface0); - padding-bottom: 0.25rem; -} - -.markdown h3 { - font-size: 1.5rem; - font-weight: semibold; - margin-top: 1.25rem; - margin-bottom: 0.5rem; - color: var(--color-ctp-text); -} - -.markdown h4 { - font-size: 1.25rem; - font-weight: semibold; - margin-top: 1rem; - margin-bottom: 0.5rem; - color: var(--color-ctp-subtext0); -} - -.markdown p { - margin-bottom: 1rem; - color: var(--color-ctp-text); -} - -.markdown a { - color: var(--color-ctp-blue); - text-decoration: underline; -} - -.markdown a:hover { - color: var(--color-ctp-sapphire); -} - -.markdown ul, -.markdown ol { - margin-left: 1.5rem; - margin-bottom: 1rem; -} - -.markdown li { - margin-bottom: 0.5rem; - color: var(--color-ctp-text); -} - -.markdown code { - background-color: var(--color-ctp-surface0); - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; - font-size: 0.875rem; - color: var(--color-ctp-pink); -} - -.markdown pre { - background-color: var(--color-ctp-mantle); - padding: 1rem; - border-radius: 0.5rem; - overflow-x: auto; - margin-bottom: 1rem; - border: 1px solid var(--color-ctp-surface0); -} - -.markdown pre code { - background-color: transparent; - padding: 0; - color: var(--color-ctp-text); -} - -.markdown blockquote { - border-left: 4px solid var(--color-ctp-blue); - padding-left: 1rem; - margin-left: 0; - margin-bottom: 1rem; - color: var(--color-ctp-subtext0); - font-style: italic; -} - -.markdown table { - width: 100%; - border-collapse: collapse; - margin-bottom: 1rem; -} - -.markdown th, -.markdown td { - border: 1px solid var(--color-ctp-surface1); - padding: 0.5rem; - text-align: left; -} - -.markdown th { - background-color: var(--color-ctp-surface0); - font-weight: semibold; - color: var(--color-ctp-text); -} - -.markdown td { - background-color: var(--color-ctp-base); - color: var(--color-ctp-text); -} - -.markdown img { - max-width: 100%; - height: auto; - border-radius: 0.5rem; - margin-bottom: 1rem; -} - -.markdown hr { - border: none; - border-top: 2px solid var(--color-ctp-surface1); - margin: 2rem 0; -} diff --git a/apps/docs/src/styles/base.css b/apps/docs/src/styles/base.css new file mode 100644 index 0000000..a1133a6 --- /dev/null +++ b/apps/docs/src/styles/base.css @@ -0,0 +1,24 @@ +html { + background-color: var(--color-ctp-base); +} + +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--color-ctp-base); + color: var(--color-ctp-text); +} + +#root { + background-color: var(--color-ctp-base); + min-height: 100vh; +} + +code { + font-family: + source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} diff --git a/apps/docs/src/styles/markdown.css b/apps/docs/src/styles/markdown.css new file mode 100644 index 0000000..58bec33 --- /dev/null +++ b/apps/docs/src/styles/markdown.css @@ -0,0 +1,133 @@ +.markdown { + line-height: 1.7; +} + +.markdown h1 { + font-size: 2.5rem; + font-weight: bold; + margin-top: 2rem; + margin-bottom: 1rem; + color: var(--color-ctp-text); + border-bottom: 2px solid var(--color-ctp-surface1); + padding-bottom: 0.5rem; +} + +.markdown h2 { + font-size: 2rem; + font-weight: bold; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + color: var(--color-ctp-text); + border-bottom: 1px solid var(--color-ctp-surface0); + padding-bottom: 0.25rem; +} + +.markdown h3 { + font-size: 1.5rem; + font-weight: semibold; + margin-top: 1.25rem; + margin-bottom: 0.5rem; + color: var(--color-ctp-text); +} + +.markdown h4 { + font-size: 1.25rem; + font-weight: semibold; + margin-top: 1rem; + margin-bottom: 0.5rem; + color: var(--color-ctp-subtext0); +} + +.markdown p { + margin-bottom: 1rem; + color: var(--color-ctp-text); +} + +.markdown a { + color: var(--color-ctp-blue); + text-decoration: underline; +} + +.markdown a:hover { + color: var(--color-ctp-sapphire); +} + +.markdown ul, +.markdown ol { + margin-left: 1.5rem; + margin-bottom: 1rem; +} + +.markdown li { + margin-bottom: 0.5rem; + color: var(--color-ctp-text); +} + +.markdown code { + background-color: var(--color-ctp-surface0); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.875rem; + color: var(--color-ctp-pink); +} + +.markdown pre { + background-color: var(--color-ctp-mantle); + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin-bottom: 1rem; + border: 1px solid var(--color-ctp-surface0); +} + +.markdown pre code { + background-color: transparent; + padding: 0; + color: var(--color-ctp-text); +} + +.markdown blockquote { + border-left: 4px solid var(--color-ctp-blue); + padding-left: 1rem; + margin-left: 0; + margin-bottom: 1rem; + color: var(--color-ctp-subtext0); + font-style: italic; +} + +.markdown table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1rem; +} + +.markdown th, +.markdown td { + border: 1px solid var(--color-ctp-surface1); + padding: 0.5rem; + text-align: left; +} + +.markdown th { + background-color: var(--color-ctp-surface0); + font-weight: semibold; + color: var(--color-ctp-text); +} + +.markdown td { + background-color: var(--color-ctp-base); + color: var(--color-ctp-text); +} + +.markdown img { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.markdown hr { + border: none; + border-top: 2px solid var(--color-ctp-surface1); + margin: 2rem 0; +} diff --git a/apps/docs/src/styles/syntax-highlighting.css b/apps/docs/src/styles/syntax-highlighting.css new file mode 100644 index 0000000..76dfd5c --- /dev/null +++ b/apps/docs/src/styles/syntax-highlighting.css @@ -0,0 +1,80 @@ +.hljs { + background-color: var(--color-ctp-mantle); + color: var(--color-ctp-text); +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-title, +.hljs-section, +.hljs-doctag, +.hljs-name, +.hljs-strong { + color: var(--color-ctp-mauve); + font-weight: bold; +} + +.hljs-comment, +.hljs-quote { + color: var(--color-ctp-surface2); + font-style: italic; +} + +.hljs-string, +.hljs-literal, +.hljs-number, +.hljs-regexp, +.hljs-variable, +.hljs-template-variable { + color: var(--color-ctp-green); +} + +.hljs-type, +.hljs-class .hljs-title { + color: var(--color-ctp-yellow); +} + +.hljs-function .hljs-title { + color: var(--color-ctp-blue); +} + +.hljs-attr, +.hljs-attribute, +.hljs-tag, +.hljs-name { + color: var(--color-ctp-sapphire); +} + +.hljs-symbol, +.hljs-bullet, +.hljs-link { + color: var(--color-ctp-peach); +} + +.hljs-built_in, +.hljs-builtin-name { + color: var(--color-ctp-maroon); +} + +.hljs-meta, +.hljs-meta .hljs-keyword { + color: var(--color-ctp-subtext0); +} + +.hljs-deletion { + background-color: var(--color-ctp-red); + color: var(--color-ctp-base); +} + +.hljs-addition { + background-color: var(--color-ctp-green); + color: var(--color-ctp-base); +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} diff --git a/apps/docs/src/styles/view-transitions.css b/apps/docs/src/styles/view-transitions.css new file mode 100644 index 0000000..5030e6d --- /dev/null +++ b/apps/docs/src/styles/view-transitions.css @@ -0,0 +1,17 @@ +@view-transition { + navigation: auto; +} + +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.2s; + animation-timing-function: ease-in-out; +} + +::view-transition-old(root) { + z-index: 1; +} + +::view-transition-new(root) { + z-index: 2; +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 4a41f70..1029541 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -24,5 +24,5 @@ } }, "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "exclude": ["dist", "node_modules", "../../packages/tsconfig/dist"] } diff --git a/apps/docs/tsconfig.node.json b/apps/docs/tsconfig.node.json index f9c52a3..5aad57b 100644 --- a/apps/docs/tsconfig.node.json +++ b/apps/docs/tsconfig.node.json @@ -6,7 +6,8 @@ "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, - "exactOptionalPropertyTypes": false + "exactOptionalPropertyTypes": false, + "outDir": "./dist" }, "include": [ "vite.config.ts", diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index 21204cc..4c51a27 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig(async (): Promise => { resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "@cv/routing": path.resolve(__dirname, "../../packages/routing/src"), }, }, server: { diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 5ad60a5..93d8a33 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -1,71 +1,28 @@ -FROM node:22-alpine +FROM node:24-alpine AS development -# ============================================================================= -# Layer 1: System dependencies (rarely changes) -# ============================================================================= -# Install OpenSSL and curl for Prisma and health checks -RUN apk add --no-cache openssl curl +RUN apk add --no-cache curl openssl -# Set working directory WORKDIR /app -# ============================================================================= -# Layer 2: Package dependencies (changes when dependencies update) -# ============================================================================= -# Copy only package.json files to leverage Docker layer caching -# This layer is cached as long as no dependencies change -COPY package*.json ./ -COPY lerna.json ./ -COPY apps/server/package*.json ./apps/server/ -COPY apps/client/package*.json ./apps/client/ -COPY packages/utils/package.json ./packages/utils/ -COPY packages/tsconfig/package.json ./packages/tsconfig/ -COPY packages/biome-config/package.json ./packages/biome-config/ -COPY packages/ui/package.json ./packages/ui/ +# Copy monorepo files +COPY package.json package-lock.json lerna.json ./ +COPY packages/*/package.json packages/*/ +COPY apps/*/package.json apps/*/ +COPY packages/ ./packages/ -# Install all dependencies (monorepo) -# This is the most expensive layer, so we want it cached -RUN npm install --ignore-scripts +# Install dependencies +RUN npm ci -# ============================================================================= -# Layer 3: Configuration files (changes less frequently than source code) -# ============================================================================= -# Copy shared configuration packages -COPY packages/tsconfig/ ./packages/tsconfig/ -COPY packages/biome-config/ ./packages/biome-config/ -COPY packages/utils/ ./packages/utils/ -COPY packages/ui/ ./packages/ui/ - -# ============================================================================= -# Layer 4: Prisma schema and generation (changes when schema updates) -# ============================================================================= -# Copy Prisma schema +# Copy Prisma schema and generate client COPY apps/server/prisma/ ./apps/server/prisma/ - -# Generate Prisma client -# This layer is cached unless the Prisma schema changes RUN npx prisma generate --schema=apps/server/prisma/schema.prisma -# ============================================================================= -# Layer 5: Source code (changes most frequently) -# ============================================================================= -# Copy server source code -# This layer changes on any code change but comes last for optimal caching -COPY apps/server/src/ ./apps/server/src/ -COPY apps/server/tsconfig*.json ./apps/server/ -COPY apps/server/test/ ./apps/server/test/ +# Copy server source +COPY apps/server/ ./apps/server/ -# Copy scripts for health checks +# Copy health check scripts COPY scripts/ ./scripts/ -# ============================================================================= -# Runtime configuration -# ============================================================================= -# Set working directory to server -WORKDIR /app/apps/server - -# Expose port EXPOSE 3000 -# Start the application -CMD ["npm", "run", "dev"] \ No newline at end of file +CMD ["npm", "run", "dev", "--workspace=@cv/server"] diff --git a/apps/server/package.json b/apps/server/package.json index e0bf25c..48298fb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,6 +9,7 @@ "dev": "nodemon --watch src -e ts --exec \"ts-node -r tsconfig-paths/register src/main.ts\"", "lint": "biome check .", "lint:fix": "biome check --write .", + "typecheck": "tsc -p tsconfig.build.json --noEmit", "test": "jest --config ./test/jest-unit.json", "test:unit": "jest --config ./test/jest-unit.json", "test:e2e": "jest --config ./test/jest-e2e.json", @@ -25,31 +26,41 @@ "seed:test": "ts-node -r tsconfig-paths/register src/scripts/seed-test.ts" }, "dependencies": { + "@cv/auth": "*", + "@cv/system": "*", "@cv/utils": "*", "@faker-js/faker": "^10.1.0", "@nestjs/apollo": "^12.2.2", "@nestjs/common": "^10.4.7", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/graphql": "^12.2.2", "@nestjs/jwt": "^10.2.0", "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.7", - "@prisma/client": "^6.17.1", + "@prisma/adapter-pg": "^7.1.0", + "@prisma/client": "^7.1.0", + "@types/cookie-parser": "^1.4.10", + "@types/handlebars": "^4.0.40", "apollo-server-express": "^3.13.0", "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", - "graphql": "^16.11.0", + "cookie-parser": "^1.4.7", + "graphql": "^16.12.0", "graphql-scalars": "^1.23.0", "graphql-type-json": "^0.3.2", + "handlebars": "^4.7.8", "joi": "^17.13.3", "nestjs-zod": "^3.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "pg": "^8.16.3", "reflect-metadata": "^0.2.2", + "resend": "^6.5.2", "rxjs": "^7.8.1", "zod": "^3.23.8" }, @@ -63,9 +74,10 @@ "@types/node": "^22.7.5", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", + "@types/pg": "^8.15.6", "jest": "^29.7.0", "nodemon": "^3.1.7", - "prisma": "^6.17.1", + "prisma": "^7.1.0", "supertest": "^6.3.4", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", diff --git a/apps/server/prisma.config.ts b/apps/server/prisma.config.ts index 9ed3133..abe7c66 100644 --- a/apps/server/prisma.config.ts +++ b/apps/server/prisma.config.ts @@ -3,4 +3,7 @@ import type { PrismaConfig } from "prisma"; export default { schema: path.join(__dirname, "prisma"), + datasource: { + url: process.env["DATABASE_URL"], + }, } satisfies PrismaConfig; diff --git a/apps/server/prisma/migrations/20251207163643_add_credentials_table/migration.sql b/apps/server/prisma/migrations/20251207163643_add_credentials_table/migration.sql new file mode 100644 index 0000000..97ff25b --- /dev/null +++ b/apps/server/prisma/migrations/20251207163643_add_credentials_table/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "credentials" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "credentials_pkey" PRIMARY KEY ("id") +); + +-- Migrate existing email/password data from users to credentials +INSERT INTO "credentials" ("id", "userId", "email", "password", "createdAt", "updatedAt") +SELECT + gen_random_uuid()::text as "id", + "id" as "userId", + "email", + "password", + "createdAt", + "updatedAt" +FROM "users" +WHERE "email" IS NOT NULL AND "password" IS NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "credentials_userId_key" ON "credentials"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "credentials_email_key" ON "credentials"("email"); + +-- AddForeignKey +ALTER TABLE "credentials" ADD CONSTRAINT "credentials_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- DropIndex +DROP INDEX IF EXISTS "users_email_key"; + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "email"; + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "password"; + diff --git a/apps/server/prisma/migrations/20251207201916_add_email_verification_and_password_reset_fields/migration.sql b/apps/server/prisma/migrations/20251207201916_add_email_verification_and_password_reset_fields/migration.sql new file mode 100644 index 0000000..0c4b719 --- /dev/null +++ b/apps/server/prisma/migrations/20251207201916_add_email_verification_and_password_reset_fields/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "credentials" ADD COLUMN "emailVerificationToken" TEXT, +ADD COLUMN "emailVerificationTokenExpiresAt" TIMESTAMP(3), +ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "emailVerifiedAt" TIMESTAMP(3), +ADD COLUMN "passwordResetToken" TEXT, +ADD COLUMN "passwordResetTokenExpiresAt" TIMESTAMP(3); diff --git a/apps/server/prisma/migrations/20251207201931_add_unique_constraints_to_tokens/migration.sql b/apps/server/prisma/migrations/20251207201931_add_unique_constraints_to_tokens/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/apps/server/prisma/migrations/20251207201931_add_unique_constraints_to_tokens/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/apps/server/prisma/migrations/20251207215106_remove_email_verified_boolean/migration.sql b/apps/server/prisma/migrations/20251207215106_remove_email_verified_boolean/migration.sql new file mode 100644 index 0000000..0ba579b --- /dev/null +++ b/apps/server/prisma/migrations/20251207215106_remove_email_verified_boolean/migration.sql @@ -0,0 +1,7 @@ +-- Migrate existing data: set emailVerifiedAt for rows where emailVerified is true +UPDATE "credentials" +SET "emailVerifiedAt" = NOW() +WHERE "emailVerified" = true AND "emailVerifiedAt" IS NULL; + +-- Drop the emailVerified column +ALTER TABLE "credentials" DROP COLUMN "emailVerified"; diff --git a/apps/server/prisma/migrations/20251209174757_add_refresh_tokens/migration.sql b/apps/server/prisma/migrations/20251209174757_add_refresh_tokens/migration.sql new file mode 100644 index 0000000..b1bc81d --- /dev/null +++ b/apps/server/prisma/migrations/20251209174757_add_refresh_tokens/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "refresh_tokens" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "userAgent" TEXT, + "ipAddress" TEXT, + "usedAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_token_idx" ON "refresh_tokens"("token"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_userId_idx" ON "refresh_tokens"("userId"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_expiresAt_idx" ON "refresh_tokens"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/prisma/migrations/20251209175507_add_encrypted_refresh_token/migration.sql b/apps/server/prisma/migrations/20251209175507_add_encrypted_refresh_token/migration.sql new file mode 100644 index 0000000..dee6f12 --- /dev/null +++ b/apps/server/prisma/migrations/20251209175507_add_encrypted_refresh_token/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `encryptedToken` to the `refresh_tokens` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "refresh_tokens" ADD COLUMN "encryptedToken" TEXT NOT NULL; diff --git a/apps/server/prisma/migrations/20251209180341_add_device_location_to_refresh_tokens/migration.sql b/apps/server/prisma/migrations/20251209180341_add_device_location_to_refresh_tokens/migration.sql new file mode 100644 index 0000000..3724f95 --- /dev/null +++ b/apps/server/prisma/migrations/20251209180341_add_device_location_to_refresh_tokens/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "refresh_tokens" ADD COLUMN "city" TEXT, +ADD COLUMN "country" TEXT, +ADD COLUMN "deviceName" TEXT, +ADD COLUMN "deviceType" TEXT; diff --git a/apps/server/prisma/models/refresh-token.prisma b/apps/server/prisma/models/refresh-token.prisma new file mode 100644 index 0000000..8fd12de --- /dev/null +++ b/apps/server/prisma/models/refresh-token.prisma @@ -0,0 +1,25 @@ +model RefreshToken { + id String @id @default(cuid()) + token String @unique + encryptedToken String + userId String + userAgent String? + ipAddress String? + deviceName String? + deviceType String? + country String? + city String? + usedAt DateTime? + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relation to User + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([token]) + @@index([userId]) + @@index([expiresAt]) + @@map("refresh_tokens") +} + diff --git a/apps/server/prisma/models/user.prisma b/apps/server/prisma/models/user.prisma index 132ff63..cde0a59 100644 --- a/apps/server/prisma/models/user.prisma +++ b/apps/server/prisma/models/user.prisma @@ -1,11 +1,12 @@ model User { id String @id @default(cuid()) - email String @unique name String - password String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Credentials (one-to-one relationship) + credentials Credentials? + // Job experiences jobExperiences UserJobExperience[] @@ -24,9 +25,31 @@ model User { // Education history educationHistory Education[] + // Refresh tokens + refreshTokens RefreshToken[] + @@map("users") } +model Credentials { + id String @id @default(cuid()) + userId String @unique + email String @unique + password String + emailVerifiedAt DateTime? + emailVerificationToken String? + emailVerificationTokenExpiresAt DateTime? + passwordResetToken String? + passwordResetTokenExpiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relation to User + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("credentials") +} + model Institution { id String @id @default(cuid()) name String @unique diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 36534e7..81aab12 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -9,5 +9,4 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } diff --git a/apps/server/src/config/config.module.ts b/apps/server/src/config/config.module.ts index 2343c6c..dbfcb08 100644 --- a/apps/server/src/config/config.module.ts +++ b/apps/server/src/config/config.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from "@nestjs/common"; +import { CorsConfigService } from "./cors.config"; import { JwtConfigService } from "./jwt.config"; /** @@ -7,7 +8,7 @@ import { JwtConfigService } from "./jwt.config"; */ @Global() @Module({ - providers: [JwtConfigService], - exports: [JwtConfigService], + providers: [JwtConfigService, CorsConfigService], + exports: [JwtConfigService, CorsConfigService], }) export class AppConfigModule {} diff --git a/apps/server/src/config/cors.config.ts b/apps/server/src/config/cors.config.ts new file mode 100644 index 0000000..88f96d4 --- /dev/null +++ b/apps/server/src/config/cors.config.ts @@ -0,0 +1,39 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Injectable() +export class CorsConfigService { + constructor(private readonly configService: ConfigService) {} + + getAllowedOrigins(): string[] { + const clientOrigin = this.configService.get("CLIENT_ORIGIN"); + const docsOrigin = this.configService.get("DOCS_ORIGIN"); + const allowedOriginsEnv = this.configService.get("ALLOWED_ORIGINS"); + + const defaultOrigins = [ + clientOrigin, + docsOrigin, + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3001", + "http://127.0.0.1:3001", + "http://localhost:3000", + "http://127.0.0.1:3000", + ].filter(Boolean) as string[]; + + if (allowedOriginsEnv) { + const envOrigins = allowedOriginsEnv + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + return [...new Set([...defaultOrigins, ...envOrigins])]; + } + + return defaultOrigins; + } + + isDevelopment(): boolean { + const nodeEnv = this.configService.get("NODE_ENV") || "development"; + return nodeEnv === "development"; + } +} diff --git a/apps/server/src/config/cors.configuration.ts b/apps/server/src/config/cors.configuration.ts new file mode 100644 index 0000000..ef4de6d --- /dev/null +++ b/apps/server/src/config/cors.configuration.ts @@ -0,0 +1,42 @@ +import type { INestApplication } from "@nestjs/common"; +import { CorsConfigService } from "./cors.config"; + +export const configureCors = (app: INestApplication): void => { + const corsConfig = app.get(CorsConfigService); + const allowedOrigins = corsConfig.getAllowedOrigins(); + const isDevelopment = corsConfig.isDevelopment(); + + app.enableCors({ + origin: (origin, callback) => { + if (!origin) { + return callback(null, true); + } + + if (isDevelopment) { + const isLocalhost = + /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/.test( + origin, + ); + if (isLocalhost) { + return callback(null, true); + } + } + + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + return callback(new Error("Not allowed by CORS"), false); + }, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "Accept", + "Origin", + "X-Requested-With", + ], + exposedHeaders: ["Content-Type", "Authorization"], + }); +}; diff --git a/apps/server/src/config/env.validation.ts b/apps/server/src/config/env.validation.ts index b2c86e4..b07190a 100644 --- a/apps/server/src/config/env.validation.ts +++ b/apps/server/src/config/env.validation.ts @@ -37,10 +37,30 @@ export const envValidationSchema = Joi.object({ CLIENT_PORT: Joi.number().optional(), VITE_SERVER_URL: Joi.string().uri().optional(), GRAPHQL_SCHEMA_URL: Joi.string().uri().optional(), + CLIENT_ORIGIN: Joi.string().uri().optional(), + DOCS_ORIGIN: Joi.string().uri().optional(), + ALLOWED_ORIGINS: Joi.string().optional(), // UI Configuration (optional for server) UI_PORT: Joi.number().optional(), // Database Port DB_PORT: Joi.number().default(5432), + + // Resend Configuration + RESEND_API_KEY: Joi.string().required(), + + // Email Configuration + EMAIL_FROM_ADDRESS: Joi.string().email().optional(), + EMAIL_FROM_NAME: Joi.string().optional(), + CLIENT_URL: Joi.string().uri().optional(), + EMAIL_VERIFICATION_TOKEN_EXPIRY: jwtExpirySchema.default("24h"), + PASSWORD_RESET_TOKEN_EXPIRY: jwtExpirySchema.default("1h"), + + // Encryption Configuration + ENCRYPTION_KEY: Joi.string().min(32).required().messages({ + "string.min": + "ENCRYPTION_KEY must be at least 32 characters long for security", + "any.required": "ENCRYPTION_KEY is required for token encryption", + }), }); diff --git a/apps/server/src/config/exception-to-http.pipe.ts b/apps/server/src/config/exception-to-http.pipe.ts new file mode 100644 index 0000000..1cc8360 --- /dev/null +++ b/apps/server/src/config/exception-to-http.pipe.ts @@ -0,0 +1,69 @@ +import { + AuthenticationError, + AuthorizationError, + EntityNotFoundError, +} from "@cv/auth"; +import { + ArgumentsHost, + Catch, + ConflictException, + ExceptionFilter, + ForbiddenException, + Logger, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; +import { DomainError } from "@/domain/errors/app-error"; +import { EntityAlreadyExistsError } from "@/domain/errors/conflict.error"; + +@Catch() +export class DomainExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(DomainExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + if (exception instanceof DomainError) { + const { code, message, variables } = exception; + + if (exception instanceof AuthenticationError) { + throw new UnauthorizedException({ + code, + message, + variables, + }); + } + + if (exception instanceof AuthorizationError) { + throw new ForbiddenException({ + code, + message, + variables, + }); + } + + if (exception instanceof EntityNotFoundError) { + throw new NotFoundException({ + code, + message, + variables, + }); + } + + if (exception instanceof EntityAlreadyExistsError) { + throw new ConflictException({ + code, + message, + variables, + }); + } + } + + if (!(exception instanceof DomainError)) { + this.logger.error( + "Unexpected error caught in DomainExceptionFilter", + exception instanceof Error ? exception.stack : String(exception), + ); + } + + throw exception; + } +} diff --git a/apps/server/src/domain/errors/app-error.ts b/apps/server/src/domain/errors/app-error.ts new file mode 100644 index 0000000..9559629 --- /dev/null +++ b/apps/server/src/domain/errors/app-error.ts @@ -0,0 +1,15 @@ +export interface ErrorVariables { + [key: string]: string | number | string[] | number[]; +} + +export abstract class DomainError extends Error { + public readonly code: string; + public readonly variables: ErrorVariables; + + constructor(code: string, message: string, variables: ErrorVariables = {}) { + super(message); + this.code = code; + this.variables = variables; + this.name = this.constructor.name; + } +} diff --git a/apps/server/src/domain/errors/authentication.error.ts b/apps/server/src/domain/errors/authentication.error.ts new file mode 100644 index 0000000..422b86c --- /dev/null +++ b/apps/server/src/domain/errors/authentication.error.ts @@ -0,0 +1,106 @@ +import { DomainError } from "./app-error"; +import { AuthenticationErrorCode } from "./error-codes"; + +export abstract class AuthenticationError extends DomainError {} + +export class NoTokenError extends AuthenticationError { + constructor() { + super(AuthenticationErrorCode.NO_TOKEN, "No authentication token provided"); + } +} + +export class InvalidCredentialsError extends AuthenticationError { + constructor() { + super(AuthenticationErrorCode.INVALID_CREDENTIALS, "Invalid credentials"); + } +} + +export class InvalidTokenError extends AuthenticationError { + constructor() { + super(AuthenticationErrorCode.INVALID_TOKEN, "Invalid token"); + } +} + +export class TokenExpiredError extends AuthenticationError { + constructor() { + super(AuthenticationErrorCode.TOKEN_EXPIRED, "Token expired"); + } +} + +export class InvalidRefreshTokenError extends AuthenticationError { + constructor() { + super( + AuthenticationErrorCode.INVALID_REFRESH_TOKEN, + "Invalid or expired refresh token", + ); + } +} + +export class CurrentPasswordIncorrectError extends AuthenticationError { + constructor() { + super( + AuthenticationErrorCode.CURRENT_PASSWORD_INCORRECT, + "Current password is incorrect", + ); + } +} + +export class PasswordIncorrectError extends AuthenticationError { + constructor() { + super(AuthenticationErrorCode.PASSWORD_INCORRECT, "Password is incorrect"); + } +} + +export class EmailAlreadyVerifiedError extends AuthenticationError { + constructor() { + super( + AuthenticationErrorCode.EMAIL_ALREADY_VERIFIED, + "Email is already verified", + ); + } +} + +export class InvalidVerificationTokenError extends AuthenticationError { + constructor() { + super( + AuthenticationErrorCode.INVALID_VERIFICATION_TOKEN, + "Invalid verification token", + ); + } +} + +export class VerificationTokenExpiredError extends AuthenticationError { + constructor() { + super( + AuthenticationErrorCode.VERIFICATION_TOKEN_EXPIRED, + "Verification token has expired", + ); + } +} + +export class InvalidPasswordResetTokenError extends AuthenticationError { + constructor() { + super( + AuthenticationErrorCode.INVALID_PASSWORD_RESET_TOKEN, + "Invalid password reset token", + ); + } +} + +export class PasswordResetTokenExpiredError extends AuthenticationError { + constructor() { + super( + AuthenticationErrorCode.PASSWORD_RESET_TOKEN_EXPIRED, + "Password reset token has expired", + ); + } +} + +export class EmailNotVerifiedError extends AuthenticationError { + constructor() { + super( + AuthenticationErrorCode.EMAIL_NOT_VERIFIED, + "Email address has not been verified. Please check your email and verify your account before logging in.", + ); + } +} diff --git a/apps/server/src/domain/errors/authorization.error.ts b/apps/server/src/domain/errors/authorization.error.ts new file mode 100644 index 0000000..0e0e1bf --- /dev/null +++ b/apps/server/src/domain/errors/authorization.error.ts @@ -0,0 +1,44 @@ +import { DomainError } from "./app-error"; +import { AuthorizationErrorCode } from "./error-codes"; + +export abstract class AuthorizationError extends DomainError {} + +export class CannotViewError extends AuthorizationError { + constructor(resourceType: string) { + super( + AuthorizationErrorCode.CANNOT_VIEW, + `You are not authorized to view this ${resourceType}`, + { resourceType }, + ); + } +} + +export class CannotCreateError extends AuthorizationError { + constructor(resourceType: string) { + super( + AuthorizationErrorCode.CANNOT_CREATE, + `You are not authorized to create ${resourceType}`, + { resourceType }, + ); + } +} + +export class CannotUpdateError extends AuthorizationError { + constructor(resourceType: string) { + super( + AuthorizationErrorCode.CANNOT_UPDATE, + `You are not authorized to update this ${resourceType}`, + { resourceType }, + ); + } +} + +export class CannotDeleteError extends AuthorizationError { + constructor(resourceType: string) { + super( + AuthorizationErrorCode.CANNOT_DELETE, + `You are not authorized to delete this ${resourceType}`, + { resourceType }, + ); + } +} diff --git a/apps/server/src/domain/errors/conflict.error.ts b/apps/server/src/domain/errors/conflict.error.ts new file mode 100644 index 0000000..fffd356 --- /dev/null +++ b/apps/server/src/domain/errors/conflict.error.ts @@ -0,0 +1,12 @@ +import { DomainError } from "./app-error"; +import { ConflictErrorCode } from "./error-codes"; + +export class EntityAlreadyExistsError extends DomainError { + constructor(entityName: string, property: string, value: string) { + super( + ConflictErrorCode.ENTITY_ALREADY_EXISTS, + `${entityName} with this ${property} already exists`, + { entityName, property, value }, + ); + } +} diff --git a/apps/server/src/domain/errors/error-codes.ts b/apps/server/src/domain/errors/error-codes.ts new file mode 100644 index 0000000..6ed846a --- /dev/null +++ b/apps/server/src/domain/errors/error-codes.ts @@ -0,0 +1,37 @@ +import { ErrorCode } from "@/modules/base/error-code.enum"; + +export const AuthenticationErrorCode = { + NO_TOKEN: ErrorCode.AUTHENTICATION_NO_TOKEN, + INVALID_TOKEN: ErrorCode.AUTHENTICATION_INVALID_TOKEN, + TOKEN_EXPIRED: ErrorCode.AUTHENTICATION_TOKEN_EXPIRED, + INVALID_CREDENTIALS: ErrorCode.AUTHENTICATION_INVALID_CREDENTIALS, + INVALID_REFRESH_TOKEN: ErrorCode.AUTHENTICATION_INVALID_REFRESH_TOKEN, + CURRENT_PASSWORD_INCORRECT: + ErrorCode.AUTHENTICATION_CURRENT_PASSWORD_INCORRECT, + PASSWORD_INCORRECT: ErrorCode.AUTHENTICATION_PASSWORD_INCORRECT, + EMAIL_ALREADY_VERIFIED: ErrorCode.AUTHENTICATION_EMAIL_ALREADY_VERIFIED, + INVALID_VERIFICATION_TOKEN: + ErrorCode.AUTHENTICATION_INVALID_VERIFICATION_TOKEN, + VERIFICATION_TOKEN_EXPIRED: + ErrorCode.AUTHENTICATION_VERIFICATION_TOKEN_EXPIRED, + INVALID_PASSWORD_RESET_TOKEN: + ErrorCode.AUTHENTICATION_INVALID_PASSWORD_RESET_TOKEN, + PASSWORD_RESET_TOKEN_EXPIRED: + ErrorCode.AUTHENTICATION_PASSWORD_RESET_TOKEN_EXPIRED, + EMAIL_NOT_VERIFIED: ErrorCode.AUTHENTICATION_EMAIL_NOT_VERIFIED, +} as const; + +export const AuthorizationErrorCode = { + CANNOT_VIEW: ErrorCode.AUTHORIZATION_CANNOT_VIEW, + CANNOT_CREATE: ErrorCode.AUTHORIZATION_CANNOT_CREATE, + CANNOT_UPDATE: ErrorCode.AUTHORIZATION_CANNOT_UPDATE, + CANNOT_DELETE: ErrorCode.AUTHORIZATION_CANNOT_DELETE, +} as const; + +export const NotFoundErrorCode = { + ENTITY_NOT_FOUND: ErrorCode.NOT_FOUND_ENTITY_NOT_FOUND, +} as const; + +export const ConflictErrorCode = { + ENTITY_ALREADY_EXISTS: ErrorCode.CONFLICT_ENTITY_ALREADY_EXISTS, +} as const; diff --git a/apps/server/src/domain/errors/not-found.error.ts b/apps/server/src/domain/errors/not-found.error.ts new file mode 100644 index 0000000..df650f2 --- /dev/null +++ b/apps/server/src/domain/errors/not-found.error.ts @@ -0,0 +1,12 @@ +import { DomainError } from "./app-error"; +import { NotFoundErrorCode } from "./error-codes"; + +export class EntityNotFoundError extends DomainError { + constructor(entityName: string, property: string, value: string) { + super( + NotFoundErrorCode.ENTITY_NOT_FOUND, + `${entityName} with ${property} ${value} not found`, + { entityName, property, value }, + ); + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 751afb3..b5d5382 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,69 +1,25 @@ import { ConfigService } from "@nestjs/config"; import { NestFactory } from "@nestjs/core"; import { ExpressAdapter } from "@nestjs/platform-express"; +import cookieParser from "cookie-parser"; import { ZodValidationPipe } from "nestjs-zod"; +import { configureCors } from "./config/cors.configuration"; +import { DomainExceptionFilter } from "./config/exception-to-http.pipe"; import { AppModule } from "./modules/app.module"; +import { GraphQLExceptionFilter } from "./modules/base/graphql-exception.filter"; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, new ExpressAdapter()); const configService = app.get(ConfigService); - // Enable CORS - const configuredOrigin = configService.get("CLIENT_ORIGIN"); - const configuredDocsOrigin = configService.get("DOCS_ORIGIN"); - const nodeEnv = configService.get("NODE_ENV") || "development"; - const isDevelopment = nodeEnv === "development"; - - const allowedOrigins = [ - configuredOrigin, - configuredDocsOrigin, - "http://localhost:5173", // Client app - "http://127.0.0.1:5173", // Client app (localhost) - "http://localhost:3001", // Docs app - "http://127.0.0.1:3001", // Docs app (localhost) - "http://localhost:3000", // GraphQL Playground - "http://127.0.0.1:3000", // GraphQL Playground (localhost) - ].filter(Boolean) as string[]; - - app.enableCors({ - origin: (origin, callback) => { - // Allow non-browser or same-origin requests with no Origin header - // This includes requests from server-to-server, Postman, etc. - if (!origin) { - return callback(null, true); - } - - // In development, allow any localhost origin - if (isDevelopment) { - const isLocalhost = - /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/.test( - origin, - ); - if (isLocalhost) { - return callback(null, true); - } - } - - // Check if origin is in allowed list - if (allowedOrigins.includes(origin)) { - return callback(null, true); - } - - return callback(new Error("Not allowed by CORS"), false); - }, - credentials: true, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], - allowedHeaders: [ - "Content-Type", - "Authorization", - "Accept", - "Origin", - "X-Requested-With", - ], - exposedHeaders: ["Content-Type", "Authorization"], - }); + app.use(cookieParser()); + configureCors(app); app.useGlobalPipes(new ZodValidationPipe()); + app.useGlobalFilters( + new DomainExceptionFilter(), + new GraphQLExceptionFilter(), + ); const port = configService.getOrThrow("PORT"); await app.listen(Number(port)); diff --git a/apps/server/src/modules/app.module.ts b/apps/server/src/modules/app.module.ts index a20bcf5..b948c95 100644 --- a/apps/server/src/modules/app.module.ts +++ b/apps/server/src/modules/app.module.ts @@ -1,15 +1,23 @@ +import { AuthModule, AuthorizationModule, UserModule } from "@cv/auth"; +import { + BaseModule, + DatabaseModule, + ResendModule, + TemplateModule, +} from "@cv/system"; import { ApolloDriver, type ApolloDriverConfig } from "@nestjs/apollo"; import { Module } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; +import { ConfigModule, ConfigService } from "@nestjs/config"; import { GraphQLModule } from "@nestjs/graphql"; +import { JwtModule } from "@nestjs/jwt"; +import type { Request, Response } from "express"; import { AppConfigModule } from "@/config/config.module"; import { envValidationSchema } from "@/config/env.validation"; import { AppModule as AppModuleComponent } from "./app/app.module"; import { ApplicationModule } from "./application/application.module"; -import { AuthModule } from "./auth/auth.module"; -import { BaseModule } from "./base/base.module"; +import { AuthenticationModule } from "./authentication/authentication.module"; +import { CurrentUserModule } from "./current-user/current-user.module"; import { CVTemplateModule } from "./cv-template/cv-template.module"; -import { DatabaseModule } from "./database/database.module"; import { SeedModule } from "./database/seed/seed.module"; import { EducationModule } from "./education/education.module"; import { CompanyModule } from "./job-experience/company/company.module"; @@ -18,7 +26,6 @@ import { LevelModule } from "./job-experience/level/level.module"; import { RoleModule } from "./job-experience/role/role.module"; import { SkillModule } from "./job-experience/skill/skill.module"; import { OrganizationModule } from "./organization/organization.module"; -import { UserModule } from "./user/user.module"; import { VacancyModule } from "./vacancies/vacancy.module"; @Module({ @@ -31,15 +38,36 @@ import { VacancyModule } from "./vacancies/vacancy.module"; allowUnknown: true, // Allow unknown environment variables }, }), + JwtModule.registerAsync({ + global: true, + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => { + const secret = configService.getOrThrow("JWT_SECRET"); + return { + secret, + signOptions: { expiresIn: "24h" }, + }; + }, + inject: [ConfigService], + }), GraphQLModule.forRoot({ driver: ApolloDriver, autoSchemaFile: true, sortSchema: true, + context: ({ req, res }: { req: Request; res: Response }) => ({ + req, + res, + }), }), AppConfigModule, BaseModule, DatabaseModule, + ResendModule, + TemplateModule, AuthModule, + AuthorizationModule, + AuthenticationModule, + CurrentUserModule, UserModule, AppModuleComponent, SkillModule, diff --git a/apps/server/src/modules/application/application-status.dataloader.ts b/apps/server/src/modules/application/application-status.dataloader.ts new file mode 100644 index 0000000..386d5bc --- /dev/null +++ b/apps/server/src/modules/application/application-status.dataloader.ts @@ -0,0 +1,58 @@ +import { PrismaService } from "@cv/system"; +import { Injectable, Scope } from "@nestjs/common"; +import DataLoader from "dataloader"; +import type { ApplicationStatusRelation } from "./application.entity"; + +@Injectable({ scope: Scope.REQUEST }) +export class ApplicationStatusDataLoaderService { + private loader: DataLoader; + + constructor(readonly prisma: PrismaService) { + this.loader = new DataLoader( + async (ids: readonly string[]) => { + const statuses = await prisma.applicationStatus.findMany({ + where: { + id: { in: [...ids] }, + }, + select: { + id: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, + }, + }); + + const statusMap = new Map(); + for (const { + id, + name, + description, + createdAt, + updatedAt, + } of statuses) { + statusMap.set(id, { + id, + name, + description, + createdAt, + updatedAt, + }); + } + + return ids.map((id) => statusMap.get(id) ?? null); + }, + ); + } + + async load(key: string): Promise { + return this.loader.load(key); + } + + async loadMany( + keys: readonly string[], + ): Promise<(ApplicationStatusRelation | null)[]> { + const results = await this.loader.loadMany(keys); + return results.map((result) => (result instanceof Error ? null : result)); + } +} diff --git a/apps/server/src/modules/application/application.entity.ts b/apps/server/src/modules/application/application.entity.ts index 9ed2894..a6cb644 100644 --- a/apps/server/src/modules/application/application.entity.ts +++ b/apps/server/src/modules/application/application.entity.ts @@ -1,4 +1,6 @@ -import { BaseEntity } from "@/modules/base/base.entity"; +import { BaseEntity } from "@cv/system"; +import { CV } from "@/modules/cv-template/graphql/cv.type"; +import { Vacancy } from "@/modules/vacancies/vacancy.entity"; export interface ApplicationStatusRelation { id: string; @@ -8,69 +10,35 @@ export interface ApplicationStatusRelation { updatedAt: Date; } -export interface VacancyRelation { - id: string; - title: string; - description: string | null; - location: string | null; - minSalary: number | null; - maxSalary: number | null; - company: { - name: string; - }; - role: { - name: string; - }; - level: { - name: string; - } | null; - jobType: { - name: string; - } | null; -} - -export interface CVRelation { - id: string; - title: string; -} - export class Application extends BaseEntity { userId: string; - vacancyId: string; - cvId?: string; + vacancy: Vacancy; + cv: CV | null; coverLetter?: string; statusId: string; - appliedAt: Date; status: ApplicationStatusRelation; - vacancy: VacancyRelation; - cv: CVRelation | null; + appliedAt: Date; constructor( id: string, userId: string, - vacancyId: string, + vacancy: Vacancy, statusId: string, + status: ApplicationStatusRelation, appliedAt: Date, createdAt: Date, updatedAt: Date, - status: ApplicationStatusRelation, - vacancy: VacancyRelation, - cv: CVRelation | null = null, - cvId?: string, + cv: CV | null = null, coverLetter?: string, ) { super(id, createdAt, updatedAt); this.userId = userId; - this.vacancyId = vacancyId; + this.vacancy = vacancy; this.statusId = statusId; - this.appliedAt = appliedAt; this.status = status; - this.vacancy = vacancy; + this.appliedAt = appliedAt; this.cv = cv; - if (cvId !== undefined) { - this.cvId = cvId; - } if (coverLetter !== undefined) { this.coverLetter = coverLetter; } diff --git a/apps/server/src/modules/application/application.error.ts b/apps/server/src/modules/application/application.error.ts new file mode 100644 index 0000000..bef4c66 --- /dev/null +++ b/apps/server/src/modules/application/application.error.ts @@ -0,0 +1,7 @@ +import { DomainError } from "@cv/system"; + +export class DuplicateApplicationError extends DomainError { + constructor() { + super("APPLICATION_DUPLICATE", "User has already applied to this vacancy"); + } +} diff --git a/apps/server/src/modules/application/application.mapper.ts b/apps/server/src/modules/application/application.mapper.ts index f9f96fe..b0d6cfe 100644 --- a/apps/server/src/modules/application/application.mapper.ts +++ b/apps/server/src/modules/application/application.mapper.ts @@ -1,17 +1,15 @@ import { Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; +import { cvMapper } from "@/modules/cv-template/cv.mapper"; +import { VacancyMapper } from "@/modules/vacancies/vacancy.mapper"; import { Application, type ApplicationStatusRelation, - type CVRelation, - type VacancyRelation, } from "./application.entity"; -type PrismaApplication = Prisma.ApplicationGetPayload>; - type PrismaApplicationWithRelations = Prisma.ApplicationGetPayload<{ include: { + user: true; status: true; vacancy: { include: { @@ -19,108 +17,59 @@ type PrismaApplicationWithRelations = Prisma.ApplicationGetPayload<{ role: true; level: true; jobType: true; + skills: true; + }; + }; + cv: { + include: { + template: true; }; }; - cv: true; }; }>; @Injectable() -export class ApplicationMapper - implements BaseMapper -{ - toDomain(prismaApplication: null): null; - toDomain(prismaApplication: PrismaApplication): Application; - toDomain(prismaApplication: PrismaApplicationWithRelations): Application; - toDomain( - prismaApplication: - | PrismaApplication - | PrismaApplicationWithRelations - | null, - ): Application | null; +export class ApplicationMapper { + constructor(private readonly vacancyMapper: VacancyMapper) {} + toDomain( - prismaApplication: - | PrismaApplication - | PrismaApplicationWithRelations - | null, + prismaApplication: PrismaApplicationWithRelations | null, ): Application | null { if (!prismaApplication) { return null; } - const hasRelations = - "status" in prismaApplication && - prismaApplication.status !== null && - "vacancy" in prismaApplication && - prismaApplication.vacancy !== null; - - if (!hasRelations) { - throw new Error( - "Application domain entity requires status and vacancy relations", - ); - } - - const withRelations = prismaApplication as PrismaApplicationWithRelations; const status: ApplicationStatusRelation = { - id: withRelations.status.id, - name: withRelations.status.name, - description: withRelations.status.description, - createdAt: withRelations.status.createdAt, - updatedAt: withRelations.status.updatedAt, + id: prismaApplication.status.id, + name: prismaApplication.status.name, + description: prismaApplication.status.description, + createdAt: prismaApplication.status.createdAt, + updatedAt: prismaApplication.status.updatedAt, }; - const vacancy: VacancyRelation = { - id: withRelations.vacancy.id, - title: withRelations.vacancy.title, - description: withRelations.vacancy.description, - location: withRelations.vacancy.location, - minSalary: withRelations.vacancy.minSalary, - maxSalary: withRelations.vacancy.maxSalary, - company: { - name: withRelations.vacancy.company.name, - }, - role: { - name: withRelations.vacancy.role.name, - }, - level: withRelations.vacancy.level - ? { name: withRelations.vacancy.level.name } - : null, - jobType: withRelations.vacancy.jobType - ? { name: withRelations.vacancy.jobType.name } - : null, - }; + const vacancy = this.vacancyMapper.toDomain(prismaApplication.vacancy); + if (!vacancy) { + throw new Error("Vacancy is required for Application"); + } - const cv: CVRelation | null = withRelations.cv - ? { - id: withRelations.cv.id, - title: withRelations.cv.title, - } + const cv = prismaApplication.cv + ? cvMapper.toDomain(prismaApplication.cv) : null; return new Application( - withRelations.id, - withRelations.userId, - withRelations.vacancyId, - withRelations.statusId, - withRelations.appliedAt, - withRelations.createdAt, - withRelations.updatedAt, - status, + prismaApplication.id, + prismaApplication.userId, vacancy, + prismaApplication.statusId, + status, + prismaApplication.appliedAt, + prismaApplication.createdAt, + prismaApplication.updatedAt, cv, - withRelations.cvId ?? undefined, - withRelations.coverLetter ?? undefined, + prismaApplication.coverLetter ?? undefined, ); } - mapToDomain(prismaApplications: PrismaApplication[]): Application[] { - return prismaApplications - .map((application) => this.toDomain(application)) - .filter( - (application): application is Application => application !== null, - ); - } - mapToDomainWithRelations( prismaApplications: PrismaApplicationWithRelations[], ): Application[] { diff --git a/apps/server/src/modules/application/application.module.ts b/apps/server/src/modules/application/application.module.ts index 15e886b..d612041 100644 --- a/apps/server/src/modules/application/application.module.ts +++ b/apps/server/src/modules/application/application.module.ts @@ -1,18 +1,32 @@ +import { AuthModule, AuthorizationModule } from "@cv/auth"; +import { DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { AuthModule } from "@/modules/auth/auth.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; +import { CVTemplateModule } from "@/modules/cv-template/cv-template.module"; +import { VacancyModule } from "@/modules/vacancies/vacancy.module"; import { ApplicationMapper } from "./application.mapper"; +import { ApplicationPolicy } from "./application.policy"; import { ApplicationService } from "./application.service"; +import { ApplicationStatusDataLoaderService } from "./application-status.dataloader"; import { ApplicationResolver } from "./graphql/application.resolver"; import { ApplicationUserFieldResolver } from "./graphql/user-field.resolver"; @Module({ - imports: [DatabaseModule, AuthModule], + imports: [ + DatabaseModule, + AuthenticationModule, + AuthorizationModule, + AuthModule, + VacancyModule, + CVTemplateModule, + ], providers: [ ApplicationService, ApplicationMapper, + ApplicationPolicy, ApplicationResolver, ApplicationUserFieldResolver, + ApplicationStatusDataLoaderService, ], exports: [ApplicationService, ApplicationMapper], }) diff --git a/apps/server/src/modules/application/application.policy.ts b/apps/server/src/modules/application/application.policy.ts new file mode 100644 index 0000000..278e667 --- /dev/null +++ b/apps/server/src/modules/application/application.policy.ts @@ -0,0 +1,7 @@ +import { Policy, UserOwnedResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Application } from "./application.entity"; + +@Injectable() +@Policy(Application) +export class ApplicationPolicy extends UserOwnedResourcePolicy {} diff --git a/apps/server/src/modules/application/application.service.ts b/apps/server/src/modules/application/application.service.ts index 8f54d2f..1cbad1f 100644 --- a/apps/server/src/modules/application/application.service.ts +++ b/apps/server/src/modules/application/application.service.ts @@ -1,94 +1,53 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; +import { notFound } from "@cv/auth"; +import { type EntityService, PrismaService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; import { Application } from "./application.entity"; +import { DuplicateApplicationError } from "./application.error"; import { ApplicationMapper } from "./application.mapper"; -@Injectable() -export class ApplicationService { - private readonly logger = new Logger(ApplicationService.name); +type ApplicationFilters = { + userId?: string; + id?: string | string[]; +}; - constructor( - private readonly prisma: PrismaService, - private readonly applicationMapper: ApplicationMapper, - ) {} +type ApplicationWhereInput = { + userId?: string; + id?: { in: string[] } | string; +}; - async createApplication( - userId: string, - vacancyId: string, - statusId: string, - cvId?: string, - coverLetter?: string, - ): Promise { - this.logger.log( - `Creating application for user ${userId} to vacancy ${vacancyId}`, - ); - - // Check if user already applied to this vacancy - const existingApplication = await this.prisma["application"].findFirst({ - where: { - userId, - vacancyId, - }, - }); - - if (existingApplication) { - throw new Error("User has already applied to this vacancy"); - } - - const application = await this.prisma["application"].create({ - data: { - userId, - vacancyId, - statusId, - cvId: cvId ?? null, - coverLetter: coverLetter ?? null, +@Injectable() +export class ApplicationService + implements EntityService +{ + readonly applicationInclude = { + user: true, + vacancy: { + include: { + company: true, + role: true, + level: true, + jobType: true, + skills: true, }, - }); - - return this.applicationMapper.toDomain(application); - } - - async findApplicationsByUser(userId: string): Promise { - this.logger.log(`Finding applications for user: ${userId}`); - - const applications = await this.prisma["application"].findMany({ - where: { userId }, + }, + status: true, + cv: { include: { - vacancy: { - include: { - company: true, - role: true, - level: true, - jobType: true, - }, - }, - status: true, - cv: true, + template: true, }, - orderBy: { appliedAt: "desc" }, - }); + }, + } satisfies Prisma.ApplicationInclude; - return this.applicationMapper.mapToDomainWithRelations(applications); - } + constructor( + private readonly prisma: PrismaService, + private readonly applicationMapper: ApplicationMapper, + ) {} async findById(id: string): Promise { - this.logger.log(`Finding application by id: ${id}`); - - const application = await this.prisma["application"].findUnique({ + const application = await this.prisma.application.findUnique({ where: { id }, - include: { - vacancy: { - include: { - company: true, - role: true, - level: true, - jobType: true, - }, - }, - status: true, - cv: true, - }, + include: this.applicationInclude, }); return application ? this.applicationMapper.toDomain(application) : null; @@ -99,72 +58,84 @@ export class ApplicationService { return application ?? notFound("Application", "id", id); } - async findByIdWithRelations(id: string): Promise { - this.logger.log(`Finding application by id with relations: ${id}`); - - const application = await this.prisma["application"].findUnique({ - where: { id }, - include: { - vacancy: { - include: { - company: true, - role: true, - level: true, - jobType: true, - }, - }, - status: true, - cv: true, - }, - }); - - return application ? this.applicationMapper.toDomain(application) : null; + private buildWhere(filters: ApplicationFilters = {}): ApplicationWhereInput { + const where: ApplicationWhereInput = {}; + if (filters.userId) { + where.userId = filters.userId; + } + if (filters.id !== undefined) { + where.id = Array.isArray(filters.id) ? { in: filters.id } : filters.id; + } + return where; } - async findApplicationsByUserWithRelations( - userId: string, - ): Promise { - this.logger.log(`Finding applications for user with relations: ${userId}`); - - const applications = await this.prisma["application"].findMany({ - where: { userId }, - include: { - vacancy: { - include: { - company: true, - role: true, - level: true, - jobType: true, - }, - }, - status: true, - cv: true, - }, + async findMany(filters: ApplicationFilters = {}): Promise { + const applications = await this.prisma.application.findMany({ + where: this.buildWhere(filters), + include: this.applicationInclude, orderBy: { appliedAt: "desc" }, }); return this.applicationMapper.mapToDomainWithRelations(applications); } - async updateApplicationStatus( - id: string, - statusId: string, - ): Promise { - this.logger.log(`Updating application ${id} status to ${statusId}`); - - const application = await this.prisma["application"].update({ - where: { id }, - data: { statusId }, - }); + async count(filters: ApplicationFilters = {}): Promise { + return this.prisma.application.count({ where: this.buildWhere(filters) }); + } - return this.applicationMapper.toDomain(application); + private buildPrismaData(entity: Application): { + statusId: string; + cvId: string | null; + coverLetter: string | null; + } { + return { + statusId: entity.statusId, + cvId: entity.cv?.id ?? null, + coverLetter: entity.coverLetter ?? null, + }; } - async deleteApplication(id: string): Promise { - this.logger.log(`Deleting application: ${id}`); + async save(entity: Application): Promise { + const data = this.buildPrismaData(entity); + + try { + const application = await this.prisma.application.upsert({ + where: { id: entity.id }, + create: { + id: entity.id, + userId: entity.userId, + vacancyId: entity.vacancy.id, + ...data, + }, + update: data, + include: this.applicationInclude, + }); + + const domain = this.applicationMapper.toDomain(application); + if (!domain) { + throw new Error("Failed to map application to domain"); + } + return domain; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" && + error.meta && + typeof error.meta === "object" && + "target" in error.meta && + Array.isArray(error.meta["target"]) && + error.meta["target"].includes("userId") && + error.meta["target"].includes("vacancyId") + ) { + throw new DuplicateApplicationError(); + } + throw error; + } + } - await this.prisma["application"].delete({ - where: { id }, + async destroy(entity: Application): Promise { + await this.prisma.application.delete({ + where: { id: entity.id }, }); } } diff --git a/apps/server/src/modules/application/graphql/application.resolver.ts b/apps/server/src/modules/application/graphql/application.resolver.ts index ac7a077..c223ef6 100644 --- a/apps/server/src/modules/application/graphql/application.resolver.ts +++ b/apps/server/src/modules/application/graphql/application.resolver.ts @@ -1,58 +1,77 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; +import { PrismaService } from "@cv/system"; import { UseGuards } from "@nestjs/common"; -import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { CurrentUser } from "@/modules/auth/current-user.decorator"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { User } from "@/modules/user/user.entity"; +import { + Args, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from "@nestjs/graphql"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { CVDataLoaderService } from "@/modules/cv-template/cv.dataloader"; +import { CV } from "@/modules/cv-template/graphql/cv.type"; +import { Vacancy } from "@/modules/vacancies/graphql/vacancy.type"; +import { VacancyDataLoaderService } from "@/modules/vacancies/vacancy.dataloader"; +import { Application as ApplicationEntity } from "../application.entity"; +import { ApplicationMapper } from "../application.mapper"; import { ApplicationService } from "../application.service"; +import { ApplicationStatusDataLoaderService } from "../application-status.dataloader"; import { CreateApplicationInput, UpdateApplicationStatusInput, } from "./application.input"; -import { Application, ApplicationConnection } from "./application.type"; +import { Application, ApplicationStatus } from "./application.type"; @Resolver(() => Application) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class ApplicationResolver { - constructor(private readonly applicationService: ApplicationService) {} - - @Query(() => ApplicationConnection) - async myApplications( - @CurrentUser() user: User, - ): Promise { - const applications = - await this.applicationService.findApplicationsByUserWithRelations( - user.id, - ); - - return new ApplicationConnection({ - edges: applications.map((app) => Application.fromDomain(app)), - totalCount: applications.length, - }); - } + constructor( + private readonly applicationService: ApplicationService, + private readonly applicationMapper: ApplicationMapper, + private readonly statusDataLoader: ApplicationStatusDataLoaderService, + private readonly vacancyDataLoader: VacancyDataLoaderService, + private readonly cvDataLoader: CVDataLoaderService, + private readonly prisma: PrismaService, + private readonly authorizationService: AuthorizationService, + ) {} @Query(() => Application, { nullable: true }) async application( @Args("id", { type: () => String }) id: string, - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, ): Promise { - const application = await this.applicationService.findByIdWithRelations(id); + const application = await this.applicationService.findById(id); - if (!application || application.userId !== user.id) { + if (!application) { return null; } + await this.authorizationService.canView( + user, + application, + ApplicationEntity, + ); + return Application.fromDomain(application); } @Mutation(() => Application) async createApplication( @Args("input") input: CreateApplicationInput, - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, ): Promise { - // Get the default "Applied" status - const appliedStatus = await this.applicationService["prisma"][ - "applicationStatus" - ].findFirst({ + await this.authorizationService.canCreate(user, ApplicationEntity, { + userId: user.id, + }); + + const appliedStatus = await this.prisma.applicationStatus.findFirst({ where: { name: "Applied" }, }); @@ -60,64 +79,129 @@ export class ApplicationResolver { throw new Error("Application status 'Applied' not found"); } - const application = await this.applicationService.createApplication( - user.id, - input.vacancyId, - appliedStatus.id, - input.cvId ?? undefined, - input.coverLetter ?? undefined, - ); + const existingApplication = await this.prisma.application.findFirst({ + where: { + userId: user.id, + vacancyId: input.vacancyId, + }, + }); - const fullApplication = await this.applicationService.findByIdWithRelations( - application.id, - ); - if (!fullApplication) { - throw new Error("Failed to fetch created application"); + if (existingApplication) { + throw new Error("User has already applied to this vacancy"); } - return Application.fromDomain(fullApplication); + const application = await this.prisma.application.create({ + data: { + userId: user.id, + vacancyId: input.vacancyId, + statusId: appliedStatus.id, + cvId: input.cvId ?? null, + coverLetter: input.coverLetter ?? null, + }, + include: this.applicationService.applicationInclude, + }); + + const domainApplication = this.applicationMapper.toDomain(application); + if (!domainApplication) { + throw new Error("Failed to map application to domain"); + } + return Application.fromDomain(domainApplication); } @Mutation(() => Application) async updateApplicationStatus( @Args("input") input: UpdateApplicationStatusInput, - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, ): Promise { - // Verify the application belongs to the user - const existingApplication = await this.applicationService.findById( + const existingApplication = await this.applicationService.findByIdOrFail( input.applicationId, ); - if (!existingApplication || existingApplication.userId !== user.id) { - throw new Error("Application not found or access denied"); + await this.authorizationService.canUpdate( + user, + existingApplication, + ApplicationEntity, + ); + + const status = await this.prisma.applicationStatus.findUnique({ + where: { id: input.statusId }, + }); + + if (!status) { + throw new Error("Application status not found"); } - const application = await this.applicationService.updateApplicationStatus( - input.applicationId, + const updatedApplication = new ApplicationEntity( + existingApplication.id, + existingApplication.userId, + existingApplication.vacancy, input.statusId, + { + id: status.id, + name: status.name, + description: status.description, + createdAt: status.createdAt, + updatedAt: status.updatedAt, + }, + existingApplication.appliedAt, + existingApplication.createdAt, + new Date(), + existingApplication.cv, + existingApplication.coverLetter, ); - const fullApplication = await this.applicationService.findByIdWithRelations( - application.id, - ); - if (!fullApplication) { - throw new Error("Failed to fetch updated application"); - } - - return Application.fromDomain(fullApplication); + const application = await this.applicationService.save(updatedApplication); + return Application.fromDomain(application); } @Mutation(() => Boolean) async deleteApplication( @Args("id", { type: () => String }) id: string, - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, ): Promise { - // Verify the application belongs to the user - const existingApplication = await this.applicationService.findById(id); - if (!existingApplication || existingApplication.userId !== user.id) { - throw new Error("Application not found or access denied"); - } + const existingApplication = + await this.applicationService.findByIdOrFail(id); + await this.authorizationService.canDelete( + user, + existingApplication, + ApplicationEntity, + ); - await this.applicationService.deleteApplication(id); + await this.applicationService.destroy(existingApplication); return true; } + + @ResolveField(() => ApplicationStatus) + async status(@Parent() application: Application): Promise { + const status = await this.statusDataLoader.load(application.statusId); + if (!status) { + throw new Error( + `Application status with id ${application.statusId} not found`, + ); + } + return new ApplicationStatus({ + id: status.id, + name: status.name, + description: status.description, + createdAt: status.createdAt, + updatedAt: status.updatedAt, + }); + } + + @ResolveField(() => Vacancy) + async vacancy(@Parent() application: Application): Promise { + const vacancy = await this.vacancyDataLoader.load(application.vacancyId); + if (!vacancy) { + throw new Error(`Vacancy with id ${application.vacancyId} not found`); + } + return Vacancy.fromDomain(vacancy); + } + + @ResolveField(() => CV, { nullable: true }) + async cv(@Parent() application: Application): Promise { + if (!application.cvId) { + return null; + } + const cv = await this.cvDataLoader.load(application.cvId); + return cv; + } } diff --git a/apps/server/src/modules/application/graphql/application.type.ts b/apps/server/src/modules/application/graphql/application.type.ts index 79a8375..0385096 100644 --- a/apps/server/src/modules/application/graphql/application.type.ts +++ b/apps/server/src/modules/application/graphql/application.type.ts @@ -1,5 +1,6 @@ -import { Field, ID, Int, ObjectType } from "@nestjs/graphql"; +import { Field, ID, ObjectType } from "@nestjs/graphql"; import { GraphQLDate } from "graphql-scalars"; +import { createConnection } from "@/modules/base/connection.factory"; import { Application as ApplicationEntity } from "../application.entity"; @ObjectType() @@ -105,6 +106,9 @@ export class CVInfo { } } +import { CV } from "@/modules/cv-template/graphql/cv.type"; +import { Vacancy } from "@/modules/vacancies/graphql/vacancy.type"; + @ObjectType() export class Application { @Field(() => ID) @@ -113,12 +117,6 @@ export class Application { @Field(() => ID) userId: string; - @Field(() => ID) - vacancyId: string; - - @Field(() => ID, { nullable: true }) - cvId: string | null; - @Field(() => String, { nullable: true }) coverLetter: string | null; @@ -134,93 +132,62 @@ export class Application { @Field(() => GraphQLDate) updatedAt: Date; + @Field(() => ID) + vacancyId: string; + + @Field(() => ID, { nullable: true }) + cvId: string | null; + @Field(() => ApplicationStatus) - status: ApplicationStatus; + status?: ApplicationStatus; - @Field(() => VacancyInfo) - vacancy: VacancyInfo; + @Field(() => Vacancy) + vacancy?: Vacancy; - @Field(() => CVInfo, { nullable: true }) - cv: CVInfo | null; + @Field(() => CV, { nullable: true }) + cv?: CV | null; constructor(data: { id: string; userId: string; - vacancyId: string; - cvId?: string | null; coverLetter?: string | null; statusId: string; + vacancyId: string; + cvId?: string | null; appliedAt: Date; createdAt: Date; updatedAt: Date; - status: ApplicationStatus; - vacancy: VacancyInfo; - cv?: CVInfo | null; }) { this.id = data.id; this.userId = data.userId; - this.vacancyId = data.vacancyId; - this.cvId = data.cvId ?? null; this.coverLetter = data.coverLetter ?? null; this.statusId = data.statusId; + this.vacancyId = data.vacancyId; + this.cvId = data.cvId ?? null; this.appliedAt = data.appliedAt; this.createdAt = data.createdAt; this.updatedAt = data.updatedAt; - this.status = data.status; - this.vacancy = data.vacancy; - this.cv = data.cv ?? null; } static fromDomain(domainApplication: ApplicationEntity): Application { return new Application({ id: domainApplication.id, userId: domainApplication.userId, - vacancyId: domainApplication.vacancyId, - cvId: domainApplication.cvId ?? null, coverLetter: domainApplication.coverLetter ?? null, statusId: domainApplication.statusId, + vacancyId: domainApplication.vacancy.id, + cvId: domainApplication.cv?.id ?? null, appliedAt: domainApplication.appliedAt, createdAt: domainApplication.createdAt, updatedAt: domainApplication.updatedAt, - status: new ApplicationStatus({ - id: domainApplication.status.id, - name: domainApplication.status.name, - description: domainApplication.status.description, - createdAt: domainApplication.status.createdAt, - updatedAt: domainApplication.status.updatedAt, - }), - vacancy: new VacancyInfo({ - id: domainApplication.vacancy.id, - title: domainApplication.vacancy.title, - description: domainApplication.vacancy.description, - location: domainApplication.vacancy.location, - minSalary: domainApplication.vacancy.minSalary, - maxSalary: domainApplication.vacancy.maxSalary, - companyName: domainApplication.vacancy.company.name, - roleName: domainApplication.vacancy.role.name, - levelName: domainApplication.vacancy.level?.name ?? null, - jobTypeName: domainApplication.vacancy.jobType?.name ?? null, - }), - cv: domainApplication.cv - ? new CVInfo({ - id: domainApplication.cv.id, - title: domainApplication.cv.title, - }) - : null, }); } } -@ObjectType() -export class ApplicationConnection { - @Field(() => [Application]) - edges: Application[]; - - @Field(() => Int) - totalCount: number; +export const { Connection: ApplicationConnection, Edge: ApplicationEdge } = + createConnection(Application, (domain) => + Application.fromDomain(domain), + ); - constructor(data: { edges: Application[]; totalCount: number }) { - this.edges = data.edges; - this.totalCount = data.totalCount; - } -} +export type ApplicationConnection = InstanceType; +export type ApplicationEdge = InstanceType; diff --git a/apps/server/src/modules/application/graphql/user-field.resolver.ts b/apps/server/src/modules/application/graphql/user-field.resolver.ts index 1837be7..3226726 100644 --- a/apps/server/src/modules/application/graphql/user-field.resolver.ts +++ b/apps/server/src/modules/application/graphql/user-field.resolver.ts @@ -1,22 +1,29 @@ +import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; +import { PageInfo } from "@cv/system"; import { UseGuards } from "@nestjs/common"; import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; import { User } from "@/modules/user/user.type"; import { ApplicationService } from "../application.service"; -import { Application, ApplicationConnection } from "./application.type"; +import { ApplicationConnection } from "./application.type"; @Resolver(() => User) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class ApplicationUserFieldResolver { constructor(private readonly applicationService: ApplicationService) {} @ResolveField(() => ApplicationConnection, { nullable: true }) async applications(@Parent() user: User): Promise { - const applications = await this.applicationService.findApplicationsByUser( - user.id, - ); - return new ApplicationConnection({ - edges: applications.map((app) => Application.fromDomain(app)), + const applications = await this.applicationService.findMany({ + userId: user.id, + }); + const edges = applications.map((app, index) => ({ + cursor: `application:${app.id}:${index}`, + node: app, + })); + const pageInfo = new PageInfo(false, false, null, null); + return ApplicationConnection.fromPaginationResult({ + edges, + pageInfo, totalCount: applications.length, }); } diff --git a/apps/server/src/modules/auth/auth.dto.ts b/apps/server/src/modules/auth/auth.dto.ts deleted file mode 100644 index 6a97559..0000000 --- a/apps/server/src/modules/auth/auth.dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface LoginDto { - email: string; - password: string; -} - -export interface RegisterDto { - email: string; - name: string; - password: string; -} - -export interface AuthResponse { - access_token: string; - refresh_token: string; - expires_at: Date; - user: { - id: string; - email: string; - name: string; - createdAt: Date; - }; -} - -export interface RefreshTokenDto { - refresh_token: string; -} - -export interface RefreshTokenResponse { - access_token: string; - refresh_token: string; - expires_at: Date; -} diff --git a/apps/server/src/modules/auth/auth.module.ts b/apps/server/src/modules/auth/auth.module.ts deleted file mode 100644 index a212947..0000000 --- a/apps/server/src/modules/auth/auth.module.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Module } from "@nestjs/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; -import { JwtModule } from "@nestjs/jwt"; -import { DatabaseModule } from "@/modules/database/database.module"; -import { UserModule } from "@/modules/user/user.module"; -import { AuthResolver } from "./auth.resolver"; -import { AuthService } from "./auth.service"; -import { JwtAuthGuard } from "./jwt-auth.guard"; -import { MeResolver } from "./me.resolver"; - -@Module({ - imports: [ - ConfigModule, - DatabaseModule, - UserModule, - JwtModule.registerAsync({ - imports: [ConfigModule], - useFactory: async (configService: ConfigService) => { - const secret = configService.getOrThrow("JWT_SECRET"); - return { - secret, - signOptions: { expiresIn: "24h" }, - }; - }, - inject: [ConfigService], - }), - ], - providers: [AuthService, AuthResolver, MeResolver, JwtAuthGuard], - exports: [AuthService, JwtModule, JwtAuthGuard], -}) -export class AuthModule {} diff --git a/apps/server/src/modules/auth/auth.resolver.ts b/apps/server/src/modules/auth/auth.resolver.ts deleted file mode 100644 index b0acb7b..0000000 --- a/apps/server/src/modules/auth/auth.resolver.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Args, Mutation, Resolver } from "@nestjs/graphql"; -import { AuthService } from "./auth.service"; -import { AuthResponse, RefreshTokenResponse } from "./auth.type"; - -@Resolver() -export class AuthResolver { - constructor(private readonly authService: AuthService) {} - - @Mutation(() => AuthResponse) - async login( - @Args("email") email: string, - @Args("password") password: string, - ): Promise { - const result = await this.authService.login({ email, password }); - return AuthResponse.fromDomain(result); - } - - @Mutation(() => AuthResponse) - async register( - @Args("name") name: string, - @Args("email") email: string, - @Args("password") password: string, - ): Promise { - const result = await this.authService.register({ name, email, password }); - return AuthResponse.fromDomain(result); - } - - @Mutation(() => RefreshTokenResponse) - async refreshToken( - @Args("refresh_token") refresh_token: string, - ): Promise { - const result = await this.authService.refreshToken({ refresh_token }); - return RefreshTokenResponse.fromDomain(result); - } -} diff --git a/apps/server/src/modules/auth/auth.service.ts b/apps/server/src/modules/auth/auth.service.ts deleted file mode 100644 index 4ca0264..0000000 --- a/apps/server/src/modules/auth/auth.service.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { - ConflictException, - Injectable, - UnauthorizedException, -} from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; -import * as bcrypt from "bcryptjs"; -import { JwtConfigService } from "@/config/jwt.config"; -import type { User } from "@/modules/user/user.entity"; -import { UserService } from "@/modules/user/user.service"; -import type { - AuthResponse, - LoginDto, - RefreshTokenDto, - RefreshTokenResponse, - RegisterDto, -} from "./auth.dto"; - -@Injectable() -export class AuthService { - constructor( - private userService: UserService, - private jwtService: JwtService, - private jwtConfig: JwtConfigService, - ) {} - - private generateTokens(user: User): { - access_token: string; - refresh_token: string; - expires_at: Date; - } { - const payload = { sub: user.id, email: user.email }; - - // Get token expiration times from config - const accessTokenExpiry = this.jwtConfig.getAccessTokenExpiry(); - const refreshTokenExpiry = this.jwtConfig.getRefreshTokenExpiry(); - - // Access token (short-lived) - const access_token = this.jwtService.sign(payload, { - expiresIn: accessTokenExpiry, - }); - - // Refresh token (long-lived) - const refresh_token = this.jwtService.sign( - { sub: user.id, type: "refresh" }, - { expiresIn: refreshTokenExpiry }, - ); - - // Calculate expiry time based on the access token expiry - const expires_at = this.jwtConfig.calculateAccessTokenExpiryDate(); - - return { access_token, refresh_token, expires_at }; - } - - async register({ - email, - name, - password, - }: RegisterDto): Promise { - if (await this.userService.exists(email)) { - throw new ConflictException("User with this email already exists"); - } - - const hashedPassword = await bcrypt.hash(password, 10); - - const user = await this.userService.create(email, name, hashedPassword); - - const tokens = this.generateTokens(user); - - return { - ...tokens, - user: { - id: user.id, - email: user.email, - name: user.name, - createdAt: user.createdAt, - }, - }; - } - - async login({ email, password }: LoginDto): Promise { - try { - const passwordHash = await this.userService.getPasswordHash(email); - - if (!(passwordHash && (await bcrypt.compare(password, passwordHash)))) { - throw new UnauthorizedException("Invalid credentials"); - } - - const user = await this.userService.findByEmailOrFail(email); - - const tokens = this.generateTokens(user); - - return { - ...tokens, - user: { - id: user.id, - email: user.email, - name: user.name, - createdAt: user.createdAt, - }, - }; - } catch (error) { - if (error instanceof UnauthorizedException) { - throw error; - } - // If user not found, throw unauthorized for security - throw new UnauthorizedException("Invalid credentials"); - } - } - - async refreshToken({ - refresh_token, - }: RefreshTokenDto): Promise { - try { - // Verify the refresh token - const payload = await this.jwtService.verifyAsync(refresh_token); - - // Ensure this is actually a refresh token - if (payload.type !== "refresh") { - throw new UnauthorizedException("Invalid refresh token"); - } - - // Get the user - const user = await this.userService.findByIdOrFail(payload.sub); - - // Generate new tokens - const tokens = this.generateTokens(user); - - return tokens; - } catch (_error) { - throw new UnauthorizedException("Invalid or expired refresh token"); - } - } - - async validateUser(userId: string): Promise { - try { - return await this.userService.findByIdOrFail(userId); - } catch { - return null; - } - } -} diff --git a/apps/server/src/modules/auth/auth.type.ts b/apps/server/src/modules/auth/auth.type.ts deleted file mode 100644 index 5ed5c92..0000000 --- a/apps/server/src/modules/auth/auth.type.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { User } from "@/modules/user/user.type"; - -@ObjectType() -export class AuthResponse { - @Field() - access_token: string; - - @Field() - refresh_token: string; - - @Field() - expires_at: string; - - @Field(() => User) - user: User; - - constructor( - access_token: string, - refresh_token: string, - expires_at: Date, - user: User, - ) { - this.access_token = access_token; - this.refresh_token = refresh_token; - this.expires_at = expires_at.toISOString(); - this.user = user; - } - - static fromDomain(domainAuth: { - access_token: string; - refresh_token: string; - expires_at: Date; - user: User; - }): AuthResponse { - return new AuthResponse( - domainAuth.access_token, - domainAuth.refresh_token, - domainAuth.expires_at, - domainAuth.user, - ); - } -} - -@ObjectType() -export class RefreshTokenResponse { - @Field() - access_token: string; - - @Field() - refresh_token: string; - - @Field() - expires_at: string; - - constructor(access_token: string, refresh_token: string, expires_at: Date) { - this.access_token = access_token; - this.refresh_token = refresh_token; - this.expires_at = expires_at.toISOString(); - } - - static fromDomain(tokens: { - access_token: string; - refresh_token: string; - expires_at: Date; - }): RefreshTokenResponse { - return new RefreshTokenResponse( - tokens.access_token, - tokens.refresh_token, - tokens.expires_at, - ); - } -} diff --git a/apps/server/src/modules/auth/jwt-auth.guard.ts b/apps/server/src/modules/auth/jwt-auth.guard.ts deleted file mode 100644 index da06f82..0000000 --- a/apps/server/src/modules/auth/jwt-auth.guard.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - type CanActivate, - type ExecutionContext, - Injectable, - UnauthorizedException, -} from "@nestjs/common"; -import { GqlExecutionContext } from "@nestjs/graphql"; -import { JwtService } from "@nestjs/jwt"; -import { AuthService } from "./auth.service"; - -@Injectable() -export class JwtAuthGuard implements CanActivate { - constructor( - private jwtService: JwtService, - private authService: AuthService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const ctx = GqlExecutionContext.create(context); - const request = ctx.getContext().req; - - const token = this.extractTokenFromHeader(request); - if (!token) { - throw new UnauthorizedException("No token provided"); - } - - try { - const payload = await this.jwtService.verifyAsync(token); - const user = await this.authService.validateUser(payload.sub); - - if (!user) { - throw new UnauthorizedException("User not found"); - } - - // Attach user to request for use in resolvers - request.user = user; - return true; - } catch { - throw new UnauthorizedException("Invalid token"); - } - } - - private extractTokenFromHeader(request: { - headers: { authorization?: string }; - }): string | undefined { - const [type, token] = request.headers.authorization?.split(" ") ?? []; - return type === "Bearer" ? token : undefined; - } -} diff --git a/apps/server/src/modules/authentication/authentication.module.ts b/apps/server/src/modules/authentication/authentication.module.ts new file mode 100644 index 0000000..a76a020 --- /dev/null +++ b/apps/server/src/modules/authentication/authentication.module.ts @@ -0,0 +1,31 @@ +import { AuthModule } from "@cv/auth"; +import { + BaseModule, + DatabaseModule, + ResendModule, + TemplateModule, +} from "@cv/system"; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { AuthenticationService } from "./authentication.service"; +import { PasswordProviderModule } from "./providers/password/password-provider.module"; +import { AuthenticationController } from "./rest/authentication.controller"; +import { TokenResolver } from "./token/token.resolver"; + +@Module({ + imports: [ + ConfigModule, + AuthModule, + EventEmitterModule.forRoot(), + BaseModule, + DatabaseModule, + ResendModule, + TemplateModule, + PasswordProviderModule, + ], + controllers: [AuthenticationController], + providers: [AuthenticationService, TokenResolver], + exports: [AuthenticationService], +}) +export class AuthenticationModule {} diff --git a/apps/server/src/modules/authentication/authentication.service.ts b/apps/server/src/modules/authentication/authentication.service.ts new file mode 100644 index 0000000..6fa452a --- /dev/null +++ b/apps/server/src/modules/authentication/authentication.service.ts @@ -0,0 +1,50 @@ +import type { User } from "@cv/auth"; +import { UserService } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +export interface RefreshTokenDto { + refresh_token: string; +} + +export interface RefreshTokenResponse { + access_token: string; + expires_at: Date; +} + +type InternalRefreshTokenResponse = RefreshTokenResponse & { + refresh_token: string; +}; + +import type { RequestMetadata } from "@cv/auth"; +import { TokenService } from "@cv/auth"; + +@Injectable() +export class AuthenticationService { + constructor( + private userService: UserService, + private tokenService: TokenService, + ) {} + + async refreshToken( + { refresh_token }: RefreshTokenDto, + requestMetadata?: RequestMetadata, + ): Promise { + const tokens = await this.tokenService.refreshTokenPair( + refresh_token, + requestMetadata, + ); + + return { + access_token: tokens.access_token, + expires_at: tokens.expires_at, + refresh_token: tokens.refresh_token, + }; + } + + async validateUser(userId: string): Promise { + try { + return await this.userService.findByIdOrFail(userId); + } catch { + return null; + } + } +} diff --git a/apps/server/src/modules/authentication/graphql/authentication.type.ts b/apps/server/src/modules/authentication/graphql/authentication.type.ts new file mode 100644 index 0000000..533f421 --- /dev/null +++ b/apps/server/src/modules/authentication/graphql/authentication.type.ts @@ -0,0 +1,43 @@ +import { Field, ObjectType } from "@nestjs/graphql"; +import { User } from "@/modules/user/user.type"; +import { TokenExpiration } from "../token/token-expiration.type"; + +@ObjectType() +export class AuthenticationResponse { + @Field(() => User) + user: User; + + @Field(() => TokenExpiration) + accessTokenExpiration: TokenExpiration; + + constructor(user: User, accessTokenExpiration: TokenExpiration) { + this.user = user; + this.accessTokenExpiration = accessTokenExpiration; + } + + static fromDomain(domainAuth: { + expires_at: Date; + user: User; + }): AuthenticationResponse { + return new AuthenticationResponse( + domainAuth.user, + TokenExpiration.fromExpiryDate(domainAuth.expires_at), + ); + } +} + +@ObjectType() +export class RefreshTokenResponse { + @Field(() => TokenExpiration) + accessTokenExpiration: TokenExpiration; + + constructor(accessTokenExpiration: TokenExpiration) { + this.accessTokenExpiration = accessTokenExpiration; + } + + static fromDomain(tokens: { expires_at: Date }): RefreshTokenResponse { + return new RefreshTokenResponse( + TokenExpiration.fromExpiryDate(tokens.expires_at), + ); + } +} diff --git a/apps/server/src/modules/authentication/graphql/graphql-context.type.ts b/apps/server/src/modules/authentication/graphql/graphql-context.type.ts new file mode 100644 index 0000000..c00022b --- /dev/null +++ b/apps/server/src/modules/authentication/graphql/graphql-context.type.ts @@ -0,0 +1,5 @@ +import type { Response } from "express"; + +export interface GraphQLContext { + res: Response; +} diff --git a/apps/server/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts b/apps/server/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts new file mode 100644 index 0000000..bbb7e78 --- /dev/null +++ b/apps/server/src/modules/authentication/providers/password/graphql/password-authentication.resolver.ts @@ -0,0 +1,165 @@ +import { + AuthCookieService, + AuthorizationService, + type User as DomainUser, + JwtAuthGuard, + PasswordAuthenticationService, + RequestMetadata, + VerifiedScopeGuard, +} from "@cv/auth"; +import { UseGuards } from "@nestjs/common"; +import { Args, Context, Mutation, Resolver } from "@nestjs/graphql"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { User } from "@/modules/user/user.type"; +import { AuthenticationResponse } from "../../../graphql/authentication.type"; +import { GraphQLContext } from "../../../graphql/graphql-context.type"; + +@Resolver() +export class PasswordAuthenticationResolver { + constructor( + private readonly passwordAuthenticationService: PasswordAuthenticationService, + private readonly authorizationService: AuthorizationService, + private readonly authCookieService: AuthCookieService, + ) {} + + @Mutation(() => AuthenticationResponse) + async login( + @Args("email") email: string, + @Args("password") password: string, + @RequestMetadata() requestMetadata: RequestMetadata, + @Context() { res }: GraphQLContext, + ): Promise { + const result = await this.passwordAuthenticationService.login( + { email, password }, + requestMetadata, + ); + + this.authCookieService.setAuthCookies( + res, + result.access_token, + result.refresh_token, + ); + + return AuthenticationResponse.fromDomain({ + expires_at: result.expires_at, + user: User.fromDomain(result.user), + }); + } + + @Mutation(() => AuthenticationResponse) + async register( + @Args("name") name: string, + @Args("email") email: string, + @Args("password") password: string, + @RequestMetadata() requestMetadata: RequestMetadata, + @Context() { res }: GraphQLContext, + ): Promise { + const result = await this.passwordAuthenticationService.register( + { + name, + email, + password, + }, + requestMetadata, + ); + this.authCookieService.setAuthCookies( + res, + result.access_token, + result.refresh_token, + ); + + return AuthenticationResponse.fromDomain({ + expires_at: result.expires_at, + user: User.fromDomain(result.user), + }); + } + + @Mutation(() => Boolean) + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) + async changePassword( + @CurrentUser() user: DomainUser, + @Args("currentPassword") currentPassword: string, + @Args("newPassword") newPassword: string, + ): Promise { + await this.authorizationService.canUpdate(user, user); + + await this.passwordAuthenticationService.changePassword(user.id, { + currentPassword, + newPassword, + }); + + return true; + } + + @Mutation(() => Boolean) + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) + async deleteAccount( + @CurrentUser() user: DomainUser, + @Args("password") password: string, + @Context() { res }: GraphQLContext, + ): Promise { + await this.authorizationService.canDelete(user, user); + + await this.passwordAuthenticationService.deleteAccount(user.id, { + password, + }); + this.authCookieService.clearAuthCookies(res); + return true; + } + + @Mutation(() => Boolean) + async sendVerificationEmail(@Args("email") email: string): Promise { + await this.passwordAuthenticationService.sendVerificationEmail(email); + return true; + } + + @Mutation(() => AuthenticationResponse) + async verifyEmail( + @Args("email") email: string, + @Args("token") token: string, + @RequestMetadata() requestMetadata: RequestMetadata, + @Context() { res }: GraphQLContext, + ): Promise { + const result = await this.passwordAuthenticationService.verifyEmail( + email, + token, + requestMetadata, + ); + this.authCookieService.setAuthCookies( + res, + result.access_token, + result.refresh_token, + ); + + return AuthenticationResponse.fromDomain({ + expires_at: result.expires_at, + user: User.fromDomain(result.user), + }); + } + + @Mutation(() => Boolean) + async requestPasswordReset(@Args("email") email: string): Promise { + await this.passwordAuthenticationService.requestPasswordReset(email); + return true; + } + + @Mutation(() => AuthenticationResponse) + async resetPassword( + @Args("token") token: string, + @Args("newPassword") newPassword: string, + @RequestMetadata() requestMetadata: RequestMetadata, + @Context() { res }: GraphQLContext, + ): Promise { + const { access_token, refresh_token, expires_at, user } = + await this.passwordAuthenticationService.resetPassword( + token, + newPassword, + requestMetadata, + ); + this.authCookieService.setAuthCookies(res, access_token, refresh_token); + return AuthenticationResponse.fromDomain({ + expires_at, + user: User.fromDomain(user), + }); + } +} diff --git a/apps/server/src/modules/authentication/providers/password/password-provider.module.ts b/apps/server/src/modules/authentication/providers/password/password-provider.module.ts new file mode 100644 index 0000000..26d295b --- /dev/null +++ b/apps/server/src/modules/authentication/providers/password/password-provider.module.ts @@ -0,0 +1,48 @@ +import { + PasswordProviderModule as AuthPasswordProviderModule, + EMAIL_CONFIG_TOKEN, + EmailConfig, + PasswordResetEmailListener, + RegistrationAttemptEmailListener, + TemplatedEmailService, + VerificationEmailListener, +} from "@cv/auth"; +import { ResendModule, TemplateModule } from "@cv/system"; +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { PasswordAuthenticationResolver } from "./graphql/password-authentication.resolver"; + +@Module({ + imports: [ + ConfigModule, + EventEmitterModule.forRoot(), + AuthPasswordProviderModule, + ResendModule, + TemplateModule, + ], + providers: [ + PasswordAuthenticationResolver, + { + provide: EMAIL_CONFIG_TOKEN, + useFactory: (configService: ConfigService): EmailConfig => { + const clientUrl = + configService.get("CLIENT_URL") ?? + configService.get("CLIENT_ORIGIN") ?? + "http://localhost:5173"; + const fromEmail = + configService.get("EMAIL_FROM_ADDRESS") ?? + "noreply@example.com"; + const fromName = configService.get("EMAIL_FROM_NAME"); + return new EmailConfig(clientUrl, fromEmail, fromName); + }, + inject: [ConfigService], + }, + TemplatedEmailService, + VerificationEmailListener, + PasswordResetEmailListener, + RegistrationAttemptEmailListener, + ], + exports: [PasswordAuthenticationResolver], +}) +export class PasswordProviderModule {} diff --git a/apps/server/src/modules/authentication/request/request-with-cookies.type.ts b/apps/server/src/modules/authentication/request/request-with-cookies.type.ts new file mode 100644 index 0000000..a10aade --- /dev/null +++ b/apps/server/src/modules/authentication/request/request-with-cookies.type.ts @@ -0,0 +1,5 @@ +import type { Request } from "express"; + +export type RequestWithCookies> = Request & { + cookies?: TCookies; +}; diff --git a/apps/server/src/modules/authentication/rest/authentication.controller.ts b/apps/server/src/modules/authentication/rest/authentication.controller.ts new file mode 100644 index 0000000..7cfb483 --- /dev/null +++ b/apps/server/src/modules/authentication/rest/authentication.controller.ts @@ -0,0 +1,42 @@ +import { AuthCookieService, RequestMetadata } from "@cv/auth"; +import { Controller, Post, Res } from "@nestjs/common"; +import type { Response } from "express"; +import { AuthenticationService } from "../authentication.service"; +import { RefreshTokenCookie } from "../token/refresh-token-cookie.decorator"; +import { RefreshTokenResponseDto } from "../token/refresh-token-response.dto"; +import { TokenExpiration } from "../token/token-expiration.type"; + +@Controller("api/auth") +export class AuthenticationController { + constructor( + private readonly authenticationService: AuthenticationService, + private readonly authCookieService: AuthCookieService, + ) {} + + @Post("credentials/refresh") + async refreshToken( + @RefreshTokenCookie() refresh_token: string, + @RequestMetadata() requestMetadata: RequestMetadata, + @Res({ passthrough: true }) res: Response, + ): Promise { + const result = await this.authenticationService.refreshToken( + { refresh_token }, + requestMetadata, + ); + + this.authCookieService.setAuthCookies( + res, + result.access_token, + result.refresh_token, + ); + + const accessTokenExpiration = TokenExpiration.fromExpiryDate( + result.expires_at, + ); + + return new RefreshTokenResponseDto({ + expiresAt: accessTokenExpiration.expiresAt.toISOString(), + expiresInSeconds: accessTokenExpiration.expiresInSeconds, + }); + } +} diff --git a/apps/server/src/modules/authentication/token/active-session.type.ts b/apps/server/src/modules/authentication/token/active-session.type.ts new file mode 100644 index 0000000..ae0d1d0 --- /dev/null +++ b/apps/server/src/modules/authentication/token/active-session.type.ts @@ -0,0 +1,75 @@ +import { Field, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +export class ActiveSession { + @Field(() => String) + id: string; + + @Field(() => String, { nullable: true }) + deviceName: string | null; + + @Field(() => String, { nullable: true }) + deviceType: string | null; + + @Field(() => String, { nullable: true }) + country: string | null; + + @Field(() => String, { nullable: true }) + city: string | null; + + @Field(() => Date) + createdAt: Date; + + @Field(() => Date) + expiresAt: Date; + + @Field(() => Boolean) + isCurrentSession: boolean; + + constructor( + id: string, + deviceName: string | null, + deviceType: string | null, + country: string | null, + city: string | null, + createdAt: Date, + expiresAt: Date, + isCurrentSession: boolean, + ) { + this.id = id; + this.deviceName = deviceName; + this.deviceType = deviceType; + this.country = country; + this.city = city; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.isCurrentSession = isCurrentSession; + } + + static fromDomain( + token: { + id: string; + deviceName: string | null; + deviceType: string | null; + country: string | null; + city: string | null; + createdAt: Date; + expiresAt: Date; + }, + currentRefreshTokenId?: string, + ): ActiveSession { + const isCurrentSession = + currentRefreshTokenId !== undefined && token.id === currentRefreshTokenId; + + return new ActiveSession( + token.id, + token.deviceName, + token.deviceType, + token.country, + token.city, + token.createdAt, + token.expiresAt, + isCurrentSession, + ); + } +} diff --git a/apps/server/src/modules/authentication/token/refresh-token-cookie.decorator.ts b/apps/server/src/modules/authentication/token/refresh-token-cookie.decorator.ts new file mode 100644 index 0000000..b8b3aab --- /dev/null +++ b/apps/server/src/modules/authentication/token/refresh-token-cookie.decorator.ts @@ -0,0 +1,43 @@ +import { unauthorized } from "@cv/system"; +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import type { Request } from "express"; + +type RequestWithCookies> = Request & { + cookies?: TCookies; +}; + +interface RefreshTokenCookieOptions { + optional?: boolean; +} + +export const RefreshTokenCookie = createParamDecorator( + ( + options: RefreshTokenCookieOptions | unknown, + context: ExecutionContext, + ): string | undefined => { + const opts = + typeof options === "object" && options !== null + ? (options as RefreshTokenCookieOptions) + : { optional: false }; + + const gqlContext = GqlExecutionContext.create(context); + const httpContext = context.switchToHttp(); + + const request: Request | RequestWithCookies<{ refresh_token?: string }> = + gqlContext.getContext().req + ? gqlContext.getContext().req + : httpContext.getRequest(); + + const token = request.cookies?.["refresh_token"]; + + if (!token) { + if (opts.optional) { + return undefined; + } + return unauthorized("Refresh token not found in cookies"); + } + + return token; + }, +); diff --git a/apps/server/src/modules/authentication/token/refresh-token-response.dto.ts b/apps/server/src/modules/authentication/token/refresh-token-response.dto.ts new file mode 100644 index 0000000..caa65a8 --- /dev/null +++ b/apps/server/src/modules/authentication/token/refresh-token-response.dto.ts @@ -0,0 +1,13 @@ +export class RefreshTokenResponseDto { + accessTokenExpiration: { + expiresAt: string; + expiresInSeconds: number; + }; + + constructor(accessTokenExpiration: { + expiresAt: string; + expiresInSeconds: number; + }) { + this.accessTokenExpiration = accessTokenExpiration; + } +} diff --git a/apps/server/src/modules/authentication/token/token-expiration.type.ts b/apps/server/src/modules/authentication/token/token-expiration.type.ts new file mode 100644 index 0000000..892cd5a --- /dev/null +++ b/apps/server/src/modules/authentication/token/token-expiration.type.ts @@ -0,0 +1,24 @@ +import { Field, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +export class TokenExpiration { + @Field(() => Date) + expiresAt: Date; + + @Field(() => Number) + expiresInSeconds: number; + + constructor(expiresAt: Date, expiresInSeconds: number) { + this.expiresAt = expiresAt; + this.expiresInSeconds = expiresInSeconds; + } + + static fromExpiryDate(expiresAt: Date): TokenExpiration { + const now = new Date(); + const expiresInSeconds = Math.max( + 0, + Math.floor((expiresAt.getTime() - now.getTime()) / 1000), + ); + return new TokenExpiration(expiresAt, expiresInSeconds); + } +} diff --git a/apps/server/src/modules/authentication/token/token.module.ts b/apps/server/src/modules/authentication/token/token.module.ts new file mode 100644 index 0000000..51dbd2f --- /dev/null +++ b/apps/server/src/modules/authentication/token/token.module.ts @@ -0,0 +1,8 @@ +import { TokenModule as AuthTokenModule } from "@cv/auth"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [AuthTokenModule], + exports: [AuthTokenModule], +}) +export class TokenModule {} diff --git a/apps/server/src/modules/authentication/token/token.resolver.ts b/apps/server/src/modules/authentication/token/token.resolver.ts new file mode 100644 index 0000000..78ecfa5 --- /dev/null +++ b/apps/server/src/modules/authentication/token/token.resolver.ts @@ -0,0 +1,95 @@ +import { + AuthCookieService, + AuthorizationService, + type User as DomainUser, + JwtAuthGuard, + RefreshToken, + RefreshTokenService, + VerifiedScopeGuard, +} from "@cv/auth"; +import { UseGuards } from "@nestjs/common"; +import { Args, Context, Mutation, Query, Resolver } from "@nestjs/graphql"; +import type { Response } from "express"; +import { CurrentRefreshTokenId } from "@/modules/current-user/current-refresh-token-id.decorator"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { GraphQLContext } from "../graphql/graphql-context.type"; +import { ActiveSession } from "./active-session.type"; + +@Resolver() +export class TokenResolver { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly authCookieService: AuthCookieService, + private readonly refreshTokenService: RefreshTokenService, + ) {} + + @Mutation(() => Boolean) + @UseGuards(JwtAuthGuard) + async logout( + @CurrentRefreshTokenId() currentRefreshTokenId: string | undefined, + @Context() { res }: GraphQLContext, + ): Promise { + if (currentRefreshTokenId) { + await this.revokeSession( + currentRefreshTokenId, + currentRefreshTokenId, + res, + ); + } else { + this.authCookieService.clearAuthCookies(res); + } + return true; + } + + @Query(() => [ActiveSession]) + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) + async activeSessions( + @CurrentUser() user: DomainUser, + @CurrentRefreshTokenId() currentRefreshTokenId: string | undefined, + ): Promise { + const sessions = + await this.refreshTokenService.findActiveSessionsByUser(user); + + await Promise.all( + sessions.map((session: RefreshToken) => + this.authorizationService.canView(user, session), + ), + ); + + return sessions.map((session) => + ActiveSession.fromDomain(session, currentRefreshTokenId), + ); + } + + @Mutation(() => Boolean) + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) + async deleteSession( + @CurrentUser() user: DomainUser, + @Args("sessionId") sessionId: string, + @CurrentRefreshTokenId() currentRefreshTokenId: string | undefined, + @Context() { res }: GraphQLContext, + ): Promise { + const session = await this.refreshTokenService.findByIdOrFail(sessionId); + await this.authorizationService.canDelete(user, session); + + await this.revokeSession(sessionId, currentRefreshTokenId, res); + + return true; + } + + private async revokeSession( + sessionId: string, + currentRefreshTokenId: string | undefined, + res: Response, + ): Promise { + await this.refreshTokenService.deleteById(sessionId); + + const isCurrentSession = + currentRefreshTokenId !== undefined && + sessionId === currentRefreshTokenId; + + if (isCurrentSession) { + this.authCookieService.clearAuthCookies(res); + } + } +} diff --git a/apps/server/src/modules/base/base.module.ts b/apps/server/src/modules/base/base.module.ts index c6e2306..f4faf8b 100644 --- a/apps/server/src/modules/base/base.module.ts +++ b/apps/server/src/modules/base/base.module.ts @@ -1,9 +1,19 @@ +import { ClockService, UuidFactoryService } from "@cv/system"; import { Module } from "@nestjs/common"; import { CursorService } from "./cursor.service"; +import { ErrorCode } from "./error-code.enum"; import { PaginationService } from "./pagination.service"; +// Register the ErrorCode enum (this is a side effect import) +ErrorCode; + @Module({ - providers: [PaginationService, CursorService], - exports: [PaginationService, CursorService], + providers: [ + PaginationService, + CursorService, + UuidFactoryService, + ClockService, + ], + exports: [PaginationService, CursorService, UuidFactoryService, ClockService], }) export class BaseModule {} diff --git a/apps/server/src/modules/base/connection.factory.ts b/apps/server/src/modules/base/connection.factory.ts new file mode 100644 index 0000000..6ff638f --- /dev/null +++ b/apps/server/src/modules/base/connection.factory.ts @@ -0,0 +1,62 @@ +import { PageInfo, type PaginationResult } from "@cv/system"; +import { raise } from "@cv/utils"; +import type { Type } from "@nestjs/common"; +import { Field, Int, ObjectType } from "@nestjs/graphql"; + +export function createConnection( + NodeGql: Type, + mapNode: (domain: TNodeDomain) => TNodeGql, + opts?: { name?: string }, +) { + const name = + opts?.name ?? NodeGql.name ?? raise("Cannot determine GraphQL type name."); + + @ObjectType(`${name}Edge`) + class Edge { + @Field() + cursor: string; + + @Field(() => NodeGql) + node: TNodeGql; + + constructor(cursor: string, node: TNodeGql) { + this.cursor = cursor; + this.node = node; + } + + static from(e: { cursor: string; node: TNodeDomain }): Edge { + return new Edge(e.cursor, mapNode(e.node)); + } + } + + @ObjectType(`${name}Connection`) + class Connection { + @Field(() => [Edge]) + edges: Edge[]; + + @Field(() => PageInfo) + pageInfo: PageInfo; + + @Field(() => Int) + totalCount: number; + + constructor(edges: Edge[], pageInfo: PageInfo, totalCount: number) { + this.edges = edges; + this.pageInfo = pageInfo; + this.totalCount = totalCount; + } + + static fromPaginationResult({ + edges, + pageInfo, + totalCount, + }: PaginationResult): Connection { + return new Connection(edges.map(Edge.from), pageInfo, totalCount); + } + } + + return { + Edge, + Connection, + } as const; +} diff --git a/apps/server/src/modules/base/error-code.enum.ts b/apps/server/src/modules/base/error-code.enum.ts new file mode 100644 index 0000000..3fdaa3f --- /dev/null +++ b/apps/server/src/modules/base/error-code.enum.ts @@ -0,0 +1,35 @@ +import { registerEnumType } from "@nestjs/graphql"; + +export enum ErrorCode { + // Authentication errors (401xx) + AUTHENTICATION_NO_TOKEN = "AUTHENTICATION_NO_TOKEN", + AUTHENTICATION_INVALID_TOKEN = "AUTHENTICATION_INVALID_TOKEN", + AUTHENTICATION_TOKEN_EXPIRED = "AUTHENTICATION_TOKEN_EXPIRED", + AUTHENTICATION_INVALID_CREDENTIALS = "AUTHENTICATION_INVALID_CREDENTIALS", + AUTHENTICATION_INVALID_REFRESH_TOKEN = "AUTHENTICATION_INVALID_REFRESH_TOKEN", + AUTHENTICATION_CURRENT_PASSWORD_INCORRECT = "AUTHENTICATION_CURRENT_PASSWORD_INCORRECT", + AUTHENTICATION_PASSWORD_INCORRECT = "AUTHENTICATION_PASSWORD_INCORRECT", + AUTHENTICATION_EMAIL_ALREADY_VERIFIED = "AUTHENTICATION_EMAIL_ALREADY_VERIFIED", + AUTHENTICATION_INVALID_VERIFICATION_TOKEN = "AUTHENTICATION_INVALID_VERIFICATION_TOKEN", + AUTHENTICATION_VERIFICATION_TOKEN_EXPIRED = "AUTHENTICATION_VERIFICATION_TOKEN_EXPIRED", + AUTHENTICATION_INVALID_PASSWORD_RESET_TOKEN = "AUTHENTICATION_INVALID_PASSWORD_RESET_TOKEN", + AUTHENTICATION_PASSWORD_RESET_TOKEN_EXPIRED = "AUTHENTICATION_PASSWORD_RESET_TOKEN_EXPIRED", + AUTHENTICATION_EMAIL_NOT_VERIFIED = "AUTHENTICATION_EMAIL_NOT_VERIFIED", + + // Authorization errors (403xx) + AUTHORIZATION_CANNOT_VIEW = "AUTHORIZATION_CANNOT_VIEW", + AUTHORIZATION_CANNOT_CREATE = "AUTHORIZATION_CANNOT_CREATE", + AUTHORIZATION_CANNOT_UPDATE = "AUTHORIZATION_CANNOT_UPDATE", + AUTHORIZATION_CANNOT_DELETE = "AUTHORIZATION_CANNOT_DELETE", + + // Not found errors (404xx) + NOT_FOUND_ENTITY_NOT_FOUND = "NOT_FOUND_ENTITY_NOT_FOUND", + + // Conflict errors (409xx) + CONFLICT_ENTITY_ALREADY_EXISTS = "CONFLICT_ENTITY_ALREADY_EXISTS", +} + +registerEnumType(ErrorCode, { + name: "ErrorCode", + description: "Application error codes", +}); diff --git a/apps/server/src/modules/base/event.interface.ts b/apps/server/src/modules/base/event.interface.ts new file mode 100644 index 0000000..d47260a --- /dev/null +++ b/apps/server/src/modules/base/event.interface.ts @@ -0,0 +1,3 @@ +export interface Event { + readonly eventName: symbol; +} diff --git a/apps/server/src/modules/base/graphql-exception.filter.ts b/apps/server/src/modules/base/graphql-exception.filter.ts new file mode 100644 index 0000000..eb2d1aa --- /dev/null +++ b/apps/server/src/modules/base/graphql-exception.filter.ts @@ -0,0 +1,149 @@ +import { + AuthenticationError, + AuthorizationError, + EntityNotFoundError, +} from "@cv/auth"; +import { DomainError } from "@cv/system"; +import { + ArgumentsHost, + Catch, + ConflictException, + ForbiddenException, + HttpException, + HttpStatus, + Logger, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; +import { GqlExceptionFilter } from "@nestjs/graphql"; +import { EntityAlreadyExistsError } from "@/domain/errors/conflict.error"; + +interface ErrorResponse { + code?: string; + message: string; + variables?: Record; +} + +@Catch() +export class GraphQLExceptionFilter implements GqlExceptionFilter { + private readonly logger = new Logger(GraphQLExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + if (exception instanceof DomainError) { + const { code, message, variables } = exception; + + if (exception instanceof AuthenticationError) { + return this.createHttpException( + new UnauthorizedException({ code, message, variables }), + ); + } + + if (exception instanceof AuthorizationError) { + return this.createHttpException( + new ForbiddenException({ code, message, variables }), + ); + } + + if (exception instanceof EntityNotFoundError) { + return this.createHttpException( + new NotFoundException({ code, message, variables }), + ); + } + + if (exception instanceof EntityAlreadyExistsError) { + return this.createHttpException( + new ConflictException({ code, message, variables }), + ); + } + } + + if (exception instanceof HttpException) { + const status = exception.getStatus(); + const response = exception.getResponse(); + + let errorResponse: ErrorResponse; + + if (typeof response === "string") { + errorResponse = { + message: response, + }; + } else if (typeof response === "object" && response !== null) { + const httpResponse = response as { + message?: string | string[]; + error?: string; + code?: string | number; + variables?: Record; + }; + + const message = + typeof httpResponse.message === "string" + ? httpResponse.message + : Array.isArray(httpResponse.message) + ? httpResponse.message.join(", ") + : httpResponse.error || "An error occurred"; + + const code = + typeof httpResponse.code === "string" + ? httpResponse.code + : httpResponse.code !== undefined + ? httpResponse.code.toString() + : status.toString(); + + errorResponse = { + code, + message, + variables: httpResponse.variables ?? {}, + }; + } else { + errorResponse = { + message: "An error occurred", + }; + } + + return this.createHttpException(exception, status, errorResponse); + } + + this.logger.error( + "Unexpected error caught in GraphQLExceptionFilter", + exception instanceof Error ? exception.stack : String(exception), + ); + + return this.createHttpException( + new HttpException( + { + code: HttpStatus.INTERNAL_SERVER_ERROR.toString(), + message: "Internal server error", + variables: {}, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ), + HttpStatus.INTERNAL_SERVER_ERROR, + { + code: HttpStatus.INTERNAL_SERVER_ERROR.toString(), + message: "Internal server error", + variables: {}, + }, + ); + } + + private createHttpException( + exception: HttpException, + status?: HttpStatus, + errorResponse?: ErrorResponse, + ): HttpException { + const actualStatus = status ?? exception.getStatus(); + const actualResponse = + errorResponse ?? + (typeof exception.getResponse() === "object" && + exception.getResponse() !== null + ? (exception.getResponse() as ErrorResponse) + : { message: exception.message }); + + const error = new HttpException(actualResponse, actualStatus); + (error as { extensions?: unknown }).extensions = { + code: actualResponse.code ?? actualStatus.toString(), + variables: actualResponse.variables ?? {}, + }; + return error; + } +} diff --git a/apps/server/src/modules/base/named-graphql-type.factory.ts b/apps/server/src/modules/base/named-graphql-type.factory.ts new file mode 100644 index 0000000..767b27c --- /dev/null +++ b/apps/server/src/modules/base/named-graphql-type.factory.ts @@ -0,0 +1,74 @@ +import type { NamedEntity } from "@cv/system"; +import type { Type } from "@nestjs/common"; +import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { createConnection } from "./connection.factory"; + +/** + * Creates a GraphQL type for NamedEntity domain objects + * Eliminates boilerplate for entities with id, name, description, timestamps + * + * @param name - The name of the GraphQL type (e.g., "Role", "Level") + * @param _DomainClass - The domain entity class (used for type inference only) + * @returns GraphQL type class with Connection and Edge types + */ +export function createNamedGraphQLType( + name: string, + // biome-ignore lint/suspicious/noExplicitAny: Constructor variance requires any[] for args + _DomainClass: abstract new (...args: any[]) => TDomain, +) { + @ObjectType(name) + class NamedGraphQLType { + @Field(() => ID) + id: string; + + @Field() + name: string; + + @Field({ nullable: true }) + description?: string; + + @Field() + createdAt: Date; + + @Field() + updatedAt: Date; + + constructor( + id: string, + name: string, + createdAt: Date, + updatedAt: Date, + description?: string, + ) { + this.id = id; + this.name = name; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + if (description !== undefined) { + this.description = description; + } + } + + static fromDomain(domain: TDomain): NamedGraphQLType { + return new NamedGraphQLType( + domain.id, + domain.name, + domain.createdAt, + domain.updatedAt, + domain.description, + ); + } + } + + const { Connection, Edge } = createConnection( + NamedGraphQLType as Type, + (domain) => NamedGraphQLType.fromDomain(domain), + { name }, + ); + + return { + Type: NamedGraphQLType, + Connection, + Edge, + }; +} diff --git a/apps/server/src/modules/base/not-found.util.ts b/apps/server/src/modules/base/not-found.util.ts index f51a5d5..2a56223 100644 --- a/apps/server/src/modules/base/not-found.util.ts +++ b/apps/server/src/modules/base/not-found.util.ts @@ -1,18 +1,16 @@ -import { NotFoundException } from "@nestjs/common"; +import { EntityNotFoundError } from "@cv/auth"; /** - * Utility function to throw a NotFoundException with a consistent message + * Utility function to throw an EntityNotFoundError with a consistent message * @param entityName - The name of the entity that was not found * @param property - The property that was searched (e.g., "id", "email", "name") * @param value - The value that was searched for - * @throws {NotFoundException} + * @throws {EntityNotFoundError} */ export const notFound = ( entityName: string, property: string, value: string, ): never => { - throw new NotFoundException( - `${entityName} with ${property} ${value} not found`, - ); + throw new EntityNotFoundError(entityName, property, value); }; diff --git a/apps/server/src/modules/base/raise.util.ts b/apps/server/src/modules/base/raise.util.ts new file mode 100644 index 0000000..1e72816 --- /dev/null +++ b/apps/server/src/modules/base/raise.util.ts @@ -0,0 +1,17 @@ +/** + * Utility function to throw an Error with the given message. + * This is useful for creating never-returning functions that help with TypeScript's control flow analysis. + * + * @param message - The error message to throw + * @throws {Error} Always throws an Error + * @returns {never} This function never returns + * + * @example + * ```typescript + * const value = getValue() ?? raise("Value not found"); + * // TypeScript knows value is not null/undefined after this line + * ``` + */ +export const raise = (message: string): never => { + throw new Error(message); +}; diff --git a/apps/server/src/modules/base/unauthorized.util.ts b/apps/server/src/modules/base/unauthorized.util.ts new file mode 100644 index 0000000..56aa8c9 --- /dev/null +++ b/apps/server/src/modules/base/unauthorized.util.ts @@ -0,0 +1,19 @@ +import { UnauthorizedException } from "@nestjs/common"; + +/** + * Throws an UnauthorizedException with the given message. + * This is useful for creating never-returning functions that help with TypeScript's control flow analysis. + * + * @param message - The error message to throw + * @throws {UnauthorizedException} Always throws an UnauthorizedException + * @returns {never} This function never returns + * + * @example + * ```typescript + * const token = getToken() ?? unauthorized("Token not found"); + * // TypeScript knows token is not null/undefined after this line + * ``` + */ +export const unauthorized = (message: string): never => { + throw new UnauthorizedException(message); +}; diff --git a/apps/server/src/modules/current-user/current-refresh-token-id.decorator.ts b/apps/server/src/modules/current-user/current-refresh-token-id.decorator.ts new file mode 100644 index 0000000..92182a7 --- /dev/null +++ b/apps/server/src/modules/current-user/current-refresh-token-id.decorator.ts @@ -0,0 +1,16 @@ +import { createParamDecorator, type ExecutionContext } from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; + +interface JwtPayload { + refreshTokenId?: string; +} + +export const CurrentRefreshTokenId = createParamDecorator( + (_data: unknown, context: ExecutionContext): string | undefined => { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req as { + jwtPayload?: JwtPayload; + }; + return request.jwtPayload?.refreshTokenId; + }, +); diff --git a/apps/server/src/modules/auth/current-user.decorator.ts b/apps/server/src/modules/current-user/current-user.decorator.ts similarity index 100% rename from apps/server/src/modules/auth/current-user.decorator.ts rename to apps/server/src/modules/current-user/current-user.decorator.ts diff --git a/apps/server/src/modules/current-user/current-user.module.ts b/apps/server/src/modules/current-user/current-user.module.ts new file mode 100644 index 0000000..06f0fb2 --- /dev/null +++ b/apps/server/src/modules/current-user/current-user.module.ts @@ -0,0 +1,11 @@ +import { UserModule } from "@cv/auth"; +import { forwardRef, Module } from "@nestjs/common"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; +import { MeResolver } from "./me.resolver"; + +@Module({ + imports: [UserModule, forwardRef(() => AuthenticationModule)], + providers: [MeResolver], + exports: [], +}) +export class CurrentUserModule {} diff --git a/apps/server/src/modules/current-user/index.ts b/apps/server/src/modules/current-user/index.ts new file mode 100644 index 0000000..ba0a537 --- /dev/null +++ b/apps/server/src/modules/current-user/index.ts @@ -0,0 +1,2 @@ +export * from "./current-user.decorator"; +export * from "./current-user.module"; diff --git a/apps/server/src/modules/auth/me.resolver.ts b/apps/server/src/modules/current-user/me.resolver.ts similarity index 65% rename from apps/server/src/modules/auth/me.resolver.ts rename to apps/server/src/modules/current-user/me.resolver.ts index 2663a15..750112b 100644 --- a/apps/server/src/modules/auth/me.resolver.ts +++ b/apps/server/src/modules/current-user/me.resolver.ts @@ -1,17 +1,17 @@ +import type { User as DomainUser } from "@cv/auth"; +import { JwtAuthGuard, UserService, VerifiedScopeGuard } from "@cv/auth"; import { UseGuards } from "@nestjs/common"; import { Query, Resolver } from "@nestjs/graphql"; -import { UserService } from "@/modules/user/user.service"; import { User } from "@/modules/user/user.type"; import { CurrentUser } from "./current-user.decorator"; -import { JwtAuthGuard } from "./jwt-auth.guard"; @Resolver(() => User) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class MeResolver { constructor(private readonly userService: UserService) {} @Query(() => User) - async me(@CurrentUser() user: User): Promise { + async me(@CurrentUser() user: DomainUser): Promise { const domainUser = await this.userService.findByIdOrFail(user.id); return User.fromDomain(domainUser); } diff --git a/apps/server/src/modules/cv-template/cv-template.mapper.ts b/apps/server/src/modules/cv-template/cv-template.mapper.ts index eb8cb8b..088ba0a 100644 --- a/apps/server/src/modules/cv-template/cv-template.mapper.ts +++ b/apps/server/src/modules/cv-template/cv-template.mapper.ts @@ -3,13 +3,13 @@ import { CVTemplate } from "./graphql/cv-template.type"; export const cvTemplateMapper = { toDomain: (template: PrismaCVTemplate): CVTemplate => { - return new CVTemplate({ - id: template.id, - name: template.name, - description: template.description, - createdAt: template.createdAt, - updatedAt: template.updatedAt, - }); + return new CVTemplate( + template.id, + template.name, + template.createdAt, + template.updatedAt, + template.description, + ); }, mapToDomain: (templates: PrismaCVTemplate[]): CVTemplate[] => { diff --git a/apps/server/src/modules/cv-template/cv-template.module.ts b/apps/server/src/modules/cv-template/cv-template.module.ts index 6704d21..e600a98 100644 --- a/apps/server/src/modules/cv-template/cv-template.module.ts +++ b/apps/server/src/modules/cv-template/cv-template.module.ts @@ -1,23 +1,34 @@ +import { AuthorizationModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { AuthModule } from "@/modules/auth/auth.module"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; +import { CVDataLoaderService } from "./cv.dataloader"; +import { CVPolicy } from "./cv.policy"; import { CVService } from "./cv.service"; +import { CVTemplatePolicy } from "./cv-template.policy"; import { CVTemplateService } from "./cv-template.service"; import { CVResolver, CVTemplateResolver } from "./graphql/cv-template.resolver"; import { CVUserFieldResolver } from "./graphql/user-field.resolver"; import { CVTemplateSeedService } from "./seed/cv-template.seed"; @Module({ - imports: [DatabaseModule, BaseModule, AuthModule], + imports: [ + DatabaseModule, + BaseModule, + AuthenticationModule, + AuthorizationModule, + ], providers: [ CVTemplateService, CVService, + CVPolicy, + CVTemplatePolicy, CVTemplateResolver, CVResolver, CVTemplateSeedService, CVUserFieldResolver, + CVDataLoaderService, ], - exports: [CVTemplateService, CVService], + exports: [CVTemplateService, CVService, CVDataLoaderService], }) export class CVTemplateModule {} diff --git a/apps/server/src/modules/cv-template/cv-template.policy.ts b/apps/server/src/modules/cv-template/cv-template.policy.ts new file mode 100644 index 0000000..704b93d --- /dev/null +++ b/apps/server/src/modules/cv-template/cv-template.policy.ts @@ -0,0 +1,7 @@ +import { Policy, PublicResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { CVTemplate } from "./graphql/cv-template.type"; + +@Injectable() +@Policy(CVTemplate) +export class CVTemplatePolicy extends PublicResourcePolicy {} diff --git a/apps/server/src/modules/cv-template/cv-template.service.ts b/apps/server/src/modules/cv-template/cv-template.service.ts index 25ad466..08edcc3 100644 --- a/apps/server/src/modules/cv-template/cv-template.service.ts +++ b/apps/server/src/modules/cv-template/cv-template.service.ts @@ -1,6 +1,6 @@ +import { notFound } from "@cv/auth"; +import { PrismaService } from "@cv/system"; import { Injectable } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; import { cvTemplateMapper } from "./cv-template.mapper"; type CVTemplateFilters = Record; @@ -10,7 +10,7 @@ export class CVTemplateService { constructor(private readonly prisma: PrismaService) {} async findMany(filters: CVTemplateFilters = {}) { - const templates = await this.prisma["cVTemplate"].findMany({ + const templates = await this.prisma.cVTemplate.findMany({ orderBy: { createdAt: "desc" }, }); @@ -18,11 +18,11 @@ export class CVTemplateService { } async count(filters: CVTemplateFilters = {}) { - return this.prisma["cVTemplate"].count(); + return this.prisma.cVTemplate.count(); } async findById(id: string) { - const template = await this.prisma["cVTemplate"].findUnique({ + const template = await this.prisma.cVTemplate.findUnique({ where: { id }, }); diff --git a/apps/server/src/modules/cv-template/cv.dataloader.ts b/apps/server/src/modules/cv-template/cv.dataloader.ts new file mode 100644 index 0000000..0e01da3 --- /dev/null +++ b/apps/server/src/modules/cv-template/cv.dataloader.ts @@ -0,0 +1,20 @@ +import { BaseDataLoaderService } from "@cv/system"; +import { Injectable, Scope } from "@nestjs/common"; +import { CVService } from "./cv.service"; +import { CV } from "./graphql/cv.type"; + +@Injectable({ scope: Scope.REQUEST }) +export class CVDataLoaderService extends BaseDataLoaderService { + constructor(readonly cvService: CVService) { + super(async (ids: readonly string[]) => { + const cvs = await cvService.findMany({ id: [...ids] }); + + const cvMap = new Map(); + for (const cv of cvs) { + cvMap.set(cv.id, cv); + } + + return ids.map((id) => cvMap.get(id) ?? null); + }); + } +} diff --git a/apps/server/src/modules/cv-template/cv.mapper.ts b/apps/server/src/modules/cv-template/cv.mapper.ts index 41c5cce..72cd724 100644 --- a/apps/server/src/modules/cv-template/cv.mapper.ts +++ b/apps/server/src/modules/cv-template/cv.mapper.ts @@ -11,14 +11,15 @@ export type PrismaCVWithTemplate = PrismaCV & { export const cvMapper = { toDomain: (cv: PrismaCVWithTemplate): CV => { - return new CV({ - id: cv.id, - title: cv.title, - introduction: cv.introduction ?? null, - template: cvTemplateMapper.toDomain(cv.template), - createdAt: cv.createdAt, - updatedAt: cv.updatedAt, - }); + return new CV( + cv.id, + cv.userId, + cv.title, + cv.createdAt, + cv.updatedAt, + cvTemplateMapper.toDomain(cv.template), + cv.introduction ?? null, + ); }, mapToDomain: (cvs: PrismaCVWithTemplate[]): CV[] => { diff --git a/apps/server/src/modules/cv-template/cv.policy.ts b/apps/server/src/modules/cv-template/cv.policy.ts new file mode 100644 index 0000000..eb25303 --- /dev/null +++ b/apps/server/src/modules/cv-template/cv.policy.ts @@ -0,0 +1,7 @@ +import { Policy, UserOwnedResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { CV } from "./graphql/cv.type"; + +@Injectable() +@Policy(CV) +export class CVPolicy extends UserOwnedResourcePolicy {} diff --git a/apps/server/src/modules/cv-template/cv.service.ts b/apps/server/src/modules/cv-template/cv.service.ts index db4d4de..e9c54cc 100644 --- a/apps/server/src/modules/cv-template/cv.service.ts +++ b/apps/server/src/modules/cv-template/cv.service.ts @@ -1,97 +1,104 @@ +import { notFound } from "@cv/auth"; +import { type EntityService, PrismaService } from "@cv/system"; import { Injectable } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; +import type { Prisma } from "@prisma/client"; import { cvMapper } from "./cv.mapper"; +import { CV } from "./graphql/cv.type"; type CVFilters = { - userId: string; + userId?: string; + id?: string | string[]; }; -@Injectable() -export class CVService { - constructor(private readonly prisma: PrismaService) {} - - async findMany(filters: CVFilters) { - const cvs = await this.prisma["cV"].findMany({ - where: { userId: filters.userId }, - orderBy: { updatedAt: "desc" }, - include: { - template: true, - }, - }); +type CVWhereInput = { + userId?: string; + id?: { in: string[] } | string; +}; - return cvMapper.mapToDomain(cvs); - } +@Injectable() +export class CVService implements EntityService { + private readonly cvInclude = { + template: true, + } satisfies Prisma.CVInclude; - async count(filters: CVFilters) { - return this.prisma["cV"].count({ - where: { userId: filters.userId }, - }); - } + constructor(private readonly prisma: PrismaService) {} - async findById(id: string) { - const cv = await this.prisma["cV"].findUnique({ + async findById(id: string): Promise { + const cv = await this.prisma.cV.findUnique({ where: { id }, - include: { - template: true, - }, + include: this.cvInclude, }); return cv ? cvMapper.toDomain(cv) : null; } - async findByIdOrFail(id: string) { + async findByIdOrFail(id: string): Promise { const cv = await this.findById(id); return cv ?? notFound("CV", "id", id); } - async create( - userId: string, - data: { - templateId: string; - title: string; - }, - ) { - const cv = await this.prisma["cV"].create({ - data: { - userId, - templateId: data.templateId, - title: data.title, - }, - include: { - template: true, - }, + private buildWhere(filters: CVFilters): CVWhereInput { + const where: CVWhereInput = {}; + + if (filters.userId) { + where.userId = filters.userId; + } + + if (filters.id !== undefined) { + where.id = Array.isArray(filters.id) ? { in: filters.id } : filters.id; + } + + return where; + } + + async findMany(filters: CVFilters): Promise { + const cvs = await this.prisma.cV.findMany({ + where: this.buildWhere(filters), + orderBy: { updatedAt: "desc" }, + include: this.cvInclude, }); - return cvMapper.toDomain(cv); + return cvMapper.mapToDomain(cvs); } - async update( - id: string, - data: { - title?: string; - }, - ) { - const updateData: Record = {}; + async count(filters: CVFilters): Promise { + return this.prisma.cV.count({ where: this.buildWhere(filters) }); + } - if (data.title !== undefined) { - updateData["title"] = data.title; - } + private buildPrismaData(entity: CV): { + title: string; + introduction: string | null; + templateId: string; + } { + return { + title: entity.title, + introduction: entity.introduction, + templateId: entity.template.id, + }; + } - const cv = await this.prisma["cV"].update({ - where: { id }, - data: updateData, - include: { - template: true, + async save(entity: CV): Promise { + const data = this.buildPrismaData(entity); + const cv = await this.prisma.cV.upsert({ + where: { id: entity.id }, + create: { + id: entity.id, + userId: entity.userId, + ...data, }, + update: data, + include: this.cvInclude, }); - - return cvMapper.toDomain(cv); + const domain = cvMapper.toDomain(cv); + if (!domain) { + throw new Error("Failed to map CV to domain"); + } + return domain; } - async delete(id: string): Promise { - await this.prisma["cV"].delete({ - where: { id }, + async destroy(entity: CV): Promise { + await this.prisma.cV.delete({ + where: { id: entity.id }, }); } } diff --git a/apps/server/src/modules/cv-template/graphql/cv-connection.type.ts b/apps/server/src/modules/cv-template/graphql/cv-connection.type.ts deleted file mode 100644 index 496d8bf..0000000 --- a/apps/server/src/modules/cv-template/graphql/cv-connection.type.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import { CV } from "./cv.type"; -import { CVEdge } from "./cv-edge.type"; - -@ObjectType() -export class CVConnection { - @Field(() => [CVEdge]) - edges: CVEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: CVEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult(result: PaginationResult): CVConnection { - const edges = result.edges.map((edge) => - CVEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: edge.node, - }), - ); - return new CVConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/cv-template/graphql/cv-edge.type.ts b/apps/server/src/modules/cv-template/graphql/cv-edge.type.ts deleted file mode 100644 index 39ee9d3..0000000 --- a/apps/server/src/modules/cv-template/graphql/cv-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { CV } from "./cv.type"; - -@ObjectType() -export class CVEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => CV) - declare node: CV; -} diff --git a/apps/server/src/modules/cv-template/graphql/cv-template-connection.type.ts b/apps/server/src/modules/cv-template/graphql/cv-template-connection.type.ts deleted file mode 100644 index ccbf2c3..0000000 --- a/apps/server/src/modules/cv-template/graphql/cv-template-connection.type.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import { CVTemplate } from "./cv-template.type"; -import { CVTemplateEdge } from "./cv-template-edge.type"; - -@ObjectType() -export class CVTemplateConnection { - @Field(() => [CVTemplateEdge]) - edges: CVTemplateEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: CVTemplateEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult, - ): CVTemplateConnection { - const edges = result.edges.map((edge) => - CVTemplateEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: edge.node, - }), - ); - return new CVTemplateConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/cv-template/graphql/cv-template-edge.type.ts b/apps/server/src/modules/cv-template/graphql/cv-template-edge.type.ts deleted file mode 100644 index fa64d32..0000000 --- a/apps/server/src/modules/cv-template/graphql/cv-template-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { CVTemplate } from "./cv-template.type"; - -@ObjectType() -export class CVTemplateEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => CVTemplate) - declare node: CVTemplate; -} diff --git a/apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts b/apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts index ab0044d..3da3d90 100644 --- a/apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts +++ b/apps/server/src/modules/cv-template/graphql/cv-template.resolver.ts @@ -1,22 +1,27 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; +import { PaginationService, UuidFactoryService } from "@cv/system"; import { UseGuards } from "@nestjs/common"; -import { Args, Context, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { PaginationService } from "@/modules/base/pagination.service"; +import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; import { CVService } from "../cv.service"; import { CVTemplateService } from "../cv-template.service"; import { CV } from "./cv.type"; -import { CVArgs } from "./cv-args.type"; -import { CVConnection } from "./cv-connection.type"; import { CreateCVInput, UpdateCVInput } from "./cv-input.type"; -import { CVTemplate } from "./cv-template.type"; +import { CVTemplate, CVTemplateConnection } from "./cv-template.type"; import { CVTemplateArgs } from "./cv-template-args.type"; -import { CVTemplateConnection } from "./cv-template-connection.type"; @Resolver(() => CVTemplate) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class CVTemplateResolver { constructor( private readonly cvTemplateService: CVTemplateService, private readonly paginationService: PaginationService, + private readonly authorizationService: AuthorizationService, ) {} @Query(() => CVTemplateConnection) @@ -40,8 +45,10 @@ export class CVTemplateResolver { } @Query(() => CVTemplate, { nullable: true }) - async cvTemplate(@Args("id") id: string) { - return this.cvTemplateService.findById(id); + async cvTemplate(@CurrentUser() user: DomainUser, @Args("id") id: string) { + const template = await this.cvTemplateService.findByIdOrFail(id); + await this.authorizationService.canView(user, template, CVTemplate); + return template; } } @@ -49,57 +56,74 @@ export class CVTemplateResolver { export class CVResolver { constructor( private readonly cvService: CVService, - private readonly paginationService: PaginationService, + private readonly cvTemplateService: CVTemplateService, + private readonly uuidFactory: UuidFactoryService, + readonly _paginationService: PaginationService, + private readonly authorizationService: AuthorizationService, ) {} - @Query(() => CVConnection) - @UseGuards(JwtAuthGuard) - async myCVs( - @Args() args: CVArgs = {}, - @Context() context: { req: { user: { id: string } } }, - ): Promise { - const userId = context.req.user.id; - const options = this.paginationService.parsePaginationArgs(args); - - const [items, totalCount] = await Promise.all([ - this.cvService.findMany({ userId }), - this.cvService.count({ userId }), - ]); - - const result = this.paginationService.buildPaginationResult( - items, - totalCount, - options, - ); - - return CVConnection.fromPaginationResult(result); - } - @Query(() => CV, { nullable: true }) - @UseGuards(JwtAuthGuard) - async cv(@Args("id") id: string) { - return this.cvService.findById(id); + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) + async cv(@CurrentUser() user: DomainUser, @Args("id") id: string) { + const cv = await this.cvService.findByIdOrFail(id); + await this.authorizationService.canView(user, cv, CV); + return cv; } @Mutation(() => CV) - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) async createCV( + @CurrentUser() user: DomainUser, @Args("input") input: CreateCVInput, - @Context() context: { req: { user: { id: string } } }, ) { - const userId = context.req.user.id; - return this.cvService.create(userId, input); + await this.authorizationService.canCreate(user, CV, { userId: user.id }); + + const template = await this.cvTemplateService.findByIdOrFail( + input.templateId, + ); + const now = new Date(); + const cv = new CV( + this.uuidFactory.generate(), + user.id, + input.title, + now, + now, + template, + null, + ); + + return this.cvService.save(cv); } @Mutation(() => CV) - @UseGuards(JwtAuthGuard) - async updateCV(@Args("id") id: string, @Args("input") input: UpdateCVInput) { - return this.cvService.update(id, input); + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) + async updateCV( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + @Args("input") input: UpdateCVInput, + ) { + const existing = await this.cvService.findByIdOrFail(id); + await this.authorizationService.canUpdate(user, existing, CV); + + const updated = new CV( + existing.id, + existing.userId, + input.title ?? existing.title, + existing.createdAt, + new Date(), + existing.template, + existing.introduction, + ); + + return this.cvService.save(updated); } @Mutation(() => Boolean) - @UseGuards(JwtAuthGuard) - async deleteCV(@Args("id") id: string) { - return this.cvService.delete(id); + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) + async deleteCV(@CurrentUser() user: DomainUser, @Args("id") id: string) { + const cv = await this.cvService.findByIdOrFail(id); + await this.authorizationService.canDelete(user, cv, CV); + await this.cvService.destroy(cv); + return true; } } diff --git a/apps/server/src/modules/cv-template/graphql/cv-template.type.ts b/apps/server/src/modules/cv-template/graphql/cv-template.type.ts index 0aaed94..c31175b 100644 --- a/apps/server/src/modules/cv-template/graphql/cv-template.type.ts +++ b/apps/server/src/modules/cv-template/graphql/cv-template.type.ts @@ -1,15 +1,11 @@ +import { BaseEntity } from "@cv/system"; import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { createConnection } from "@/modules/base/connection.factory"; @ObjectType() -export class CVTemplate { +export class CVTemplate extends BaseEntity { @Field(() => ID) - id: string; - - @Field(() => Date) - createdAt: Date; - - @Field(() => Date) - updatedAt: Date; + declare id: string; @Field(() => String) name: string; @@ -17,17 +13,27 @@ export class CVTemplate { @Field(() => String, { nullable: true }) description?: string | null; - constructor(data: { - id: string; - name: string; - description?: string | null; - createdAt: Date; - updatedAt: Date; - }) { - this.id = data.id; - this.createdAt = data.createdAt; - this.updatedAt = data.updatedAt; - this.name = data.name; - this.description = data.description ?? null; + @Field(() => Date) + declare createdAt: Date; + + @Field(() => Date) + declare updatedAt: Date; + + constructor( + id: string, + name: string, + createdAt: Date, + updatedAt: Date, + description?: string | null, + ) { + super(id, createdAt, updatedAt); + this.name = name; + this.description = description ?? null; } } + +export const { Connection: CVTemplateConnection, Edge: CVTemplateEdge } = + createConnection(CVTemplate, (domain) => domain); + +export type CVTemplateConnection = InstanceType; +export type CVTemplateEdge = InstanceType; diff --git a/apps/server/src/modules/cv-template/graphql/cv.type.ts b/apps/server/src/modules/cv-template/graphql/cv.type.ts index 75e46d9..57aae14 100644 --- a/apps/server/src/modules/cv-template/graphql/cv.type.ts +++ b/apps/server/src/modules/cv-template/graphql/cv.type.ts @@ -1,16 +1,15 @@ +import { BaseEntity } from "@cv/system"; import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { createConnection } from "@/modules/base/connection.factory"; import { CVTemplate } from "./cv-template.type"; @ObjectType() -export class CV { +export class CV extends BaseEntity { @Field(() => ID) - id: string; + declare id: string; - @Field(() => Date) - createdAt: Date; - - @Field(() => Date) - updatedAt: Date; + @Field(() => String) + userId: string; @Field(() => String) title: string; @@ -21,19 +20,37 @@ export class CV { @Field(() => CVTemplate) template: CVTemplate; - constructor(data: { - id: string; - title: string; - introduction?: string | null; - template: CVTemplate; - createdAt: Date; - updatedAt: Date; - }) { - this.id = data.id; - this.createdAt = data.createdAt; - this.updatedAt = data.updatedAt; - this.title = data.title; - this.introduction = data.introduction ?? null; - this.template = data.template; + @Field(() => Date) + declare createdAt: Date; + + @Field(() => Date) + declare updatedAt: Date; + + constructor( + id: string, + userId: string, + title: string, + createdAt: Date, + updatedAt: Date, + template: CVTemplate, + introduction?: string | null, + ) { + super(id, createdAt, updatedAt); + this.userId = userId; + this.title = title; + this.introduction = introduction ?? null; + this.template = template; + } + + static fromDomain(cv: CV): CV { + return cv; } } + +export const { Connection: CVConnection, Edge: CVEdge } = createConnection< + CV, + CV +>(CV, (domain) => domain); + +export type CVConnection = InstanceType; +export type CVEdge = InstanceType; diff --git a/apps/server/src/modules/cv-template/graphql/user-field.resolver.ts b/apps/server/src/modules/cv-template/graphql/user-field.resolver.ts index d4bc010..e78ba01 100644 --- a/apps/server/src/modules/cv-template/graphql/user-field.resolver.ts +++ b/apps/server/src/modules/cv-template/graphql/user-field.resolver.ts @@ -1,14 +1,14 @@ +import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; +import { PaginationService } from "@cv/system"; import { UseGuards } from "@nestjs/common"; import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { PaginationService } from "@/modules/base/pagination.service"; import { User } from "@/modules/user/user.type"; import { CVService } from "../cv.service"; +import { CVConnection } from "./cv.type"; import { CVArgs } from "./cv-args.type"; -import { CVConnection } from "./cv-connection.type"; @Resolver(() => User) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class CVUserFieldResolver { constructor( private readonly cvService: CVService, diff --git a/apps/server/src/modules/cv-template/seed/cv-template.seed.ts b/apps/server/src/modules/cv-template/seed/cv-template.seed.ts index dcc45ad..9272347 100644 --- a/apps/server/src/modules/cv-template/seed/cv-template.seed.ts +++ b/apps/server/src/modules/cv-template/seed/cv-template.seed.ts @@ -1,5 +1,5 @@ +import { PrismaService } from "@cv/system"; import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Seeder } from "@/modules/database/seed/seed.service"; import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; diff --git a/apps/server/src/modules/database/prisma.service.ts b/apps/server/src/modules/database/prisma.service.ts index 3e47f8c..5807f48 100644 --- a/apps/server/src/modules/database/prisma.service.ts +++ b/apps/server/src/modules/database/prisma.service.ts @@ -4,21 +4,20 @@ import { type OnModuleInit, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "@prisma/client"; +import { Pool } from "pg"; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { - constructor(readonly configService: ConfigService) { - super({ - datasources: { - db: { - url: configService.getOrThrow("DATABASE_URL"), - }, - }, - }); + constructor(private readonly configService: ConfigService) { + const connectionString = configService.getOrThrow("DATABASE_URL"); + const pool = new Pool({ connectionString }); + const adapter = new PrismaPg(pool); + super({ adapter }); } async onModuleInit() { diff --git a/apps/server/src/modules/database/seed/seed.module.ts b/apps/server/src/modules/database/seed/seed.module.ts index 9280f13..85813be 100644 --- a/apps/server/src/modules/database/seed/seed.module.ts +++ b/apps/server/src/modules/database/seed/seed.module.ts @@ -1,11 +1,19 @@ +import { AuthModule } from "@cv/auth"; +import { DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; import { DiscoveryModule } from "@nestjs/core"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { UserSeedService } from "@/modules/user/seed/user.seed"; import { SeedService } from "./seed.service"; @Module({ - imports: [DatabaseModule, DiscoveryModule], - providers: [SeedService], + imports: [ + ConfigModule.forRoot(), + DatabaseModule, + DiscoveryModule, + AuthModule, + ], + providers: [SeedService, UserSeedService], exports: [SeedService], }) export class SeedModule {} diff --git a/apps/server/src/modules/database/seed/seed.service.ts b/apps/server/src/modules/database/seed/seed.service.ts index 6f536df..938573b 100644 --- a/apps/server/src/modules/database/seed/seed.service.ts +++ b/apps/server/src/modules/database/seed/seed.service.ts @@ -1,6 +1,6 @@ +import { PrismaService } from "@cv/system"; import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; import { DiscoveryService } from "@nestjs/core"; -import { PrismaService } from "@/modules/database/prisma.service"; import { SEEDER_METADATA_KEY } from "./seeder.decorator"; export interface Seeder { diff --git a/apps/server/src/modules/education/education.entity.ts b/apps/server/src/modules/education/education.entity.ts index 2c6fa1d..6e704e9 100644 --- a/apps/server/src/modules/education/education.entity.ts +++ b/apps/server/src/modules/education/education.entity.ts @@ -1,6 +1,6 @@ -import { BaseEntity } from "@/domain/base.entity"; +import { User } from "@cv/auth"; +import { BaseEntity } from "@cv/system"; import type { Skill } from "@/modules/job-experience/skill/skill.entity"; -import { User } from "@/modules/user/user.entity"; import { Institution } from "./institution.entity"; export class Education extends BaseEntity { @@ -8,8 +8,7 @@ export class Education extends BaseEntity { id: string, public readonly userId: string, public readonly user: User | null, - public readonly institutionId: string, - public readonly institution: Institution | null, + public readonly institution: Institution, public readonly degree: string, public readonly fieldOfStudy: string | null, public readonly startDate: Date, diff --git a/apps/server/src/modules/education/education.mapper.ts b/apps/server/src/modules/education/education.mapper.ts index 68e5001..802240e 100644 --- a/apps/server/src/modules/education/education.mapper.ts +++ b/apps/server/src/modules/education/education.mapper.ts @@ -1,15 +1,19 @@ +import { UserMapper } from "@cv/auth"; +import type { BaseMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; import { SkillMapper } from "@/modules/job-experience/skill/skill.mapper"; -import { UserMapper } from "@/modules/user/user.mapper"; import { Education } from "./education.entity"; import { InstitutionMapper } from "./institution.mapper"; type PrismaEducation = Prisma.EducationGetPayload<{ include: { institution: true; - user: true; + user: { + include: { + credentials: true; + }; + }; skills: true; }; }>; @@ -34,12 +38,18 @@ export class EducationMapper implements BaseMapper { return null; } + const institution = this.institutionMapper.toDomain( + prismaEducation.institution, + ); + if (!institution) { + throw new Error("Institution is required for Education"); + } + return new Education( prismaEducation.id, prismaEducation.userId, this.userMapper.toDomain(prismaEducation.user), - prismaEducation.institutionId, - this.institutionMapper.toDomain(prismaEducation.institution), + institution, prismaEducation.degree, prismaEducation.fieldOfStudy, prismaEducation.startDate, diff --git a/apps/server/src/modules/education/education.module.ts b/apps/server/src/modules/education/education.module.ts index c610a27..0ce88ee 100644 --- a/apps/server/src/modules/education/education.module.ts +++ b/apps/server/src/modules/education/education.module.ts @@ -1,25 +1,37 @@ +import { AuthorizationModule, UserModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { AuthModule } from "@/modules/auth/auth.module"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; import { SkillModule } from "@/modules/job-experience/skill/skill.module"; -import { UserModule } from "@/modules/user/user.module"; import { EducationMapper } from "./education.mapper"; +import { EducationPolicy } from "./education.policy"; import { EducationService } from "./education.service"; import { EducationResolver } from "./graphql/education.resolver"; import { InstitutionResolver } from "./graphql/institution.resolver"; import { EducationUserFieldResolver } from "./graphql/user-field.resolver"; +import { InstitutionFactory } from "./institution.factory"; import { InstitutionMapper } from "./institution.mapper"; +import { InstitutionPolicy } from "./institution.policy"; import { InstitutionService } from "./institution.service"; @Module({ - imports: [DatabaseModule, BaseModule, AuthModule, UserModule, SkillModule], + imports: [ + DatabaseModule, + BaseModule, + AuthenticationModule, + AuthorizationModule, + UserModule, + SkillModule, + ], providers: [ EducationService, EducationMapper, + EducationPolicy, EducationResolver, InstitutionService, InstitutionMapper, + InstitutionFactory, + InstitutionPolicy, InstitutionResolver, EducationUserFieldResolver, ], diff --git a/apps/server/src/modules/education/education.policy.ts b/apps/server/src/modules/education/education.policy.ts new file mode 100644 index 0000000..601969f --- /dev/null +++ b/apps/server/src/modules/education/education.policy.ts @@ -0,0 +1,7 @@ +import { Policy, UserOwnedResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Education } from "./education.entity"; + +@Injectable() +@Policy(Education) +export class EducationPolicy extends UserOwnedResourcePolicy {} diff --git a/apps/server/src/modules/education/education.service.ts b/apps/server/src/modules/education/education.service.ts index 7f65b0b..bc48b3f 100644 --- a/apps/server/src/modules/education/education.service.ts +++ b/apps/server/src/modules/education/education.service.ts @@ -1,17 +1,23 @@ +import { notFound } from "@cv/auth"; +import type { PaginationOptions, PaginationResult } from "@cv/system"; +import { PaginationService, PrismaService } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; -import { notFound } from "@/modules/base/not-found.util"; -import { PaginationService } from "@/modules/base/pagination.service"; -import type { - PaginationOptions, - PaginationResult, -} from "@/modules/base/pagination.types"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Education } from "./education.entity"; import { EducationMapper } from "./education.mapper"; @Injectable() export class EducationService { + private readonly educationInclude = { + institution: true, + user: { + include: { + credentials: true, + }, + }, + skills: true, + } satisfies Prisma.EducationInclude; + constructor( private readonly prisma: PrismaService, private readonly educationMapper: EducationMapper, @@ -30,13 +36,9 @@ export class EducationService { ); const [items, totalCount] = await Promise.all([ - this.prisma["education"].findMany({ + this.prisma.education.findMany({ ...queryOptions, - include: { - institution: true, - user: true, - skills: true, - }, + include: this.educationInclude, }), this.count(userId), ]); @@ -57,20 +59,16 @@ export class EducationService { } async findById(id: string): Promise { - const education = await this.prisma["education"].findUnique({ + const education = await this.prisma.education.findUnique({ where: { id }, - include: { - institution: true, - user: true, - skills: true, - }, + include: this.educationInclude, }); return this.educationMapper.toDomain(education); } async count(userId?: string): Promise { const where: Prisma.EducationWhereInput = userId ? { userId } : {}; - return this.prisma["education"].count({ + return this.prisma.education.count({ where, }); } @@ -103,13 +101,9 @@ export class EducationService { }; } - const education = await this.prisma["education"].create({ + const education = await this.prisma.education.create({ data: createData, - include: { - institution: true, - user: true, - skills: true, - }, + include: this.educationInclude, }); const domainEducation = this.educationMapper.toDomain(education); if (!domainEducation) { @@ -130,7 +124,7 @@ export class EducationService { throw new Error("Education not found or does not belong to user"); } - await this.prisma["education"].delete({ + await this.prisma.education.delete({ where: { id }, }); } diff --git a/apps/server/src/modules/education/graphql/education-connection.type.ts b/apps/server/src/modules/education/graphql/education-connection.type.ts deleted file mode 100644 index 4919c83..0000000 --- a/apps/server/src/modules/education/graphql/education-connection.type.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import type { Education as EducationEntity } from "../education.entity"; -import { Education } from "./education.type"; -import { EducationEdge } from "./education-edge.type"; - -@ObjectType() -export class EducationConnection { - @Field(() => [EducationEdge]) - edges: EducationEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: EducationEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult, - ): EducationConnection { - const edges = result.edges.map((edge) => - EducationEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: Education.fromDomain(edge.node), - }), - ); - return new EducationConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/education/graphql/education-edge.type.ts b/apps/server/src/modules/education/graphql/education-edge.type.ts deleted file mode 100644 index 901c0ac..0000000 --- a/apps/server/src/modules/education/graphql/education-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { Education } from "./education.type"; - -@ObjectType() -export class EducationEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => Education) - declare node: Education; -} diff --git a/apps/server/src/modules/education/graphql/education.resolver.ts b/apps/server/src/modules/education/graphql/education.resolver.ts index 29251a6..ff6963b 100644 --- a/apps/server/src/modules/education/graphql/education.resolver.ts +++ b/apps/server/src/modules/education/graphql/education.resolver.ts @@ -1,27 +1,29 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; import { UseGuards } from "@nestjs/common"; -import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { CurrentUser } from "@/modules/auth/current-user.decorator"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { User } from "@/modules/user/user.type"; +import { Args, Mutation, Resolver } from "@nestjs/graphql"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { InstitutionService } from "@/modules/education/institution.service"; +import { Education as EducationEntity } from "../education.entity"; import { EducationService } from "../education.service"; import { Education } from "./education.type"; @Resolver(() => Education) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class EducationResolver { - constructor(private readonly educationService: EducationService) {} - - @Query(() => [Education]) - async myEducationHistory(@CurrentUser() user: User): Promise { - const result = await this.educationService.findManyForUser(user.id); - return result.edges - .map((edge) => edge.node) - .map((education) => Education.fromDomain(education)); - } + constructor( + private readonly educationService: EducationService, + private readonly institutionService: InstitutionService, + private readonly authorizationService: AuthorizationService, + ) {} @Mutation(() => Education) async createEducation( - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, @Args("institutionId") institutionId: string, @Args("degree") degree: string, @Args("startDate") startDate: Date, @@ -31,6 +33,12 @@ export class EducationResolver { @Args("skillIds", { type: () => [String], nullable: true }) skillIds?: string[], ): Promise { + await this.authorizationService.canCreate(user, EducationEntity, { + userId: user.id, + }); + + await this.institutionService.findByIdOrFail(institutionId); + const education = await this.educationService.create(user.id, { institutionId, degree, @@ -45,9 +53,11 @@ export class EducationResolver { @Mutation(() => Boolean) async deleteEducation( - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, @Args("id") id: string, ): Promise { + const education = await this.educationService.findByIdOrFail(id); + await this.authorizationService.canDelete(user, education, EducationEntity); await this.educationService.delete(id, user.id); return true; } diff --git a/apps/server/src/modules/education/graphql/education.type.ts b/apps/server/src/modules/education/graphql/education.type.ts index 4c63031..83264b9 100644 --- a/apps/server/src/modules/education/graphql/education.type.ts +++ b/apps/server/src/modules/education/graphql/education.type.ts @@ -1,5 +1,7 @@ import { Field, ObjectType } from "@nestjs/graphql"; +import { createConnection } from "@/modules/base/connection.factory"; import { Skill } from "@/modules/job-experience/skill/graphql/skill.type"; +import type { Education as EducationEntityType } from "../education.entity"; import { Education as EducationEntity } from "../education.entity"; import { Institution } from "./institution.type"; @@ -11,11 +13,8 @@ export class Education { @Field(() => String) userId: string; - @Field(() => String) - institutionId: string; - - @Field(() => Institution, { nullable: true }) - institution: Institution | null; + @Field(() => Institution) + institution: Institution; @Field(() => String) degree: string; @@ -44,8 +43,7 @@ export class Education { constructor(data: { id: string; userId: string; - institutionId: string; - institution: Institution | null; + institution: Institution; degree: string; fieldOfStudy: string | null; startDate: Date; @@ -57,7 +55,6 @@ export class Education { }) { this.id = data.id; this.userId = data.userId; - this.institutionId = data.institutionId; this.institution = data.institution; this.degree = data.degree; this.fieldOfStudy = data.fieldOfStudy; @@ -73,10 +70,7 @@ export class Education { return new Education({ id: education.id, userId: education.userId, - institutionId: education.institutionId, - institution: education.institution - ? Institution.fromDomain(education.institution) - : null, + institution: Institution.fromDomain(education.institution), degree: education.degree, fieldOfStudy: education.fieldOfStudy, startDate: education.startDate, @@ -90,3 +84,11 @@ export class Education { }); } } + +export const { Connection: EducationConnection, Edge: EducationEdge } = + createConnection(Education, (domain) => + Education.fromDomain(domain), + ); + +export type EducationConnection = InstanceType; +export type EducationEdge = InstanceType; diff --git a/apps/server/src/modules/education/graphql/institution.resolver.ts b/apps/server/src/modules/education/graphql/institution.resolver.ts index affec9d..044cc02 100644 --- a/apps/server/src/modules/education/graphql/institution.resolver.ts +++ b/apps/server/src/modules/education/graphql/institution.resolver.ts @@ -1,13 +1,25 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; import { UseGuards } from "@nestjs/common"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { Institution as InstitutionEntity } from "../institution.entity"; +import { InstitutionFactory } from "../institution.factory"; import { InstitutionService } from "../institution.service"; import { Institution } from "./institution.type"; @Resolver(() => Institution) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class InstitutionResolver { - constructor(private readonly institutionService: InstitutionService) {} + constructor( + private readonly institutionService: InstitutionService, + private readonly institutionFactory: InstitutionFactory, + private readonly authorizationService: AuthorizationService, + ) {} @Query(() => [Institution]) async institutions(): Promise { @@ -19,13 +31,16 @@ export class InstitutionResolver { @Mutation(() => Institution) async createInstitution( + @CurrentUser() user: DomainUser, @Args("name") name: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const institution = await this.institutionService.create({ + await this.authorizationService.canCreate(user, InstitutionEntity); + const entity = this.institutionFactory.create({ name, description: description ?? null, }); + const institution = await this.institutionService.save(entity); return Institution.fromDomain(institution); } } diff --git a/apps/server/src/modules/education/graphql/institution.type.ts b/apps/server/src/modules/education/graphql/institution.type.ts index bfdb577..86de99b 100644 --- a/apps/server/src/modules/education/graphql/institution.type.ts +++ b/apps/server/src/modules/education/graphql/institution.type.ts @@ -1,44 +1,16 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { Institution as InstitutionEntity } from "../institution.entity"; +import { createNamedGraphQLType } from "@/modules/base/named-graphql-type.factory"; +import { Institution as InstitutionDomain } from "../institution.entity"; -@ObjectType() -export class Institution { - @Field(() => String) - id: string; +const { Type, Connection, Edge } = createNamedGraphQLType( + "Institution", + InstitutionDomain, +); - @Field(() => String) - name: string; +export const Institution = Type; +export type Institution = InstanceType; - @Field(() => String, { nullable: true }) - description: string | null; +export const InstitutionConnection = Connection; +export type InstitutionConnection = InstanceType; - @Field(() => Date) - createdAt: Date; - - @Field(() => Date) - updatedAt: Date; - - constructor(data: { - id: string; - name: string; - description: string | null; - createdAt: Date; - updatedAt: Date; - }) { - this.id = data.id; - this.name = data.name; - this.description = data.description; - this.createdAt = data.createdAt; - this.updatedAt = data.updatedAt; - } - - static fromDomain(institution: InstitutionEntity): Institution { - return new Institution({ - id: institution.id, - name: institution.name, - description: institution.description, - createdAt: institution.createdAt, - updatedAt: institution.updatedAt, - }); - } -} +export const InstitutionEdge = Edge; +export type InstitutionEdge = InstanceType; diff --git a/apps/server/src/modules/education/graphql/user-field.resolver.ts b/apps/server/src/modules/education/graphql/user-field.resolver.ts index 3093a4e..e2471c9 100644 --- a/apps/server/src/modules/education/graphql/user-field.resolver.ts +++ b/apps/server/src/modules/education/graphql/user-field.resolver.ts @@ -1,14 +1,13 @@ +import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; +import { PaginationArgs, PaginationService } from "@cv/system"; import { UseGuards } from "@nestjs/common"; import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { PaginationService } from "@/modules/base/pagination.service"; -import { PaginationArgs } from "@/modules/base/pagination.types"; import { User } from "@/modules/user/user.type"; import { EducationService } from "../education.service"; -import { EducationConnection } from "./education-connection.type"; +import { EducationConnection } from "./education.type"; @Resolver(() => User) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class EducationUserFieldResolver { constructor( private readonly educationService: EducationService, diff --git a/apps/server/src/modules/education/institution.entity.ts b/apps/server/src/modules/education/institution.entity.ts index 1d43000..1f0f42e 100644 --- a/apps/server/src/modules/education/institution.entity.ts +++ b/apps/server/src/modules/education/institution.entity.ts @@ -1,13 +1,3 @@ -import { BaseEntity } from "@/domain/base.entity"; +import { NamedEntity } from "@cv/system"; -export class Institution extends BaseEntity { - constructor( - id: string, - public readonly name: string, - public readonly description: string | null, - createdAt: Date, - updatedAt: Date, - ) { - super(id, createdAt, updatedAt); - } -} +export class Institution extends NamedEntity {} diff --git a/apps/server/src/modules/education/institution.factory.ts b/apps/server/src/modules/education/institution.factory.ts new file mode 100644 index 0000000..8809e54 --- /dev/null +++ b/apps/server/src/modules/education/institution.factory.ts @@ -0,0 +1,29 @@ +import { ClockService, Factory, UuidFactoryService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import { Institution } from "./institution.entity"; + +type CreateInstitutionDto = { + name: string; + description?: string | null; +}; + +@Injectable() +export class InstitutionFactory + implements Factory +{ + constructor( + private readonly uuidFactory: UuidFactoryService, + private readonly clock: ClockService, + ) {} + + create(dto: CreateInstitutionDto): Institution { + const now = this.clock.now(); + return new Institution( + this.uuidFactory.generate(), + dto.name, + now, + now, + dto.description ?? undefined, + ); + } +} diff --git a/apps/server/src/modules/education/institution.mapper.ts b/apps/server/src/modules/education/institution.mapper.ts index 1b815ef..69913fe 100644 --- a/apps/server/src/modules/education/institution.mapper.ts +++ b/apps/server/src/modules/education/institution.mapper.ts @@ -1,40 +1,22 @@ +import { createNamedEntityMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; -import type { Prisma } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; +import type { Institution as PrismaInstitution } from "@prisma/client"; import { Institution } from "./institution.entity"; -type PrismaInstitution = Prisma.InstitutionGetPayload>; - @Injectable() -export class InstitutionMapper - implements BaseMapper -{ - /** - * Maps a Prisma Institution entity to a domain Institution entity - * Uses overloads to return the correct type based on input - */ +export class InstitutionMapper { + private mapper = createNamedEntityMapper( + Institution, + ); + toDomain(prismaInstitution: null): null; toDomain(prismaInstitution: PrismaInstitution): Institution; toDomain(prismaInstitution: PrismaInstitution | null): Institution | null; toDomain(prismaInstitution: PrismaInstitution | null): Institution | null { - if (!prismaInstitution) { - return null; - } - - return new Institution( - prismaInstitution.id, - prismaInstitution.name, - prismaInstitution.description, - prismaInstitution.createdAt, - prismaInstitution.updatedAt, - ); + return this.mapper.toDomain(prismaInstitution); } mapToDomain(prismaInstitutions: PrismaInstitution[]): Institution[] { - return prismaInstitutions - .map((institution) => this.toDomain(institution)) - .filter( - (institution): institution is Institution => institution !== null, - ); + return this.mapper.mapToDomain(prismaInstitutions); } } diff --git a/apps/server/src/modules/education/institution.policy.ts b/apps/server/src/modules/education/institution.policy.ts new file mode 100644 index 0000000..273d6c5 --- /dev/null +++ b/apps/server/src/modules/education/institution.policy.ts @@ -0,0 +1,11 @@ +import { Policy, PublicResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Institution } from "./institution.entity"; + +@Injectable() +@Policy(Institution) +export class InstitutionPolicy extends PublicResourcePolicy { + override create(): boolean { + return true; + } +} diff --git a/apps/server/src/modules/education/institution.service.ts b/apps/server/src/modules/education/institution.service.ts index 8755fc9..b48c5fa 100644 --- a/apps/server/src/modules/education/institution.service.ts +++ b/apps/server/src/modules/education/institution.service.ts @@ -1,49 +1,11 @@ +import { NamedEntityService, PrismaService } from "@cv/system"; import { Injectable } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Institution } from "./institution.entity"; import { InstitutionMapper } from "./institution.mapper"; @Injectable() -export class InstitutionService { - constructor( - private readonly prisma: PrismaService, - private readonly institutionMapper: InstitutionMapper, - ) {} - - async findMany(): Promise { - const institutions = await this.prisma["institution"].findMany({ - orderBy: { name: "asc" }, - }); - return this.institutionMapper.mapToDomain(institutions); - } - - async findById(id: string): Promise { - const institution = await this.prisma["institution"].findUnique({ - where: { id }, - }); - return this.institutionMapper.toDomain(institution); - } - - async findByIdOrFail(id: string): Promise { - const institution = await this.findById(id); - return institution ?? notFound("Institution", "id", id); - } - - async create(data: { - name: string; - description?: string | null; - }): Promise { - const institution = await this.prisma["institution"].create({ - data: { - name: data.name, - description: data.description ?? null, - }, - }); - const domainInstitution = this.institutionMapper.toDomain(institution); - if (!domainInstitution) { - throw new Error("Failed to create institution"); - } - return domainInstitution; +export class InstitutionService extends NamedEntityService { + constructor(prisma: PrismaService, institutionMapper: InstitutionMapper) { + super(prisma, institutionMapper, prisma.institution, "Institution"); } } diff --git a/apps/server/src/modules/job-experience/company/company.dataloader.ts b/apps/server/src/modules/job-experience/company/company.dataloader.ts new file mode 100644 index 0000000..a5ad5c5 --- /dev/null +++ b/apps/server/src/modules/job-experience/company/company.dataloader.ts @@ -0,0 +1,23 @@ +import { BaseDataLoaderService } from "@cv/system"; +import { Injectable, Scope } from "@nestjs/common"; +import { Company } from "./company.entity"; +import { CompanyService } from "./company.service"; + +@Injectable({ scope: Scope.REQUEST }) +export class CompanyDataLoaderService extends BaseDataLoaderService< + string, + Company +> { + constructor(readonly companyService: CompanyService) { + super(async (ids: readonly string[]) => { + const companies = await companyService.findMany({ id: [...ids] }); + + const companyMap = new Map(); + for (const company of companies) { + companyMap.set(company.id, company); + } + + return ids.map((id) => companyMap.get(id) ?? null); + }); + } +} diff --git a/apps/server/src/modules/job-experience/company/company.entity.ts b/apps/server/src/modules/job-experience/company/company.entity.ts index 3b82439..52f5799 100644 --- a/apps/server/src/modules/job-experience/company/company.entity.ts +++ b/apps/server/src/modules/job-experience/company/company.entity.ts @@ -1,25 +1,14 @@ -import { BaseEntity } from "@/modules/base/base.entity"; - -export class Company extends BaseEntity { - name: string; - description?: string; - website?: string; +import { NamedEntity } from "@cv/system"; +export class Company extends NamedEntity { constructor( id: string, name: string, createdAt: Date, updatedAt: Date, description?: string, - website?: string, + public website?: string, ) { - super(id, createdAt, updatedAt); - this.name = name; - if (description !== undefined) { - this.description = description; - } - if (website !== undefined) { - this.website = website; - } + super(id, name, createdAt, updatedAt, description); } } diff --git a/apps/server/src/modules/job-experience/company/company.factory.ts b/apps/server/src/modules/job-experience/company/company.factory.ts new file mode 100644 index 0000000..8aac3b3 --- /dev/null +++ b/apps/server/src/modules/job-experience/company/company.factory.ts @@ -0,0 +1,24 @@ +import { ClockService, Factory, UuidFactoryService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import type { CreateCompanyDto } from "./company.dto"; +import { Company } from "./company.entity"; + +@Injectable() +export class CompanyFactory implements Factory { + constructor( + private readonly uuidFactory: UuidFactoryService, + private readonly clock: ClockService, + ) {} + + create(dto: CreateCompanyDto): Company { + const now = this.clock.now(); + return new Company( + this.uuidFactory.generate(), + dto.name, + now, + now, + dto.description, + dto.website, + ); + } +} diff --git a/apps/server/src/modules/job-experience/company/company.mapper.ts b/apps/server/src/modules/job-experience/company/company.mapper.ts index 9324d0c..5f78324 100644 --- a/apps/server/src/modules/job-experience/company/company.mapper.ts +++ b/apps/server/src/modules/job-experience/company/company.mapper.ts @@ -1,17 +1,9 @@ import { Injectable } from "@nestjs/common"; import type { Company as PrismaCompany } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; import { Company } from "@/modules/job-experience/company/company.entity"; -/** - * Mapper service for converting between Prisma Company entities and domain Company entities - */ @Injectable() -export class CompanyMapper implements BaseMapper { - /** - * Maps a Prisma Company entity to a domain Company entity - * Uses overloads to return the correct type based on input - */ +export class CompanyMapper { toDomain(prismaCompany: null): null; toDomain(prismaCompany: PrismaCompany): Company; toDomain(prismaCompany: PrismaCompany | null): Company | null; @@ -29,9 +21,6 @@ export class CompanyMapper implements BaseMapper { ); } - /** - * Maps an array of Prisma Company entities to domain Company entities - */ mapToDomain(prismaCompanies: PrismaCompany[]): Company[] { return prismaCompanies.map((company) => this.toDomain(company)); } diff --git a/apps/server/src/modules/job-experience/company/company.module.ts b/apps/server/src/modules/job-experience/company/company.module.ts index e9c0ff0..30da4ab 100644 --- a/apps/server/src/modules/job-experience/company/company.module.ts +++ b/apps/server/src/modules/job-experience/company/company.module.ts @@ -1,13 +1,29 @@ +import { AuthorizationModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; import { CompanyService } from "@/modules/job-experience/company/company.service"; +import { CompanyDataLoaderService } from "./company.dataloader"; +import { CompanyFactory } from "./company.factory"; import { CompanyMapper } from "./company.mapper"; +import { CompanyPolicy } from "./company.policy"; import { CompanyResolver } from "./graphql/company.resolver"; @Module({ - imports: [DatabaseModule, BaseModule], - providers: [CompanyService, CompanyResolver, CompanyMapper], - exports: [CompanyService, CompanyMapper], + imports: [ + DatabaseModule, + BaseModule, + AuthenticationModule, + AuthorizationModule, + ], + providers: [ + CompanyService, + CompanyResolver, + CompanyMapper, + CompanyFactory, + CompanyPolicy, + CompanyDataLoaderService, + ], + exports: [CompanyService, CompanyMapper, CompanyDataLoaderService], }) export class CompanyModule {} diff --git a/apps/server/src/modules/job-experience/company/company.policy.ts b/apps/server/src/modules/job-experience/company/company.policy.ts new file mode 100644 index 0000000..4520273 --- /dev/null +++ b/apps/server/src/modules/job-experience/company/company.policy.ts @@ -0,0 +1,11 @@ +import { Policy, PublicResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Company } from "./company.entity"; + +@Injectable() +@Policy(Company) +export class CompanyPolicy extends PublicResourcePolicy { + override create(): boolean { + return true; + } +} diff --git a/apps/server/src/modules/job-experience/company/company.service.ts b/apps/server/src/modules/job-experience/company/company.service.ts index 67be0b1..3828c89 100644 --- a/apps/server/src/modules/job-experience/company/company.service.ts +++ b/apps/server/src/modules/job-experience/company/company.service.ts @@ -1,85 +1,22 @@ +import { NamedEntityService, PrismaService } from "@cv/system"; import { Injectable } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Company } from "@/modules/job-experience/company/company.entity"; -import type { CreateCompanyDto, UpdateCompanyDto } from "./company.dto"; import { CompanyMapper } from "./company.mapper"; -type CompanyFilters = { - searchTerm?: string | undefined; -}; - @Injectable() -export class CompanyService { - constructor( - private prisma: PrismaService, - private companyMapper: CompanyMapper, - ) {} - - async create(data: CreateCompanyDto): Promise { - const prismaCompany = await this.prisma.company.create({ - data, - }); - return this.companyMapper.toDomain(prismaCompany); - } - - async findMany(filters: CompanyFilters = {}): Promise { - const where = filters.searchTerm - ? { - name: { - contains: filters.searchTerm, - mode: "insensitive" as const, - }, - } - : {}; - - const prismaCompanies = await this.prisma.company.findMany({ - where, - orderBy: { name: "asc" }, - }); - - return this.companyMapper.mapToDomain(prismaCompanies); - } - - async count(filters: CompanyFilters = {}): Promise { - const where = filters.searchTerm - ? { - name: { - contains: filters.searchTerm, - mode: "insensitive" as const, - }, - } - : {}; - - return this.prisma.company.count({ where }); - } - - async findById(id: string): Promise { - const prismaCompany = await this.prisma.company.findUnique({ - where: { id }, - }); - return this.companyMapper.toDomain(prismaCompany); - } - - async findByIdOrFail(id: string): Promise { - const company = await this.findById(id); - return company ?? notFound("Company", "id", id); - } - - async update( - id: string, - updateCompanyDto: UpdateCompanyDto, - ): Promise { - const prismaCompany = await this.prisma.company.update({ - where: { id }, - data: updateCompanyDto, - }); - return this.companyMapper.toDomain(prismaCompany); +export class CompanyService extends NamedEntityService { + constructor(prisma: PrismaService, companyMapper: CompanyMapper) { + super(prisma, companyMapper, prisma.company, "Company"); } - async delete(id: string): Promise { - await this.prisma.company.delete({ - where: { id }, - }); + protected override buildPrismaData(entity: Company): { + name: string; + description: string | null; + website: string | null; + } { + return { + ...super.buildPrismaData(entity), + website: entity.website ?? null, + }; } } diff --git a/apps/server/src/modules/job-experience/company/graphql/company-connection-args.type.ts b/apps/server/src/modules/job-experience/company/graphql/company-connection-args.type.ts index 722fe24..b133c34 100644 --- a/apps/server/src/modules/job-experience/company/graphql/company-connection-args.type.ts +++ b/apps/server/src/modules/job-experience/company/graphql/company-connection-args.type.ts @@ -1,6 +1,6 @@ +import { BasePaginationArgs } from "@cv/system"; import { ArgsType, Field } from "@nestjs/graphql"; import { GraphQLString } from "graphql"; -import { BasePaginationArgs } from "@/modules/base/pagination.types"; @ArgsType() export class CompanyConnectionArgs extends BasePaginationArgs { diff --git a/apps/server/src/modules/job-experience/company/graphql/company-connection.type.ts b/apps/server/src/modules/job-experience/company/graphql/company-connection.type.ts deleted file mode 100644 index f2f1839..0000000 --- a/apps/server/src/modules/job-experience/company/graphql/company-connection.type.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import { Company as CompanyDomain } from "@/modules/job-experience/company/company.entity"; -import { Company } from "./company.type"; -import { CompanyEdge } from "./company-edge.type"; - -@ObjectType() -export class CompanyConnection { - @Field(() => [CompanyEdge]) - edges: CompanyEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: CompanyEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult, - ): CompanyConnection { - const edges = result.edges.map((edge) => - CompanyEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: Company.fromDomain(edge.node), - }), - ); - return new CompanyConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/job-experience/company/graphql/company-edge.type.ts b/apps/server/src/modules/job-experience/company/graphql/company-edge.type.ts deleted file mode 100644 index f5d1dda..0000000 --- a/apps/server/src/modules/job-experience/company/graphql/company-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { Company } from "./company.type"; - -@ObjectType() -export class CompanyEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => Company) - declare node: Company; -} diff --git a/apps/server/src/modules/job-experience/company/graphql/company.resolver.ts b/apps/server/src/modules/job-experience/company/graphql/company.resolver.ts index 12a3b40..50a8d74 100644 --- a/apps/server/src/modules/job-experience/company/graphql/company.resolver.ts +++ b/apps/server/src/modules/job-experience/company/graphql/company.resolver.ts @@ -1,15 +1,30 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; +import { PaginationService } from "@cv/system"; +import { UseGuards } from "@nestjs/common"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { PaginationService } from "@/modules/base/pagination.service"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { + Company, + Company as CompanyEntity, +} from "@/modules/job-experience/company/company.entity"; +import { CompanyFactory } from "@/modules/job-experience/company/company.factory"; import { CompanyService } from "@/modules/job-experience/company/company.service"; -import { Company } from "./company.type"; -import { CompanyConnection } from "./company-connection.type"; +import { CompanyConnection, Company as CompanyGraphQL } from "./company.type"; import { CompanyConnectionArgs } from "./company-connection-args.type"; -@Resolver(() => Company) +@Resolver(() => CompanyGraphQL) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class CompanyResolver { constructor( private readonly companyService: CompanyService, + private readonly companyFactory: CompanyFactory, private readonly paginationService: PaginationService, + private readonly authorizationService: AuthorizationService, ) {} @Query(() => CompanyConnection) @@ -33,58 +48,82 @@ export class CompanyResolver { return CompanyConnection.fromPaginationResult(result); } - @Query(() => Company) - async company(@Args("id") id: string): Promise { + @Query(() => CompanyGraphQL) + async company( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { const domainCompany = await this.companyService.findByIdOrFail(id); - return Company.fromDomain(domainCompany); + await this.authorizationService.canView(user, domainCompany, CompanyEntity); + return CompanyGraphQL.fromDomain(domainCompany); } - @Mutation(() => Company) + @Mutation(() => CompanyGraphQL) async createCompany( + @CurrentUser() user: DomainUser, @Args("name") name: string, @Args("description", { nullable: true }) description?: string, @Args("website", { nullable: true }) website?: string, - ): Promise { - const createData: { name: string; description?: string; website?: string } = - { name }; + ): Promise { + await this.authorizationService.canCreate(user, CompanyEntity); + const dto: { name: string; description?: string; website?: string } = { + name, + }; if (description !== undefined) { - createData.description = description; + dto.description = description; } if (website !== undefined) { - createData.website = website; + dto.website = website; } - const domainCompany = await this.companyService.create(createData); - return Company.fromDomain(domainCompany); + const entity = this.companyFactory.create(dto); + const domainCompany = await this.companyService.save(entity); + return CompanyGraphQL.fromDomain(domainCompany); } - @Mutation(() => Company) + @Mutation(() => CompanyGraphQL) async updateCompany( + @CurrentUser() user: DomainUser, @Args("id") id: string, @Args("name", { nullable: true }) name?: string, @Args("description", { nullable: true }) description?: string, @Args("website", { nullable: true }) website?: string, - ): Promise { - const updateData: { + ): Promise { + const company = await this.companyService.findByIdOrFail(id); + await this.authorizationService.canUpdate(user, company, CompanyEntity); + const dto: { name?: string; description?: string; website?: string; } = {}; if (name !== undefined) { - updateData.name = name; + dto.name = name; } if (description !== undefined) { - updateData.description = description; + dto.description = description; } if (website !== undefined) { - updateData.website = website; + dto.website = website; } - const domainCompany = await this.companyService.update(id, updateData); - return Company.fromDomain(domainCompany); + const updatedEntity = new Company( + company.id, + dto.name ?? company.name, + company.createdAt, + new Date(), + dto.description !== undefined ? dto.description : company.description, + dto.website !== undefined ? dto.website : company.website, + ); + const domainCompany = await this.companyService.save(updatedEntity); + return CompanyGraphQL.fromDomain(domainCompany); } @Mutation(() => Boolean) - async deleteCompany(@Args("id") id: string): Promise { - await this.companyService.delete(id); + async deleteCompany( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { + const company = await this.companyService.findByIdOrFail(id); + await this.authorizationService.canDelete(user, company, CompanyEntity); + await this.companyService.destroy(company); return true; } } diff --git a/apps/server/src/modules/job-experience/company/graphql/company.type.ts b/apps/server/src/modules/job-experience/company/graphql/company.type.ts index 6b91058..057f880 100644 --- a/apps/server/src/modules/job-experience/company/graphql/company.type.ts +++ b/apps/server/src/modules/job-experience/company/graphql/company.type.ts @@ -1,5 +1,7 @@ import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { createConnection } from "@/modules/base/connection.factory"; import type { Company as DomainCompany } from "@/modules/job-experience/company/company.entity"; +import { Company as CompanyDomain } from "@/modules/job-experience/company/company.entity"; @ObjectType() export class Company { @@ -52,3 +54,11 @@ export class Company { ); } } + +export const { Connection: CompanyConnection, Edge: CompanyEdge } = + createConnection(Company, (domain) => + Company.fromDomain(domain), + ); + +export type CompanyConnection = InstanceType; +export type CompanyEdge = InstanceType; diff --git a/apps/server/src/modules/job-experience/employment/employment.module.ts b/apps/server/src/modules/job-experience/employment/employment.module.ts index 31df849..08d66d3 100644 --- a/apps/server/src/modules/job-experience/employment/employment.module.ts +++ b/apps/server/src/modules/job-experience/employment/employment.module.ts @@ -1,7 +1,7 @@ +import { AuthorizationModule, UserModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { AuthModule } from "@/modules/auth/auth.module"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; import { CompanyModule } from "@/modules/job-experience/company/company.module"; import { LevelModule } from "@/modules/job-experience/level/level.module"; import { RoleModule } from "@/modules/job-experience/role/role.module"; @@ -9,13 +9,16 @@ import { SkillModule } from "@/modules/job-experience/skill/skill.module"; import { EmploymentResolver } from "./graphql/employment.resolver"; import { UserFieldResolver } from "./graphql/user-field.resolver"; import { UserJobExperienceMapper } from "./user-job-experience.mapper"; +import { UserJobExperiencePolicy } from "./user-job-experience.policy"; import { UserJobExperienceService } from "./user-job-experience.service"; @Module({ imports: [ DatabaseModule, BaseModule, - AuthModule, + AuthenticationModule, + AuthorizationModule, + UserModule, CompanyModule, RoleModule, LevelModule, @@ -26,6 +29,7 @@ import { UserJobExperienceService } from "./user-job-experience.service"; EmploymentResolver, UserFieldResolver, UserJobExperienceMapper, + UserJobExperiencePolicy, ], exports: [UserJobExperienceService, UserJobExperienceMapper], }) diff --git a/apps/server/src/modules/job-experience/employment/graphql/employment.resolver.ts b/apps/server/src/modules/job-experience/employment/graphql/employment.resolver.ts index f1a83f2..b1ac271 100644 --- a/apps/server/src/modules/job-experience/employment/graphql/employment.resolver.ts +++ b/apps/server/src/modules/job-experience/employment/graphql/employment.resolver.ts @@ -1,21 +1,26 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; import { UseGuards } from "@nestjs/common"; -import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { CurrentUser } from "@/modules/auth/current-user.decorator"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; +import { Args, Mutation, Resolver } from "@nestjs/graphql"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; import { CompanyService } from "@/modules/job-experience/company/company.service"; import type { CreateUserJobExperienceDto, UpdateUserJobExperienceDto, } from "@/modules/job-experience/employment/user-job-experience.dto"; +import { UserJobExperience as UserJobExperienceEntity } from "@/modules/job-experience/employment/user-job-experience.entity"; import { UserJobExperienceService } from "@/modules/job-experience/employment/user-job-experience.service"; import { LevelService } from "@/modules/job-experience/level/level.service"; import { RoleService } from "@/modules/job-experience/role/role.service"; import { SkillService } from "@/modules/job-experience/skill/skill.service"; -import type { User } from "@/modules/user/user.type"; import { UserJobExperience } from "./user-job-experience.type"; @Resolver(() => UserJobExperience) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class EmploymentResolver { constructor( private readonly userJobExperienceService: UserJobExperienceService, @@ -23,20 +28,12 @@ export class EmploymentResolver { private readonly roleService: RoleService, private readonly levelService: LevelService, private readonly skillService: SkillService, + private readonly authorizationService: AuthorizationService, ) {} - @Query(() => [UserJobExperience]) - async myEmploymentHistory( - @CurrentUser() user: User, - ): Promise { - const domainExperiences = - await this.userJobExperienceService.findForUser(user); - return domainExperiences.map((exp) => UserJobExperience.fromDomain(exp)); - } - @Mutation(() => UserJobExperience) async createJobExperience( - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, @Args("companyId") companyId: string, @Args("roleId") roleId: string, @Args("levelId") levelId: string, @@ -46,7 +43,10 @@ export class EmploymentResolver { @Args("skillIds", { type: () => [String], nullable: true }) skillIds?: string[], ): Promise { - // Fetch full entities + await this.authorizationService.canCreate(user, UserJobExperienceEntity, { + userId: user.id, + }); + const company = await this.companyService.findByIdOrFail(companyId); const role = await this.roleService.findByIdOrFail(roleId); const level = await this.levelService.findByIdOrFail(levelId); @@ -82,6 +82,7 @@ export class EmploymentResolver { @Mutation(() => UserJobExperience) async updateJobExperience( + @CurrentUser() user: DomainUser, @Args("id") id: string, @Args("companyId", { nullable: true }) companyId?: string, @Args("roleId", { nullable: true }) roleId?: string, @@ -92,6 +93,14 @@ export class EmploymentResolver { @Args("skillIds", { type: () => [String], nullable: true }) skillIds?: string[], ): Promise { + const existingExperience = + await this.userJobExperienceService.findByIdOrFail(id); + await this.authorizationService.canUpdate( + user, + existingExperience, + UserJobExperienceEntity, + ); + const updateData: UpdateUserJobExperienceDto = {}; if (companyId !== undefined) { @@ -127,16 +136,33 @@ export class EmploymentResolver { } @Mutation(() => Boolean) - async deleteJobExperience(@Args("id") id: string): Promise { + async deleteJobExperience( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { + const experience = await this.userJobExperienceService.findByIdOrFail(id); + await this.authorizationService.canDelete( + user, + experience, + UserJobExperienceEntity, + ); await this.userJobExperienceService.delete(id); return true; } @Mutation(() => UserJobExperience) async addSkillsToJobExperience( + @CurrentUser() user: DomainUser, @Args("experienceId") experienceId: string, @Args("skillIds", { type: () => [String] }) skillIds: string[], ): Promise { + const experience = + await this.userJobExperienceService.findByIdOrFail(experienceId); + await this.authorizationService.canUpdate( + user, + experience, + UserJobExperienceEntity, + ); const domainExperience = await this.userJobExperienceService.addSkills( experienceId, skillIds, @@ -146,9 +172,17 @@ export class EmploymentResolver { @Mutation(() => UserJobExperience) async removeSkillsFromJobExperience( + @CurrentUser() user: DomainUser, @Args("experienceId") experienceId: string, @Args("skillIds", { type: () => [String] }) skillIds: string[], ): Promise { + const experience = + await this.userJobExperienceService.findByIdOrFail(experienceId); + await this.authorizationService.canUpdate( + user, + experience, + UserJobExperienceEntity, + ); const domainExperience = await this.userJobExperienceService.removeSkills( experienceId, skillIds, diff --git a/apps/server/src/modules/job-experience/employment/graphql/user-field.resolver.ts b/apps/server/src/modules/job-experience/employment/graphql/user-field.resolver.ts index 94e3f95..c5472ff 100644 --- a/apps/server/src/modules/job-experience/employment/graphql/user-field.resolver.ts +++ b/apps/server/src/modules/job-experience/employment/graphql/user-field.resolver.ts @@ -1,14 +1,13 @@ +import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; +import { PaginationArgs, PaginationService } from "@cv/system"; import { UseGuards } from "@nestjs/common"; import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { PaginationService } from "@/modules/base/pagination.service"; -import { PaginationArgs } from "@/modules/base/pagination.types"; import { UserJobExperienceService } from "@/modules/job-experience/employment/user-job-experience.service"; import { User } from "@/modules/user/user.type"; -import { UserJobExperienceConnection } from "./user-job-experience-connection.type"; +import { UserJobExperienceConnection } from "./user-job-experience.type"; @Resolver(() => User) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class UserFieldResolver { constructor( private readonly userJobExperienceService: UserJobExperienceService, diff --git a/apps/server/src/modules/job-experience/employment/graphql/user-job-experience-connection.type.ts b/apps/server/src/modules/job-experience/employment/graphql/user-job-experience-connection.type.ts deleted file mode 100644 index e4d9be3..0000000 --- a/apps/server/src/modules/job-experience/employment/graphql/user-job-experience-connection.type.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import type { UserJobExperience as UserJobExperienceEntity } from "../user-job-experience.entity"; -import { UserJobExperience } from "./user-job-experience.type"; -import { UserJobExperienceEdge } from "./user-job-experience-edge.type"; - -@ObjectType() -export class UserJobExperienceConnection { - @Field(() => [UserJobExperienceEdge]) - edges: UserJobExperienceEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor( - edges: UserJobExperienceEdge[], - pageInfo: PageInfo, - totalCount: number, - ) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult, - ): UserJobExperienceConnection { - const edges = result.edges.map((edge) => - UserJobExperienceEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: UserJobExperience.fromDomain(edge.node), - }), - ); - return new UserJobExperienceConnection( - edges, - result.pageInfo, - result.totalCount, - ); - } -} diff --git a/apps/server/src/modules/job-experience/employment/graphql/user-job-experience-edge.type.ts b/apps/server/src/modules/job-experience/employment/graphql/user-job-experience-edge.type.ts deleted file mode 100644 index 765576c..0000000 --- a/apps/server/src/modules/job-experience/employment/graphql/user-job-experience-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { UserJobExperience } from "./user-job-experience.type"; - -@ObjectType() -export class UserJobExperienceEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => UserJobExperience) - declare node: UserJobExperience; -} diff --git a/apps/server/src/modules/job-experience/employment/graphql/user-job-experience.type.ts b/apps/server/src/modules/job-experience/employment/graphql/user-job-experience.type.ts index 16dc3eb..7db1726 100644 --- a/apps/server/src/modules/job-experience/employment/graphql/user-job-experience.type.ts +++ b/apps/server/src/modules/job-experience/employment/graphql/user-job-experience.type.ts @@ -1,6 +1,8 @@ import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { createConnection } from "@/modules/base/connection.factory"; import { Company } from "@/modules/job-experience/company/graphql/company.type"; import type { UserJobExperience as DomainUserJobExperience } from "@/modules/job-experience/employment/user-job-experience.entity"; +import { UserJobExperience as UserJobExperienceEntity } from "@/modules/job-experience/employment/user-job-experience.entity"; import { Level } from "@/modules/job-experience/level/graphql/level.type"; import { Role } from "@/modules/job-experience/role/graphql/role.type"; import { Skill } from "@/modules/job-experience/skill/graphql/skill.type"; @@ -87,3 +89,16 @@ export class UserJobExperience { ); } } + +export const { + Connection: UserJobExperienceConnection, + Edge: UserJobExperienceEdge, +} = createConnection( + UserJobExperience, + (domain) => UserJobExperience.fromDomain(domain), +); + +export type UserJobExperienceConnection = InstanceType< + typeof UserJobExperienceConnection +>; +export type UserJobExperienceEdge = InstanceType; diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.entity.ts b/apps/server/src/modules/job-experience/employment/user-job-experience.entity.ts index 467e6e7..3674898 100644 --- a/apps/server/src/modules/job-experience/employment/user-job-experience.entity.ts +++ b/apps/server/src/modules/job-experience/employment/user-job-experience.entity.ts @@ -1,10 +1,12 @@ -import { BaseEntity } from "@/modules/base/base.entity"; +import type { User } from "@cv/auth"; +import { BaseEntity } from "@cv/system"; import type { Company } from "@/modules/job-experience/company/company.entity"; import type { Level } from "@/modules/job-experience/level/level.entity"; import type { Role } from "@/modules/job-experience/role/role.entity"; import type { Skill } from "@/modules/job-experience/skill/skill.entity"; export class UserJobExperience extends BaseEntity { + userId: string; startDate: Date; endDate?: Date; description?: string; @@ -14,9 +16,11 @@ export class UserJobExperience extends BaseEntity { role: Role; level: Level; skills?: Skill[]; + user?: User; constructor( id: string, + userId: string, startDate: Date, createdAt: Date, updatedAt: Date, @@ -26,8 +30,10 @@ export class UserJobExperience extends BaseEntity { endDate?: Date, description?: string, skills?: Skill[], + user?: User, ) { super(id, createdAt, updatedAt); + this.userId = userId; this.startDate = startDate; this.company = company; this.role = role; @@ -41,5 +47,8 @@ export class UserJobExperience extends BaseEntity { if (skills !== undefined) { this.skills = skills; } + if (user !== undefined) { + this.user = user; + } } } diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.mapper.ts b/apps/server/src/modules/job-experience/employment/user-job-experience.mapper.ts index 16de920..3620c99 100644 --- a/apps/server/src/modules/job-experience/employment/user-job-experience.mapper.ts +++ b/apps/server/src/modules/job-experience/employment/user-job-experience.mapper.ts @@ -1,12 +1,14 @@ +import { UserMapper } from "@cv/auth"; +import type { BaseMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Company as PrismaCompany, Level as PrismaLevel, Role as PrismaRole, Skill as PrismaSkill, + User as PrismaUser, UserJobExperience as PrismaUserJobExperience, } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; import { CompanyMapper } from "@/modules/job-experience/company/company.mapper"; import { LevelMapper } from "@/modules/job-experience/level/level.mapper"; import { RoleMapper } from "@/modules/job-experience/role/role.mapper"; @@ -21,6 +23,23 @@ type PrismaUserJobExperienceWithRelations = PrismaUserJobExperience & { role: PrismaRole; level: PrismaLevel; skills?: PrismaSkill[]; + user?: + | (PrismaUser & { + credentials: { + id: string; + userId: string; + email: string; + password: string; + emailVerifiedAt: Date | null; + emailVerificationToken: string | null; + emailVerificationTokenExpiresAt: Date | null; + passwordResetToken: string | null; + passwordResetTokenExpiresAt: Date | null; + createdAt: Date; + updatedAt: Date; + } | null; + }) + | null; }; /** @@ -35,6 +54,7 @@ export class UserJobExperienceMapper private roleMapper: RoleMapper, private levelMapper: LevelMapper, private skillMapper: SkillMapper, + private userMapper: UserMapper, ) {} /** * Maps a Prisma UserJobExperience entity to a domain UserJobExperience entity @@ -56,6 +76,7 @@ export class UserJobExperienceMapper return new UserJobExperience( prismaExperience.id, + prismaExperience.userId, prismaExperience.startDate, prismaExperience.createdAt, prismaExperience.updatedAt, @@ -67,6 +88,9 @@ export class UserJobExperienceMapper prismaExperience.skills ? this.skillMapper.mapToDomain(prismaExperience.skills) : undefined, + prismaExperience.user + ? this.userMapper.toDomain(prismaExperience.user) + : undefined, ); } diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.policy.ts b/apps/server/src/modules/job-experience/employment/user-job-experience.policy.ts new file mode 100644 index 0000000..10a515d --- /dev/null +++ b/apps/server/src/modules/job-experience/employment/user-job-experience.policy.ts @@ -0,0 +1,7 @@ +import { Policy, UserOwnedResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { UserJobExperience } from "./user-job-experience.entity"; + +@Injectable() +@Policy(UserJobExperience) +export class UserJobExperiencePolicy extends UserOwnedResourcePolicy {} diff --git a/apps/server/src/modules/job-experience/employment/user-job-experience.service.ts b/apps/server/src/modules/job-experience/employment/user-job-experience.service.ts index e0b1b87..47a11a6 100644 --- a/apps/server/src/modules/job-experience/employment/user-job-experience.service.ts +++ b/apps/server/src/modules/job-experience/employment/user-job-experience.service.ts @@ -1,12 +1,8 @@ +import { notFound } from "@cv/auth"; +import type { PaginationOptions, PaginationResult } from "@cv/system"; +import { PaginationService, PrismaService } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; -import { notFound } from "@/modules/base/not-found.util"; -import { PaginationService } from "@/modules/base/pagination.service"; -import type { - PaginationOptions, - PaginationResult, -} from "@/modules/base/pagination.types"; -import { PrismaService } from "@/modules/database/prisma.service"; import type { CreateUserJobExperienceDto, UpdateUserJobExperienceDto, @@ -42,6 +38,18 @@ type PrismaUserJobExperienceUpdateData = { @Injectable() export class UserJobExperienceService { + private readonly userJobExperienceInclude = { + user: { + include: { + credentials: true, + }, + }, + company: true, + role: true, + level: true, + skills: true, + } satisfies Prisma.UserJobExperienceInclude; + constructor( private prisma: PrismaService, private userJobExperienceMapper: UserJobExperienceMapper, @@ -75,12 +83,7 @@ export class UserJobExperienceService { const prismaExperience = await this.prisma.userJobExperience.create({ data: createData, - include: { - company: true, - role: true, - level: true, - skills: true, - }, + include: this.userJobExperienceInclude, }); return this.userJobExperienceMapper.toDomain(prismaExperience); } @@ -99,12 +102,7 @@ export class UserJobExperienceService { const [items, totalCount] = await Promise.all([ this.prisma.userJobExperience.findMany({ ...queryOptions, - include: { - company: true, - role: true, - level: true, - skills: true, - }, + include: this.userJobExperienceInclude, }), this.prisma.userJobExperience.count({ where }), ]); @@ -132,12 +130,7 @@ export class UserJobExperienceService { async findById(id: string): Promise { const prismaExperience = await this.prisma.userJobExperience.findUnique({ where: { id }, - include: { - company: true, - role: true, - level: true, - skills: true, - }, + include: this.userJobExperienceInclude, }); return this.userJobExperienceMapper.toDomain(prismaExperience); } @@ -182,12 +175,7 @@ export class UserJobExperienceService { const prismaExperience = await this.prisma.userJobExperience.update({ where: { id }, data: updateData, - include: { - company: true, - role: true, - level: true, - skills: true, - }, + include: this.userJobExperienceInclude, }); return this.userJobExperienceMapper.toDomain(prismaExperience); } @@ -209,13 +197,7 @@ export class UserJobExperienceService { connect: skillIds.map((id) => ({ id })), }, }, - include: { - user: true, - company: true, - role: true, - level: true, - skills: true, - }, + include: this.userJobExperienceInclude, }); return this.userJobExperienceMapper.toDomain(prismaExperience); } @@ -231,13 +213,7 @@ export class UserJobExperienceService { disconnect: skillIds.map((id) => ({ id })), }, }, - include: { - user: true, - company: true, - role: true, - level: true, - skills: true, - }, + include: this.userJobExperienceInclude, }); return this.userJobExperienceMapper.toDomain(prismaExperience); } diff --git a/apps/server/src/modules/job-experience/level/graphql/level-connection-args.type.ts b/apps/server/src/modules/job-experience/level/graphql/level-connection-args.type.ts index 9381982..606bc87 100644 --- a/apps/server/src/modules/job-experience/level/graphql/level-connection-args.type.ts +++ b/apps/server/src/modules/job-experience/level/graphql/level-connection-args.type.ts @@ -1,6 +1,6 @@ +import { BasePaginationArgs } from "@cv/system"; import { ArgsType, Field } from "@nestjs/graphql"; import { GraphQLString } from "graphql"; -import { BasePaginationArgs } from "@/modules/base/pagination.types"; @ArgsType() export class LevelConnectionArgs extends BasePaginationArgs { diff --git a/apps/server/src/modules/job-experience/level/graphql/level-connection.type.ts b/apps/server/src/modules/job-experience/level/graphql/level-connection.type.ts deleted file mode 100644 index 105f0dd..0000000 --- a/apps/server/src/modules/job-experience/level/graphql/level-connection.type.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import { Level as LevelDomain } from "@/modules/job-experience/level/level.entity"; -import { Level } from "./level.type"; -import { LevelEdge } from "./level-edge.type"; - -@ObjectType() -export class LevelConnection { - @Field(() => [LevelEdge]) - edges: LevelEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: LevelEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult, - ): LevelConnection { - const edges = result.edges.map((edge) => - LevelEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: Level.fromDomain(edge.node), - }), - ); - return new LevelConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/job-experience/level/graphql/level-edge.type.ts b/apps/server/src/modules/job-experience/level/graphql/level-edge.type.ts deleted file mode 100644 index 64db49b..0000000 --- a/apps/server/src/modules/job-experience/level/graphql/level-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { Level } from "./level.type"; - -@ObjectType() -export class LevelEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => Level) - declare node: Level; -} diff --git a/apps/server/src/modules/job-experience/level/graphql/level.resolver.ts b/apps/server/src/modules/job-experience/level/graphql/level.resolver.ts index 4cb40af..d3f1f69 100644 --- a/apps/server/src/modules/job-experience/level/graphql/level.resolver.ts +++ b/apps/server/src/modules/job-experience/level/graphql/level.resolver.ts @@ -1,15 +1,27 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; +import { PaginationService } from "@cv/system"; +import { UseGuards } from "@nestjs/common"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { PaginationService } from "@/modules/base/pagination.service"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { Level as LevelEntity } from "@/modules/job-experience/level/level.entity"; +import { LevelFactory } from "@/modules/job-experience/level/level.factory"; import { LevelService } from "@/modules/job-experience/level/level.service"; -import { Level } from "./level.type"; -import { LevelConnection } from "./level-connection.type"; +import { Level, LevelConnection } from "./level.type"; import { LevelConnectionArgs } from "./level-connection-args.type"; @Resolver(() => Level) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class LevelResolver { constructor( private readonly levelService: LevelService, + private readonly levelFactory: LevelFactory, private readonly paginationService: PaginationService, + private readonly authorizationService: AuthorizationService, ) {} @Query(() => LevelConnection) @@ -34,44 +46,66 @@ export class LevelResolver { } @Query(() => Level) - async level(@Args("id") id: string): Promise { + async level( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { const domainLevel = await this.levelService.findByIdOrFail(id); + await this.authorizationService.canView(user, domainLevel, LevelEntity); return Level.fromDomain(domainLevel); } @Mutation(() => Level) async createLevel( + @CurrentUser() user: DomainUser, @Args("name") name: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const createData: { name: string; description?: string } = { name }; + await this.authorizationService.canCreate(user, LevelEntity); + const dto: { name: string; description?: string } = { name }; if (description !== undefined) { - createData.description = description; + dto.description = description; } - const domainLevel = await this.levelService.create(createData); + const entity = this.levelFactory.create(dto); + const domainLevel = await this.levelService.save(entity); return Level.fromDomain(domainLevel); } @Mutation(() => Level) async updateLevel( + @CurrentUser() user: DomainUser, @Args("id") id: string, @Args("name", { nullable: true }) name?: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const updateData: { name?: string; description?: string } = {}; + const level = await this.levelService.findByIdOrFail(id); + await this.authorizationService.canUpdate(user, level, LevelEntity); + const dto: { name?: string; description?: string } = {}; if (name !== undefined) { - updateData.name = name; + dto.name = name; } if (description !== undefined) { - updateData.description = description; + dto.description = description; } - const domainLevel = await this.levelService.update(id, updateData); + const updatedEntity = new Level( + level.id, + dto.name ?? level.name, + level.createdAt, + new Date(), + dto.description !== undefined ? dto.description : level.description, + ); + const domainLevel = await this.levelService.save(updatedEntity); return Level.fromDomain(domainLevel); } @Mutation(() => Boolean) - async deleteLevel(@Args("id") id: string): Promise { - await this.levelService.delete(id); + async deleteLevel( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { + const level = await this.levelService.findByIdOrFail(id); + await this.authorizationService.canDelete(user, level, LevelEntity); + await this.levelService.destroy(level); return true; } } diff --git a/apps/server/src/modules/job-experience/level/graphql/level.type.ts b/apps/server/src/modules/job-experience/level/graphql/level.type.ts index 9e208fe..99e9162 100644 --- a/apps/server/src/modules/job-experience/level/graphql/level.type.ts +++ b/apps/server/src/modules/job-experience/level/graphql/level.type.ts @@ -1,46 +1,13 @@ -import { Field, ID, ObjectType } from "@nestjs/graphql"; -import type { Level as DomainLevel } from "@/modules/job-experience/level/level.entity"; +import { createNamedGraphQLType } from "@/modules/base/named-graphql-type.factory"; +import { Level as LevelDomain } from "@/modules/job-experience/level/level.entity"; -@ObjectType() -export class Level { - @Field(() => ID) - id: string; +const { Type, Connection, Edge } = createNamedGraphQLType("Level", LevelDomain); - @Field() - name: string; +export const Level = Type; +export type Level = InstanceType; - @Field({ nullable: true }) - description?: string; +export const LevelConnection = Connection; +export type LevelConnection = InstanceType; - @Field() - createdAt: Date; - - @Field() - updatedAt: Date; - - constructor( - id: string, - name: string, - createdAt: Date, - updatedAt: Date, - description?: string, - ) { - this.id = id; - this.name = name; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - if (description !== undefined) { - this.description = description; - } - } - - static fromDomain(domainLevel: DomainLevel): Level { - return new Level( - domainLevel.id, - domainLevel.name, - domainLevel.createdAt, - domainLevel.updatedAt, - domainLevel.description, - ); - } -} +export const LevelEdge = Edge; +export type LevelEdge = InstanceType; diff --git a/apps/server/src/modules/job-experience/level/level.dataloader.ts b/apps/server/src/modules/job-experience/level/level.dataloader.ts new file mode 100644 index 0000000..46bcd8e --- /dev/null +++ b/apps/server/src/modules/job-experience/level/level.dataloader.ts @@ -0,0 +1,23 @@ +import { BaseDataLoaderService } from "@cv/system"; +import { Injectable, Scope } from "@nestjs/common"; +import { Level } from "./level.entity"; +import { LevelService } from "./level.service"; + +@Injectable({ scope: Scope.REQUEST }) +export class LevelDataLoaderService extends BaseDataLoaderService< + string, + Level +> { + constructor(readonly levelService: LevelService) { + super(async (ids: readonly string[]) => { + const levels = await levelService.findMany({ id: [...ids] }); + + const levelMap = new Map(); + for (const level of levels) { + levelMap.set(level.id, level); + } + + return ids.map((id) => levelMap.get(id) ?? null); + }); + } +} diff --git a/apps/server/src/modules/job-experience/level/level.entity.ts b/apps/server/src/modules/job-experience/level/level.entity.ts index 01e5323..331b4b4 100644 --- a/apps/server/src/modules/job-experience/level/level.entity.ts +++ b/apps/server/src/modules/job-experience/level/level.entity.ts @@ -1,20 +1,3 @@ -import { BaseEntity } from "@/modules/base/base.entity"; +import { NamedEntity } from "@cv/system"; -export class Level extends BaseEntity { - name: string; - description?: string; - - constructor( - id: string, - name: string, - createdAt: Date, - updatedAt: Date, - description?: string, - ) { - super(id, createdAt, updatedAt); - this.name = name; - if (description !== undefined) { - this.description = description; - } - } -} +export class Level extends NamedEntity {} diff --git a/apps/server/src/modules/job-experience/level/level.factory.ts b/apps/server/src/modules/job-experience/level/level.factory.ts new file mode 100644 index 0000000..b21500f --- /dev/null +++ b/apps/server/src/modules/job-experience/level/level.factory.ts @@ -0,0 +1,23 @@ +import { ClockService, Factory, UuidFactoryService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import type { CreateLevelDto } from "./level.dto"; +import { Level } from "./level.entity"; + +@Injectable() +export class LevelFactory implements Factory { + constructor( + private readonly uuidFactory: UuidFactoryService, + private readonly clock: ClockService, + ) {} + + create(dto: CreateLevelDto): Level { + const now = this.clock.now(); + return new Level( + this.uuidFactory.generate(), + dto.name, + now, + now, + dto.description, + ); + } +} diff --git a/apps/server/src/modules/job-experience/level/level.mapper.ts b/apps/server/src/modules/job-experience/level/level.mapper.ts index e70f8cf..7d4bb25 100644 --- a/apps/server/src/modules/job-experience/level/level.mapper.ts +++ b/apps/server/src/modules/job-experience/level/level.mapper.ts @@ -1,37 +1,23 @@ +import { createNamedEntityMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Level as PrismaLevel } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; import { Level } from "@/modules/job-experience/level/level.entity"; /** * Mapper service for converting between Prisma Level entities and domain Level entities */ @Injectable() -export class LevelMapper implements BaseMapper { - /** - * Maps a Prisma Level entity to a domain Level entity - * Uses overloads to return the correct type based on input - */ +export class LevelMapper { + private mapper = createNamedEntityMapper(Level); + toDomain(prismaLevel: null): null; toDomain(prismaLevel: PrismaLevel): Level; toDomain(prismaLevel: PrismaLevel | null): Level | null; toDomain(prismaLevel: PrismaLevel | null): Level | null { - if (prismaLevel === null) { - return null; - } - return new Level( - prismaLevel.id, - prismaLevel.name, - prismaLevel.createdAt, - prismaLevel.updatedAt, - prismaLevel.description ?? undefined, - ); + return this.mapper.toDomain(prismaLevel); } - /** - * Maps an array of Prisma Level entities to domain Level entities - */ mapToDomain(prismaLevels: PrismaLevel[]): Level[] { - return prismaLevels.map((level) => this.toDomain(level)); + return this.mapper.mapToDomain(prismaLevels); } } diff --git a/apps/server/src/modules/job-experience/level/level.module.ts b/apps/server/src/modules/job-experience/level/level.module.ts index e0e54eb..071a9ba 100644 --- a/apps/server/src/modules/job-experience/level/level.module.ts +++ b/apps/server/src/modules/job-experience/level/level.module.ts @@ -1,13 +1,29 @@ +import { AuthorizationModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; import { LevelService } from "@/modules/job-experience/level/level.service"; import { LevelResolver } from "./graphql/level.resolver"; +import { LevelDataLoaderService } from "./level.dataloader"; +import { LevelFactory } from "./level.factory"; import { LevelMapper } from "./level.mapper"; +import { LevelPolicy } from "./level.policy"; @Module({ - imports: [DatabaseModule, BaseModule], - providers: [LevelService, LevelResolver, LevelMapper], - exports: [LevelService, LevelMapper], + imports: [ + DatabaseModule, + BaseModule, + AuthenticationModule, + AuthorizationModule, + ], + providers: [ + LevelService, + LevelResolver, + LevelMapper, + LevelFactory, + LevelPolicy, + LevelDataLoaderService, + ], + exports: [LevelService, LevelMapper, LevelDataLoaderService], }) export class LevelModule {} diff --git a/apps/server/src/modules/job-experience/level/level.policy.ts b/apps/server/src/modules/job-experience/level/level.policy.ts new file mode 100644 index 0000000..4101686 --- /dev/null +++ b/apps/server/src/modules/job-experience/level/level.policy.ts @@ -0,0 +1,11 @@ +import { Policy, PublicResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Level } from "./level.entity"; + +@Injectable() +@Policy(Level) +export class LevelPolicy extends PublicResourcePolicy { + override create(): boolean { + return true; + } +} diff --git a/apps/server/src/modules/job-experience/level/level.service.ts b/apps/server/src/modules/job-experience/level/level.service.ts index d890580..56dd311 100644 --- a/apps/server/src/modules/job-experience/level/level.service.ts +++ b/apps/server/src/modules/job-experience/level/level.service.ts @@ -1,82 +1,11 @@ +import { NamedEntityService, PrismaService } from "@cv/system"; import { Injectable } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Level } from "@/modules/job-experience/level/level.entity"; -import type { CreateLevelDto, UpdateLevelDto } from "./level.dto"; import { LevelMapper } from "./level.mapper"; -type LevelFilters = { - searchTerm?: string | undefined; -}; - @Injectable() -export class LevelService { - constructor( - private prisma: PrismaService, - private levelMapper: LevelMapper, - ) {} - - async create(createLevelDto: CreateLevelDto): Promise { - const prismaLevel = await this.prisma.level.create({ - data: createLevelDto, - }); - return this.levelMapper.toDomain(prismaLevel); - } - - async findMany(filters: LevelFilters = {}): Promise { - const where = filters.searchTerm - ? { - name: { - contains: filters.searchTerm, - mode: "insensitive" as const, - }, - } - : {}; - - const prismaLevels = await this.prisma.level.findMany({ - where, - orderBy: { name: "asc" }, - }); - - return this.levelMapper.mapToDomain(prismaLevels); - } - - async count(filters: LevelFilters = {}): Promise { - const where = filters.searchTerm - ? { - name: { - contains: filters.searchTerm, - mode: "insensitive" as const, - }, - } - : {}; - - return this.prisma.level.count({ where }); - } - - async findById(id: string): Promise { - const prismaLevel = await this.prisma.level.findUnique({ - where: { id }, - }); - return this.levelMapper.toDomain(prismaLevel); - } - - async findByIdOrFail(id: string): Promise { - const level = await this.findById(id); - return level ?? notFound("Level", "id", id); - } - - async update(id: string, updateLevelDto: UpdateLevelDto): Promise { - const prismaLevel = await this.prisma.level.update({ - where: { id }, - data: updateLevelDto, - }); - return this.levelMapper.toDomain(prismaLevel); - } - - async delete(id: string): Promise { - await this.prisma.level.delete({ - where: { id }, - }); +export class LevelService extends NamedEntityService { + constructor(prisma: PrismaService, levelMapper: LevelMapper) { + super(prisma, levelMapper, prisma.level, "Level"); } } diff --git a/apps/server/src/modules/job-experience/role/graphql/role-connection-args.type.ts b/apps/server/src/modules/job-experience/role/graphql/role-connection-args.type.ts index 8a9fbfb..fee2439 100644 --- a/apps/server/src/modules/job-experience/role/graphql/role-connection-args.type.ts +++ b/apps/server/src/modules/job-experience/role/graphql/role-connection-args.type.ts @@ -1,6 +1,6 @@ +import { BasePaginationArgs } from "@cv/system"; import { ArgsType, Field } from "@nestjs/graphql"; import { GraphQLString } from "graphql"; -import { BasePaginationArgs } from "@/modules/base/pagination.types"; @ArgsType() export class RoleConnectionArgs extends BasePaginationArgs { diff --git a/apps/server/src/modules/job-experience/role/graphql/role-connection.type.ts b/apps/server/src/modules/job-experience/role/graphql/role-connection.type.ts deleted file mode 100644 index 5edbd4e..0000000 --- a/apps/server/src/modules/job-experience/role/graphql/role-connection.type.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import { Role as RoleDomain } from "@/modules/job-experience/role/role.entity"; -import { Role } from "./role.type"; -import { RoleEdge } from "./role-edge.type"; - -@ObjectType() -export class RoleConnection { - @Field(() => [RoleEdge]) - edges: RoleEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: RoleEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult, - ): RoleConnection { - const edges = result.edges.map((edge) => - RoleEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: Role.fromDomain(edge.node), - }), - ); - return new RoleConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/job-experience/role/graphql/role-edge.type.ts b/apps/server/src/modules/job-experience/role/graphql/role-edge.type.ts deleted file mode 100644 index 6e88909..0000000 --- a/apps/server/src/modules/job-experience/role/graphql/role-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { Role } from "./role.type"; - -@ObjectType() -export class RoleEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => Role) - declare node: Role; -} diff --git a/apps/server/src/modules/job-experience/role/graphql/role.resolver.ts b/apps/server/src/modules/job-experience/role/graphql/role.resolver.ts index ce6769d..4eeeacb 100644 --- a/apps/server/src/modules/job-experience/role/graphql/role.resolver.ts +++ b/apps/server/src/modules/job-experience/role/graphql/role.resolver.ts @@ -1,15 +1,27 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; +import { PaginationService } from "@cv/system"; +import { UseGuards } from "@nestjs/common"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { PaginationService } from "@/modules/base/pagination.service"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { Role as RoleEntity } from "@/modules/job-experience/role/role.entity"; +import { RoleFactory } from "@/modules/job-experience/role/role.factory"; import { RoleService } from "@/modules/job-experience/role/role.service"; -import { Role } from "./role.type"; -import { RoleConnection } from "./role-connection.type"; +import { Role, RoleConnection } from "./role.type"; import { RoleConnectionArgs } from "./role-connection-args.type"; @Resolver(() => Role) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class RoleResolver { constructor( private readonly roleService: RoleService, + private readonly roleFactory: RoleFactory, private readonly paginationService: PaginationService, + private readonly authorizationService: AuthorizationService, ) {} @Query(() => RoleConnection) @@ -32,44 +44,66 @@ export class RoleResolver { } @Query(() => Role) - async role(@Args("id") id: string): Promise { + async role( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { const domainRole = await this.roleService.findByIdOrFail(id); + await this.authorizationService.canView(user, domainRole, RoleEntity); return Role.fromDomain(domainRole); } @Mutation(() => Role) async createRole( + @CurrentUser() user: DomainUser, @Args("name") name: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const createData: { name: string; description?: string } = { name }; + await this.authorizationService.canCreate(user, RoleEntity); + const dto: { name: string; description?: string } = { name }; if (description !== undefined) { - createData.description = description; + dto.description = description; } - const domainRole = await this.roleService.create(createData); + const entity = this.roleFactory.create(dto); + const domainRole = await this.roleService.save(entity); return Role.fromDomain(domainRole); } @Mutation(() => Role) async updateRole( + @CurrentUser() user: DomainUser, @Args("id") id: string, @Args("name", { nullable: true }) name?: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const updateData: { name?: string; description?: string } = {}; + const role = await this.roleService.findByIdOrFail(id); + await this.authorizationService.canUpdate(user, role, RoleEntity); + const dto: { name?: string; description?: string } = {}; if (name !== undefined) { - updateData.name = name; + dto.name = name; } if (description !== undefined) { - updateData.description = description; + dto.description = description; } - const domainRole = await this.roleService.update(id, updateData); + const updatedEntity = new Role( + role.id, + dto.name ?? role.name, + role.createdAt, + new Date(), + dto.description !== undefined ? dto.description : role.description, + ); + const domainRole = await this.roleService.save(updatedEntity); return Role.fromDomain(domainRole); } @Mutation(() => Boolean) - async deleteRole(@Args("id") id: string): Promise { - await this.roleService.delete(id); + async deleteRole( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { + const role = await this.roleService.findByIdOrFail(id); + await this.authorizationService.canDelete(user, role, RoleEntity); + await this.roleService.destroy(role); return true; } } diff --git a/apps/server/src/modules/job-experience/role/graphql/role.type.ts b/apps/server/src/modules/job-experience/role/graphql/role.type.ts index ad51729..311a329 100644 --- a/apps/server/src/modules/job-experience/role/graphql/role.type.ts +++ b/apps/server/src/modules/job-experience/role/graphql/role.type.ts @@ -1,46 +1,13 @@ -import { Field, ID, ObjectType } from "@nestjs/graphql"; -import type { Role as DomainRole } from "@/modules/job-experience/role/role.entity"; +import { createNamedGraphQLType } from "@/modules/base/named-graphql-type.factory"; +import { Role as RoleDomain } from "@/modules/job-experience/role/role.entity"; -@ObjectType() -export class Role { - @Field(() => ID) - id: string; +const { Type, Connection, Edge } = createNamedGraphQLType("Role", RoleDomain); - @Field() - name: string; +export const Role = Type; +export type Role = InstanceType; - @Field({ nullable: true }) - description?: string; +export const RoleConnection = Connection; +export type RoleConnection = InstanceType; - @Field() - createdAt: Date; - - @Field() - updatedAt: Date; - - constructor( - id: string, - name: string, - createdAt: Date, - updatedAt: Date, - description?: string, - ) { - this.id = id; - this.name = name; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - if (description !== undefined) { - this.description = description; - } - } - - static fromDomain(domainRole: DomainRole): Role { - return new Role( - domainRole.id, - domainRole.name, - domainRole.createdAt, - domainRole.updatedAt, - domainRole.description, - ); - } -} +export const RoleEdge = Edge; +export type RoleEdge = InstanceType; diff --git a/apps/server/src/modules/job-experience/role/role.dataloader.ts b/apps/server/src/modules/job-experience/role/role.dataloader.ts new file mode 100644 index 0000000..57c9a4b --- /dev/null +++ b/apps/server/src/modules/job-experience/role/role.dataloader.ts @@ -0,0 +1,20 @@ +import { BaseDataLoaderService } from "@cv/system"; +import { Injectable, Scope } from "@nestjs/common"; +import { Role } from "./role.entity"; +import { RoleService } from "./role.service"; + +@Injectable({ scope: Scope.REQUEST }) +export class RoleDataLoaderService extends BaseDataLoaderService { + constructor(readonly roleService: RoleService) { + super(async (ids: readonly string[]) => { + const roles = await roleService.findMany({ id: [...ids] }); + + const roleMap = new Map(); + for (const role of roles) { + roleMap.set(role.id, role); + } + + return ids.map((id) => roleMap.get(id) ?? null); + }); + } +} diff --git a/apps/server/src/modules/job-experience/role/role.entity.ts b/apps/server/src/modules/job-experience/role/role.entity.ts index 9bd00c0..51ff3eb 100644 --- a/apps/server/src/modules/job-experience/role/role.entity.ts +++ b/apps/server/src/modules/job-experience/role/role.entity.ts @@ -1,20 +1,3 @@ -import { BaseEntity } from "@/modules/base/base.entity"; +import { NamedEntity } from "@cv/system"; -export class Role extends BaseEntity { - name: string; - description?: string; - - constructor( - id: string, - name: string, - createdAt: Date, - updatedAt: Date, - description?: string, - ) { - super(id, createdAt, updatedAt); - this.name = name; - if (description !== undefined) { - this.description = description; - } - } -} +export class Role extends NamedEntity {} diff --git a/apps/server/src/modules/job-experience/role/role.factory.ts b/apps/server/src/modules/job-experience/role/role.factory.ts new file mode 100644 index 0000000..a1a95f9 --- /dev/null +++ b/apps/server/src/modules/job-experience/role/role.factory.ts @@ -0,0 +1,23 @@ +import { ClockService, Factory, UuidFactoryService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import type { CreateRoleDto } from "./role.dto"; +import { Role } from "./role.entity"; + +@Injectable() +export class RoleFactory implements Factory { + constructor( + private readonly uuidFactory: UuidFactoryService, + private readonly clock: ClockService, + ) {} + + create(dto: CreateRoleDto): Role { + const now = this.clock.now(); + return new Role( + this.uuidFactory.generate(), + dto.name, + now, + now, + dto.description, + ); + } +} diff --git a/apps/server/src/modules/job-experience/role/role.mapper.ts b/apps/server/src/modules/job-experience/role/role.mapper.ts index b5f5c05..6d53b80 100644 --- a/apps/server/src/modules/job-experience/role/role.mapper.ts +++ b/apps/server/src/modules/job-experience/role/role.mapper.ts @@ -1,37 +1,23 @@ +import { createNamedEntityMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Role as PrismaRole } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; import { Role } from "@/modules/job-experience/role/role.entity"; /** * Mapper service for converting between Prisma Role entities and domain Role entities */ @Injectable() -export class RoleMapper implements BaseMapper { - /** - * Maps a Prisma Role entity to a domain Role entity - * Uses overloads to return the correct type based on input - */ +export class RoleMapper { + private mapper = createNamedEntityMapper(Role); + toDomain(prismaRole: null): null; toDomain(prismaRole: PrismaRole): Role; toDomain(prismaRole: PrismaRole | null): Role | null; toDomain(prismaRole: PrismaRole | null): Role | null { - if (prismaRole === null) { - return null; - } - return new Role( - prismaRole.id, - prismaRole.name, - prismaRole.createdAt, - prismaRole.updatedAt, - prismaRole.description ?? undefined, - ); + return this.mapper.toDomain(prismaRole); } - /** - * Maps an array of Prisma Role entities to domain Role entities - */ mapToDomain(prismaRoles: PrismaRole[]): Role[] { - return prismaRoles.map((role) => this.toDomain(role)); + return this.mapper.mapToDomain(prismaRoles); } } diff --git a/apps/server/src/modules/job-experience/role/role.module.ts b/apps/server/src/modules/job-experience/role/role.module.ts index 4434739..75d825d 100644 --- a/apps/server/src/modules/job-experience/role/role.module.ts +++ b/apps/server/src/modules/job-experience/role/role.module.ts @@ -1,13 +1,29 @@ +import { AuthorizationModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; import { RoleService } from "@/modules/job-experience/role/role.service"; import { RoleResolver } from "./graphql/role.resolver"; +import { RoleDataLoaderService } from "./role.dataloader"; +import { RoleFactory } from "./role.factory"; import { RoleMapper } from "./role.mapper"; +import { RolePolicy } from "./role.policy"; @Module({ - imports: [DatabaseModule, BaseModule], - providers: [RoleService, RoleResolver, RoleMapper], - exports: [RoleService, RoleMapper], + imports: [ + DatabaseModule, + BaseModule, + AuthenticationModule, + AuthorizationModule, + ], + providers: [ + RoleService, + RoleResolver, + RoleMapper, + RoleFactory, + RolePolicy, + RoleDataLoaderService, + ], + exports: [RoleService, RoleMapper, RoleDataLoaderService], }) export class RoleModule {} diff --git a/apps/server/src/modules/job-experience/role/role.policy.ts b/apps/server/src/modules/job-experience/role/role.policy.ts new file mode 100644 index 0000000..ebd04a6 --- /dev/null +++ b/apps/server/src/modules/job-experience/role/role.policy.ts @@ -0,0 +1,11 @@ +import { Policy, PublicResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Role } from "./role.entity"; + +@Injectable() +@Policy(Role) +export class RolePolicy extends PublicResourcePolicy { + override create(): boolean { + return true; + } +} diff --git a/apps/server/src/modules/job-experience/role/role.service.ts b/apps/server/src/modules/job-experience/role/role.service.ts index 7434e71..e16f8da 100644 --- a/apps/server/src/modules/job-experience/role/role.service.ts +++ b/apps/server/src/modules/job-experience/role/role.service.ts @@ -1,82 +1,11 @@ +import { NamedEntityService, PrismaService } from "@cv/system"; import { Injectable } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Role } from "@/modules/job-experience/role/role.entity"; -import type { CreateRoleDto, UpdateRoleDto } from "./role.dto"; import { RoleMapper } from "./role.mapper"; -type RoleFilters = { - searchTerm?: string | undefined; -}; - @Injectable() -export class RoleService { - constructor( - private prisma: PrismaService, - private roleMapper: RoleMapper, - ) {} - - async create(createRoleDto: CreateRoleDto): Promise { - const prismaRole = await this.prisma.role.create({ - data: createRoleDto, - }); - return this.roleMapper.toDomain(prismaRole); - } - - async findMany(filters: RoleFilters = {}): Promise { - const where = filters.searchTerm - ? { - name: { - contains: filters.searchTerm, - mode: "insensitive" as const, - }, - } - : {}; - - const prismaRoles = await this.prisma.role.findMany({ - where, - orderBy: { name: "asc" }, - }); - - return this.roleMapper.mapToDomain(prismaRoles); - } - - async count(filters: RoleFilters = {}): Promise { - const where = filters.searchTerm - ? { - name: { - contains: filters.searchTerm, - mode: "insensitive" as const, - }, - } - : {}; - - return this.prisma.role.count({ where }); - } - - async findById(id: string): Promise { - const prismaRole = await this.prisma.role.findUnique({ - where: { id }, - }); - return this.roleMapper.toDomain(prismaRole); - } - - async findByIdOrFail(id: string): Promise { - const role = await this.findById(id); - return role ?? notFound("Role", "id", id); - } - - async update(id: string, updateRoleDto: UpdateRoleDto): Promise { - const prismaRole = await this.prisma.role.update({ - where: { id }, - data: updateRoleDto, - }); - return this.roleMapper.toDomain(prismaRole); - } - - async delete(id: string): Promise { - await this.prisma.role.delete({ - where: { id }, - }); +export class RoleService extends NamedEntityService { + constructor(prisma: PrismaService, roleMapper: RoleMapper) { + super(prisma, roleMapper, prisma.role, "Role"); } } diff --git a/apps/server/src/modules/job-experience/seed/job-experience.seed.ts b/apps/server/src/modules/job-experience/seed/job-experience.seed.ts index f975309..aa9dda0 100644 --- a/apps/server/src/modules/job-experience/seed/job-experience.seed.ts +++ b/apps/server/src/modules/job-experience/seed/job-experience.seed.ts @@ -1,6 +1,6 @@ +import { PrismaService } from "@cv/system"; import { faker } from "@faker-js/faker"; import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Seeder } from "@/modules/database/seed/seed.service"; import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; import { ReferenceDataSeedService } from "./reference-data.seed"; @@ -26,10 +26,12 @@ export class JobExperienceSeedService implements Seeder { this.refs.ensureLevels(prisma), ]); - // Get test user - const testUser = await prisma["user"].findUnique({ + // Get test user by email from credentials + const credentials = await prisma["credentials"].findUnique({ where: { email: "test@test.test" }, + include: { user: true }, }); + const testUser = credentials?.user ?? null; if (!testUser) { this.logger.warn("Test user not found, skipping job experience seeding"); diff --git a/apps/server/src/modules/job-experience/seed/reference-data.seed.ts b/apps/server/src/modules/job-experience/seed/reference-data.seed.ts index eea70e8..dc7983d 100644 --- a/apps/server/src/modules/job-experience/seed/reference-data.seed.ts +++ b/apps/server/src/modules/job-experience/seed/reference-data.seed.ts @@ -1,6 +1,6 @@ +import { PrismaService } from "@cv/system"; import { faker } from "@faker-js/faker"; import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Seeder } from "@/modules/database/seed/seed.service"; import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; diff --git a/apps/server/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts b/apps/server/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts index 29021de..21a93c5 100644 --- a/apps/server/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts +++ b/apps/server/src/modules/job-experience/skill/graphql/skill-connection-args.type.ts @@ -1,19 +1,5 @@ -import { ArgsType, Field, Int } from "@nestjs/graphql"; +import { SearchablePaginationArgs } from "@cv/system"; +import { ArgsType } from "@nestjs/graphql"; @ArgsType() -export class SkillConnectionArgs { - @Field(() => Int, { nullable: true }) - first?: number | null; - - @Field(() => String, { nullable: true }) - after?: string | null; - - @Field(() => Int, { nullable: true }) - last?: number | null; - - @Field(() => String, { nullable: true }) - before?: string | null; - - @Field(() => String, { nullable: true }) - searchTerm?: string | null; -} +export class SkillConnectionArgs extends SearchablePaginationArgs {} diff --git a/apps/server/src/modules/job-experience/skill/graphql/skill-connection.type.ts b/apps/server/src/modules/job-experience/skill/graphql/skill-connection.type.ts deleted file mode 100644 index a903716..0000000 --- a/apps/server/src/modules/job-experience/skill/graphql/skill-connection.type.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import { Skill as SkillDomain } from "@/modules/job-experience/skill/skill.entity"; -import { Skill } from "./skill.type"; -import { SkillEdge } from "./skill-edge.type"; - -@ObjectType() -export class SkillConnection { - @Field(() => [SkillEdge]) - edges: SkillEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: SkillEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult, - ): SkillConnection { - const edges = result.edges.map((edge) => - SkillEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: Skill.fromDomain(edge.node), - }), - ); - return new SkillConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/job-experience/skill/graphql/skill-edge.type.ts b/apps/server/src/modules/job-experience/skill/graphql/skill-edge.type.ts deleted file mode 100644 index d1bf368..0000000 --- a/apps/server/src/modules/job-experience/skill/graphql/skill-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { Skill } from "./skill.type"; - -@ObjectType() -export class SkillEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => Skill) - declare node: Skill; -} diff --git a/apps/server/src/modules/job-experience/skill/graphql/skill.resolver.ts b/apps/server/src/modules/job-experience/skill/graphql/skill.resolver.ts index a3c55ac..2750619 100644 --- a/apps/server/src/modules/job-experience/skill/graphql/skill.resolver.ts +++ b/apps/server/src/modules/job-experience/skill/graphql/skill.resolver.ts @@ -1,15 +1,27 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; +import { PaginationService } from "@cv/system"; +import { UseGuards } from "@nestjs/common"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { PaginationService } from "@/modules/base/pagination.service"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { Skill as SkillEntity } from "@/modules/job-experience/skill/skill.entity"; +import { SkillFactory } from "@/modules/job-experience/skill/skill.factory"; import { SkillService } from "@/modules/job-experience/skill/skill.service"; -import { Skill } from "./skill.type"; -import { SkillConnection } from "./skill-connection.type"; +import { Skill, SkillConnection } from "./skill.type"; import { SkillConnectionArgs } from "./skill-connection-args.type"; @Resolver(() => Skill) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class SkillResolver { constructor( private readonly skillService: SkillService, + private readonly skillFactory: SkillFactory, private readonly paginationService: PaginationService, + private readonly authorizationService: AuthorizationService, ) {} @Query(() => SkillConnection) @@ -34,44 +46,66 @@ export class SkillResolver { } @Query(() => Skill) - async skill(@Args("id") id: string): Promise { + async skill( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { const domainSkill = await this.skillService.findByIdOrFail(id); + await this.authorizationService.canView(user, domainSkill, SkillEntity); return Skill.fromDomain(domainSkill); } @Mutation(() => Skill) async createSkill( + @CurrentUser() user: DomainUser, @Args("name") name: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const createData: { name: string; description?: string } = { name }; + await this.authorizationService.canCreate(user, SkillEntity); + const dto: { name: string; description?: string } = { name }; if (description !== undefined) { - createData.description = description; + dto.description = description; } - const domainSkill = await this.skillService.create(createData); + const entity = this.skillFactory.create(dto); + const domainSkill = await this.skillService.save(entity); return Skill.fromDomain(domainSkill); } @Mutation(() => Skill) async updateSkill( + @CurrentUser() user: DomainUser, @Args("id") id: string, @Args("name", { nullable: true }) name?: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const updateData: { name?: string; description?: string } = {}; + const skill = await this.skillService.findByIdOrFail(id); + await this.authorizationService.canUpdate(user, skill, SkillEntity); + const dto: { name?: string; description?: string } = {}; if (name !== undefined) { - updateData.name = name; + dto.name = name; } if (description !== undefined) { - updateData.description = description; + dto.description = description; } - const domainSkill = await this.skillService.update(id, updateData); + const updatedEntity = new Skill( + skill.id, + dto.name ?? skill.name, + skill.createdAt, + new Date(), + dto.description !== undefined ? dto.description : skill.description, + ); + const domainSkill = await this.skillService.save(updatedEntity); return Skill.fromDomain(domainSkill); } @Mutation(() => Boolean) - async deleteSkill(@Args("id") id: string): Promise { - await this.skillService.delete(id); + async deleteSkill( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { + const skill = await this.skillService.findByIdOrFail(id); + await this.authorizationService.canDelete(user, skill, SkillEntity); + await this.skillService.destroy(skill); return true; } } diff --git a/apps/server/src/modules/job-experience/skill/graphql/skill.type.ts b/apps/server/src/modules/job-experience/skill/graphql/skill.type.ts index b555b5e..91aad65 100644 --- a/apps/server/src/modules/job-experience/skill/graphql/skill.type.ts +++ b/apps/server/src/modules/job-experience/skill/graphql/skill.type.ts @@ -1,46 +1,13 @@ -import { Field, ID, ObjectType } from "@nestjs/graphql"; -import type { Skill as DomainSkill } from "@/modules/job-experience/skill/skill.entity"; +import { createNamedGraphQLType } from "@/modules/base/named-graphql-type.factory"; +import { Skill as SkillDomain } from "@/modules/job-experience/skill/skill.entity"; -@ObjectType() -export class Skill { - @Field(() => ID) - id: string; +const { Type, Connection, Edge } = createNamedGraphQLType("Skill", SkillDomain); - @Field() - name: string; +export const Skill = Type; +export type Skill = InstanceType; - @Field({ nullable: true }) - description?: string; +export const SkillConnection = Connection; +export type SkillConnection = InstanceType; - @Field() - createdAt: Date; - - @Field() - updatedAt: Date; - - constructor( - id: string, - name: string, - createdAt: Date, - updatedAt: Date, - description?: string, - ) { - this.id = id; - this.name = name; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - if (description !== undefined) { - this.description = description; - } - } - - static fromDomain(domainSkill: DomainSkill): Skill { - return new Skill( - domainSkill.id, - domainSkill.name, - domainSkill.createdAt, - domainSkill.updatedAt, - domainSkill.description, - ); - } -} +export const SkillEdge = Edge; +export type SkillEdge = InstanceType; diff --git a/apps/server/src/modules/job-experience/skill/skill.entity.ts b/apps/server/src/modules/job-experience/skill/skill.entity.ts index 812a964..47c7f29 100644 --- a/apps/server/src/modules/job-experience/skill/skill.entity.ts +++ b/apps/server/src/modules/job-experience/skill/skill.entity.ts @@ -1,20 +1,3 @@ -import { BaseEntity } from "@/modules/base/base.entity"; +import { NamedEntity } from "@cv/system"; -export class Skill extends BaseEntity { - name: string; - description?: string; - - constructor( - id: string, - name: string, - createdAt: Date, - updatedAt: Date, - description?: string, - ) { - super(id, createdAt, updatedAt); - this.name = name; - if (description !== undefined) { - this.description = description; - } - } -} +export class Skill extends NamedEntity {} diff --git a/apps/server/src/modules/job-experience/skill/skill.factory.ts b/apps/server/src/modules/job-experience/skill/skill.factory.ts new file mode 100644 index 0000000..7efb996 --- /dev/null +++ b/apps/server/src/modules/job-experience/skill/skill.factory.ts @@ -0,0 +1,23 @@ +import { ClockService, Factory, UuidFactoryService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import type { CreateSkillDto } from "./skill.dto"; +import { Skill } from "./skill.entity"; + +@Injectable() +export class SkillFactory implements Factory { + constructor( + private readonly uuidFactory: UuidFactoryService, + private readonly clock: ClockService, + ) {} + + create(dto: CreateSkillDto): Skill { + const now = this.clock.now(); + return new Skill( + this.uuidFactory.generate(), + dto.name, + now, + now, + dto.description, + ); + } +} diff --git a/apps/server/src/modules/job-experience/skill/skill.mapper.ts b/apps/server/src/modules/job-experience/skill/skill.mapper.ts index bdfd77e..47f3463 100644 --- a/apps/server/src/modules/job-experience/skill/skill.mapper.ts +++ b/apps/server/src/modules/job-experience/skill/skill.mapper.ts @@ -1,37 +1,23 @@ +import { createNamedEntityMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Skill as PrismaSkill } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; import { Skill } from "@/modules/job-experience/skill/skill.entity"; /** * Mapper service for converting between Prisma Skill entities and domain Skill entities */ @Injectable() -export class SkillMapper implements BaseMapper { - /** - * Maps a Prisma Skill entity to a domain Skill entity - * Uses overloads to return the correct type based on input - */ +export class SkillMapper { + private mapper = createNamedEntityMapper(Skill); + toDomain(prismaSkill: null): null; toDomain(prismaSkill: PrismaSkill): Skill; toDomain(prismaSkill: PrismaSkill | null): Skill | null; toDomain(prismaSkill: PrismaSkill | null): Skill | null { - if (prismaSkill === null) { - return null; - } - return new Skill( - prismaSkill.id, - prismaSkill.name, - prismaSkill.createdAt, - prismaSkill.updatedAt, - prismaSkill.description ?? undefined, - ); + return this.mapper.toDomain(prismaSkill); } - /** - * Maps an array of Prisma Skill entities to domain Skill entities - */ mapToDomain(prismaSkills: PrismaSkill[]): Skill[] { - return prismaSkills.map((skill) => this.toDomain(skill)); + return this.mapper.mapToDomain(prismaSkills); } } diff --git a/apps/server/src/modules/job-experience/skill/skill.module.ts b/apps/server/src/modules/job-experience/skill/skill.module.ts index 796b613..b23ff33 100644 --- a/apps/server/src/modules/job-experience/skill/skill.module.ts +++ b/apps/server/src/modules/job-experience/skill/skill.module.ts @@ -1,18 +1,28 @@ +import { AuthorizationModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; import { SkillService } from "@/modules/job-experience/skill/skill.service"; import { JobExperienceSeedService } from "../seed/job-experience.seed"; import { ReferenceDataSeedService } from "../seed/reference-data.seed"; import { SkillResolver } from "./graphql/skill.resolver"; +import { SkillFactory } from "./skill.factory"; import { SkillMapper } from "./skill.mapper"; +import { SkillPolicy } from "./skill.policy"; @Module({ - imports: [DatabaseModule, BaseModule], + imports: [ + DatabaseModule, + BaseModule, + AuthenticationModule, + AuthorizationModule, + ], providers: [ SkillService, SkillResolver, SkillMapper, + SkillFactory, + SkillPolicy, ReferenceDataSeedService, JobExperienceSeedService, ], diff --git a/apps/server/src/modules/job-experience/skill/skill.policy.ts b/apps/server/src/modules/job-experience/skill/skill.policy.ts new file mode 100644 index 0000000..b9f77f0 --- /dev/null +++ b/apps/server/src/modules/job-experience/skill/skill.policy.ts @@ -0,0 +1,11 @@ +import { Policy, PublicResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Skill } from "./skill.entity"; + +@Injectable() +@Policy(Skill) +export class SkillPolicy extends PublicResourcePolicy { + override create(): boolean { + return true; + } +} diff --git a/apps/server/src/modules/job-experience/skill/skill.service.ts b/apps/server/src/modules/job-experience/skill/skill.service.ts index 151b7c6..5918cb9 100644 --- a/apps/server/src/modules/job-experience/skill/skill.service.ts +++ b/apps/server/src/modules/job-experience/skill/skill.service.ts @@ -1,95 +1,43 @@ +import { + type NamedEntityFilters, + NamedEntityService, + PrismaService, +} from "@cv/system"; import { Injectable } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Skill } from "@/modules/job-experience/skill/skill.entity"; -import type { CreateSkillDto, UpdateSkillDto } from "./skill.dto"; import { SkillMapper } from "./skill.mapper"; -type SkillFilters = { - searchTerm?: string | undefined; +type SkillFilters = NamedEntityFilters & { + vacancyId?: string | undefined; }; -@Injectable() -export class SkillService { - constructor( - private prisma: PrismaService, - private skillMapper: SkillMapper, - ) {} - - async create(createSkillDto: CreateSkillDto): Promise { - const prismaSkill = await this.prisma["skill"].create({ - data: createSkillDto, - }); - return this.skillMapper.toDomain(prismaSkill); - } - - async findMany(filters: SkillFilters = {}): Promise { - const where = filters.searchTerm - ? { - name: { - contains: filters.searchTerm, - mode: "insensitive" as const, - }, - } - : {}; - - const prismaSkills = await this.prisma["skill"].findMany({ - where, - orderBy: { name: "asc" }, - }); - - return this.skillMapper.mapToDomain(prismaSkills); - } - - async count(filters: SkillFilters = {}): Promise { - const where = filters.searchTerm - ? { - name: { - contains: filters.searchTerm, - mode: "insensitive" as const, - }, - } - : {}; - - return this.prisma["skill"].count({ where }); - } - - async findById(id: string): Promise { - const prismaSkill = await this.prisma["skill"].findUnique({ - where: { id }, - }); - return this.skillMapper.toDomain(prismaSkill); - } +type SkillWhereInput = { + name?: { contains: string; mode: "insensitive" }; + vacancies?: { some: { id: string } }; + id?: { in: string[] } | string; +}; - async findByVacancyId(vacancyId: string): Promise { - const prismaSkills = await this.prisma["skill"].findMany({ - where: { - vacancies: { - some: { - id: vacancyId, - }, +@Injectable() +export class SkillService extends NamedEntityService< + Skill, + SkillFilters, + SkillWhereInput +> { + constructor(prisma: PrismaService, skillMapper: SkillMapper) { + super(prisma, skillMapper, prisma.skill, "Skill"); + } + + protected override buildWhere(filters: SkillFilters = {}): SkillWhereInput { + const where = super.buildWhere(filters) as SkillWhereInput; + + if (filters.vacancyId) { + where.vacancies = { + some: { + id: filters.vacancyId, }, - }, - }); - return this.skillMapper.mapToDomain(prismaSkills); - } - - async findByIdOrFail(id: string): Promise { - const skill = await this.findById(id); - return skill ?? notFound("Skill", "id", id); - } - - async update(id: string, updateSkillDto: UpdateSkillDto): Promise { - const prismaSkill = await this.prisma["skill"].update({ - where: { id }, - data: updateSkillDto, - }); - return this.skillMapper.toDomain(prismaSkill); - } + }; + } - async delete(id: string): Promise { - await this.prisma["skill"].delete({ - where: { id }, - }); + return where; } } diff --git a/apps/server/src/modules/organization/graphql/membership-connection-args.type.ts b/apps/server/src/modules/organization/graphql/membership-connection-args.type.ts index 82d12a7..300f5a0 100644 --- a/apps/server/src/modules/organization/graphql/membership-connection-args.type.ts +++ b/apps/server/src/modules/organization/graphql/membership-connection-args.type.ts @@ -1,25 +1,8 @@ -import { ArgsType, Field, Int } from "@nestjs/graphql"; +import { SortablePaginationArgs } from "@cv/system"; +import { ArgsType, Field } from "@nestjs/graphql"; @ArgsType() -export class MembershipConnectionArgs { - @Field(() => Int, { nullable: true }) - first?: number | null; - - @Field(() => String, { nullable: true }) - after?: string | null; - - @Field(() => Int, { nullable: true }) - last?: number | null; - - @Field(() => String, { nullable: true }) - before?: string | null; - - @Field(() => String, { nullable: true }) - searchTerm?: string | null; - +export class MembershipConnectionArgs extends SortablePaginationArgs { @Field(() => String, { nullable: true, defaultValue: "createdAt" }) - sortBy?: string | null; - - @Field(() => String, { nullable: true, defaultValue: "asc" }) - sortOrder?: string | null; + override sortBy: string | null = "createdAt"; } diff --git a/apps/server/src/modules/organization/graphql/membership-connection.type.ts b/apps/server/src/modules/organization/graphql/membership-connection.type.ts deleted file mode 100644 index bc3f4b3..0000000 --- a/apps/server/src/modules/organization/graphql/membership-connection.type.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import { Membership } from "./membership.type"; -import { MembershipEdge } from "./membership-edge.type"; - -@ObjectType() -export class MembershipConnection { - @Field(() => [MembershipEdge]) - edges: MembershipEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: MembershipEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult<{ - id: string; - createdAt: Date; - user: { - id: string; - name: string; - email: string; - createdAt: Date; - }; - role: { - id: string; - name: string; - description: string | null; - color: string | null; - createdAt: Date; - updatedAt: Date; - }; - }>, - ): MembershipConnection { - const edges = result.edges.map((edge) => - MembershipEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: Membership.fromDomain({ - id: edge.node.id, - joinedAt: edge.node.createdAt, - user: edge.node.user, - role: edge.node.role, - }), - }), - ); - return new MembershipConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/organization/graphql/membership-edge.type.ts b/apps/server/src/modules/organization/graphql/membership-edge.type.ts deleted file mode 100644 index 2d602d5..0000000 --- a/apps/server/src/modules/organization/graphql/membership-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { Membership } from "./membership.type"; - -@ObjectType() -export class MembershipEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => Membership) - declare node: Membership; -} diff --git a/apps/server/src/modules/organization/graphql/membership.type.ts b/apps/server/src/modules/organization/graphql/membership.type.ts index c89ce4c..619f961 100644 --- a/apps/server/src/modules/organization/graphql/membership.type.ts +++ b/apps/server/src/modules/organization/graphql/membership.type.ts @@ -1,4 +1,5 @@ import { Field, ObjectType } from "@nestjs/graphql"; +import { createConnection } from "@/modules/base/connection.factory"; import { OrganizationRole } from "@/modules/organization/organization-role.entity"; import { User } from "@/modules/user/user.type"; @@ -42,3 +43,46 @@ export class Membership { }); } } + +type MembershipRow = { + id: string; + createdAt: Date; + user: { + id: string; + name: string; + createdAt: Date; + credentials?: { + email: string; + } | null; + }; + role: { + id: string; + name: string; + description: string | null; + color: string | null; + createdAt: Date; + updatedAt: Date; + }; +}; + +export const { Connection: MembershipConnection, Edge: MembershipEdge } = + createConnection( + Membership, + ({ user, id, createdAt, role }) => { + const graphqlUser = new User( + user.id, + user.name, + user.createdAt, + user.credentials?.email ?? null, + ); + return Membership.fromDomain({ + id, + joinedAt: createdAt, + user: graphqlUser, + role: OrganizationRole.fromDomain(role), + }); + }, + ); + +export type MembershipConnection = InstanceType; +export type MembershipEdge = InstanceType; diff --git a/apps/server/src/modules/organization/graphql/organization.resolver.ts b/apps/server/src/modules/organization/graphql/organization.resolver.ts index 81acbca..852d08f 100644 --- a/apps/server/src/modules/organization/graphql/organization.resolver.ts +++ b/apps/server/src/modules/organization/graphql/organization.resolver.ts @@ -1,3 +1,11 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + UserService, + VerifiedScopeGuard, +} from "@cv/auth"; +import { PaginationService } from "@cv/system"; import { UseGuards } from "@nestjs/common"; import { Args, @@ -7,28 +15,40 @@ import { ResolveField, Resolver, } from "@nestjs/graphql"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { PaginationService } from "@/modules/base/pagination.service"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; import { Organization } from "@/modules/organization/organization.entity"; +import { OrganizationFactory } from "@/modules/organization/organization.factory"; +import type { Membership } from "@/modules/organization/organization.service"; import { type MembershipSortField, type MembershipSortOrder, OrganizationService, } from "@/modules/organization/organization.service"; -import { MembershipConnection } from "./membership-connection.type"; +import { MembershipConnection } from "./membership.type"; import { MembershipConnectionArgs } from "./membership-connection-args.type"; @Resolver(() => Organization) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class OrganizationResolver { constructor( private readonly organizationService: OrganizationService, + private readonly organizationFactory: OrganizationFactory, private readonly paginationService: PaginationService, + private readonly authorizationService: AuthorizationService, + private readonly userService: UserService, ) {} @Query(() => Organization, { name: "organization", nullable: true }) - async getOrganization(@Args("id") id: string): Promise { - return this.organizationService.findById(id); + async getOrganization( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { + const organization = await this.organizationService.findById(id); + if (!organization) { + return null; + } + await this.authorizationService.canView(user, organization, Organization); + return organization; } @ResolveField(() => MembershipConnection) @@ -55,7 +75,7 @@ export class OrganizationResolver { ), ]); - const result = this.paginationService.buildPaginationResult( + const result = this.paginationService.buildPaginationResult( items, totalCount, paginationOptions, @@ -73,57 +93,87 @@ export class OrganizationResolver { @Mutation(() => Organization) async createOrganization( + @CurrentUser() user: DomainUser, @Args("name") name: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const data: { name: string; description?: string } = { name }; + await this.authorizationService.canCreate(user, Organization); + const dto: { name: string; description?: string } = { name }; if (description !== undefined) { - data.description = description; + dto.description = description; } - return this.organizationService.create(data); + const entity = this.organizationFactory.create(dto); + return this.organizationService.save(entity); } @Mutation(() => Organization) async updateOrganization( + @CurrentUser() user: DomainUser, @Args("id") id: string, @Args("name", { nullable: true }) name?: string, @Args("description", { nullable: true }) description?: string, ): Promise { - const data: { name?: string; description?: string } = {}; + const organization = await this.organizationService.findByIdOrFail(id); + await this.authorizationService.canUpdate(user, organization, Organization); + const dto: { name?: string; description?: string } = {}; if (name !== undefined) { - data.name = name; + dto.name = name; } if (description !== undefined) { - data.description = description; + dto.description = description; } - return this.organizationService.update(id, data); + const updatedEntity = new Organization( + organization.id, + dto.name ?? organization.name, + organization.createdAt, + new Date(), + dto.description !== undefined + ? dto.description + : organization.description, + ); + return this.organizationService.save(updatedEntity); } @Mutation(() => Boolean) - async deleteOrganization(@Args("id") id: string): Promise { - await this.organizationService.delete(id); + async deleteOrganization( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { + const organization = await this.organizationService.findByIdOrFail(id); + await this.authorizationService.canDelete(user, organization, Organization); + await this.organizationService.destroy(organization); return true; } @Mutation(() => Boolean) async addUserToOrganization( + @CurrentUser() user: DomainUser, @Args("organizationId") organizationId: string, @Args("userId") userId: string, ): Promise { + const organization = + await this.organizationService.findByIdOrFail(organizationId); + await this.authorizationService.canUpdate(user, organization, Organization); + const targetUser = await this.userService.findByIdOrFail(userId); return this.organizationService.addUserToOrganization( - organizationId, - userId, + organization, + targetUser, ); } @Mutation(() => Boolean) async removeUserFromOrganization( + @CurrentUser() user: DomainUser, @Args("organizationId") organizationId: string, @Args("userId") userId: string, ): Promise { + const organization = + await this.organizationService.findByIdOrFail(organizationId); + await this.authorizationService.canUpdate(user, organization, Organization); + const targetUser = await this.userService.findByIdOrFail(userId); return this.organizationService.removeUserFromOrganization( - organizationId, - userId, + organization, + targetUser, ); } } diff --git a/apps/server/src/modules/organization/graphql/user-field.resolver.ts b/apps/server/src/modules/organization/graphql/user-field.resolver.ts index fd36394..0f42509 100644 --- a/apps/server/src/modules/organization/graphql/user-field.resolver.ts +++ b/apps/server/src/modules/organization/graphql/user-field.resolver.ts @@ -1,14 +1,26 @@ +import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; +import { UseGuards } from "@nestjs/common"; import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; import { Organization } from "@/modules/organization/organization.entity"; import { OrganizationService } from "@/modules/organization/organization.service"; import { User } from "@/modules/user/user.type"; @Resolver(() => User) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class UserFieldResolver { constructor(private readonly organizationService: OrganizationService) {} @ResolveField(() => [Organization]) async organizations(@Parent() user: User): Promise { - return this.organizationService.findForUser(user.id); + const memberships = await this.organizationService[ + "prisma" + ].membership.findMany({ + where: { userId: user.id }, + include: { organization: true }, + orderBy: { createdAt: "asc" }, + }); + return this.organizationService["organizationMapper"].mapToDomain( + memberships.map((m) => m.organization), + ); } } diff --git a/apps/server/src/modules/organization/organization-role.entity.ts b/apps/server/src/modules/organization/organization-role.entity.ts index 4e8b165..fc523e0 100644 --- a/apps/server/src/modules/organization/organization-role.entity.ts +++ b/apps/server/src/modules/organization/organization-role.entity.ts @@ -1,39 +1,38 @@ -import { Field, ObjectType } from "@nestjs/graphql"; +import { BaseEntity } from "@cv/system"; +import { Field, ID, ObjectType } from "@nestjs/graphql"; @ObjectType() -export class OrganizationRole { - @Field(() => String) - id: string; +export class OrganizationRole extends BaseEntity { + @Field(() => ID) + declare id: string; @Field(() => String) - name: string; + declare name: string; @Field(() => String, { nullable: true }) - description: string | null; + declare description: string | null; @Field(() => String, { nullable: true }) - color: string | null; + declare color: string | null; @Field(() => Date) - createdAt: Date; + declare createdAt: Date; @Field(() => Date) - updatedAt: Date; - - constructor(data: { - id: string; - name: string; - description?: string | null; - color?: string | null; - createdAt: Date; - updatedAt: Date; - }) { - this.id = data.id; - this.name = data.name; - this.description = data.description ?? null; - this.color = data.color ?? null; - this.createdAt = data.createdAt; - this.updatedAt = data.updatedAt; + declare updatedAt: Date; + + constructor( + id: string, + name: string, + createdAt: Date, + updatedAt: Date, + description: string | null = null, + color: string | null = null, + ) { + super(id, createdAt, updatedAt); + this.name = name; + this.description = description; + this.color = color; } static fromDomain(domainRole: { @@ -44,13 +43,13 @@ export class OrganizationRole { createdAt: Date; updatedAt: Date; }): OrganizationRole { - return new OrganizationRole({ - id: domainRole.id, - name: domainRole.name, - description: domainRole.description ?? null, - color: domainRole.color ?? null, - createdAt: domainRole.createdAt, - updatedAt: domainRole.updatedAt, - }); + return new OrganizationRole( + domainRole.id, + domainRole.name, + domainRole.createdAt, + domainRole.updatedAt, + domainRole.description, + domainRole.color, + ); } } diff --git a/apps/server/src/modules/organization/organization-role.mapper.ts b/apps/server/src/modules/organization/organization-role.mapper.ts index 0a9b2f8..6a3130d 100644 --- a/apps/server/src/modules/organization/organization-role.mapper.ts +++ b/apps/server/src/modules/organization/organization-role.mapper.ts @@ -1,6 +1,6 @@ +import type { BaseMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { OrganizationRole as PrismaOrganizationRole } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; import { OrganizationRole } from "./organization-role.entity"; @Injectable() @@ -14,14 +14,14 @@ export class OrganizationRoleMapper if (prismaRole === null) { return null; } - return new OrganizationRole({ - id: prismaRole.id, - name: prismaRole.name, - description: prismaRole.description, - color: prismaRole.color, - createdAt: prismaRole.createdAt, - updatedAt: prismaRole.updatedAt, - }); + return new OrganizationRole( + prismaRole.id, + prismaRole.name, + prismaRole.createdAt, + prismaRole.updatedAt, + prismaRole.description, + prismaRole.color, + ); } mapToDomain(prismaRoles: PrismaOrganizationRole[]): OrganizationRole[] { diff --git a/apps/server/src/modules/organization/organization-role.policy.ts b/apps/server/src/modules/organization/organization-role.policy.ts new file mode 100644 index 0000000..3e8f726 --- /dev/null +++ b/apps/server/src/modules/organization/organization-role.policy.ts @@ -0,0 +1,7 @@ +import { Policy, PublicResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { OrganizationRole } from "./organization-role.entity"; + +@Injectable() +@Policy(OrganizationRole) +export class OrganizationRolePolicy extends PublicResourcePolicy {} diff --git a/apps/server/src/modules/organization/organization-role.service.ts b/apps/server/src/modules/organization/organization-role.service.ts index f1868ad..231bf1b 100644 --- a/apps/server/src/modules/organization/organization-role.service.ts +++ b/apps/server/src/modules/organization/organization-role.service.ts @@ -1,6 +1,6 @@ +import { notFound } from "@cv/auth"; +import { PrismaService } from "@cv/system"; import { Injectable, Logger } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; import { OrganizationRole } from "./organization-role.entity"; import { OrganizationRoleMapper } from "./organization-role.mapper"; @@ -16,7 +16,7 @@ export class OrganizationRoleService { async findById(id: string): Promise { this.logger.log(`Finding organization role by id: ${id}`); - const role = await this.prisma["organizationRole"].findUnique({ + const role = await this.prisma.organizationRole.findUnique({ where: { id }, }); @@ -33,7 +33,7 @@ export class OrganizationRoleService { async findAll(): Promise { this.logger.log("Finding all organization roles"); - const roles = await this.prisma["organizationRole"].findMany({ + const roles = await this.prisma.organizationRole.findMany({ orderBy: { name: "asc" }, }); return this.organizationRoleMapper.mapToDomain(roles); @@ -46,7 +46,7 @@ export class OrganizationRoleService { }): Promise { this.logger.log(`Creating organization role: ${data.name}`); - const role = await this.prisma["organizationRole"].create({ + const role = await this.prisma.organizationRole.create({ data: { name: data.name, description: data.description ?? null, @@ -67,7 +67,7 @@ export class OrganizationRoleService { ): Promise { this.logger.log(`Updating organization role: ${id}`); - const role = await this.prisma["organizationRole"].update({ + const role = await this.prisma.organizationRole.update({ where: { id }, data: { ...(data.name !== undefined && { name: data.name }), @@ -84,7 +84,7 @@ export class OrganizationRoleService { async delete(id: string): Promise { this.logger.log(`Deleting organization role: ${id}`); - await this.prisma["organizationRole"].delete({ + await this.prisma.organizationRole.delete({ where: { id }, }); } diff --git a/apps/server/src/modules/organization/organization.entity.ts b/apps/server/src/modules/organization/organization.entity.ts index 1d9d643..927a1f1 100644 --- a/apps/server/src/modules/organization/organization.entity.ts +++ b/apps/server/src/modules/organization/organization.entity.ts @@ -1,34 +1,33 @@ -import { Field, ObjectType } from "@nestjs/graphql"; +import { BaseEntity } from "@cv/system"; +import { Field, ID, ObjectType } from "@nestjs/graphql"; @ObjectType() -export class Organization { - @Field(() => String) - id: string; +export class Organization extends BaseEntity { + @Field(() => ID) + declare id: string; @Field(() => String) - name: string; + declare name: string; @Field(() => String, { nullable: true }) - description: string | null; + declare description: string | null; @Field(() => Date) - createdAt: Date; + declare createdAt: Date; @Field(() => Date) - updatedAt: Date; + declare updatedAt: Date; - constructor(data: { - id: string; - name: string; - description?: string | null; - createdAt: Date; - updatedAt: Date; - }) { - this.id = data.id; - this.name = data.name; - this.description = data.description ?? null; - this.createdAt = data.createdAt; - this.updatedAt = data.updatedAt; + constructor( + id: string, + name: string, + createdAt: Date, + updatedAt: Date, + description: string | null = null, + ) { + super(id, createdAt, updatedAt); + this.name = name; + this.description = description; } static fromDomain(domainOrg: { @@ -38,12 +37,12 @@ export class Organization { createdAt: Date; updatedAt: Date; }): Organization { - return new Organization({ - id: domainOrg.id, - name: domainOrg.name, - description: domainOrg.description ?? null, - createdAt: domainOrg.createdAt, - updatedAt: domainOrg.updatedAt, - }); + return new Organization( + domainOrg.id, + domainOrg.name, + domainOrg.createdAt, + domainOrg.updatedAt, + domainOrg.description, + ); } } diff --git a/apps/server/src/modules/organization/organization.factory.ts b/apps/server/src/modules/organization/organization.factory.ts new file mode 100644 index 0000000..a1b03f4 --- /dev/null +++ b/apps/server/src/modules/organization/organization.factory.ts @@ -0,0 +1,25 @@ +import { ClockService, Factory, UuidFactoryService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import type { CreateOrganizationDto } from "./organization.dto"; +import { Organization } from "./organization.entity"; + +@Injectable() +export class OrganizationFactory + implements Factory +{ + constructor( + private readonly uuidFactory: UuidFactoryService, + private readonly clock: ClockService, + ) {} + + create(dto: CreateOrganizationDto): Organization { + const now = this.clock.now(); + return new Organization( + this.uuidFactory.generate(), + dto.name, + now, + now, + dto.description ?? null, + ); + } +} diff --git a/apps/server/src/modules/organization/organization.mapper.ts b/apps/server/src/modules/organization/organization.mapper.ts index 8f48dbe..d0b22b0 100644 --- a/apps/server/src/modules/organization/organization.mapper.ts +++ b/apps/server/src/modules/organization/organization.mapper.ts @@ -1,6 +1,6 @@ +import type { BaseMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Organization as PrismaOrganization } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; import { Organization } from "./organization.entity"; @Injectable() @@ -14,13 +14,13 @@ export class OrganizationMapper if (prismaOrganization === null) { return null; } - return new Organization({ - id: prismaOrganization.id, - name: prismaOrganization.name, - description: prismaOrganization.description, - createdAt: prismaOrganization.createdAt, - updatedAt: prismaOrganization.updatedAt, - }); + return new Organization( + prismaOrganization.id, + prismaOrganization.name, + prismaOrganization.createdAt, + prismaOrganization.updatedAt, + prismaOrganization.description, + ); } mapToDomain(prismaOrganizations: PrismaOrganization[]): Organization[] { diff --git a/apps/server/src/modules/organization/organization.module.ts b/apps/server/src/modules/organization/organization.module.ts index 9883253..d99afd3 100644 --- a/apps/server/src/modules/organization/organization.module.ts +++ b/apps/server/src/modules/organization/organization.module.ts @@ -1,21 +1,32 @@ +import { AuthorizationModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { AuthModule } from "@/modules/auth/auth.module"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; import { OrganizationResolver } from "./graphql/organization.resolver"; import { UserFieldResolver } from "./graphql/user-field.resolver"; +import { OrganizationFactory } from "./organization.factory"; import { OrganizationMapper } from "./organization.mapper"; +import { OrganizationPolicy } from "./organization.policy"; import { OrganizationService } from "./organization.service"; import { OrganizationRoleMapper } from "./organization-role.mapper"; +import { OrganizationRolePolicy } from "./organization-role.policy"; import { OrganizationRoleService } from "./organization-role.service"; import { MembershipSeedService } from "./seed/membership.seed"; import { OrganizationSeedService } from "./seed/organization.seed"; @Module({ - imports: [DatabaseModule, BaseModule, AuthModule], + imports: [ + DatabaseModule, + BaseModule, + AuthenticationModule, + AuthorizationModule, + ], providers: [ OrganizationService, OrganizationRoleService, + OrganizationFactory, + OrganizationPolicy, + OrganizationRolePolicy, OrganizationResolver, UserFieldResolver, OrganizationMapper, diff --git a/apps/server/src/modules/organization/organization.policy.ts b/apps/server/src/modules/organization/organization.policy.ts new file mode 100644 index 0000000..cdb1a85 --- /dev/null +++ b/apps/server/src/modules/organization/organization.policy.ts @@ -0,0 +1,11 @@ +import { Policy, PublicResourcePolicy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Organization } from "./organization.entity"; + +@Injectable() +@Policy(Organization) +export class OrganizationPolicy extends PublicResourcePolicy { + override create(): boolean { + return true; + } +} diff --git a/apps/server/src/modules/organization/organization.service.ts b/apps/server/src/modules/organization/organization.service.ts index 077bb3c..1825c3f 100644 --- a/apps/server/src/modules/organization/organization.service.ts +++ b/apps/server/src/modules/organization/organization.service.ts @@ -1,11 +1,7 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { notFound, User } from "@cv/auth"; +import { type EntityService, PrismaService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; -import type { - CreateOrganizationDto, - UpdateOrganizationDto, -} from "./organization.dto"; import { Organization } from "./organization.entity"; import { OrganizationMapper } from "./organization.mapper"; @@ -19,8 +15,10 @@ export type Membership = { user: { id: string; name: string; - email: string; createdAt: Date; + credentials?: { + email: string; + } | null; }; role: { id: string; @@ -35,18 +33,18 @@ export type Membership = { export type MembershipSortField = "createdAt" | "userName" | "roleName"; export type MembershipSortOrder = "asc" | "desc"; -@Injectable() -export class OrganizationService { - private readonly logger = new Logger(OrganizationService.name); +type OrganizationFilters = Record; +@Injectable() +export class OrganizationService + implements EntityService +{ constructor( private readonly prisma: PrismaService, private readonly organizationMapper: OrganizationMapper, ) {} async findById(id: string): Promise { - this.logger.log(`Finding organization by id: ${id}`); - const organization = await this.prisma.organization.findUnique({ where: { id }, }); @@ -54,32 +52,12 @@ export class OrganizationService { return this.organizationMapper.toDomain(organization); } - async findForUser(userId: string): Promise { - this.logger.log(`Finding organizations for user: ${userId}`); - - const memberships = await this.prisma["membership"].findMany({ - where: { userId }, - include: { - organization: true, - }, - orderBy: { createdAt: "asc" }, - }); - - return this.organizationMapper.mapToDomain( - memberships.map((m) => m.organization), - ); - } - async findMembershipsByOrganization( organizationId: string, sortBy: MembershipSortField = "createdAt", sortOrder: MembershipSortOrder = "asc", searchTerm?: string, ): Promise { - this.logger.log( - `Finding memberships for organization: ${organizationId}, sortBy: ${sortBy}, sortOrder: ${sortOrder}, searchTerm: ${searchTerm}`, - ); - const whereClause: Prisma.MembershipWhereInput = { organizationId }; // Add search functionality for user names @@ -92,15 +70,19 @@ export class OrganizationService { }; } - const memberships = await this.prisma["membership"].findMany({ + const memberships = await this.prisma.membership.findMany({ where: whereClause, include: { user: { select: { id: true, name: true, - email: true, createdAt: true, + credentials: { + select: { + email: true, + }, + }, }, }, role: { @@ -129,10 +111,6 @@ export class OrganizationService { organizationId: string, searchTerm?: string, ): Promise { - this.logger.log( - `Getting membership count for organization: ${organizationId}, searchTerm: ${searchTerm}`, - ); - const whereClause: Prisma.MembershipWhereInput = { organizationId }; // Add search functionality for user names @@ -145,75 +123,62 @@ export class OrganizationService { }; } - return this.prisma["membership"].count({ + return this.prisma.membership.count({ where: whereClause, }); } - async create( - createOrganizationDto: CreateOrganizationDto, - ): Promise { - this.logger.log(`Creating organization: ${createOrganizationDto.name}`); + async findByIdOrFail(id: string): Promise { + return (await this.findById(id)) ?? notFound("Organization", "id", id); + } - const organization = await this.prisma.organization.create({ - data: { - name: createOrganizationDto.name, - description: createOrganizationDto.description ?? null, - }, + async findMany(_filters: OrganizationFilters = {}): Promise { + const organizations = await this.prisma.organization.findMany({ + orderBy: { name: "asc" }, }); + return this.organizationMapper.mapToDomain(organizations); + } - return this.organizationMapper.toDomain(organization); + async count(_filters: OrganizationFilters = {}): Promise { + return this.prisma.organization.count(); } - async update( - id: string, - updateOrganizationDto: UpdateOrganizationDto, - ): Promise { - this.logger.log(`Updating organization: ${id}`); - - const updateData: Partial<{ - name: string; - description: string | null; - }> = {}; - if (updateOrganizationDto.name !== undefined) { - updateData["name"] = updateOrganizationDto.name; - } - if (updateOrganizationDto.description !== undefined) { - updateData["description"] = updateOrganizationDto.description ?? null; - } + private buildPrismaData({ name, description }: Organization): { + name: string; + description: string | null; + } { + return { + name, + description: description ?? null, + }; + } - const organization = await this.prisma.organization.update({ + async save(entity: Organization): Promise { + const { id } = entity; + const data = this.buildPrismaData(entity); + const organization = await this.prisma.organization.upsert({ where: { id }, - data: updateData, + create: { id, ...data }, + update: data, }); - return this.organizationMapper.toDomain(organization); } - async findByIdOrFail(id: string): Promise { - const organization = await this.findById(id); - return organization ?? notFound("Organization", "id", id); - } - - async delete(id: string): Promise { - this.logger.log(`Deleting organization: ${id}`); - + async destroy(entity: Organization): Promise { await this.prisma.organization.delete({ - where: { id }, + where: { id: entity.id }, }); } async addUserToOrganization( - organizationId: string, - userId: string, + { id: organizationId }: Organization, + { id: userId }: User, roleId?: string, ): Promise { - this.logger.log(`Adding user ${userId} to organization ${organizationId}`); - // Default to member role if no role specified const defaultRoleId = roleId || "member_role_id"; - await this.prisma["membership"].create({ + await this.prisma.membership.create({ data: { organizationId, userId, @@ -225,14 +190,10 @@ export class OrganizationService { } async removeUserFromOrganization( - organizationId: string, - userId: string, + { id: organizationId }: Organization, + { id: userId }: User, ): Promise { - this.logger.log( - `Removing user ${userId} from organization ${organizationId}`, - ); - - await this.prisma["membership"].deleteMany({ + await this.prisma.membership.deleteMany({ where: { organizationId, userId, diff --git a/apps/server/src/modules/organization/seed/membership.seed.ts b/apps/server/src/modules/organization/seed/membership.seed.ts index c6fb2dc..7076c91 100644 --- a/apps/server/src/modules/organization/seed/membership.seed.ts +++ b/apps/server/src/modules/organization/seed/membership.seed.ts @@ -1,6 +1,6 @@ +import { PrismaService } from "@cv/system"; import { faker } from "@faker-js/faker"; import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Seeder } from "@/modules/database/seed/seed.service"; import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; @@ -30,11 +30,20 @@ export class MembershipSeedService implements Seeder { // First, ensure test@test.test is in multiple organizations await this.ensureTestUserMemberships(prisma, allUsers, allOrganizations); + // Get all credentials to find test user by email + const allCredentials = await prisma["credentials"].findMany({ + include: { user: true }, + }); + const testUserCredentials = allCredentials.find( + (c) => c.email === "test@test.test", + ); + const testUserIds = testUserCredentials ? [testUserCredentials.userId] : []; + // Then assign other users to organizations await Promise.all( allUsers.map(async (user) => { // Skip test@test.test as it's already handled above - if (user.email === "test@test.test") { + if (testUserIds.includes(user.id)) { return; } @@ -78,18 +87,23 @@ export class MembershipSeedService implements Seeder { async ensureTestUserMemberships( prisma: PrismaService, - allUsers: Array<{ id: string; email: string }>, + allUsers: Array<{ id: string }>, allOrganizations: Array<{ id: string }>, ): Promise { - const testUser = allUsers.find((user) => user.email === "test@test.test"); + const testCredentials = await prisma["credentials"].findUnique({ + where: { email: "test@test.test" }, + include: { user: true }, + }); - if (!testUser) { + if (!testCredentials) { this.logger.warn( "Test user not found, skipping test user membership assignment", ); return; } + const testUser = { id: testCredentials.userId }; + // Add test@test.test to the first 5 organizations const organizationsForTestUser = allOrganizations.slice(0, 5); diff --git a/apps/server/src/modules/organization/seed/organization.seed.ts b/apps/server/src/modules/organization/seed/organization.seed.ts index c5a8473..b3843e0 100644 --- a/apps/server/src/modules/organization/seed/organization.seed.ts +++ b/apps/server/src/modules/organization/seed/organization.seed.ts @@ -1,6 +1,6 @@ +import { PrismaService } from "@cv/system"; import { faker } from "@faker-js/faker"; import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Seeder } from "@/modules/database/seed/seed.service"; import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; diff --git a/apps/server/src/modules/user/seed/user.seed.ts b/apps/server/src/modules/user/seed/user.seed.ts index d343f65..522bc78 100644 --- a/apps/server/src/modules/user/seed/user.seed.ts +++ b/apps/server/src/modules/user/seed/user.seed.ts @@ -1,5 +1,6 @@ +import { CredentialsService, UserService } from "@cv/auth"; +import { PrismaService } from "@cv/system"; import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Seeder } from "@/modules/database/seed/seed.service"; import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; @@ -8,40 +9,49 @@ import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decora export class UserSeedService implements Seeder { private readonly logger = new Logger(UserSeedService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly _prisma: PrismaService, + private readonly userService: UserService, + private readonly credentialsService: CredentialsService, + ) {} async seed(prisma: PrismaService): Promise { this.logger.log("Seeding users..."); - await this.ensureTestUser(prisma); - await this.ensureAdditionalTestUsers(prisma); + await this.ensureTestUser(); + await this.ensureAdditionalTestUsers(); } - async ensureTestUser( - prisma: PrismaService = this.prisma, - ): Promise<{ id: string; email: string; name: string }> { - let testUser = await prisma["user"].findUnique({ - where: { email: "test@test.test" }, - }); + async ensureTestUser(): Promise<{ id: string; email: string; name: string }> { + const testEmail = "test@test.test"; + let credentials = await this.credentialsService.findByEmail(testEmail); - if (!testUser) { + if (!credentials) { this.logger.log("Creating test user..."); - testUser = await prisma["user"].create({ - data: { - email: "test@test.test", - name: "Test User", - // bcrypt hash for "password" - password: - "$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", - }, + const user = await this.userService.create("Test User"); + credentials = await this.credentialsService.create( + user.id, + testEmail, + // bcrypt hash for "password" + "$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", + ); + await this._prisma["credentials"].update({ + where: { userId: user.id }, + data: { emailVerifiedAt: new Date() }, }); } - return { id: testUser.id, email: testUser.email, name: testUser.name }; + const user = await this.userService.findByIdOrFail(credentials.userId); + + return { + id: user.id, + email: credentials.email, + name: user.name, + }; } - async ensureAdditionalTestUsers( - prisma: PrismaService = this.prisma, - ): Promise> { + async ensureAdditionalTestUsers(): Promise< + Array<{ id: string; email: string; name: string }> + > { const testUsers = [ { email: "alice@example.com", name: "Alice Johnson" }, { email: "bob@example.com", name: "Bob Smith" }, @@ -62,24 +72,32 @@ export class UserSeedService implements Seeder { const createdUsers = await Promise.all( testUsers.map(async (userData) => { - let user = await prisma["user"].findUnique({ - where: { email: userData.email }, - }); + let credentials = await this.credentialsService.findByEmail( + userData.email, + ); - if (!user) { + if (!credentials) { this.logger.log(`Creating test user: ${userData.name}`); - user = await prisma["user"].create({ - data: { - email: userData.email, - name: userData.name, - // bcrypt hash for "password" - password: - "$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", - }, + const { id: userId } = await this.userService.create(userData.name); + credentials = await this.credentialsService.create( + userId, + userData.email, + // bcrypt hash for "password" + "$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", + ); + await this._prisma["credentials"].update({ + where: { userId }, + data: { emailVerifiedAt: new Date() }, }); } - return { id: user.id, email: user.email, name: user.name }; + const user = await this.userService.findByIdOrFail(credentials.userId); + + return { + id: user.id, + email: credentials.email, + name: user.name, + }; }), ); diff --git a/apps/server/src/modules/user/user.entity.ts b/apps/server/src/modules/user/user.entity.ts deleted file mode 100644 index 517aa95..0000000 --- a/apps/server/src/modules/user/user.entity.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BaseEntity } from "@/modules/base/base.entity"; - -export class User extends BaseEntity { - email: string; - name: string; - - constructor( - id: string, - email: string, - name: string, - createdAt: Date, - updatedAt: Date, - ) { - super(id, createdAt, updatedAt); - this.email = email; - this.name = name; - } -} diff --git a/apps/server/src/modules/user/user.mapper.ts b/apps/server/src/modules/user/user.mapper.ts deleted file mode 100644 index 686cc1c..0000000 --- a/apps/server/src/modules/user/user.mapper.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import type { User as PrismaUser } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; -import { User } from "./user.entity"; - -/** - * Mapper service for converting between Prisma User entities and domain User entities - */ -@Injectable() -export class UserMapper implements BaseMapper { - /** - * Maps a Prisma User entity to a domain User entity - * Uses overloads to return the correct type based on input - */ - toDomain(prismaUser: null): null; - toDomain(prismaUser: PrismaUser): User; - toDomain(prismaUser: PrismaUser | null): User | null; - toDomain(prismaUser: PrismaUser | null): User | null { - if (prismaUser === null) { - return null; - } - return new User( - prismaUser.id, - prismaUser.email, - prismaUser.name, - prismaUser.createdAt, - prismaUser.updatedAt, - ); - } - - /** - * Maps an array of Prisma User entities to domain User entities - */ - mapToDomain(prismaUsers: PrismaUser[]): User[] { - return prismaUsers.map((user) => this.toDomain(user)); - } -} diff --git a/apps/server/src/modules/user/user.module.ts b/apps/server/src/modules/user/user.module.ts deleted file mode 100644 index c055bea..0000000 --- a/apps/server/src/modules/user/user.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from "@nestjs/common"; -import { DatabaseModule } from "@/modules/database/database.module"; -import { UserSeedService } from "./seed/user.seed"; -import { UserMapper } from "./user.mapper"; -import { UserService } from "./user.service"; - -@Module({ - imports: [DatabaseModule], - providers: [UserService, UserMapper, UserSeedService], - exports: [UserService, UserMapper], -}) -export class UserModule {} diff --git a/apps/server/src/modules/user/user.service.ts b/apps/server/src/modules/user/user.service.ts deleted file mode 100644 index a438e29..0000000 --- a/apps/server/src/modules/user/user.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { notFound } from "@/modules/base/not-found.util"; -import { PrismaService } from "@/modules/database/prisma.service"; -import { User } from "./user.entity"; -import { UserMapper } from "./user.mapper"; - -@Injectable() -export class UserService { - constructor( - private prisma: PrismaService, - private userMapper: UserMapper, - ) {} - - async create(email: string, name: string, password: string): Promise { - const prismaUser = await this.prisma["user"].create({ - data: { - email, - name, - password, - }, - }); - - return this.userMapper.toDomain(prismaUser); - } - - async findByEmail(email: string): Promise { - const prismaUser = await this.prisma["user"].findUnique({ - where: { email }, - }); - - return this.userMapper.toDomain(prismaUser); - } - - async findById(id: string): Promise { - const prismaUser = await this.prisma["user"].findUnique({ - where: { id }, - }); - - return this.userMapper.toDomain(prismaUser); - } - - async exists(email: string): Promise { - const user = await this.prisma["user"].findUnique({ - where: { email }, - select: { id: true }, - }); - - return user !== null; - } - - /** - * Gets user password hash for authentication (Prisma level only) - */ - async getPasswordHash(email: string): Promise { - const prismaUser = await this.prisma["user"].findUnique({ - where: { email }, - select: { password: true }, - }); - - return prismaUser?.password ?? null; - } - - async findByEmailOrFail(email: string): Promise { - const user = await this.findByEmail(email); - return user ?? notFound("User", "email", email); - } - - async findByIdOrFail(id: string): Promise { - const user = await this.findById(id); - return user ?? notFound("User", "id", id); - } -} diff --git a/apps/server/src/modules/user/user.type.ts b/apps/server/src/modules/user/user.type.ts index 9a15f3f..7b3eccf 100644 --- a/apps/server/src/modules/user/user.type.ts +++ b/apps/server/src/modules/user/user.type.ts @@ -1,23 +1,26 @@ +import type { User as DomainUser } from "@cv/auth"; import { Field, ID, ObjectType } from "@nestjs/graphql"; import { ApplicationConnection } from "@/modules/application/graphql/application.type"; -import { CVConnection } from "@/modules/cv-template/graphql/cv-connection.type"; -import { EducationConnection } from "@/modules/education/graphql/education-connection.type"; -import { UserJobExperienceConnection } from "@/modules/job-experience/employment/graphql/user-job-experience-connection.type"; +import { CVConnection } from "@/modules/cv-template/graphql/cv.type"; +import { EducationConnection } from "@/modules/education/graphql/education.type"; +import { UserJobExperienceConnection } from "@/modules/job-experience/employment/graphql/user-job-experience.type"; import { Organization } from "@/modules/organization/organization.entity"; -import type { User as DomainUser } from "@/modules/user/user.entity"; -import { VacancyConnection } from "@/modules/vacancies/graphql/vacancy-connection.type"; +import { VacancyConnection } from "@/modules/vacancies/graphql/vacancy.type"; @ObjectType() export class User { @Field(() => ID) id: string; - @Field() - email: string; - @Field() name: string; + @Field(() => String, { nullable: true }) + email: string | null; + + @Field(() => Date, { nullable: true }) + emailVerifiedAt: Date | null; + @Field(() => Date) createdAt: Date; @@ -41,14 +44,16 @@ export class User { constructor( id: string, - email: string, name: string, createdAt: Date, + email?: string | null, + emailVerifiedAt?: Date | null, organizations?: Organization[] | undefined, ) { this.id = id; - this.email = email; this.name = name; + this.email = email ?? null; + this.emailVerifiedAt = emailVerifiedAt ?? null; this.createdAt = createdAt; this.organizations = organizations ?? undefined; } @@ -59,9 +64,10 @@ export class User { ): User { return new User( domainUser.id, - domainUser.email, domainUser.name, domainUser.createdAt, + domainUser.credentials?.email ?? null, + domainUser.credentials?.emailVerifiedAt ?? null, organizations, ); } diff --git a/apps/server/src/modules/vacancies/graphql/user-field.resolver.ts b/apps/server/src/modules/vacancies/graphql/user-field.resolver.ts index 9659e80..a7a8625 100644 --- a/apps/server/src/modules/vacancies/graphql/user-field.resolver.ts +++ b/apps/server/src/modules/vacancies/graphql/user-field.resolver.ts @@ -1,15 +1,14 @@ +import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; +import { PaginationArgs, PaginationService } from "@cv/system"; import { UseGuards } from "@nestjs/common"; import { Args, Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { PaginationService } from "@/modules/base/pagination.service"; -import { PaginationArgs } from "@/modules/base/pagination.types"; import { User } from "@/modules/user/user.type"; import { VacancyService } from "../vacancy.service"; -import { VacancyConnection } from "./vacancy-connection.type"; +import { VacancyConnection } from "./vacancy.type"; import { VacancyFilterInput } from "./vacancy-filter.input"; @Resolver(() => User) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class VacancyUserFieldResolver { constructor( private readonly vacancyService: VacancyService, @@ -23,9 +22,15 @@ export class VacancyUserFieldResolver { @Args("filter", { nullable: true }) filter?: VacancyFilterInput, ): Promise { const options = this.paginationService.parsePaginationArgs(paginationArgs); - const result = await this.vacancyService.findManyForUserWithFilters( - user.id, - filter, + const filters = filter ? { filter, userId: user.id } : { userId: user.id }; + const [items, totalCount] = await Promise.all([ + this.vacancyService.findMany(filters), + this.vacancyService.count(filters), + ]); + + const result = this.paginationService.buildPaginationResult( + items, + totalCount, options, ); return VacancyConnection.fromPaginationResult(result); diff --git a/apps/server/src/modules/vacancies/graphql/vacancy-connection.type.ts b/apps/server/src/modules/vacancies/graphql/vacancy-connection.type.ts deleted file mode 100644 index 08c207f..0000000 --- a/apps/server/src/modules/vacancies/graphql/vacancy-connection.type.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Field, Int, ObjectType } from "@nestjs/graphql"; -import { PageInfo, PaginationResult } from "@/modules/base/pagination.types"; -import type { Vacancy as VacancyEntity } from "../vacancy.entity"; -import { Vacancy } from "./vacancy.type"; -import { VacancyEdge } from "./vacancy-edge.type"; - -@ObjectType() -export class VacancyConnection { - @Field(() => [VacancyEdge]) - edges: VacancyEdge[]; - - @Field(() => PageInfo) - pageInfo: PageInfo; - - @Field(() => Int) - totalCount: number; - - constructor(edges: VacancyEdge[], pageInfo: PageInfo, totalCount: number) { - this.edges = edges; - this.pageInfo = pageInfo; - this.totalCount = totalCount; - } - - /** - * Static factory method to create a connection from pagination result - */ - static fromPaginationResult( - result: PaginationResult, - ): VacancyConnection { - const edges = result.edges.map((edge) => - VacancyEdge.fromPaginationEdge({ - cursor: edge.cursor, - node: Vacancy.fromDomain(edge.node), - }), - ); - return new VacancyConnection(edges, result.pageInfo, result.totalCount); - } -} diff --git a/apps/server/src/modules/vacancies/graphql/vacancy-edge.type.ts b/apps/server/src/modules/vacancies/graphql/vacancy-edge.type.ts deleted file mode 100644 index b5a825a..0000000 --- a/apps/server/src/modules/vacancies/graphql/vacancy-edge.type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { GraphQLString } from "graphql"; -import { BaseEdge } from "@/modules/base/connection.types"; -import { Vacancy } from "./vacancy.type"; - -@ObjectType() -export class VacancyEdge extends BaseEdge { - @Field(() => GraphQLString) - declare cursor: string; - - @Field(() => Vacancy) - declare node: Vacancy; -} diff --git a/apps/server/src/modules/vacancies/graphql/vacancy.resolver.ts b/apps/server/src/modules/vacancies/graphql/vacancy.resolver.ts index 2fd0f2d..03b0621 100644 --- a/apps/server/src/modules/vacancies/graphql/vacancy.resolver.ts +++ b/apps/server/src/modules/vacancies/graphql/vacancy.resolver.ts @@ -1,56 +1,47 @@ +import type { User as DomainUser } from "@cv/auth"; +import { + AuthorizationService, + JwtAuthGuard, + VerifiedScopeGuard, +} from "@cv/auth"; import { UseGuards } from "@nestjs/common"; import { Args, Mutation, Parent, - Query, ResolveField, Resolver, } from "@nestjs/graphql"; -import { CurrentUser } from "@/modules/auth/current-user.decorator"; -import { JwtAuthGuard } from "@/modules/auth/jwt-auth.guard"; -import { CompanyService } from "@/modules/job-experience/company/company.service"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; +import { CompanyDataLoaderService } from "@/modules/job-experience/company/company.dataloader"; import { Company } from "@/modules/job-experience/company/graphql/company.type"; import { Level } from "@/modules/job-experience/level/graphql/level.type"; -import { LevelService } from "@/modules/job-experience/level/level.service"; +import { LevelDataLoaderService } from "@/modules/job-experience/level/level.dataloader"; import { Role } from "@/modules/job-experience/role/graphql/role.type"; -import { RoleService } from "@/modules/job-experience/role/role.service"; +import { RoleDataLoaderService } from "@/modules/job-experience/role/role.dataloader"; import { Skill } from "@/modules/job-experience/skill/graphql/skill.type"; import { SkillService } from "@/modules/job-experience/skill/skill.service"; -import { User } from "@/modules/user/user.type"; -import type { Vacancy as VacancyEntity } from "@/modules/vacancies/vacancy.entity"; +import { Vacancy as VacancyEntity } from "@/modules/vacancies/vacancy.entity"; +import { VacancyFactory } from "@/modules/vacancies/vacancy.factory"; import { VacancyService } from "@/modules/vacancies/vacancy.service"; import { Vacancy } from "./vacancy.type"; -import { VacancyFilterInput } from "./vacancy-filter.input"; @Resolver(() => Vacancy) -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VerifiedScopeGuard) export class VacancyResolver { constructor( private readonly vacancyService: VacancyService, - private readonly companyService: CompanyService, - private readonly roleService: RoleService, - private readonly levelService: LevelService, + private readonly vacancyFactory: VacancyFactory, + private readonly companyDataLoader: CompanyDataLoaderService, + private readonly roleDataLoader: RoleDataLoaderService, + private readonly levelDataLoader: LevelDataLoaderService, private readonly skillService: SkillService, + private readonly authorizationService: AuthorizationService, ) {} - @Query(() => [Vacancy]) - async myVacancies( - @CurrentUser() user: User, - @Args("filter", { nullable: true }) filter?: VacancyFilterInput, - ): Promise { - const domainVacancies = await this.vacancyService.findForUserWithFilters( - user.id, - filter, - ); - return domainVacancies.map((vacancy: VacancyEntity) => - Vacancy.fromDomain(vacancy), - ); - } - @Mutation(() => Vacancy) async createVacancy( - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, @Args("title") title: string, @Args("companyId") companyId: string, @Args("roleId") roleId: string, @@ -66,11 +57,28 @@ export class VacancyResolver { @Args("isActive", { nullable: true }) isActive?: boolean, @Args("isPublic", { nullable: true }) isPublic?: boolean, ): Promise { + await this.authorizationService.canCreate(user, VacancyEntity, { + ownerId: user.id, + }); + + const [company, role, level] = await Promise.all([ + this.companyDataLoader.load(companyId), + this.roleDataLoader.load(roleId), + levelId ? this.levelDataLoader.load(levelId) : Promise.resolve(null), + ]); + + if (!company) { + throw new Error("Company not found"); + } + if (!role) { + throw new Error("Role not found"); + } + const createData = { title, - companyId, - roleId, - ...(levelId !== undefined && { levelId }), + company, + role, + level, ...(jobTypeId !== undefined && { jobTypeId }), ...(description !== undefined && { description }), ...(requirements !== undefined && { requirements }), @@ -83,49 +91,55 @@ export class VacancyResolver { ...(isPublic !== undefined && { isPublic }), }; - const domainVacancy = await this.vacancyService.create(user.id, createData); + const entity = this.vacancyFactory.create({ + ownerId: user.id, + ...createData, + }); + const domainVacancy = await this.vacancyService.save(entity); return Vacancy.fromDomain(domainVacancy); } @Mutation(() => Boolean) async deleteVacancy( - @CurrentUser() user: User, + @CurrentUser() user: DomainUser, @Args("id") id: string, ): Promise { - await this.vacancyService.delete(id, user.id); + const vacancy = await this.vacancyService.findByIdOrFail(id); + await this.authorizationService.canDelete(user, vacancy, VacancyEntity); + await this.vacancyService.destroy(vacancy); return true; } - @ResolveField(() => Company, { nullable: true }) - async company(@Parent() vacancy: Vacancy) { - if (!vacancy.companyId) { - return null; + @ResolveField(() => Company) + async company(@Parent() vacancy: Vacancy): Promise { + const company = await this.companyDataLoader.load(vacancy.companyId); + if (!company) { + throw new Error(`Company with id ${vacancy.companyId} not found`); } - const company = await this.companyService.findById(vacancy.companyId); - return company ? Company.fromDomain(company) : null; + return Company.fromDomain(company); } - @ResolveField(() => Role, { nullable: true }) - async role(@Parent() vacancy: Vacancy) { - if (!vacancy.roleId) { - return null; + @ResolveField(() => Role) + async role(@Parent() vacancy: Vacancy): Promise { + const role = await this.roleDataLoader.load(vacancy.roleId); + if (!role) { + throw new Error(`Role with id ${vacancy.roleId} not found`); } - const role = await this.roleService.findById(vacancy.roleId); - return role ? Role.fromDomain(role) : null; + return Role.fromDomain(role); } @ResolveField(() => Level, { nullable: true }) - async level(@Parent() vacancy: Vacancy) { + async level(@Parent() vacancy: Vacancy): Promise { if (!vacancy.levelId) { return null; } - const level = await this.levelService.findById(vacancy.levelId); + const level = await this.levelDataLoader.load(vacancy.levelId); return level ? Level.fromDomain(level) : null; } @ResolveField(() => [Skill], { nullable: true }) - async skills(@Parent() vacancy: Vacancy) { - const skills = await this.skillService.findByVacancyId(vacancy.id); + async skills(@Parent() { id }: Vacancy) { + const skills = await this.skillService.findMany({ vacancyId: id }); return skills.map((skill) => Skill.fromDomain(skill)); } } diff --git a/apps/server/src/modules/vacancies/graphql/vacancy.type.ts b/apps/server/src/modules/vacancies/graphql/vacancy.type.ts index 425841f..d419715 100644 --- a/apps/server/src/modules/vacancies/graphql/vacancy.type.ts +++ b/apps/server/src/modules/vacancies/graphql/vacancy.type.ts @@ -1,5 +1,10 @@ import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { createConnection } from "@/modules/base/connection.factory"; +import { Company } from "@/modules/job-experience/company/graphql/company.type"; +import { Level } from "@/modules/job-experience/level/graphql/level.type"; +import { Role } from "@/modules/job-experience/role/graphql/role.type"; import type { Vacancy as DomainVacancy } from "@/modules/vacancies/vacancy.entity"; +import { Vacancy as VacancyEntity } from "@/modules/vacancies/vacancy.entity"; @ObjectType() export class Vacancy { @@ -21,6 +26,15 @@ export class Vacancy { @Field(() => String, { nullable: true }) levelId: string | null; + @Field(() => Company) + company?: Company; + + @Field(() => Role) + role?: Role; + + @Field(() => Level, { nullable: true }) + level?: Level | null; + @Field(() => String, { nullable: true }) jobTypeId: string | null; @@ -102,9 +116,9 @@ export class Vacancy { id: domainVacancy.id, title: domainVacancy.title, ownerId: domainVacancy.ownerId, - companyId: domainVacancy.companyId, - roleId: domainVacancy.roleId, - levelId: domainVacancy.levelId ?? null, + companyId: domainVacancy.company.id, + roleId: domainVacancy.role.id, + levelId: domainVacancy.level?.id ?? null, jobTypeId: domainVacancy.jobTypeId ?? null, description: domainVacancy.description ?? null, requirements: domainVacancy.requirements ?? null, @@ -120,3 +134,11 @@ export class Vacancy { }); } } + +export const { Connection: VacancyConnection, Edge: VacancyEdge } = + createConnection(Vacancy, (domain) => + Vacancy.fromDomain(domain), + ); + +export type VacancyConnection = InstanceType; +export type VacancyEdge = InstanceType; diff --git a/apps/server/src/modules/vacancies/seed/vacancy.seed.ts b/apps/server/src/modules/vacancies/seed/vacancy.seed.ts index 2f26c5f..a054e1c 100644 --- a/apps/server/src/modules/vacancies/seed/vacancy.seed.ts +++ b/apps/server/src/modules/vacancies/seed/vacancy.seed.ts @@ -1,6 +1,6 @@ +import { PrismaService } from "@cv/system"; import { faker } from "@faker-js/faker"; import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "@/modules/database/prisma.service"; import { Seeder } from "@/modules/database/seed/seed.service"; import { Seeder as SeederDecorator } from "@/modules/database/seed/seeder.decorator"; diff --git a/apps/server/src/modules/vacancies/vacancy.dataloader.ts b/apps/server/src/modules/vacancies/vacancy.dataloader.ts new file mode 100644 index 0000000..a92a44c --- /dev/null +++ b/apps/server/src/modules/vacancies/vacancy.dataloader.ts @@ -0,0 +1,23 @@ +import { BaseDataLoaderService } from "@cv/system"; +import { Injectable, Scope } from "@nestjs/common"; +import { Vacancy } from "./vacancy.entity"; +import { VacancyService } from "./vacancy.service"; + +@Injectable({ scope: Scope.REQUEST }) +export class VacancyDataLoaderService extends BaseDataLoaderService< + string, + Vacancy +> { + constructor(readonly vacancyService: VacancyService) { + super(async (ids: readonly string[]) => { + const vacancies = await vacancyService.findMany({ id: [...ids] }); + + const vacancyMap = new Map(); + for (const vacancy of vacancies) { + vacancyMap.set(vacancy.id, vacancy); + } + + return ids.map((id) => vacancyMap.get(id) ?? null); + }); + } +} diff --git a/apps/server/src/modules/vacancies/vacancy.entity.ts b/apps/server/src/modules/vacancies/vacancy.entity.ts index 288c983..1f31de7 100644 --- a/apps/server/src/modules/vacancies/vacancy.entity.ts +++ b/apps/server/src/modules/vacancies/vacancy.entity.ts @@ -1,11 +1,14 @@ -import { BaseEntity } from "@/modules/base/base.entity"; +import { BaseEntity } from "@cv/system"; +import { Company } from "@/modules/job-experience/company/company.entity"; +import { Level } from "@/modules/job-experience/level/level.entity"; +import { Role } from "@/modules/job-experience/role/role.entity"; export class Vacancy extends BaseEntity { title: string; ownerId: string; - companyId: string; - roleId: string; - levelId?: string; + company: Company; + role: Role; + level: Level | null; jobTypeId?: string; description?: string; requirements?: string; @@ -21,11 +24,11 @@ export class Vacancy extends BaseEntity { id: string, title: string, ownerId: string, - companyId: string, - roleId: string, + company: Company, + role: Role, createdAt: Date, updatedAt: Date, - levelId?: string, + level: Level | null = null, jobTypeId?: string, description?: string, requirements?: string, @@ -40,14 +43,12 @@ export class Vacancy extends BaseEntity { super(id, createdAt, updatedAt); this.title = title; this.ownerId = ownerId; - this.companyId = companyId; - this.roleId = roleId; + this.company = company; + this.role = role; + this.level = level; this.isActive = isActive; this.isPublic = isPublic; - if (levelId !== undefined) { - this.levelId = levelId; - } if (jobTypeId !== undefined) { this.jobTypeId = jobTypeId; } diff --git a/apps/server/src/modules/vacancies/vacancy.factory.ts b/apps/server/src/modules/vacancies/vacancy.factory.ts new file mode 100644 index 0000000..8c32002 --- /dev/null +++ b/apps/server/src/modules/vacancies/vacancy.factory.ts @@ -0,0 +1,56 @@ +import { ClockService, Factory, UuidFactoryService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import { Company } from "@/modules/job-experience/company/company.entity"; +import { Level } from "@/modules/job-experience/level/level.entity"; +import { Role } from "@/modules/job-experience/role/role.entity"; +import { Vacancy } from "./vacancy.entity"; + +export interface CreateVacancyDto { + ownerId: string; + title: string; + company: Company; + role: Role; + level?: Level | null; + jobTypeId?: string; + description?: string; + requirements?: string; + location?: string; + minSalary?: number; + maxSalary?: number; + applicationUrl?: string; + deadline?: Date; + isActive?: boolean; + isPublic?: boolean; +} + +@Injectable() +export class VacancyFactory implements Factory { + constructor( + private readonly uuidFactory: UuidFactoryService, + private readonly clock: ClockService, + ) {} + + create(createVacancyDto: CreateVacancyDto): Vacancy { + const now = this.clock.now(); + return new Vacancy( + this.uuidFactory.generate(), + createVacancyDto.title, + createVacancyDto.ownerId, + createVacancyDto.company, + createVacancyDto.role, + now, + now, + createVacancyDto.level ?? null, + createVacancyDto.jobTypeId, + createVacancyDto.description, + createVacancyDto.requirements, + createVacancyDto.location, + createVacancyDto.minSalary, + createVacancyDto.maxSalary, + createVacancyDto.applicationUrl, + createVacancyDto.deadline, + createVacancyDto.isActive ?? true, + createVacancyDto.isPublic ?? false, + ); + } +} diff --git a/apps/server/src/modules/vacancies/vacancy.mapper.ts b/apps/server/src/modules/vacancies/vacancy.mapper.ts index e5f7b05..1a6a17d 100644 --- a/apps/server/src/modules/vacancies/vacancy.mapper.ts +++ b/apps/server/src/modules/vacancies/vacancy.mapper.ts @@ -1,26 +1,56 @@ import { Injectable } from "@nestjs/common"; -import type { Vacancy as PrismaVacancy } from "@prisma/client"; -import type { BaseMapper } from "@/modules/base/mapper.interface"; +import type { Prisma } from "@prisma/client"; +import { CompanyMapper } from "@/modules/job-experience/company/company.mapper"; +import { LevelMapper } from "@/modules/job-experience/level/level.mapper"; +import { RoleMapper } from "@/modules/job-experience/role/role.mapper"; import { Vacancy } from "./vacancy.entity"; +type PrismaVacancyWithRelations = Prisma.VacancyGetPayload<{ + include: { + company: true; + role: true; + level: true; + jobType: true; + skills: true; + }; +}>; + @Injectable() -export class VacancyMapper implements BaseMapper { - toDomain(prismaVacancy: null): null; - toDomain(prismaVacancy: PrismaVacancy): Vacancy; - toDomain(prismaVacancy: PrismaVacancy | null): Vacancy | null; - toDomain(prismaVacancy: PrismaVacancy | null): Vacancy | null { - if (prismaVacancy === null) { +export class VacancyMapper { + constructor( + private readonly companyMapper: CompanyMapper, + private readonly roleMapper: RoleMapper, + private readonly levelMapper: LevelMapper, + ) {} + + toDomain(prismaVacancy: PrismaVacancyWithRelations | null): Vacancy | null { + if (!prismaVacancy) { return null; } + + const company = this.companyMapper.toDomain(prismaVacancy.company); + if (!company) { + throw new Error("Company is required for Vacancy"); + } + + const role = this.roleMapper.toDomain(prismaVacancy.role); + if (!role) { + throw new Error("Role is required for Vacancy"); + } + + const level = prismaVacancy.level + ? this.levelMapper.toDomain(prismaVacancy.level) + : null; + return new Vacancy( prismaVacancy.id, prismaVacancy.title, prismaVacancy.ownerId, - prismaVacancy.companyId, - prismaVacancy.roleId, + company, + role, prismaVacancy.createdAt, prismaVacancy.updatedAt, - prismaVacancy.levelId ?? undefined, + level, prismaVacancy.jobTypeId ?? undefined, prismaVacancy.description ?? undefined, prismaVacancy.requirements ?? undefined, @@ -34,7 +64,9 @@ export class VacancyMapper implements BaseMapper { ); } - mapToDomain(prismaVacancies: PrismaVacancy[]): Vacancy[] { - return prismaVacancies.map((v) => this.toDomain(v)); + mapToDomain(prismaVacancies: PrismaVacancyWithRelations[]): Vacancy[] { + return prismaVacancies + .map((v) => this.toDomain(v)) + .filter((v): v is Vacancy => v !== null); } } diff --git a/apps/server/src/modules/vacancies/vacancy.module.ts b/apps/server/src/modules/vacancies/vacancy.module.ts index 6222558..5e6f45e 100644 --- a/apps/server/src/modules/vacancies/vacancy.module.ts +++ b/apps/server/src/modules/vacancies/vacancy.module.ts @@ -1,7 +1,7 @@ +import { AuthorizationModule } from "@cv/auth"; +import { BaseModule, DatabaseModule } from "@cv/system"; import { Module } from "@nestjs/common"; -import { AuthModule } from "@/modules/auth/auth.module"; -import { BaseModule } from "@/modules/base/base.module"; -import { DatabaseModule } from "@/modules/database/database.module"; +import { AuthenticationModule } from "@/modules/authentication/authentication.module"; import { CompanyModule } from "@/modules/job-experience/company/company.module"; import { LevelModule } from "@/modules/job-experience/level/level.module"; import { RoleModule } from "@/modules/job-experience/role/role.module"; @@ -9,7 +9,10 @@ import { SkillModule } from "@/modules/job-experience/skill/skill.module"; import { VacancyUserFieldResolver } from "./graphql/user-field.resolver"; import { VacancyResolver } from "./graphql/vacancy.resolver"; import { VacancySeedService } from "./seed/vacancy.seed"; +import { VacancyDataLoaderService } from "./vacancy.dataloader"; +import { VacancyFactory } from "./vacancy.factory"; import { VacancyMapper } from "./vacancy.mapper"; +import { VacancyPolicy } from "./vacancy.policy"; import { VacancyService } from "./vacancy.service"; @Module({ @@ -20,15 +23,19 @@ import { VacancyService } from "./vacancy.service"; RoleModule, LevelModule, SkillModule, - AuthModule, + AuthenticationModule, + AuthorizationModule, ], providers: [ VacancyService, + VacancyFactory, VacancyResolver, VacancyMapper, + VacancyPolicy, VacancyUserFieldResolver, VacancySeedService, + VacancyDataLoaderService, ], - exports: [VacancyService, VacancyMapper], + exports: [VacancyService, VacancyMapper, VacancyDataLoaderService], }) export class VacancyModule {} diff --git a/apps/server/src/modules/vacancies/vacancy.policy.ts b/apps/server/src/modules/vacancies/vacancy.policy.ts new file mode 100644 index 0000000..aee1cf1 --- /dev/null +++ b/apps/server/src/modules/vacancies/vacancy.policy.ts @@ -0,0 +1,12 @@ +import type { User } from "@cv/auth"; +import { OwnerOwnedResourcePolicy, Policy } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import { Vacancy } from "./vacancy.entity"; + +@Injectable() +@Policy(Vacancy) +export class VacancyPolicy extends OwnerOwnedResourcePolicy { + override view(user: User, resource: Vacancy): boolean | Promise { + return resource.isPublic || resource.ownerId === user.id; + } +} diff --git a/apps/server/src/modules/vacancies/vacancy.service.ts b/apps/server/src/modules/vacancies/vacancy.service.ts index 8b3cf8e..73e4739 100644 --- a/apps/server/src/modules/vacancies/vacancy.service.ts +++ b/apps/server/src/modules/vacancies/vacancy.service.ts @@ -1,72 +1,54 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { notFound } from "@cv/auth"; +import { type EntityService, PrismaService, raise } from "@cv/system"; +import { ensureArray } from "@cv/utils"; +import { Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; -import { notFound } from "@/modules/base/not-found.util"; -import { PaginationService } from "@/modules/base/pagination.service"; -import type { - PaginationOptions, - PaginationResult, -} from "@/modules/base/pagination.types"; -import { PrismaService } from "@/modules/database/prisma.service"; import { VacancyFilterInput } from "./graphql/vacancy-filter.input"; import { Vacancy } from "./vacancy.entity"; import { VacancyMapper } from "./vacancy.mapper"; -export interface CreateVacancyDto { - title: string; - companyId: string; - roleId: string; - levelId?: string; - jobTypeId?: string; - description?: string; - requirements?: string; - location?: string; - minSalary?: number; - maxSalary?: number; - applicationUrl?: string; - deadline?: Date; - isActive?: boolean; - isPublic?: boolean; -} - -export type UpdateVacancyDto = Partial; +type VacancyFilters = { + filter?: VacancyFilterInput; + userId?: string; + id?: string | string[]; +}; @Injectable() -export class VacancyService { +export class VacancyService implements EntityService { + private readonly vacancyInclude = { + company: true, + role: true, + level: true, + jobType: true, + skills: true, + } satisfies Prisma.VacancyInclude; + constructor( private readonly prisma: PrismaService, private readonly vacancyMapper: VacancyMapper, - private readonly paginationService: PaginationService, ) {} async findById(id: string): Promise { - const vacancy = await this.prisma["vacancy"].findUnique({ + const vacancy = await this.prisma.vacancy.findUnique({ where: { id }, + include: this.vacancyInclude, }); return this.vacancyMapper.toDomain(vacancy); } - async findByIdAndUser(id: string, userId: string): Promise { - const vacancy = await this.prisma["vacancy"].findFirst({ - where: { - id, - ownerId: userId, - }, - }); - - return this.vacancyMapper.toDomain(vacancy); - } - - private buildWhereClause( - filter?: VacancyFilterInput, - userId?: string, - ): Prisma.VacancyWhereInput { + private buildWhere(filters?: VacancyFilters): Prisma.VacancyWhereInput { + const { filter, userId, id } = filters ?? {}; const where: Prisma.VacancyWhereInput = {}; if (userId) { where.ownerId = userId; } + if (id !== undefined) { + where.id = Array.isArray(id) ? { in: id } : id; + } + if (!filter) { return where; } @@ -75,21 +57,21 @@ export class VacancyService { // Search term filter if (filter.searchTerm) { + const searchFilter = { + contains: filter.searchTerm, + mode: "insensitive", + } as const; const searchConditions: Prisma.VacancyWhereInput[] = [ - { title: { contains: filter.searchTerm, mode: "insensitive" } }, - { description: { contains: filter.searchTerm, mode: "insensitive" } }, + { title: searchFilter }, + { description: searchFilter }, { - requirements: { contains: filter.searchTerm, mode: "insensitive" }, + requirements: searchFilter, }, - { location: { contains: filter.searchTerm, mode: "insensitive" } }, + { location: searchFilter }, ]; if (where.OR) { - const existingORConditions: Prisma.VacancyWhereInput[] = Array.isArray( - where.OR, - ) - ? where.OR - : [where.OR]; + const existingORConditions = ensureArray(where.OR); andConditions.push({ OR: existingORConditions }); delete where.OR; } @@ -146,166 +128,82 @@ export class VacancyService { return where; } - async findMany( - filter?: VacancyFilterInput, - userId?: string, - options: PaginationOptions = {}, - ): Promise> { - const where = this.buildWhereClause(filter, userId); - const queryOptions = this.paginationService.buildQueryOptions( + async findMany(filters?: VacancyFilters): Promise { + const where = this.buildWhere(filters); + const items = await this.prisma.vacancy.findMany({ where, - { createdAt: "desc" }, - options, - ); - - const [items, totalCount] = await Promise.all([ - this.prisma["vacancy"].findMany({ - ...queryOptions, - include: { - company: true, - role: true, - level: true, - skills: true, - }, - }), - this.prisma["vacancy"].count({ where }), - ]); - - const domainVacancies = this.vacancyMapper.mapToDomain(items); - return this.paginationService.buildPaginationResult( - domainVacancies, - totalCount, - options, - ); - } + include: this.vacancyInclude, + orderBy: { createdAt: "desc" }, + }); - async findForUser(userId: string): Promise { - const result = await this.findMany(undefined, userId); - return result.edges.map((edge) => edge.node); + return this.vacancyMapper.mapToDomain(items); } - async findForUserWithFilters( - userId: string, - filter?: VacancyFilterInput, - ): Promise { - const result = await this.findMany(filter, userId); - return result.edges.map((edge) => edge.node); + async count(filters?: VacancyFilters): Promise { + return this.prisma.vacancy.count({ where: this.buildWhere(filters) }); } - async findManyForUserWithFilters( - userId: string, - filter?: VacancyFilterInput, - options: PaginationOptions = {}, - ): Promise> { - return this.findMany(filter, userId, options); + async findByIdOrFail(id: string): Promise { + const vacancy = await this.findById(id); + return vacancy ?? notFound("Vacancy", "id", id); } - async create( - userId: string, - createVacancyDto: CreateVacancyDto, - ): Promise { - const vacancy = await this.prisma["vacancy"].create({ - data: { - ownerId: userId, // Set owner to the user creating the vacancy - title: createVacancyDto.title, - companyId: createVacancyDto.companyId, - roleId: createVacancyDto.roleId, - levelId: createVacancyDto.levelId ?? null, - jobTypeId: createVacancyDto.jobTypeId ?? null, - description: createVacancyDto.description ?? null, - requirements: createVacancyDto.requirements ?? null, - location: createVacancyDto.location ?? null, - minSalary: createVacancyDto.minSalary ?? null, - maxSalary: createVacancyDto.maxSalary ?? null, - applicationUrl: createVacancyDto.applicationUrl ?? null, - deadline: createVacancyDto.deadline ?? null, - isActive: createVacancyDto.isActive ?? true, - isPublic: createVacancyDto.isPublic ?? false, - }, - }); - - return this.vacancyMapper.toDomain(vacancy); + private buildPrismaData(entity: Vacancy): { + title: string; + companyId: string; + roleId: string; + levelId: string | null; + jobTypeId: string | null; + description: string | null; + requirements: string | null; + location: string | null; + minSalary: number | null; + maxSalary: number | null; + applicationUrl: string | null; + deadline: Date | null; + isActive: boolean; + isPublic: boolean; + } { + return { + title: entity.title, + companyId: entity.company.id, + roleId: entity.role.id, + levelId: entity.level?.id ?? null, + jobTypeId: entity.jobTypeId ?? null, + description: entity.description ?? null, + requirements: entity.requirements ?? null, + location: entity.location ?? null, + minSalary: entity.minSalary ?? null, + maxSalary: entity.maxSalary ?? null, + applicationUrl: entity.applicationUrl ?? null, + deadline: entity.deadline ?? null, + isActive: entity.isActive, + isPublic: entity.isPublic, + }; } - async update( - id: string, - userId: string, - updateVacancyDto: UpdateVacancyDto, - ): Promise { - // First check if the vacancy exists and the user is the owner - const existingVacancy = await this.findById(id); - if (!existingVacancy || existingVacancy.ownerId !== userId) { - throw new NotFoundException( - `Vacancy with ID ${id} not found or user is not the owner`, - ); - } - - const vacancy = await this.prisma["vacancy"].update({ - where: { id }, - data: { - ...(updateVacancyDto.title !== undefined && { - title: updateVacancyDto.title, - }), - ...(updateVacancyDto.companyId !== undefined && { - companyId: updateVacancyDto.companyId, - }), - ...(updateVacancyDto.roleId !== undefined && { - roleId: updateVacancyDto.roleId, - }), - ...(updateVacancyDto.levelId !== undefined && { - levelId: updateVacancyDto.levelId ?? null, - }), - ...(updateVacancyDto.jobTypeId !== undefined && { - jobTypeId: updateVacancyDto.jobTypeId ?? null, - }), - ...(updateVacancyDto.description !== undefined && { - description: updateVacancyDto.description ?? null, - }), - ...(updateVacancyDto.requirements !== undefined && { - requirements: updateVacancyDto.requirements ?? null, - }), - ...(updateVacancyDto.location !== undefined && { - location: updateVacancyDto.location ?? null, - }), - ...(updateVacancyDto.minSalary !== undefined && { - minSalary: updateVacancyDto.minSalary ?? null, - }), - ...(updateVacancyDto.maxSalary !== undefined && { - maxSalary: updateVacancyDto.maxSalary ?? null, - }), - ...(updateVacancyDto.applicationUrl !== undefined && { - applicationUrl: updateVacancyDto.applicationUrl ?? null, - }), - ...(updateVacancyDto.deadline !== undefined && { - deadline: updateVacancyDto.deadline ?? null, - }), - ...(updateVacancyDto.isActive !== undefined && { - isActive: updateVacancyDto.isActive, - }), - ...(updateVacancyDto.isPublic !== undefined && { - isPublic: updateVacancyDto.isPublic, - }), + async save(entity: Vacancy): Promise { + const data = this.buildPrismaData(entity); + const { ownerId, id } = entity; + const vacancy = await this.prisma.vacancy.upsert({ + where: { id: entity.id }, + create: { + id, + ownerId, + ...data, }, + update: data, + include: this.vacancyInclude, }); - return this.vacancyMapper.toDomain(vacancy); - } - - async findByIdOrFail(id: string): Promise { - const vacancy = await this.findById(id); - return vacancy ?? notFound("Vacancy", "id", id); + return ( + this.vacancyMapper.toDomain(vacancy) ?? + raise("Failed to map vacancy to domain") + ); } - async delete(id: string, userId: string): Promise { - // First check if the vacancy exists and the user is the owner - const existingVacancy = await this.findById(id); - if (!existingVacancy || existingVacancy.ownerId !== userId) { - throw new NotFoundException( - `Vacancy with ID ${id} not found or user is not the owner`, - ); - } - - await this.prisma["vacancy"].delete({ + async destroy({ id }: Vacancy): Promise { + await this.prisma.vacancy.delete({ where: { id }, }); } diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index d05be24..745f854 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -8,6 +8,8 @@ "emitDecoratorMetadata": true, "paths": { "@/*": ["./src/*"], + "@cv/auth": ["../../packages/auth/src"], + "@cv/system": ["../../packages/system/src"], "@cv/utils": ["../../packages/utils"] }, "noUncheckedIndexedAccess": true, @@ -21,6 +23,6 @@ "strictPropertyInitialization": true, "noImplicitAny": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.d.ts"], "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] } diff --git a/biome.json b/biome.json index 3e4b11d..a11990d 100644 --- a/biome.json +++ b/biome.json @@ -8,7 +8,12 @@ "!**/dist", "!**/build", "!**/coverage", - "!**/coverage-unit" + "!**/coverage-unit", + "!**/generated", + "!**/generated/**", + "!apps/client/src/generated", + "!apps/client/src/generated/**", + "!**/*.css" ], "ignoreUnknown": false }, @@ -54,7 +59,22 @@ }, "overrides": [ { - "includes": ["apps/server/**/*"], + "includes": [ + "**/generated/**", + "**/generated/**/*", + "**/*.generated.*", + "**/*.generated.ts", + "**/*.generated.js" + ], + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + } + }, + { + "includes": ["apps/server/**/*", "packages/auth/**/*"], "linter": { "rules": { "correctness": { @@ -78,21 +98,33 @@ } }, { - "includes": ["apps/client/**/*"], + "includes": ["apps/client/**/*", "apps/docs/**/*"], "linter": { "rules": { "complexity": { "useLiteralKeys": "off" + }, + "suspicious": { + "noExplicitAny": "warn" } } } }, { "includes": ["**/*.css"], + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + } + }, + { + "includes": ["**/*.module.ts"], "linter": { "rules": { - "suspicious": { - "noUnknownAtRules": "off" + "complexity": { + "noStaticOnlyClass": "off" } } } diff --git a/docker-compose.yml b/docker-compose.yml index d243096..601745b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,41 +10,39 @@ services: volumes: - db-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 5s + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cv} -d ${POSTGRES_DB:-cv}"] + interval: 10s timeout: 5s - retries: 5 - start_period: 10s + retries: 3 server: build: context: . dockerfile: apps/server/Dockerfile - working_dir: /app - command: sh -c "cd apps/server && npm run prisma:deploy && npm run dev" environment: PORT: ${SERVER_PORT:-3000} JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-here} JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m} JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d} DATABASE_URL: ${DATABASE_URL:-postgresql://cv:cv@db:5432/cv} - POSTGRES_USER: ${POSTGRES_USER:-cv} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cv} - POSTGRES_DB: ${POSTGRES_DB:-cv} - PRISMA_ENABLE_TRACING: ${PRISMA_ENABLE_TRACING:-false} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + RESEND_API_KEY: ${RESEND_API_KEY} depends_on: db: condition: service_healthy ports: - - "${SERVER_PORT:-3000}:${SERVER_PORT:-3000}" + - "${SERVER_PORT:-3000}:3000" volumes: - - .:/app + - ./apps/server/src:/app/apps/server/src + - ./apps/server/prisma:/app/apps/server/prisma + - ./packages:/app/packages - npm-cache:/root/.npm + command: sh -c "npm run prisma:deploy --workspace=@cv/server && npm run dev --workspace=@cv/server" healthcheck: - test: ["CMD", "sh", "/app/scripts/health-check-server.sh"] - interval: 5s + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 15s timeout: 5s - retries: 5 + retries: 3 start_period: 30s client: @@ -52,91 +50,49 @@ services: context: . dockerfile: apps/client/Dockerfile target: development - working_dir: /app - command: sh -c "cd apps/client && npm run codegen && npm run dev" environment: VITE_SERVER_URL: ${VITE_SERVER_URL:-http://localhost:3000} VITE_DOCS_URL: ${VITE_DOCS_URL:-http://localhost:3001} - GRAPHQL_SCHEMA_URL: ${GRAPHQL_SCHEMA_URL:-http://localhost:3000/graphql} + GRAPHQL_SCHEMA_URL: ${GRAPHQL_SCHEMA_URL:-http://server:3000/graphql} depends_on: server: condition: service_healthy - restart: true ports: - - "${CLIENT_PORT:-5173}:${CLIENT_PORT:-5173}" + - "${CLIENT_PORT:-5173}:5173" volumes: - ./apps/client/src:/app/apps/client/src - - ./apps/client/public:/app/apps/client/public - - ./apps/client/index.html:/app/apps/client/index.html - - ./apps/client/vite.config.ts:/app/apps/client/vite.config.ts - - ./apps/client/tsconfig.json:/app/apps/client/tsconfig.json - - ./apps/client/package.json:/app/apps/client/package.json - - ./apps/client/package-lock.json:/app/apps/client/package-lock.json - ./packages:/app/packages - npm-cache:/root/.npm - - /app/apps/client/node_modules # Anonymous volume to prevent host node_modules from overriding + command: sh -c "npm run codegen --workspace=@cv/client && npm run dev --workspace=@cv/client" healthcheck: - test: ["CMD", "/app/scripts/health-check-client.sh"] - interval: 10s + test: ["CMD", "curl", "-f", "http://localhost:5173"] + interval: 15s timeout: 5s retries: 3 - start_period: 30s + start_period: 20s docs: build: context: . dockerfile: apps/docs/Dockerfile target: development - working_dir: /app - command: sh -c "cd apps/docs && npm run dev" environment: VITE_CLIENT_URL: ${VITE_CLIENT_URL:-http://localhost:5173} VITE_SERVER_URL: ${VITE_SERVER_URL:-http://localhost:3000} - VITE_DOCS_URL: ${VITE_DOCS_URL:-http://localhost:3001} - VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL:-http://localhost:3000/graphql} - VITE_DB_HOST: ${VITE_DB_HOST:-localhost} - VITE_DB_PORT: ${VITE_DB_PORT:-5432} ports: - "${DOCS_PORT:-3001}:3001" volumes: - ./apps/docs/src:/app/apps/docs/src - ./apps/docs/content:/app/apps/docs/content - - /app/node_modules + - ./packages:/app/packages - npm-cache:/root/.npm healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001"] - interval: 10s + interval: 15s timeout: 5s retries: 3 - start_period: 10s - - prisma-studio: - build: - context: . - dockerfile: apps/server/Dockerfile - working_dir: /app/apps/server - command: npx prisma studio --hostname 0.0.0.0 --port 5555 - environment: - DATABASE_URL: ${DATABASE_URL:-postgresql://cv:cv@db:5432/cv} - POSTGRES_USER: ${POSTGRES_USER:-cv} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cv} - POSTGRES_DB: ${POSTGRES_DB:-cv} - depends_on: - db: - condition: service_healthy - ports: - - "5555:5555" - volumes: - - .:/app - - npm-cache:/root/.npm - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:5555 || exit 1"] - interval: 5s - timeout: 5s - retries: 5 - start_period: 15s + start_period: 20s volumes: db-data: npm-cache: - diff --git a/package-lock.json b/package-lock.json index 08324a5..8fcb9ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ ], "dependencies": { "@cspotcode/source-map-support": "^0.8.1", - "@prisma/client": "^6.17.1" + "@prisma/client": "^7.1.0" }, "devDependencies": { "@biomejs/biome": "^2.2.6", @@ -23,6 +23,7 @@ "name": "@cv/client", "version": "0.0.0", "dependencies": { + "@cv/routing": "*", "@cv/ui": "*", "@cv/utils": "*", "@tanstack/react-query": "^5.59.0", @@ -31,11 +32,11 @@ "@types/react-router-dom": "^5.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.0.0", - "graphql": "^16.8.1", + "graphql": "^16.12.0", "graphql-request": "^6.1.0", "graphql-type-json": "^0.3.2", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-router-dom": "^7.9.4", "tailwind-merge": "^2.0.0", "zod": "^3.25.76" @@ -44,14 +45,14 @@ "@biomejs/biome": "^2.2.6", "@cv/biome-config": "*", "@cv/tsconfig": "*", - "@graphql-codegen/cli": "^5.0.0", - "@graphql-codegen/typescript": "^4.0.1", - "@graphql-codegen/typescript-operations": "^4.0.1", + "@graphql-codegen/cli": "^6.1.0", + "@graphql-codegen/typescript": "^5.0.6", + "@graphql-codegen/typescript-operations": "^5.0.6", "@graphql-codegen/typescript-react-query": "^6.1.0", "@tailwindcss/postcss": "^4.1.15", "@tailwindcss/vite": "^4.0.0", - "@types/react": "^18.3.11", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.0.6", + "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "^5.6.3", @@ -63,13 +64,14 @@ "version": "0.0.0", "dependencies": { "@catppuccin/palette": "^1.4.0", + "@cv/routing": "*", "@cv/ui": "*", "@mdx-js/react": "^3.1.1", "@mdx-js/rollup": "^3.1.1", "@types/mdx": "^2.0.13", "highlight.js": "^11.10.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-markdown": "^9.0.1", "react-router-dom": "^6.26.1", "rehype-highlight": "^7.0.0", @@ -79,8 +81,8 @@ "@tailwindcss/postcss": "^4.0.0", "@tailwindcss/vite": "^4.0.0", "@types/mdast": "^4.0.4", - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.0.6", + "@types/react-dom": "^19.0.3", "@types/unist": "^3.0.3", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", @@ -160,8 +162,6 @@ }, "apps/docs/node_modules/@esbuild/darwin-arm64": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -483,8 +483,6 @@ }, "apps/docs/node_modules/esbuild": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -522,8 +520,6 @@ }, "apps/docs/node_modules/react-router-dom": { "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0", @@ -539,8 +535,6 @@ }, "apps/docs/node_modules/react-router-dom/node_modules/react-router": { "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.0" @@ -554,8 +548,6 @@ }, "apps/docs/node_modules/vite": { "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -616,31 +608,41 @@ "name": "@cv/server", "version": "0.0.0", "dependencies": { + "@cv/auth": "*", + "@cv/system": "*", "@cv/utils": "*", "@faker-js/faker": "^10.1.0", "@nestjs/apollo": "^12.2.2", "@nestjs/common": "^10.4.7", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/graphql": "^12.2.2", "@nestjs/jwt": "^10.2.0", "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.7", - "@prisma/client": "^6.17.1", + "@prisma/adapter-pg": "^7.1.0", + "@prisma/client": "^7.1.0", + "@types/cookie-parser": "^1.4.10", + "@types/handlebars": "^4.0.40", "apollo-server-express": "^3.13.0", "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", - "graphql": "^16.11.0", + "cookie-parser": "^1.4.7", + "graphql": "^16.12.0", "graphql-scalars": "^1.23.0", "graphql-type-json": "^0.3.2", + "handlebars": "^4.7.8", "joi": "^17.13.3", "nestjs-zod": "^3.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "pg": "^8.16.3", "reflect-metadata": "^0.2.2", + "resend": "^6.5.2", "rxjs": "^7.8.1", "zod": "^3.23.8" }, @@ -654,9 +656,10 @@ "@types/node": "^22.7.5", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", + "@types/pg": "^8.15.6", "jest": "^29.7.0", "nodemon": "^3.1.7", - "prisma": "^6.17.1", + "prisma": "^7.1.0", "supertest": "^6.3.4", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", @@ -683,7 +686,6 @@ "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", "license": "MIT", - "peer": true, "peerDependencies": { "graphql": "14.x || 15.x || 16.x" } @@ -719,7 +721,6 @@ "integrity": "sha512-jKRlf+sBMMdKYrjMoiWKne42Eb6paBfDOr08KJnUaeaiyWFj+/040FjVPQI7YGLfdwnYIsl1NUUqS2UdgezJDg==", "deprecated": "Apollo Server v4 is deprecated and will transition to end-of-life on January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v5 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.", "license": "MIT", - "peer": true, "dependencies": { "@apollo/cache-control-types": "^1.0.3", "@apollo/server-gateway-interface": "^1.1.1", @@ -759,7 +760,6 @@ "integrity": "sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==", "deprecated": "@apollo/server-gateway-interface v1 is part of Apollo Server v4, which is deprecated and will transition to end-of-life on January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v2 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.", "license": "MIT", - "peer": true, "dependencies": { "@apollo/usage-reporting-protobuf": "^4.1.1", "@apollo/utils.fetcher": "^2.0.0", @@ -786,12 +786,44 @@ "@apollo/server": "^4.0.0" } }, + "node_modules/@apollo/server/node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@apollo/server/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@apollo/server/node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, "node_modules/@apollo/server/node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -805,7 +837,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -824,7 +855,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-2.0.2.tgz", "integrity": "sha512-UkS3xqnVFLZ3JFpEmU/2cM2iKJotQXMoSTgxXsfQgXLC5gR1WaepoXagmYnPSA7Q/2cmnyTYK5OgAgoC4RULPg==", "license": "MIT", - "peer": true, "dependencies": { "@apollo/utils.isnodelike": "^2.0.1", "sha.js": "^2.4.11" @@ -838,7 +868,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", "license": "MIT", - "peer": true, "engines": { "node": ">=14" }, @@ -851,7 +880,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-2.0.1.tgz", "integrity": "sha512-jvvon885hEyWXd4H6zpWeN3tl88QcWnHp5gWF5OPF34uhvoR+DFqcNxs9vrRaBBSY3qda3Qe0bdud7tz2zGx1A==", "license": "MIT", - "peer": true, "engines": { "node": ">=14" } @@ -861,7 +889,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-2.0.1.tgz", "integrity": "sha512-w41XyepR+jBEuVpoRM715N2ZD0xMD413UiJx8w5xnAZD2ZkSJnMJBoIzauK83kJpSgNuR6ywbV29jG9NmxjK0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=14" } @@ -871,7 +898,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-2.1.1.tgz", "integrity": "sha512-qVo5PvUUMD8oB9oYvq4ViCjYAMWnZ5zZwEjNF37L2m1u528x5mueMlU+Cr1UinupCgdB78g+egA1G98rbJ03Vw==", "license": "MIT", - "peer": true, "dependencies": { "@apollo/utils.logger": "^2.0.1", "lru-cache": "^7.14.1" @@ -885,7 +911,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -895,7 +920,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-2.0.1.tgz", "integrity": "sha512-YuplwLHaHf1oviidB7MxnCXAdHp3IqYV8n0momZ3JfLniae92eYqMIx+j5qJFX6WKJPs6q7bczmV4lXIsTu5Pg==", "license": "MIT", - "peer": true, "engines": { "node": ">=14" } @@ -905,7 +929,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", "license": "MIT", - "peer": true, "engines": { "node": ">=14" }, @@ -918,7 +941,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", "license": "MIT", - "peer": true, "engines": { "node": ">=14" }, @@ -931,7 +953,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", "license": "MIT", - "peer": true, "dependencies": { "lodash.sortby": "^4.7.0" }, @@ -947,7 +968,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", "license": "MIT", - "peer": true, "engines": { "node": ">=14" }, @@ -960,7 +980,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", "license": "MIT", - "peer": true, "dependencies": { "@apollo/usage-reporting-protobuf": "^4.1.0", "@apollo/utils.dropunuseddefinitions": "^2.0.1", @@ -981,7 +1000,6 @@ "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-2.0.1.tgz", "integrity": "sha512-YBDiuAX9i1lLc6GeTy1m7DGLFn/gMnvXqlalOIMjM7DeOgIacEjjfwPqb0M1CQ2v11HhR15d1NmxJoRCfrNqcA==", "license": "MIT", - "peer": true, "engines": { "node": ">=14" } @@ -1049,9 +1067,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "dev": true, "license": "MIT", "engines": { @@ -1059,21 +1077,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -1100,14 +1119,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1185,18 +1204,18 @@ "license": "ISC" }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", - "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.3", + "@babel/traverse": "^7.28.5", "semver": "^6.3.1" }, "engines": { @@ -1227,14 +1246,14 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1338,9 +1357,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -1372,13 +1391,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -1730,9 +1749,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.4.tgz", - "integrity": "sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz", + "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==", "dev": true, "license": "MIT", "dependencies": { @@ -1784,14 +1803,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", - "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.0" + "@babel/traverse": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2093,18 +2112,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/types": "^7.28.5", "debug": "^4.3.1" }, "engines": { @@ -2112,14 +2131,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2133,9 +2152,9 @@ "license": "MIT" }, "node_modules/@biomejs/biome": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.6.tgz", - "integrity": "sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.10.tgz", + "integrity": "sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -2149,20 +2168,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.2.6", - "@biomejs/cli-darwin-x64": "2.2.6", - "@biomejs/cli-linux-arm64": "2.2.6", - "@biomejs/cli-linux-arm64-musl": "2.2.6", - "@biomejs/cli-linux-x64": "2.2.6", - "@biomejs/cli-linux-x64-musl": "2.2.6", - "@biomejs/cli-win32-arm64": "2.2.6", - "@biomejs/cli-win32-x64": "2.2.6" + "@biomejs/cli-darwin-arm64": "2.3.10", + "@biomejs/cli-darwin-x64": "2.3.10", + "@biomejs/cli-linux-arm64": "2.3.10", + "@biomejs/cli-linux-arm64-musl": "2.3.10", + "@biomejs/cli-linux-x64": "2.3.10", + "@biomejs/cli-linux-x64-musl": "2.3.10", + "@biomejs/cli-win32-arm64": "2.3.10", + "@biomejs/cli-win32-x64": "2.3.10" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.6.tgz", - "integrity": "sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.10.tgz", + "integrity": "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==", "cpu": [ "arm64" ], @@ -2177,9 +2196,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.6.tgz", - "integrity": "sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.10.tgz", + "integrity": "sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==", "cpu": [ "x64" ], @@ -2194,9 +2213,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.6.tgz", - "integrity": "sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.10.tgz", + "integrity": "sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==", "cpu": [ "arm64" ], @@ -2211,9 +2230,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.6.tgz", - "integrity": "sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.10.tgz", + "integrity": "sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==", "cpu": [ "arm64" ], @@ -2228,9 +2247,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.6.tgz", - "integrity": "sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.10.tgz", + "integrity": "sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==", "cpu": [ "x64" ], @@ -2245,9 +2264,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.6.tgz", - "integrity": "sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.10.tgz", + "integrity": "sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==", "cpu": [ "x64" ], @@ -2262,9 +2281,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.6.tgz", - "integrity": "sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.10.tgz", + "integrity": "sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==", "cpu": [ "arm64" ], @@ -2279,9 +2298,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.6.tgz", - "integrity": "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.10.tgz", + "integrity": "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==", "cpu": [ "x64" ], @@ -2296,9 +2315,9 @@ } }, "node_modules/@borewit/text-codec": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", - "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", "license": "MIT", "funding": { "type": "github", @@ -2330,6 +2349,43 @@ "node": ">=22.0.0" } }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -2342,6 +2398,10 @@ "node": ">=12" } }, + "node_modules/@cv/auth": { + "resolved": "packages/auth", + "link": true + }, "node_modules/@cv/biome-config": { "resolved": "packages/biome-config", "link": true @@ -2354,10 +2414,18 @@ "resolved": "apps/docs", "link": true }, + "node_modules/@cv/routing": { + "resolved": "packages/routing", + "link": true + }, "node_modules/@cv/server": { "resolved": "apps/server", "link": true }, + "node_modules/@cv/system": { + "resolved": "packages/system", + "link": true + }, "node_modules/@cv/tsconfig": { "resolved": "packages/tsconfig", "link": true @@ -2370,10 +2438,41 @@ "resolved": "packages/utils", "link": true }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz", + "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.6.tgz", + "integrity": "sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.2" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.7.tgz", + "integrity": "sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.3.2" + } + }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", "dev": true, "license": "MIT", "dependencies": { @@ -2382,9 +2481,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "dev": true, "license": "MIT", "dependencies": { @@ -2402,9 +2501,9 @@ } }, "node_modules/@envelop/core": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.3.2.tgz", - "integrity": "sha512-06Mu7fmyKzk09P2i2kHpGfItqLLgCq7uO5/nX4fc/iHMplWPNuAx4iYR+WXUQoFHDnP6EUbceQNQ5iyeMz9f3g==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.4.0.tgz", + "integrity": "sha512-/1fat63pySE8rw/dZZArEVytLD90JApY85deDJ0/34gm+yhQ3k70CloSUevxoOE4YCGveG3s9SJJfQeeB4NAtQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2445,175 +2544,582 @@ "node": ">=18.0.0" } }, - "node_modules/@faker-js/faker": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", - "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", - "npm": ">=10" + "node": ">=18" } }, - "node_modules/@fastify/busboy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", - "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@graphql-codegen/add": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.3.tgz", - "integrity": "sha512-SxXPmramkth8XtBlAHu4H4jYcYXM/o3p01+psU+0NADQowA8jtYkK6MW5rV6T+CxkEaNZItfSmZRPgIuypcqnA==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", - "tslib": "~2.6.0" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@graphql-codegen/add/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true, - "license": "0BSD" - }, - "node_modules/@graphql-codegen/cli": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.7.tgz", - "integrity": "sha512-h/sxYvSaWtxZxo8GtaA8SvcHTyViaaPd7dweF/hmRDpaQU1o3iU3EZxlcJ+oLTunU0tSMFsnrIXm/mhXxI11Cw==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/generator": "^7.18.13", - "@babel/template": "^7.18.10", - "@babel/types": "^7.18.13", - "@graphql-codegen/client-preset": "^4.8.2", - "@graphql-codegen/core": "^4.0.2", - "@graphql-codegen/plugin-helpers": "^5.1.1", - "@graphql-tools/apollo-engine-loader": "^8.0.0", - "@graphql-tools/code-file-loader": "^8.0.0", - "@graphql-tools/git-loader": "^8.0.0", - "@graphql-tools/github-loader": "^8.0.0", - "@graphql-tools/graphql-file-loader": "^8.0.0", - "@graphql-tools/json-file-loader": "^8.0.0", - "@graphql-tools/load": "^8.1.0", - "@graphql-tools/prisma-loader": "^8.0.0", - "@graphql-tools/url-loader": "^8.0.0", - "@graphql-tools/utils": "^10.0.0", - "@whatwg-node/fetch": "^0.10.0", - "chalk": "^4.1.0", - "cosmiconfig": "^8.1.3", - "debounce": "^1.2.0", - "detect-indent": "^6.0.0", - "graphql-config": "^5.1.1", - "inquirer": "^8.0.0", - "is-glob": "^4.0.1", - "jiti": "^1.17.1", - "json-to-pretty-yaml": "^1.2.2", - "listr2": "^4.0.5", - "log-symbols": "^4.0.0", - "micromatch": "^4.0.5", - "shell-quote": "^1.7.3", - "string-env-interpolation": "^1.0.1", - "ts-log": "^2.2.3", - "tslib": "^2.4.0", - "yaml": "^2.3.1", - "yargs": "^17.0.0" - }, - "bin": { - "gql-gen": "cjs/bin.js", - "graphql-code-generator": "cjs/bin.js", - "graphql-codegen": "cjs/bin.js", - "graphql-codegen-esm": "esm/bin.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@parcel/watcher": "^2.1.0", - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - }, - "peerDependenciesMeta": { - "@parcel/watcher": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@graphql-codegen/cli/node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@graphql-codegen/cli/node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@graphql-codegen/cli/node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@faker-js/faker": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", + "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@graphql-codegen/add": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-6.0.0.tgz", + "integrity": "sha512-biFdaURX0KTwEJPQ1wkT6BRgNasqgQ5KbCI1a3zwtLtO7XTo7/vKITPylmiU27K5DSOWYnY/1jfSqUAEBuhZrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^6.0.0", + "tslib": "~2.6.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@graphql-codegen/add/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@graphql-codegen/cli": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.1.0.tgz", + "integrity": "sha512-7w3Zq5IFONVOBcyOiP01Nv9WRxGS/TEaBCAb/ALYA3xHq95dqKCpoGnxt/Ut9R18jiS+aMgT0gc8Tr8sHy44jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.18.13", + "@babel/template": "^7.18.10", + "@babel/types": "^7.18.13", + "@graphql-codegen/client-preset": "^5.2.0", + "@graphql-codegen/core": "^5.0.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-tools/apollo-engine-loader": "^8.0.0", + "@graphql-tools/code-file-loader": "^8.0.0", + "@graphql-tools/git-loader": "^8.0.0", + "@graphql-tools/github-loader": "^9.0.0", + "@graphql-tools/graphql-file-loader": "^8.0.0", + "@graphql-tools/json-file-loader": "^8.0.0", + "@graphql-tools/load": "^8.1.0", + "@graphql-tools/url-loader": "^9.0.0", + "@graphql-tools/utils": "^10.0.0", + "@inquirer/prompts": "^7.8.2", + "@whatwg-node/fetch": "^0.10.0", + "chalk": "^4.1.0", + "cosmiconfig": "^9.0.0", + "debounce": "^2.0.0", + "detect-indent": "^6.0.0", + "graphql-config": "^5.1.1", + "is-glob": "^4.0.1", + "jiti": "^2.3.0", + "json-to-pretty-yaml": "^1.2.2", + "listr2": "^9.0.0", + "log-symbols": "^4.0.0", + "micromatch": "^4.0.5", + "shell-quote": "^1.7.3", + "string-env-interpolation": "^1.0.1", + "ts-log": "^2.2.3", + "tslib": "^2.4.0", + "yaml": "^2.3.1", + "yargs": "^17.0.0" + }, + "bin": { + "gql-gen": "cjs/bin.js", + "graphql-code-generator": "cjs/bin.js", + "graphql-codegen": "cjs/bin.js", + "graphql-codegen-esm": "esm/bin.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@parcel/watcher": "^2.1.0", + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "@parcel/watcher": { + "optional": true + } + } + }, + "node_modules/@graphql-codegen/cli/node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } }, "node_modules/@graphql-codegen/client-preset": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.8.3.tgz", - "integrity": "sha512-QpEsPSO9fnRxA6Z66AmBuGcwHjZ6dYSxYo5ycMlYgSPzAbyG8gn/kWljofjJfWqSY+T/lRn+r8IXTH14ml24vQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.2.2.tgz", + "integrity": "sha512-1xufIJZr04ylx0Dnw49m8Jrx1s1kujUNVm+Tp5cPRsQmgPN9VjB7wWY7CGD8ArStv6Vjb0a31Xnm5I+VzZM+Rw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", - "@graphql-codegen/add": "^5.0.3", - "@graphql-codegen/gql-tag-operations": "4.0.17", - "@graphql-codegen/plugin-helpers": "^5.1.1", - "@graphql-codegen/typed-document-node": "^5.1.2", - "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/typescript-operations": "^4.6.1", - "@graphql-codegen/visitor-plugin-common": "^5.8.0", + "@graphql-codegen/add": "^6.0.0", + "@graphql-codegen/gql-tag-operations": "5.1.2", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/typed-document-node": "^6.1.5", + "@graphql-codegen/typescript": "^5.0.7", + "@graphql-codegen/typescript-operations": "^5.0.7", + "@graphql-codegen/visitor-plugin-common": "^6.2.2", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", "@graphql-typed-document-node/core": "3.2.0", @@ -2640,29 +3146,32 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/core": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-4.0.2.tgz", - "integrity": "sha512-IZbpkhwVqgizcjNiaVzNAzm/xbWT6YnGgeOLwVjm4KbJn3V2jchVtuzHH09G5/WkkLSk2wgbXNdwjM41JxO6Eg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-5.0.0.tgz", + "integrity": "sha512-vLTEW0m8LbE4xgRwbFwCdYxVkJ1dBlVJbQyLb9Q7bHnVFgHAP982Xo8Uv7FuPBmON+2IbTjkCqhFLHVZbqpvjQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "node_modules/@graphql-codegen/core/node_modules/@graphql-tools/merge": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.1.tgz", - "integrity": "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w==", + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.6.tgz", + "integrity": "sha512-bTnP+4oom4nDjmkS3Ykbe+ljAp/RIiWP3R35COMmuucS24iQxGLa9Hn8VMkLIoaoPxgz6xk+dbC43jtkNsFoBw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", + "@graphql-tools/utils": "^10.11.0", "tslib": "^2.4.0" }, "engines": { @@ -2673,14 +3182,14 @@ } }, "node_modules/@graphql-codegen/core/node_modules/@graphql-tools/schema": { - "version": "10.0.25", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.25.tgz", - "integrity": "sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw==", + "version": "10.0.30", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.30.tgz", + "integrity": "sha512-yPXU17uM/LR90t92yYQqn9mAJNOVZJc0nQtYeZyZeQZeQjwIGlTubvvoDL0fFVk+wZzs4YQOgds2NwSA4npodA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^9.1.1", - "@graphql-tools/utils": "^10.9.1", + "@graphql-tools/merge": "^9.1.6", + "@graphql-tools/utils": "^10.11.0", "tslib": "^2.4.0" }, "engines": { @@ -2698,14 +3207,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-4.0.17.tgz", - "integrity": "sha512-2pnvPdIG6W9OuxkrEZ6hvZd142+O3B13lvhrZ48yyEBh2ujtmKokw0eTwDHtlXUqjVS0I3q7+HB2y12G/m69CA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.1.2.tgz", + "integrity": "sha512-BIv66VJ2bKlpfXBeVakJxihBSKnBIdGFLMaFdnGPxqYlKIzaGffjsGbhViPwwBinmBChW4Se6PU4Py7eysYEiA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/visitor-plugin-common": "6.2.2", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" @@ -2725,9 +3234,9 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/plugin-helpers": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz", - "integrity": "sha512-28GHODK2HY1NhdyRcPP3sCz0Kqxyfiz7boIZ8qIxFYmpLYnlDgiYok5fhFLVSZihyOpCs4Fa37gVHf/Q4I2FEg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.1.0.tgz", + "integrity": "sha512-JJypehWTcty9kxKiqH7TQOetkGdOYjY78RHlI+23qB59cV2wxjFFVf8l7kmuXS4cpGVUNfIjFhVr7A1W7JMtdA==", "dev": true, "license": "MIT", "dependencies": { @@ -2753,16 +3262,19 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/schema-ast": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-4.1.0.tgz", - "integrity": "sha512-kZVn0z+th9SvqxfKYgztA6PM7mhnSZaj4fiuBWvMTqA+QqQ9BBed6Pz41KuD/jr0gJtnlr2A4++/0VlpVbCTmQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-5.0.0.tgz", + "integrity": "sha512-jn7Q3PKQc0FxXjbpo9trxzlz/GSFQWxL042l0iC8iSbM/Ar+M7uyBwMtXPsev/3Razk+osQyreghIz0d2+6F7Q==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } @@ -2775,14 +3287,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typed-document-node": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-5.1.2.tgz", - "integrity": "sha512-jaxfViDqFRbNQmfKwUY8hDyjnLTw2Z7DhGutxoOiiAI0gE/LfPe0LYaVFKVmVOOD7M3bWxoWfu4slrkbWbUbEw==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.1.5.tgz", + "integrity": "sha512-6dgEPz+YRMzSPpATj7tsKh/L6Y8OZImiyXIUzvSq/dRAEgoinahrES5y/eZQyc7CVxfoFCyHF9KMQQ9jiLn7lw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/visitor-plugin-common": "6.2.2", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "~2.6.0" @@ -2802,15 +3314,15 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typescript": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.6.tgz", - "integrity": "sha512-vpw3sfwf9A7S+kIUjyFxuvrywGxd4lmwmyYnnDVjVE4kSQ6Td3DpqaPTy8aNQ6O96vFoi/bxbZS2BW49PwSUUA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.7.tgz", + "integrity": "sha512-kZwcu9Iat5RWXxLGPnDbG6qVbGTigF25/aGqCG/DCQ1Al8RufSjVXhIOkJBp7QWAqXn3AupHXL1WTMXP7xs4dQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/schema-ast": "^4.0.2", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/schema-ast": "^5.0.0", + "@graphql-codegen/visitor-plugin-common": "6.2.2", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2822,15 +3334,15 @@ } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.6.1.tgz", - "integrity": "sha512-k92laxhih7s0WZ8j5WMIbgKwhe64C0As6x+PdcvgZFMudDJ7rPJ/hFqJ9DCRxNjXoHmSjnr6VUuQZq4lT1RzCA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.0.7.tgz", + "integrity": "sha512-5N3myNse1putRQlp8+l1k9ayvc98oq2mPJx0zN8MTOlTBxcb2grVPFRLy5wJJjuv9NffpyCkVJ9LvUaf8mqQgg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/typescript": "^5.0.7", + "@graphql-codegen/visitor-plugin-common": "6.2.2", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -3014,6 +3526,16 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/@graphql-codegen/typescript-react-query/node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/@graphql-codegen/typescript-react-query/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3101,19 +3623,19 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-5.8.0.tgz", - "integrity": "sha512-lC1E1Kmuzi3WZUlYlqB4fP6+CvbKH9J+haU1iWmgsBx5/sO2ROeXJG4Dmt8gP03bI2BwjiwV5WxCEMlyeuzLnA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.2.2.tgz", + "integrity": "sha512-wEJ4zJj58PKlXISItZfr0xIHyM1lAuRfoflPegsb1L17Mx5+YzNOy0WAlLele3yzyV89WvCiprFKMcVQ7KfDXg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", - "dependency-graph": "^0.11.0", + "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "~2.6.0" @@ -3133,24 +3655,24 @@ "license": "0BSD" }, "node_modules/@graphql-hive/signal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-1.0.0.tgz", - "integrity": "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-2.0.0.tgz", + "integrity": "sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@graphql-tools/apollo-engine-loader": { - "version": "8.0.22", - "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.22.tgz", - "integrity": "sha512-ssD2wNxeOTRcUEkuGcp0KfZAGstL9YLTe/y3erTDZtOs2wL1TJESw8NVAp+3oUHPeHKBZQB4Z6RFEbPgMdT2wA==", + "version": "8.0.27", + "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.27.tgz", + "integrity": "sha512-XT4BvqmRXkVaT8GgNb9/pr8u4M4vTcvGuI2GlvK+albrJNIV8VxTpsdVYma3kw+VtSIYrxEvLixlfDA/KdmDpg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/fetch": "^0.10.0", + "@graphql-tools/utils": "^10.11.0", + "@whatwg-node/fetch": "^0.10.13", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0" }, @@ -3162,33 +3684,335 @@ } }, "node_modules/@graphql-tools/batch-execute": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.19.tgz", - "integrity": "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-10.0.4.tgz", + "integrity": "sha512-t8E0ILelbaIju0aNujMkKetUmbv3/07nxGSv0kEGLBk9GNtEmQ/Bjj8ZTo2WN35/Fy70zCHz2F/48Nx/Ec48cA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/promise-helpers": "^1.3.0", + "@graphql-tools/utils": "^10.10.3", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/code-file-loader": { + "version": "8.1.27", + "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.27.tgz", + "integrity": "sha512-q3GDbm+7m3DiAnqxa+lYMgYZd49+ez6iGFfXHmzP6qAnf5WlBxRNKNjNVuxOgoV30DCr+vOJfoXeU7VN1qqGWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/graphql-tag-pluck": "8.3.26", + "@graphql-tools/utils": "^10.11.0", + "globby": "^11.0.3", + "tslib": "^2.4.0", + "unixify": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/delegate": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-12.0.2.tgz", + "integrity": "sha512-1X93onxNgOzRvnZ8Xulwi6gNuBeuDxvGYOjUHEZyesPCsaWsyiVj1Wk6Pw/DTPGLy70sOFUKQGcaZbWnDORM2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/batch-execute": "^10.0.4", + "@graphql-tools/executor": "^1.4.13", + "@graphql-tools/schema": "^10.0.29", + "@graphql-tools/utils": "^10.10.3", + "@repeaterjs/repeater": "^3.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/delegate/node_modules/@graphql-tools/merge": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.6.tgz", + "integrity": "sha512-bTnP+4oom4nDjmkS3Ykbe+ljAp/RIiWP3R35COMmuucS24iQxGLa9Hn8VMkLIoaoPxgz6xk+dbC43jtkNsFoBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.11.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/delegate/node_modules/@graphql-tools/schema": { + "version": "10.0.30", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.30.tgz", + "integrity": "sha512-yPXU17uM/LR90t92yYQqn9mAJNOVZJc0nQtYeZyZeQZeQjwIGlTubvvoDL0fFVk+wZzs4YQOgds2NwSA4npodA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.1.6", + "@graphql-tools/utils": "^10.11.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/documents": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/documents/-/documents-1.0.1.tgz", + "integrity": "sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.0.tgz", + "integrity": "sha512-3HzAxfexmynEWwRB56t/BT+xYKEYLGPvJudR1jfs+XZX8bpfqujEhqVFoxmkpEE8BbFcKuBNoQyGkTi1eFJ+hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.11.0", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-common": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-1.0.5.tgz", + "integrity": "sha512-gsBRxP4ui8s7/ppKGCJUQ9xxTNoFpNYmEirgM52EHo74hL5hrpS5o4zOmBH33+9t2ZasBziIfupYtLNa0DgK0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.4.0", + "@graphql-tools/utils": "^10.10.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-graphql-ws": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-3.1.3.tgz", + "integrity": "sha512-q4k8KLoH2U51XdWJRdiW/KIKbBOtJ1mcILv0ALvBkOF99C3vwGj2zr4U0AMGCD3HzML2mPZuajhfYo/xB/pnZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-common": "^1.0.5", + "@graphql-tools/utils": "^10.10.3", + "@whatwg-node/disposablestack": "^0.0.6", + "graphql-ws": "^6.0.6", + "isows": "^1.0.7", + "tslib": "^2.8.1", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-http": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-3.0.7.tgz", + "integrity": "sha512-sHjtiUZmRtkjhpSzMhxT2ywAGzHjuB1rHsiaSLAq8U5BQg5WoLakKYD7BajgVHwNbfWEc+NnFiJI7ldyhiciiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-hive/signal": "^2.0.0", + "@graphql-tools/executor-common": "^1.0.5", + "@graphql-tools/utils": "^10.10.3", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "meros": "^1.3.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor-legacy-ws": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.24.tgz", + "integrity": "sha512-wfSpOJCxeBcwVXy3JS4TB4oLwVICuVKPlPQhcAjTRPWYwKerE0HosgUzxCX1fEQ4l1B1OMgKWRglGpoXExKqsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.11.0", + "@types/ws": "^8.0.0", + "isomorphic-ws": "^5.0.0", + "tslib": "^2.4.0", + "ws": "^8.17.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/git-loader": { + "version": "8.0.31", + "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.31.tgz", + "integrity": "sha512-xVHM1JecjpU2P0aOj/IaIUc3w6It8sWOdrJElWFZdY9yfWRqXFYwfemtsn/JOrJDIJXYeGpJ304OeqJD5vFIEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/graphql-tag-pluck": "8.3.26", + "@graphql-tools/utils": "^10.11.0", + "is-glob": "4.0.3", + "micromatch": "^4.0.8", + "tslib": "^2.4.0", + "unixify": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/github-loader": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-9.0.5.tgz", + "integrity": "sha512-89FRDQGMlzL3607BCQtJhKEiQaZtTmdAnyC5Hmi9giTQXVzEXBbMEZOU0qILxj64cr+smNBx5XqxQ1xn0uZeEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-http": "^3.0.6", + "@graphql-tools/graphql-tag-pluck": "^8.3.26", + "@graphql-tools/utils": "^10.11.0", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.0.0", + "sync-fetch": "0.6.0-2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/graphql-file-loader": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.1.8.tgz", + "integrity": "sha512-dZi9Cw+NWEzJAqzIUON9qjZfjebjcoT4H6jqLkEoAv6kRtTq52m4BLXgFWjMHU7PNLE9OOHB9St7UeZQL+GYrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/import": "7.1.8", + "@graphql-tools/utils": "^10.11.0", + "globby": "^11.0.3", + "tslib": "^2.4.0", + "unixify": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/graphql-tag-pluck": { + "version": "8.3.26", + "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.26.tgz", + "integrity": "sha512-hLsX++KA3YR/PnNJGBq1weSAY8XUUAQFfOSHanLHA2qs5lcNgU6KWbiLiRsJ/B/ZNi2ZO687dhzeZ4h4Yt0V6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "@graphql-tools/utils": "^10.11.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/import": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.1.8.tgz", + "integrity": "sha512-aUKHMbaeHhCkS867mNCk9sJuvd9xE3Ocr+alwdvILkDxHf7Xaumx4mK8tN9FAXeKhQWGGD5QpkIBnUzt2xoX/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.11.0", + "@theguild/federation-composition": "^0.21.0", + "resolve-from": "5.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/code-file-loader": { - "version": "8.1.22", - "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.22.tgz", - "integrity": "sha512-FSka29kqFkfFmw36CwoQ+4iyhchxfEzPbXOi37lCEjWLHudGaPkXc3RyB9LdmBxx3g3GHEu43a5n5W8gfcrMdA==", + "node_modules/@graphql-tools/json-file-loader": { + "version": "8.0.25", + "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.25.tgz", + "integrity": "sha512-Dnr9z818Kdn3rfoZO/+/ZQUqWavjV7AhEp4edV1mGsX+J1HFkNC3WMl6MD3W0hth2HWLQpCFJDdOPnchxnFNfA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/graphql-tag-pluck": "8.3.21", - "@graphql-tools/utils": "^10.9.1", + "@graphql-tools/utils": "^10.11.0", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" @@ -3200,38 +4024,33 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/delegate": { - "version": "10.2.23", - "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.2.23.tgz", - "integrity": "sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w==", + "node_modules/@graphql-tools/load": { + "version": "8.1.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.1.7.tgz", + "integrity": "sha512-RxrHOC4vVI50+Q1mwgpmTVCB/UDDYVEGD/g/hP3tT2BW9F3rJ7Z3Lmt/nGfPQuWPao3w6vgJ9oSAWtism7CU5w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/batch-execute": "^9.0.19", - "@graphql-tools/executor": "^1.4.9", - "@graphql-tools/schema": "^10.0.25", - "@graphql-tools/utils": "^10.9.1", - "@repeaterjs/repeater": "^3.0.6", - "@whatwg-node/promise-helpers": "^1.3.0", - "dataloader": "^2.2.3", - "dset": "^3.1.2", - "tslib": "^2.8.1" + "@graphql-tools/schema": "^10.0.30", + "@graphql-tools/utils": "^10.11.0", + "p-limit": "3.1.0", + "tslib": "^2.4.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=16.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/delegate/node_modules/@graphql-tools/merge": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.1.tgz", - "integrity": "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w==", + "node_modules/@graphql-tools/load/node_modules/@graphql-tools/merge": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.6.tgz", + "integrity": "sha512-bTnP+4oom4nDjmkS3Ykbe+ljAp/RIiWP3R35COMmuucS24iQxGLa9Hn8VMkLIoaoPxgz6xk+dbC43jtkNsFoBw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", + "@graphql-tools/utils": "^10.11.0", "tslib": "^2.4.0" }, "engines": { @@ -3241,15 +4060,15 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/delegate/node_modules/@graphql-tools/schema": { - "version": "10.0.25", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.25.tgz", - "integrity": "sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw==", + "node_modules/@graphql-tools/load/node_modules/@graphql-tools/schema": { + "version": "10.0.30", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.30.tgz", + "integrity": "sha512-yPXU17uM/LR90t92yYQqn9mAJNOVZJc0nQtYeZyZeQZeQjwIGlTubvvoDL0fFVk+wZzs4YQOgds2NwSA4npodA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^9.1.1", - "@graphql-tools/utils": "^10.9.1", + "@graphql-tools/merge": "^9.1.6", + "@graphql-tools/utils": "^10.11.0", "tslib": "^2.4.0" }, "engines": { @@ -3259,178 +4078,175 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/documents": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/documents/-/documents-1.0.1.tgz", - "integrity": "sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA==", + "node_modules/@graphql-tools/load/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { - "lodash.sortby": "^4.7.0", - "tslib": "^2.4.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", + "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^9.2.1", + "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/executor": { - "version": "1.4.9", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.4.9.tgz", - "integrity": "sha512-SAUlDT70JAvXeqV87gGzvDzUGofn39nvaVcVhNf12Dt+GfWHtNNO/RCn/Ea4VJaSLGzraUd41ObnN3i80EBU7w==", - "dev": true, + "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", + "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@graphql-typed-document-node/core": "^3.2.0", - "@repeaterjs/repeater": "^3.0.4", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/promise-helpers": "^1.0.0", + "@graphql-typed-document-node/core": "^3.1.1", "tslib": "^2.4.0" }, - "engines": { - "node": ">=16.0.0" - }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/executor-common": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.4.tgz", - "integrity": "sha512-SEH/OWR+sHbknqZyROCFHcRrbZeUAyjCsgpVWCRjqjqRbiJiXq6TxNIIOmpXgkrXWW/2Ev4Wms6YSGJXjdCs6Q==", - "dev": true, + "node_modules/@graphql-tools/mock": { + "version": "8.7.20", + "resolved": "https://registry.npmjs.org/@graphql-tools/mock/-/mock-8.7.20.tgz", + "integrity": "sha512-ljcHSJWjC/ZyzpXd5cfNhPI7YljRVvabKHPzKjEs5ElxWu2cdlLGvyNYepApXDsM/OJG/2xuhGM+9GWu5gEAPQ==", "license": "MIT", "dependencies": { - "@envelop/core": "^5.2.3", - "@graphql-tools/utils": "^10.8.1" - }, - "engines": { - "node": ">=18.0.0" + "@graphql-tools/schema": "^9.0.18", + "@graphql-tools/utils": "^9.2.1", + "fast-json-stable-stringify": "^2.1.0", + "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/executor-graphql-ws": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-2.0.7.tgz", - "integrity": "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q==", - "dev": true, + "node_modules/@graphql-tools/mock/node_modules/@graphql-tools/utils": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", + "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", "license": "MIT", "dependencies": { - "@graphql-tools/executor-common": "^0.0.6", - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/disposablestack": "^0.0.6", - "graphql-ws": "^6.0.6", - "isomorphic-ws": "^5.0.0", - "tslib": "^2.8.1", - "ws": "^8.18.3" - }, - "engines": { - "node": ">=18.0.0" + "@graphql-typed-document-node/core": "^3.1.1", + "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/executor-graphql-ws/node_modules/@graphql-tools/executor-common": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.6.tgz", - "integrity": "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow==", + "node_modules/@graphql-tools/optimize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-2.0.0.tgz", + "integrity": "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==", "dev": true, "license": "MIT", "dependencies": { - "@envelop/core": "^5.3.0", - "@graphql-tools/utils": "^10.9.1" + "tslib": "^2.4.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=16.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/executor-http": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-1.3.3.tgz", - "integrity": "sha512-LIy+l08/Ivl8f8sMiHW2ebyck59JzyzO/yF9SFS4NH6MJZUezA1xThUXCDIKhHiD56h/gPojbkpcFvM2CbNE7A==", + "node_modules/@graphql-tools/relay-operation-optimizer": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.26.tgz", + "integrity": "sha512-cVdS2Hw4hg/WgPVV2wRIzZM975pW5k4vdih3hR4SvEDQVr6MmozmlTQSqzMyi9yg8LKTq540Oz3bYQa286yGmg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-hive/signal": "^1.0.0", - "@graphql-tools/executor-common": "^0.0.4", - "@graphql-tools/utils": "^10.8.1", - "@repeaterjs/repeater": "^3.0.4", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/fetch": "^0.10.4", - "@whatwg-node/promise-helpers": "^1.3.0", - "meros": "^1.2.1", - "tslib": "^2.8.1" + "@ardatan/relay-compiler": "^12.0.3", + "@graphql-tools/utils": "^10.11.0", + "tslib": "^2.4.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=16.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/executor-legacy-ws": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.19.tgz", - "integrity": "sha512-bEbv/SlEdhWQD0WZLUX1kOenEdVZk1yYtilrAWjRUgfHRZoEkY9s+oiqOxnth3z68wC2MWYx7ykkS5hhDamixg==", - "dev": true, + "node_modules/@graphql-tools/schema": { + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", + "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@types/ws": "^8.0.0", - "isomorphic-ws": "^5.0.0", + "@graphql-tools/merge": "^8.4.1", + "@graphql-tools/utils": "^9.2.1", "tslib": "^2.4.0", - "ws": "^8.17.1" + "value-or-promise": "^1.0.12" }, - "engines": { - "node": ">=16.0.0" + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", + "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/git-loader": { - "version": "8.0.26", - "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.26.tgz", - "integrity": "sha512-0g+9eng8DaT4ZmZvUmPgjLTgesUa6M8xrDjNBltRldZkB055rOeUgJiKmL6u8PjzI5VxkkVsn0wtAHXhDI2UXQ==", + "node_modules/@graphql-tools/url-loader": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-9.0.5.tgz", + "integrity": "sha512-EPNhZBBL48TudLdyenOw1wV9dI7vsinWLLxSTtkx4zUQxmU+p/LxMyf7MUwjmp3yFZhR/9XchsTZX6uvOyXWqA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/graphql-tag-pluck": "8.3.21", - "@graphql-tools/utils": "^10.9.1", - "is-glob": "4.0.3", - "micromatch": "^4.0.8", + "@graphql-tools/executor-graphql-ws": "^3.1.2", + "@graphql-tools/executor-http": "^3.0.6", + "@graphql-tools/executor-legacy-ws": "^1.1.24", + "@graphql-tools/utils": "^10.11.0", + "@graphql-tools/wrap": "^11.0.0", + "@types/ws": "^8.0.0", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.0.0", + "isomorphic-ws": "^5.0.0", + "sync-fetch": "0.6.0-2", "tslib": "^2.4.0", - "unixify": "^1.0.0" + "ws": "^8.17.1" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/github-loader": { - "version": "8.0.22", - "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-8.0.22.tgz", - "integrity": "sha512-uQ4JNcNPsyMkTIgzeSbsoT9hogLjYrZooLUYd173l5eUGUi49EAcsGdiBCKaKfEjanv410FE8hjaHr7fjSRkJw==", + "node_modules/@graphql-tools/utils": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", + "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/executor-http": "^1.1.9", - "@graphql-tools/graphql-tag-pluck": "^8.3.21", - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/fetch": "^0.10.0", + "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", - "sync-fetch": "0.6.0-2", + "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "engines": { @@ -3440,39 +4256,34 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/graphql-file-loader": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.1.2.tgz", - "integrity": "sha512-VB6ttpwkqCu0KsA1/Wmev4qsu05Qfw49kgVSKkPjuyDQfVaqtr9ewEQRkX5CqnqHGEeLl6sOlNGEMM5fCVMWGQ==", + "node_modules/@graphql-tools/wrap": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-11.1.2.tgz", + "integrity": "sha512-TcKZzUzJNmuyMBQ1oMdnxhBUUacN/5VEJu0/1KVce2aIzCwTTaN9JTU3MgjO7l5Ixn4QLkc6XbxYNv0cHDQgtQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/import": "7.1.2", - "@graphql-tools/utils": "^10.9.1", - "globby": "^11.0.3", - "tslib": "^2.4.0", - "unixify": "^1.0.0" + "@graphql-tools/delegate": "^12.0.2", + "@graphql-tools/schema": "^10.0.29", + "@graphql-tools/utils": "^10.10.3", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.8.1" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/graphql-tag-pluck": { - "version": "8.3.21", - "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.21.tgz", - "integrity": "sha512-TJhELNvR1tmghXMi6HVKp/Swxbx1rcSp/zdkuJZT0DCM3vOY11FXY6NW3aoxumcuYDNN3jqXcCPKstYGFPi5GQ==", + "node_modules/@graphql-tools/wrap/node_modules/@graphql-tools/merge": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.6.tgz", + "integrity": "sha512-bTnP+4oom4nDjmkS3Ykbe+ljAp/RIiWP3R35COMmuucS24iQxGLa9Hn8VMkLIoaoPxgz6xk+dbC43jtkNsFoBw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", - "@graphql-tools/utils": "^10.9.1", + "@graphql-tools/utils": "^10.11.0", "tslib": "^2.4.0" }, "engines": { @@ -3482,16 +4293,15 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/import": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.1.2.tgz", - "integrity": "sha512-+tlNQbLEqAA4LdWoLwM1tckx95lo8WIKd8vhj99b9rLwN/KfLwHWzdS3jnUFK7+99vmHmN1oE5v5zmqJz0MTKw==", + "node_modules/@graphql-tools/wrap/node_modules/@graphql-tools/schema": { + "version": "10.0.30", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.30.tgz", + "integrity": "sha512-yPXU17uM/LR90t92yYQqn9mAJNOVZJc0nQtYeZyZeQZeQjwIGlTubvvoDL0fFVk+wZzs4YQOgds2NwSA4npodA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@theguild/federation-composition": "^0.20.1", - "resolve-from": "5.0.0", + "@graphql-tools/merge": "^9.1.6", + "@graphql-tools/utils": "^10.11.0", "tslib": "^2.4.0" }, "engines": { @@ -3501,389 +4311,424 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/json-file-loader": { - "version": "8.0.20", - "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.20.tgz", - "integrity": "sha512-5v6W+ZLBBML5SgntuBDLsYoqUvwfNboAwL6BwPHi3z/hH1f8BS9/0+MCW9OGY712g7E4pc3y9KqS67mWF753eA==", + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", + "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@hutson/parse-repository-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz", + "integrity": "sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", "dev": true, "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "globby": "^11.0.3", - "tslib": "^2.4.0", - "unixify": "^1.0.0" - }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "node": ">=18" } }, - "node_modules/@graphql-tools/load": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.1.2.tgz", - "integrity": "sha512-WhDPv25/jRND+0uripofMX0IEwo6mrv+tJg6HifRmDu8USCD7nZhufT0PP7lIcuutqjIQFyogqT70BQsy6wOgw==", + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/schema": "^10.0.25", - "@graphql-tools/utils": "^10.9.1", - "p-limit": "3.1.0", - "tslib": "^2.4.0" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/load/node_modules/@graphql-tools/merge": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.1.tgz", - "integrity": "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w==", + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/load/node_modules/@graphql-tools/schema": { - "version": "10.0.25", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.25.tgz", - "integrity": "sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw==", + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^9.1.1", - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/load/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/@inquirer/core/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, + "license": "ISC", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 12" } }, - "node_modules/@graphql-tools/merge": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", - "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "tslib": "^2.4.0" + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@graphql-tools/mock": { - "version": "8.7.20", - "resolved": "https://registry.npmjs.org/@graphql-tools/mock/-/mock-8.7.20.tgz", - "integrity": "sha512-ljcHSJWjC/ZyzpXd5cfNhPI7YljRVvabKHPzKjEs5ElxWu2cdlLGvyNYepApXDsM/OJG/2xuhGM+9GWu5gEAPQ==", + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/schema": "^9.0.18", - "@graphql-tools/utils": "^9.2.1", - "fast-json-stable-stringify": "^2.1.0", - "tslib": "^2.4.0" + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/mock/node_modules/@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "tslib": "^2.4.0" + "engines": { + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/optimize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-2.0.0.tgz", - "integrity": "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==", + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.4.0" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/prisma-loader": { - "version": "8.0.17", - "resolved": "https://registry.npmjs.org/@graphql-tools/prisma-loader/-/prisma-loader-8.0.17.tgz", - "integrity": "sha512-fnuTLeQhqRbA156pAyzJYN0KxCjKYRU5bz1q/SKOwElSnAU4k7/G1kyVsWLh7fneY78LoMNH5n+KlFV8iQlnyg==", - "deprecated": "This package was intended to be used with an older versions of Prisma.\\nThe newer versions of Prisma has a different approach to GraphQL integration.\\nTherefore, this package is no longer needed and has been deprecated and removed.\\nLearn more: https://www.prisma.io/graphql", + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/url-loader": "^8.0.15", - "@graphql-tools/utils": "^10.5.6", - "@types/js-yaml": "^4.0.0", - "@whatwg-node/fetch": "^0.10.0", - "chalk": "^4.1.0", - "debug": "^4.3.1", - "dotenv": "^16.0.0", - "graphql-request": "^6.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "jose": "^5.0.0", - "js-yaml": "^4.0.0", - "lodash": "^4.17.20", - "scuid": "^1.1.0", - "tslib": "^2.4.0", - "yaml-ast-parser": "^0.0.43" + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/relay-operation-optimizer": { - "version": "7.0.21", - "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.21.tgz", - "integrity": "sha512-vMdU0+XfeBh9RCwPqRsr3A05hPA3MsahFn/7OAwXzMySA5EVnSH5R4poWNs3h1a0yT0tDPLhxORhK7qJdSWj2A==", + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", "dev": true, "license": "MIT", - "dependencies": { - "@ardatan/relay-compiler": "^12.0.3", - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" - }, "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "node": ">=18" } }, - "node_modules/@graphql-tools/schema": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-9.0.19.tgz", - "integrity": "sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==", + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^8.4.1", - "@graphql-tools/utils": "^9.2.1", - "tslib": "^2.4.0", - "value-or-promise": "^1.0.12" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", - "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "tslib": "^2.4.0" + "engines": { + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/url-loader": { - "version": "8.0.33", - "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.33.tgz", - "integrity": "sha512-Fu626qcNHcqAj8uYd7QRarcJn5XZ863kmxsg1sm0fyjyfBJnsvC7ddFt6Hayz5kxVKfsnjxiDfPMXanvsQVBKw==", + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/executor-graphql-ws": "^2.0.1", - "@graphql-tools/executor-http": "^1.1.9", - "@graphql-tools/executor-legacy-ws": "^1.1.19", - "@graphql-tools/utils": "^10.9.1", - "@graphql-tools/wrap": "^10.0.16", - "@types/ws": "^8.0.0", - "@whatwg-node/fetch": "^0.10.0", - "@whatwg-node/promise-helpers": "^1.0.0", - "isomorphic-ws": "^5.0.0", - "sync-fetch": "0.6.0-2", - "tslib": "^2.4.0", - "ws": "^8.17.1" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/utils": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.9.1.tgz", - "integrity": "sha512-B1wwkXk9UvU7LCBkPs8513WxOQ2H8Fo5p8HR1+Id9WmYE5+bd51vqN+MbrqvWczHCH2gwkREgHJN88tE0n1FCw==", + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", - "cross-inspect": "1.0.1", - "dset": "^3.1.4", - "tslib": "^2.4.0" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/wrap": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.1.4.tgz", - "integrity": "sha512-7pyNKqXProRjlSdqOtrbnFRMQAVamCmEREilOXtZujxY6kYit3tvWWSjUrcIOheltTffoRh7EQSjpy2JDCzasg==", + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/delegate": "^10.2.23", - "@graphql-tools/schema": "^10.0.25", - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/promise-helpers": "^1.3.0", - "tslib": "^2.8.1" + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/wrap/node_modules/@graphql-tools/merge": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.1.tgz", - "integrity": "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w==", + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-tools/wrap/node_modules/@graphql-tools/schema": { - "version": "10.0.25", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.25.tgz", - "integrity": "sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw==", + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^9.1.1", - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, "license": "MIT", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "license": "BSD-3-Clause", "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@hutson/parse-repository-url": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz", - "integrity": "sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==", - "dev": true, - "license": "Apache-2.0", + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, "engines": { - "node": ">=6.9.0" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", - "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", "dev": true, "license": "MIT", - "dependencies": { - "chardet": "^2.1.0", - "iconv-lite": "^0.7.0" - }, "engines": { "node": ">=18" }, @@ -4034,9 +4879,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -4687,6 +5532,20 @@ "node": ">= 12" } }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.12.1.tgz", + "integrity": "sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -4737,6 +5596,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.20.tgz", "integrity": "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -4804,6 +5664,7 @@ "integrity": "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -4836,11 +5697,25 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", + "integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==", + "license": "MIT", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/graphql": { "version": "12.2.2", "resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-12.2.2.tgz", "integrity": "sha512-lUDy/1uqbRA1kBKpXcmY0aHhcPbfeG52Wg5+9Jzd1d57dwSjCAmuO+mWy5jz9ugopVCZeK0S/kdAMvA+r9fNdA==", "license": "MIT", + "peer": true, "dependencies": { "@graphql-tools/merge": "9.0.11", "@graphql-tools/schema": "10.0.10", @@ -5051,6 +5926,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", "integrity": "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==", "license": "MIT", + "peer": true, "dependencies": { "body-parser": "1.20.3", "cors": "2.8.5", @@ -5067,6 +5943,157 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@nestjs/platform-express/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/@nestjs/platform-express/node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/@nestjs/platform-express/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@nestjs/platform-express/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@nestjs/platform-express/node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@nestjs/platform-express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/@nestjs/testing": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz", @@ -5468,9 +6495,9 @@ } }, "node_modules/@nx/devkit": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-20.8.2.tgz", - "integrity": "sha512-rr9p2/tZDQivIpuBUpZaFBK6bZ+b5SAjZk75V4tbCUqGW3+5OPuVvBPm+X+7PYwUF6rwSpewxkjWNeGskfCe+Q==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-20.8.3.tgz", + "integrity": "sha512-5lbfJ6ICFOiGeirldQOU5fQ/W/VQ8L3dfWnmHG4UgpWSLoK/YFdRf4lTB4rS0aDXsBL0gyWABz3sZGLPGNYnPA==", "dev": true, "license": "MIT", "dependencies": { @@ -5514,9 +6541,9 @@ } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.8.2.tgz", - "integrity": "sha512-t+bmCn6sRPNGU6hnSyWNvbQYA/KgsxGZKYlaCLRwkNhI2akModcBUqtktJzCKd1XHDqs6EkEFBWjFr8/kBEkSg==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.8.3.tgz", + "integrity": "sha512-BeYnPAcnaerg6q+qR0bAb0nebwwrsvm4STSVqqVlaqLmmQpU3Bfpx44CEa5d6T9b0V11ZqVE/bkmRhMqhUcrhw==", "cpu": [ "arm64" ], @@ -5531,9 +6558,9 @@ } }, "node_modules/@nx/nx-darwin-x64": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-20.8.2.tgz", - "integrity": "sha512-pt/wmDLM31Es8/EzazlyT5U+ou2l60rfMNFGCLqleHEQ0JUTc0KWnOciBLbHIQFiPsCQZJFEKyfV5V/ncePmmw==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-20.8.3.tgz", + "integrity": "sha512-RIFg1VkQ4jhI+ErqEZuIeGBcJGD8t+u9J5CdQBDIASd8QRhtudBkiYLYCJb+qaQly09G7nVfxuyItlS2uRW3qA==", "cpu": [ "x64" ], @@ -5548,9 +6575,9 @@ } }, "node_modules/@nx/nx-freebsd-x64": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.8.2.tgz", - "integrity": "sha512-joZxFbgJfkHkB9uMIJr73Gpnm9pnpvr0XKGbWC409/d2x7q1qK77tKdyhGm+A3+kaZFwstNVPmCUtUwJYyU6LA==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.8.3.tgz", + "integrity": "sha512-boQTgMUdnqpZhHMrV/xgnp/dTg5dfxw8I4d16NBwmW4j+Sez7zi/dydgsJpfZsj8TicOHvPu6KK4W5wzp82NPw==", "cpu": [ "x64" ], @@ -5565,9 +6592,9 @@ } }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.8.2.tgz", - "integrity": "sha512-98O/qsxn4vIMPY/FyzvmVrl7C5yFhCUVk0/4PF+PA2SvtQ051L1eMRY6bq/lb69qfN6szJPZ41PG5mPx0NeLZw==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.8.3.tgz", + "integrity": "sha512-wpiNyY1igx1rLN3EsTLum2lDtblFijdBZB9/9u/6UDub4z9CaQ4yaC4h9n5v7yFYILwfL44YTsQKzrE+iv0y1Q==", "cpu": [ "arm" ], @@ -5582,9 +6609,9 @@ } }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.8.2.tgz", - "integrity": "sha512-h6a+HxwfSpxsi4KpxGgPh9GDBmD2E+XqGCdfYpobabxqEBvlnIlJyuDhlRR06cTWpuNXHpRdrVogmV6m/YbtDg==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.8.3.tgz", + "integrity": "sha512-nbi/eZtJfWxuDwdUCiP+VJolFubtrz6XxVtB26eMAkODnREOKELHZtMOrlm8JBZCdtWCvTqibq9Az74XsqSfdA==", "cpu": [ "arm64" ], @@ -5599,9 +6626,9 @@ } }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.8.2.tgz", - "integrity": "sha512-4Ev+jM0VAxDHV/dFgMXjQTCXS4I8W4oMe7FSkXpG8RUn6JK659DC8ExIDPoGIh+Cyqq6r6mw1CSia+ciQWICWQ==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.8.3.tgz", + "integrity": "sha512-LTTGzI8YVPlF1v0YlVf+exM+1q7rpsiUbjTTHJcfHFRU5t4BsiZD54K19Y1UBg1XFx5cwhEaIomSmJ88RwPPVQ==", "cpu": [ "arm64" ], @@ -5616,9 +6643,9 @@ } }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.8.2.tgz", - "integrity": "sha512-nR0ev+wxu+nQYRd7bhqggOxK7UfkV6h+Ko1mumUFyrM5GvPpz/ELhjJFSnMcOkOMcvH0b6G5uTBJvN1XWCkbmg==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.8.3.tgz", + "integrity": "sha512-SlA4GtXvQbSzSIWLgiIiLBOjdINPOUR/im+TUbaEMZ8wiGrOY8cnk0PVt95TIQJVBeXBCeb5HnoY0lHJpMOODg==", "cpu": [ "x64" ], @@ -5633,9 +6660,9 @@ } }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.8.2.tgz", - "integrity": "sha512-ost41l5yc2aq2Gc9bMMpaPi/jkXqbXEMEPHrxWKuKmaek3K2zbVDQzvBBNcQKxf/mlCsrqN4QO0mKYSRRqag5A==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.8.3.tgz", + "integrity": "sha512-MNzkEwPktp5SQH9dJDH2wP9hgG9LsBDhKJXJfKw6sUI/6qz5+/aAjFziKy+zBnhU4AO1yXt5qEWzR8lDcIriVQ==", "cpu": [ "x64" ], @@ -5650,9 +6677,9 @@ } }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.8.2.tgz", - "integrity": "sha512-0SEOqT/daBG5WtM9vOGilrYaAuf1tiALdrFavY62+/arXYxXemUKmRI5qoKDTnvoLMBGkJs6kxhMO5b7aUXIvQ==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.8.3.tgz", + "integrity": "sha512-qUV7CyXKwRCM/lkvyS6Xa1MqgAuK5da6w27RAehh7LATBUKn1I4/M7DGn6L7ERCxpZuh1TrDz9pUzEy0R+Ekkg==", "cpu": [ "arm64" ], @@ -5667,9 +6694,9 @@ } }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.8.2.tgz", - "integrity": "sha512-iIsY+tVqes/NOqTbJmggL9Juie/iaDYlWgXA9IUv88FE9thqWKhVj4/tCcPjsOwzD+1SVna3YISEEFsx5UV4ew==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.8.3.tgz", + "integrity": "sha512-gX1G8u6W6EPX6PO/wv07+B++UHyCHBXyVWXITA3Kv6HoSajOxIa2Kk1rv1iDQGmX1WWxBaj3bUyYJAFBDITe4w==", "cpu": [ "x64" ], @@ -5699,6 +6726,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -5858,9 +6886,9 @@ } }, "node_modules/@paralleldrive/cuid2": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", - "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", "dev": true, "license": "MIT", "dependencies": { @@ -5874,6 +6902,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", @@ -6172,21 +7201,8 @@ "node": ">= 10.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/@pkgjs/parseargs": { @@ -6200,18 +7216,31 @@ "node": ">=14" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.2.0.tgz", + "integrity": "sha512-euIdQ13cRB2wZ3jPsnDnFhINquo1PYFPCg6yVL8b2rp3EdinQHsX9EDdCtRr489D5uhphcRk463OdQAFlsCr0w==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.2.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz", - "integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==", - "hasInstallScript": true, + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.2.0.tgz", + "integrity": "sha512-JdLF8lWZ+LjKGKpBqyAlenxd/kXjd1Abf/xK+6vUA7R7L2Suo6AFTHFRpPSdAKCan9wzdFApsUpSa/F6+t1AtA==", "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.2.0" + }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { "prisma": "*", - "typescript": ">=5.1.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "prisma": { @@ -6222,67 +7251,153 @@ } } }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.2.0.tgz", + "integrity": "sha512-dn7oB53v0tqkB0wBdMuTNFNPdEbfICEUe82Tn9FoKAhJCUkDH+fmyEp0ClciGh+9Hp2Tuu2K52kth2MTLstvmA==", + "license": "Apache-2.0" + }, "node_modules/@prisma/config": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.1.tgz", - "integrity": "sha512-fs8wY6DsvOCzuiyWVckrVs1LOcbY4LZNz8ki4uUIQ28jCCzojTGqdLhN2Jl5lDnC1yI8/gNIKpsWDM8pLhOdwA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.2.0.tgz", + "integrity": "sha512-qmvSnfQ6l/srBW1S7RZGfjTQhc44Yl3ldvU6y3pgmuLM+83SBDs6UQVgMtQuMRe9J3gGqB0RF8wER6RlXEr6jQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.16.12", + "effect": "3.18.4", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.1.tgz", - "integrity": "sha512-Vf7Tt5Wh9XcndpbmeotuqOMLWPTjEKCsgojxXP2oxE1/xYe7PtnP76hsouG9vis6fctX+TxgmwxTuYi/+xc7dQ==", - "devOptional": true, + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", "license": "Apache-2.0" }, + "node_modules/@prisma/dev": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.17.0.tgz", + "integrity": "sha512-6sGebe5jxX+FEsQTpjHLzvOGPn6ypFQprcs3jcuIWv1Xp/5v6P/rjfdvAwTkP2iF6pDx2tCd8vGLNWcsWzImTA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.3.2", + "@electric-sql/pglite-socket": "0.0.6", + "@electric-sql/pglite-tools": "0.2.7", + "@hono/node-server": "1.19.6", + "@mrleebo/prisma-ast": "0.12.1", + "@prisma/get-platform": "6.8.2", + "@prisma/query-plan-executor": "6.18.0", + "foreground-child": "3.3.1", + "get-port-please": "3.1.2", + "hono": "4.10.6", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.21.3", + "std-env": "3.9.0", + "valibot": "1.2.0", + "zeptomatch": "2.0.2" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.2.0.tgz", + "integrity": "sha512-gzrUcbI9VmHS24Uf+0+7DNzdIw7keglJsD5m/MHxQOU68OhGVzlphQRobLiDMn8CHNA2XN8uugwKjudVtnfMVQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, "node_modules/@prisma/engines": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.1.tgz", - "integrity": "sha512-D95Ik3GYZkqZ8lSR4EyFOJ/tR33FcYRP8kK61o+WMsyD10UfJwd7+YielflHfKwiGodcqKqoraWw8ElAgMDbPw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.2.0.tgz", + "integrity": "sha512-HUeOI/SvCDsHrR9QZn24cxxZcujOjcS3w1oW/XVhnSATAli5SRMOfp/WkG3TtT5rCxDA4xOnlJkW7xkho4nURA==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.17.1", - "@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", - "@prisma/fetch-engine": "6.17.1", - "@prisma/get-platform": "6.17.1" + "@prisma/debug": "7.2.0", + "@prisma/engines-version": "7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3", + "@prisma/fetch-engine": "7.2.0", + "@prisma/get-platform": "7.2.0" } }, "node_modules/@prisma/engines-version": { - "version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac.tgz", - "integrity": "sha512-17140E3huOuD9lMdJ9+SF/juOf3WR3sTJMVyyenzqUPbuH+89nPhSWcrY+Mf7tmSs6HvaO+7S+HkELinn6bhdg==", + "version": "7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3.tgz", + "integrity": "sha512-KezsjCZDsbjNR7SzIiVlUsn9PnLePI7r5uxABlwL+xoerurZTfgQVbIjvjF2sVr3Uc0ZcsnREw3F84HvbggGdA==", "devOptional": true, "license": "Apache-2.0" }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, "node_modules/@prisma/fetch-engine": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.1.tgz", - "integrity": "sha512-AYZiHOs184qkDMiTeshyJCtyL4yERkjfTkJiSJdYuSfc24m94lTNL5+GFinZ6vVz+ktX4NJzHKn1zIFzGTWrWg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.2.0.tgz", + "integrity": "sha512-Z5XZztJ8Ap+wovpjPD2lQKnB8nWFGNouCrglaNFjxIWAGWz0oeHXwUJRiclIoSSXN/ptcs9/behptSk8d0Yy6w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0", + "@prisma/engines-version": "7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3", + "@prisma/get-platform": "7.2.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.17.1", - "@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac", - "@prisma/get-platform": "6.17.1" + "@prisma/debug": "7.2.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.1.tgz", - "integrity": "sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.8.2.tgz", + "integrity": "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.17.1" + "@prisma/debug": "6.8.2" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.8.2.tgz", + "integrity": "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-6.18.0.tgz", + "integrity": "sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/studio-core": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.9.0.tgz", + "integrity": "sha512-xA2zoR/ADu/NCSQuriBKTh6Ps4XjU0bErkEcgMfnSGh346K1VI7iWKnoq1l2DoxUqiddPHIEWwtxJ6xCHG6W7g==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, "node_modules/@protobufjs/aspromise": { @@ -6349,15 +7464,6 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, - "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@repeaterjs/repeater": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", @@ -6401,9 +7507,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "cpu": [ "arm" ], @@ -6414,9 +7520,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "cpu": [ "arm64" ], @@ -6427,9 +7533,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "cpu": [ "arm64" ], @@ -6440,9 +7546,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "cpu": [ "x64" ], @@ -6453,9 +7559,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "cpu": [ "arm64" ], @@ -6466,9 +7572,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "cpu": [ "x64" ], @@ -6479,9 +7585,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "cpu": [ "arm" ], @@ -6492,9 +7598,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "cpu": [ "arm" ], @@ -6505,9 +7611,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "cpu": [ "arm64" ], @@ -6518,9 +7624,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "cpu": [ "arm64" ], @@ -6531,9 +7637,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "cpu": [ "loong64" ], @@ -6544,9 +7650,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "cpu": [ "ppc64" ], @@ -6557,9 +7663,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "cpu": [ "riscv64" ], @@ -6570,9 +7676,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "cpu": [ "riscv64" ], @@ -6583,9 +7689,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "cpu": [ "s390x" ], @@ -6596,9 +7702,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "cpu": [ "x64" ], @@ -6609,9 +7715,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "cpu": [ "x64" ], @@ -6622,9 +7728,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "cpu": [ "arm64" ], @@ -6635,9 +7741,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "cpu": [ "arm64" ], @@ -6648,9 +7754,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "cpu": [ "ia32" ], @@ -6661,9 +7767,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "cpu": [ "x64" ], @@ -6674,9 +7780,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "cpu": [ "x64" ], @@ -6800,374 +7906,96 @@ "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@tailwindcss/cli": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.16.tgz", - "integrity": "sha512-dsnANPrh2ZooHyZ/8uJhc9ecpcYtufToc21NY09NS9vF16rxPCjJ8dP7TUAtPqlUJTHSmRkN2hCdoYQIlgh4fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@parcel/watcher": "^2.5.1", - "@tailwindcss/node": "4.1.16", - "@tailwindcss/oxide": "4.1.16", - "enhanced-resolve": "^5.18.3", - "mri": "^1.2.0", - "picocolors": "^1.1.1", - "tailwindcss": "4.1.16" - }, - "bin": { - "tailwindcss": "dist/index.mjs" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/node": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", - "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.19", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.16" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", - "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.16", - "@tailwindcss/oxide-darwin-arm64": "4.1.16", - "@tailwindcss/oxide-darwin-x64": "4.1.16", - "@tailwindcss/oxide-freebsd-x64": "4.1.16", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", - "@tailwindcss/oxide-linux-x64-musl": "4.1.16", - "@tailwindcss/oxide-wasm32-wasi": "4.1.16", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", - "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", - "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", - "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", - "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", - "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", - "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", - "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", - "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", - "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", - "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", - "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "type-detect": "4.0.8" } }, - "node_modules/@tailwindcss/cli/node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", - "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", - "cpu": [ - "x64" - ], + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@tailwindcss/cli/node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz", + "integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==", "dev": true, "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "enhanced-resolve": "^5.18.3", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.18" + }, "bin": { - "jiti": "lib/jiti-cli.mjs" + "tailwindcss": "dist/index.mjs" } }, - "node_modules/@tailwindcss/cli/node_modules/tailwindcss": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", - "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", - "dev": true, - "license": "MIT" - }, "node_modules/@tailwindcss/node": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz", - "integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.0", + "jiti": "^2.6.1", "lightningcss": "1.30.2", - "magic-string": "^0.30.19", + "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.15" - } - }, - "node_modules/@tailwindcss/node/node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz", - "integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.15", - "@tailwindcss/oxide-darwin-arm64": "4.1.15", - "@tailwindcss/oxide-darwin-x64": "4.1.15", - "@tailwindcss/oxide-freebsd-x64": "4.1.15", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.15", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.15", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.15", - "@tailwindcss/oxide-linux-x64-musl": "4.1.15", - "@tailwindcss/oxide-wasm32-wasi": "4.1.15", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.15", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.15" + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz", - "integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "cpu": [ "arm64" ], @@ -7182,9 +8010,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz", - "integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", "cpu": [ "arm64" ], @@ -7199,9 +8027,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz", - "integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "cpu": [ "x64" ], @@ -7216,9 +8044,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz", - "integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "cpu": [ "x64" ], @@ -7233,9 +8061,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz", - "integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "cpu": [ "arm" ], @@ -7250,9 +8078,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz", - "integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "cpu": [ "arm64" ], @@ -7267,9 +8095,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz", - "integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "cpu": [ "arm64" ], @@ -7284,9 +8112,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz", - "integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ "x64" ], @@ -7301,9 +8129,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz", - "integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ "x64" ], @@ -7318,9 +8146,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz", - "integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -7336,10 +8164,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", + "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, @@ -7348,9 +8176,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz", - "integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ "arm64" ], @@ -7365,9 +8193,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz", - "integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ "x64" ], @@ -7382,38 +8210,38 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.15.tgz", - "integrity": "sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.15", - "@tailwindcss/oxide": "4.1.15", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", - "tailwindcss": "4.1.15" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.15.tgz", - "integrity": "sha512-B6s60MZRTUil+xKoZoGe6i0Iar5VuW+pmcGlda2FX+guDuQ1G1sjiIy1W0frneVpeL/ZjZ4KEgWZHNrIm++2qA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.15", - "@tailwindcss/oxide": "4.1.15", - "tailwindcss": "4.1.15" + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "node_modules/@tanstack/query-core": { - "version": "5.90.5", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz", - "integrity": "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==", + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", "license": "MIT", "funding": { "type": "github", @@ -7421,9 +8249,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.90.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz", - "integrity": "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==", + "version": "5.92.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", + "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", "license": "MIT", "funding": { "type": "github", @@ -7431,12 +8259,13 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.5", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.5.tgz", - "integrity": "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==", + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", + "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", "license": "MIT", + "peer": true, "dependencies": { - "@tanstack/query-core": "5.90.5" + "@tanstack/query-core": "5.90.16" }, "funding": { "type": "github", @@ -7447,29 +8276,29 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz", - "integrity": "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==", + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", + "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.90.1" + "@tanstack/query-devtools": "5.92.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", - "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "version": "3.13.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.14.tgz", + "integrity": "sha512-WG0d7mBD54eA7dgA3+sO5csS0B49QKqM6Gy5Rf31+Oq/LTKROQSao9m2N/vz1IqVragOKU5t5k1LAcqh/DfTxw==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.12" + "@tanstack/virtual-core": "3.13.14" }, "funding": { "type": "github", @@ -7481,9 +8310,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", - "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "version": "3.13.14", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.14.tgz", + "integrity": "sha512-b5Uvd8J2dc7ICeX9SRb/wkCxWk7pUwN214eEPAQsqrsktSKTCmyLxOQWSMgogBByXclZeAdgZ3k4o0fIYUIBqQ==", "license": "MIT", "funding": { "type": "github", @@ -7491,14 +8320,14 @@ } }, "node_modules/@theguild/federation-composition": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@theguild/federation-composition/-/federation-composition-0.20.1.tgz", - "integrity": "sha512-lwYYKCeHmstOtbMtzxC0BQKWsUPYbEVRVdJ3EqR4jSpcF4gvNf3MOJv6yuvq6QsKqgYZURKRBszmg7VEDoi5Aw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@theguild/federation-composition/-/federation-composition-0.21.1.tgz", + "integrity": "sha512-iw1La4tbRaWKBgz+J9b1ydxv+kgt+7n04ZgD8HSeDJodLsLAxbXj/gLif5f2vyMa98ommBQ73ztBe8zOzGq5YQ==", "dev": true, "license": "MIT", "dependencies": { "constant-case": "^3.0.4", - "debug": "4.4.1", + "debug": "4.4.3", "json5": "^2.2.3", "lodash.sortby": "^4.7.0" }, @@ -7509,24 +8338,6 @@ "graphql": "^16.0.0" } }, - "node_modules/@theguild/federation-composition/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -7552,9 +8363,9 @@ "license": "MIT" }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT" }, @@ -7719,6 +8530,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.12", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", @@ -7750,15 +8570,15 @@ } }, "node_modules/@types/express": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", - "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { @@ -7773,6 +8593,18 @@ "@types/send": "*" } }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -7783,6 +8615,12 @@ "@types/node": "*" } }, + "node_modules/@types/handlebars": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.0.40.tgz", + "integrity": "sha512-sGWNtsjNrLOdKha2RV1UeF8+UbQnPSG7qbe5wwbni0mw4h2gHXyPFUMOC+xwGirIiiydM/HSqjDO4rk6NFB18w==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -7842,13 +8680,6 @@ "pretty-format": "^29.0.0" } }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/jsonwebtoken": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", @@ -7906,10 +8737,11 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", - "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -7919,7 +8751,6 @@ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.4" @@ -7976,11 +8807,17 @@ "@types/passport": "*" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } }, "node_modules/@types/qs": { "version": "6.14.0", @@ -7995,23 +8832,23 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.26", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", - "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-router": { @@ -8036,32 +8873,21 @@ } }, "node_modules/@types/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", - "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/serve-static": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", - "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -8079,9 +8905,9 @@ "license": "MIT" }, "node_modules/@types/validator": { - "version": "13.15.3", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", - "integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==", + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, "node_modules/@types/ws": { @@ -8095,9 +8921,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -8153,13 +8979,13 @@ } }, "node_modules/@whatwg-node/fetch": { - "version": "0.10.11", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.11.tgz", - "integrity": "sha512-eR8SYtf9Nem1Tnl0IWrY33qJ5wCtIWlt3Fs3c6V4aAaTFLtkEQErXu3SSZg/XCHrj9hXSJ8/8t+CdMk5Qec/ZA==", + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", "dev": true, "license": "MIT", "dependencies": { - "@whatwg-node/node-fetch": "^0.8.0", + "@whatwg-node/node-fetch": "^0.8.3", "urlpattern-polyfill": "^10.0.0" }, "engines": { @@ -8167,9 +8993,9 @@ } }, "node_modules/@whatwg-node/node-fetch": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.1.tgz", - "integrity": "sha512-cQmQEo7IsI0EPX9VrwygXVzrVlX43Jb7/DBZSmpnC7xH4xkyOnn/HykHpTaQk7TUs7zh59A5uTGqx3p2Ouzffw==", + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.4.tgz", + "integrity": "sha512-AlKLc57loGoyYlrzDbejB9EeR+pfdJdGzbYnkEuZaGekFboBwzfVYVMsy88PMriqPI1ORpiGYGgSSWpx7a2sDA==", "dev": true, "license": "MIT", "dependencies": { @@ -8227,9 +9053,9 @@ } }, "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -8290,6 +9116,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8961,16 +9788,6 @@ "dev": true, "license": "MIT" }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -9025,9 +9842,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", "dev": true, "funding": [ { @@ -9045,10 +9862,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, @@ -9077,10 +9893,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "dev": true, "license": "MIT", "dependencies": { @@ -9306,9 +10132,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9444,9 +10270,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -9463,12 +10289,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -9625,16 +10452,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/c12/node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/cacache": { "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", @@ -9756,9 +10573,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", "dev": true, "funding": [ { @@ -9905,12 +10722,27 @@ } }, "node_modules/chardet": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", - "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", "dev": true, "license": "MIT" }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, "node_modules/chokidar": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", @@ -9983,17 +10815,19 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", - "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { - "@types/validator": "^13.11.8", + "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", - "validator": "^13.9.0" + "validator": "^13.15.20" } }, "node_modules/class-variance-authority": { @@ -10045,22 +10879,68 @@ } }, "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cli-width": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", @@ -10539,14 +11419,27 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -10681,7 +11574,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -10696,14 +11589,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10735,9 +11628,9 @@ "license": "MIT" }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/dargs": { @@ -10764,7 +11657,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", - "dev": true, "license": "MIT" }, "node_modules/dateformat": { @@ -10778,11 +11670,17 @@ } }, "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", + "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/debug": { "version": "4.4.3", @@ -10942,6 +11840,16 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -10952,13 +11860,13 @@ } }, "node_modules/dependency-graph": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", - "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6.0" + "node": ">=4" } }, "node_modules/deprecation": { @@ -11005,13 +11913,16 @@ } }, "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "dev": true, "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, "engines": { - "node": ">=8" + "node": ">=0.10" } }, "node_modules/detect-newline": { @@ -11190,9 +12101,9 @@ "license": "MIT" }, "node_modules/effect": { - "version": "3.16.12", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz", - "integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==", + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -11217,9 +12128,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -11268,6 +12179,7 @@ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -11296,9 +12208,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -11345,6 +12257,19 @@ "node": ">=4" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -11407,6 +12332,12 @@ "node": ">= 0.4" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/esast-util-from-estree": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", @@ -11440,9 +12371,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -11453,32 +12384,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escalade": { @@ -11630,6 +12561,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -11695,39 +12632,39 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -11761,10 +12698,25 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/express/node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "devOptional": true, "license": "MIT" }, @@ -11837,10 +12789,16 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -12007,17 +12965,17 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -12124,7 +13082,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -12141,7 +13099,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -12151,9 +13109,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -12205,16 +13163,16 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -12248,9 +13206,9 @@ } }, "node_modules/front-matter/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -12269,9 +13227,9 @@ "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "dev": true, "license": "MIT", "dependencies": { @@ -12326,6 +13284,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12346,6 +13314,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -12497,6 +13478,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-port-please": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.2.tgz", + "integrity": "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -12642,9 +13630,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -12738,14 +13726,22 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/graphql": { - "version": "16.11.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", - "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -12782,14 +13778,165 @@ } } }, - "node_modules/graphql-config/node_modules/@graphql-tools/merge": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.1.tgz", - "integrity": "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w==", + "node_modules/graphql-config/node_modules/@graphql-hive/signal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-1.0.0.tgz", + "integrity": "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/batch-execute": { + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.19.tgz", + "integrity": "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.9.1", + "@whatwg-node/promise-helpers": "^1.3.0", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/delegate": { + "version": "10.2.23", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.2.23.tgz", + "integrity": "sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/batch-execute": "^9.0.19", + "@graphql-tools/executor": "^1.4.9", + "@graphql-tools/schema": "^10.0.25", + "@graphql-tools/utils": "^10.9.1", + "@repeaterjs/repeater": "^3.0.6", + "@whatwg-node/promise-helpers": "^1.3.0", + "dataloader": "^2.2.3", + "dset": "^3.1.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor-common": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.6.tgz", + "integrity": "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.3.0", + "@graphql-tools/utils": "^10.9.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor-graphql-ws": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-2.0.7.tgz", + "integrity": "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q==", "dev": true, "license": "MIT", "dependencies": { + "@graphql-tools/executor-common": "^0.0.6", "@graphql-tools/utils": "^10.9.1", + "@whatwg-node/disposablestack": "^0.0.6", + "graphql-ws": "^6.0.6", + "isomorphic-ws": "^5.0.0", + "tslib": "^2.8.1", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor-http": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-1.3.3.tgz", + "integrity": "sha512-LIy+l08/Ivl8f8sMiHW2ebyck59JzyzO/yF9SFS4NH6MJZUezA1xThUXCDIKhHiD56h/gPojbkpcFvM2CbNE7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-hive/signal": "^1.0.0", + "@graphql-tools/executor-common": "^0.0.4", + "@graphql-tools/utils": "^10.8.1", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.4", + "@whatwg-node/promise-helpers": "^1.3.0", + "meros": "^1.2.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/executor-http/node_modules/@graphql-tools/executor-common": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.4.tgz", + "integrity": "sha512-SEH/OWR+sHbknqZyROCFHcRrbZeUAyjCsgpVWCRjqjqRbiJiXq6TxNIIOmpXgkrXWW/2Ev4Wms6YSGJXjdCs6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.2.3", + "@graphql-tools/utils": "^10.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/merge": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.6.tgz", + "integrity": "sha512-bTnP+4oom4nDjmkS3Ykbe+ljAp/RIiWP3R35COMmuucS24iQxGLa9Hn8VMkLIoaoPxgz6xk+dbC43jtkNsFoBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.11.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/schema": { + "version": "10.0.30", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.30.tgz", + "integrity": "sha512-yPXU17uM/LR90t92yYQqn9mAJNOVZJc0nQtYeZyZeQZeQjwIGlTubvvoDL0fFVk+wZzs4YQOgds2NwSA4npodA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.1.6", + "@graphql-tools/utils": "^10.11.0", "tslib": "^2.4.0" }, "engines": { @@ -12799,6 +13946,53 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/graphql-config/node_modules/@graphql-tools/url-loader": { + "version": "8.0.33", + "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.33.tgz", + "integrity": "sha512-Fu626qcNHcqAj8uYd7QRarcJn5XZ863kmxsg1sm0fyjyfBJnsvC7ddFt6Hayz5kxVKfsnjxiDfPMXanvsQVBKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-graphql-ws": "^2.0.1", + "@graphql-tools/executor-http": "^1.1.9", + "@graphql-tools/executor-legacy-ws": "^1.1.19", + "@graphql-tools/utils": "^10.9.1", + "@graphql-tools/wrap": "^10.0.16", + "@types/ws": "^8.0.0", + "@whatwg-node/fetch": "^0.10.0", + "@whatwg-node/promise-helpers": "^1.0.0", + "isomorphic-ws": "^5.0.0", + "sync-fetch": "0.6.0-2", + "tslib": "^2.4.0", + "ws": "^8.17.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-config/node_modules/@graphql-tools/wrap": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.1.4.tgz", + "integrity": "sha512-7pyNKqXProRjlSdqOtrbnFRMQAVamCmEREilOXtZujxY6kYit3tvWWSjUrcIOheltTffoRh7EQSjpy2JDCzasg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/delegate": "^10.2.23", + "@graphql-tools/schema": "^10.0.25", + "@graphql-tools/utils": "^10.9.1", + "@whatwg-node/promise-helpers": "^1.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/graphql-config/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -12836,16 +14030,6 @@ } } }, - "node_modules/graphql-config/node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/graphql-config/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -12959,7 +14143,6 @@ "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -13171,6 +14354,17 @@ "node": ">=12.0.0" } }, + "node_modules/hono": { + "version": "4.10.6", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.6.tgz", + "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -13224,6 +14418,15 @@ "node": ">= 0.8" } }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -13238,6 +14441,13 @@ "node": ">= 14" } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -13263,10 +14473,10 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "dev": true, + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -13490,9 +14700,9 @@ } }, "node_modules/inline-style-parser": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", - "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, "node_modules/inquirer": { @@ -13550,9 +14760,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "dev": true, "license": "MIT", "engines": { @@ -13827,6 +15037,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -13983,6 +15200,22 @@ "ws": "*" } }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -14109,6 +15342,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -14818,13 +16052,13 @@ } }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/joi": { @@ -14840,20 +16074,11 @@ "@sideway/pinpoint": "^2.0.0" } }, - "node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -15038,12 +16263,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -15231,9 +16456,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.12.24", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.24.tgz", - "integrity": "sha512-l5IlyL9AONj4voSd7q9xkuQOL4u8Ty44puTic7J88CmdXkxfGsRfoVLXHCxppwehgpb/Chdb80FFehHqjN3ItQ==", + "version": "1.12.33", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz", + "integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==", "license": "MIT" }, "node_modules/lightningcss": { @@ -15242,6 +16467,7 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -15497,6 +16723,26 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/lines-and-columns": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.3.tgz", @@ -15508,46 +16754,110 @@ } }, "node_modules/listr2": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-4.0.5.tgz", - "integrity": "sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^2.1.0", - "colorette": "^2.0.16", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rfdc": "^1.3.0", - "rxjs": "^7.5.5", - "through": "^2.3.8", - "wrap-ansi": "^7.0.0" + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -15641,84 +16951,230 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.omit": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", + "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, - "node_modules/lodash.omit": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", - "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==", - "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "license": "MIT" + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/log-symbols": { + "node_modules/log-update/node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/loglevel": { @@ -15754,6 +17210,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -15804,10 +17261,26 @@ "dev": true, "license": "ISC" }, + "node_modules/lru.min": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", + "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16170,9 +17643,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -17323,6 +18796,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -17644,6 +19130,47 @@ "dev": true, "license": "ISC" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -17683,7 +19210,6 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, "license": "MIT" }, "node_modules/nestjs-zod": { @@ -17827,16 +19353,16 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.25", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.25.tgz", - "integrity": "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, "node_modules/nodemon": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", - "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", "dev": true, "license": "MIT", "dependencies": { @@ -18002,16 +19528,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-bundled": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz", @@ -18134,9 +19650,9 @@ "license": "MIT" }, "node_modules/nx": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/nx/-/nx-20.8.2.tgz", - "integrity": "sha512-mDKpbH3vEpUFDx0rrLh+tTqLq1PYU8KiD/R7OVZGd1FxQxghx2HOl32MiqNsfPcw6AvKlXhslbwIESV+N55FLQ==", + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/nx/-/nx-20.8.3.tgz", + "integrity": "sha512-8w815WSMWar3A/LFzwtmEY+E8cVW62lMiFuPDXje+C8O8hFndfvscP56QHNMn2Zdhz3q0+BZUe+se4Em1BKYdA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -18181,16 +19697,16 @@ "nx-cloud": "bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "20.8.2", - "@nx/nx-darwin-x64": "20.8.2", - "@nx/nx-freebsd-x64": "20.8.2", - "@nx/nx-linux-arm-gnueabihf": "20.8.2", - "@nx/nx-linux-arm64-gnu": "20.8.2", - "@nx/nx-linux-arm64-musl": "20.8.2", - "@nx/nx-linux-x64-gnu": "20.8.2", - "@nx/nx-linux-x64-musl": "20.8.2", - "@nx/nx-win32-arm64-msvc": "20.8.2", - "@nx/nx-win32-x64-msvc": "20.8.2" + "@nx/nx-darwin-arm64": "20.8.3", + "@nx/nx-darwin-x64": "20.8.3", + "@nx/nx-freebsd-x64": "20.8.3", + "@nx/nx-linux-arm-gnueabihf": "20.8.3", + "@nx/nx-linux-arm64-gnu": "20.8.3", + "@nx/nx-linux-arm64-musl": "20.8.3", + "@nx/nx-linux-x64-gnu": "20.8.3", + "@nx/nx-linux-x64-musl": "20.8.3", + "@nx/nx-win32-arm64-msvc": "20.8.3", + "@nx/nx-win32-x64-msvc": "20.8.3" }, "peerDependencies": { "@swc-node/register": "^1.8.0", @@ -18732,6 +20248,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -18809,7 +20326,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -18910,6 +20427,114 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pgpass/node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -19006,6 +20631,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19036,6 +20662,59 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -19065,26 +20744,34 @@ } }, "node_modules/prisma": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.1.tgz", - "integrity": "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.2.0.tgz", + "integrity": "sha512-jSdHWgWOgFF24+nRyyNRVBIgGDQEsMEF8KPHvhBBg3jWyR9fUAK0Nq9ThUmiGlNgq2FA7vSk/ZoCvefod+a8qg==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "6.17.1", - "@prisma/engines": "6.17.1" + "@prisma/config": "7.2.0", + "@prisma/dev": "0.17.0", + "@prisma/engines": "7.2.0", + "@prisma/studio-core": "0.9.0", + "mysql2": "3.15.3", + "postgres": "3.4.7" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { - "typescript": ">=5.1.0" + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, "typescript": { "optional": true } @@ -19195,6 +20882,18 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -19271,6 +20970,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -19349,28 +21054,26 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.3" } }, "node_modules/react-is": { @@ -19418,9 +21121,9 @@ } }, "node_modules/react-router": { - "version": "7.9.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", - "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -19440,12 +21143,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz", - "integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz", + "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", "license": "MIT", + "peer": true, "dependencies": { - "react-router": "7.9.4" + "react-router": "7.11.0" }, "engines": { "node": ">=20.0.0" @@ -19456,12 +21160,16 @@ } }, "node_modules/react-router/node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/read": { @@ -19805,7 +21513,15 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "devOptional": true, + "license": "MIT" }, "node_modules/rehype-highlight": { "version": "7.0.2", @@ -19931,6 +21647,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remeda": { + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.21.3.tgz", + "integrity": "sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.39.1" + } + }, + "node_modules/remeda/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "devOptional": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/remedial": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/remedial/-/remedial-1.0.8.tgz", @@ -19972,6 +21711,32 @@ "dev": true, "license": "ISC" }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resend": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.6.0.tgz", + "integrity": "sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==", + "license": "MIT", + "dependencies": { + "svix": "1.76.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -20044,7 +21809,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -20142,10 +21907,11 @@ } }, "node_modules/rollup": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -20157,28 +21923,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" } }, @@ -20251,19 +22017,9 @@ "license": "MIT" }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/scuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz", - "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==", - "dev": true, + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/semver": { @@ -20279,24 +22035,24 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -20317,13 +22073,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/send/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/sentence-case": { @@ -20338,16 +22105,22 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" @@ -20361,9 +22134,9 @@ "license": "ISC" }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, "node_modules/set-function-length": { @@ -20433,7 +22206,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -20446,7 +22219,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -20541,7 +22314,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/signedsource": { @@ -20600,18 +22373,49 @@ } }, "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/smart-buffer": { @@ -20683,7 +22487,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -20796,6 +22599,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ssri": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", @@ -20833,14 +22646,21 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -21014,21 +22834,21 @@ } }, "node_modules/style-to-js": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz", - "integrity": "sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", "license": "MIT", "dependencies": { - "style-to-object": "1.0.11" + "style-to-object": "1.0.14" } }, "node_modules/style-to-object": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.11.tgz", - "integrity": "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", "license": "MIT", "dependencies": { - "inline-style-parser": "0.2.4" + "inline-style-parser": "0.2.7" } }, "node_modules/subscriptions-transport-ws": { @@ -21054,15 +22874,6 @@ "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", "license": "MIT" }, - "node_modules/subscriptions-transport-ws/node_modules/symbol-observable": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", - "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/subscriptions-transport-ws/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -21160,6 +22971,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.76.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.76.1.tgz", + "integrity": "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "@types/node": "^22.7.5", + "es6-promise": "^4.2.8", + "fast-sha256": "^1.3.0", + "url-parse": "^1.5.10", + "uuid": "^10.0.0" + } + }, "node_modules/swap-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz", @@ -21170,6 +22995,15 @@ "tslib": "^2.0.3" } }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sync-fetch": { "version": "0.6.0-2", "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.6.0-2.tgz", @@ -21225,9 +23059,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz", - "integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, "license": "MIT" }, @@ -21448,11 +23282,14 @@ } }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.12", @@ -21540,12 +23377,12 @@ } }, "node_modules/token-types": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", - "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "license": "MIT", "dependencies": { - "@borewit/text-codec": "^0.1.0", + "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" }, @@ -21614,9 +23451,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -21692,6 +23529,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -21838,6 +23676,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21877,7 +23716,6 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { @@ -22150,9 +23988,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -22200,6 +24038,16 @@ "tslib": "^2.0.3" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/urlpattern-polyfill": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", @@ -22226,7 +24074,6 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -22269,6 +24116,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -22291,9 +24153,9 @@ } }, "node_modules/validator": { - "version": "13.15.15", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", - "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -22346,13 +24208,14 @@ } }, "node_modules/vite": { - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -22557,7 +24420,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi": { @@ -22723,6 +24585,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -22781,9 +24644,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "bin": { @@ -22791,15 +24654,11 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yaml-ast-parser": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -22852,11 +24711,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zeptomatch": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.0.2.tgz", + "integrity": "sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.10" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -22871,26 +24754,94 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/auth": { + "name": "@cv/auth", + "version": "0.0.0", + "dependencies": { + "@cv/system": "*", + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/graphql": "^12.2.2", + "@nestjs/jwt": "^10.2.0", + "bcryptjs": "^2.4.3", + "graphql": "^16.12.0", + "reflect-metadata": "^0.2.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.6", + "@cv/biome-config": "*", + "@cv/tsconfig": "*", + "typescript": "^5.6.3" + }, + "peerDependencies": { + "@cv/system": "*", + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/graphql": "^12.2.2", + "@nestjs/jwt": "^10.2.0", + "bcryptjs": "^2.4.3", + "graphql": "^16.12.0" + } + }, "packages/biome-config": { "name": "@cv/biome-config", "version": "0.0.0" }, - "packages/design-system": { - "name": "@cv/design-system", + "packages/routing": { + "name": "@cv/routing", + "version": "0.0.0", + "devDependencies": { + "@types/react": "^19.0.6", + "@types/react-dom": "^19.0.3", + "typescript": "^5.5.3" + }, + "peerDependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router-dom": "^7.9.4" + } + }, + "packages/system": { + "name": "@cv/system", "version": "0.0.0", - "extraneous": true, "dependencies": { - "@catppuccin/tailwindcss": "^1.0.0", - "@tailwindcss/postcss": "^4.0.0", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "tailwindcss": "^4.0.0" + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/graphql": "^12.2.2", + "@prisma/adapter-pg": "^7.1.0", + "@prisma/client": "^7.1.0", + "dataloader": "^2.2.3", + "graphql": "^16.12.0", + "handlebars": "^4.7.8", + "pg": "^8.16.3", + "reflect-metadata": "^0.2.2", + "resend": "^6.5.2" }, "devDependencies": { "@biomejs/biome": "^2.2.6", - "@cv/biome-config": "0.0.0", - "@cv/tsconfig": "0.0.0", + "@cv/biome-config": "*", + "@cv/tsconfig": "*", "typescript": "^5.6.3" + }, + "peerDependencies": { + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/graphql": "^12.2.2", + "@prisma/adapter-pg": "^7.1.0", + "@prisma/client": "^7.1.0", + "graphql": "^16.12.0", + "handlebars": "^4.7.8", + "pg": "^8.16.3", + "resend": "^6.5.2" } }, "packages/tsconfig": { @@ -22906,19 +24857,20 @@ "@tanstack/react-virtual": "^3.11.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "tailwind-merge": "^2.5.3" + "tailwind-merge": "^2.5.3", + "zod": "^3.25.76" }, "devDependencies": { "@tailwindcss/cli": "^4.1.16", "@tailwindcss/postcss": "^4.0.0", - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.0.6", + "@types/react-dom": "^19.0.3", "tailwindcss": "^4.0.0", "typescript": "^5.5.3" }, "peerDependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^19.2.3", + "react-dom": "^19.2.3" } }, "packages/utils": { diff --git a/package.json b/package.json index f9dc38c..152dc02 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "start": "lerna run start --stream", "lint": "lerna run lint --stream", "lint:fix": "lerna run lint:fix --stream", + "typecheck": "lerna run typecheck --parallel --no-bail --stream", "codegen": "lerna run prisma:generate --scope=@cv/server && lerna run codegen --scope=@cv/client", "prisma:generate": "lerna run prisma:generate --scope=@cv/server", "prisma:migrate": "lerna run prisma:migrate --scope=@cv/server", @@ -26,6 +27,6 @@ }, "dependencies": { "@cspotcode/source-map-support": "^0.8.1", - "@prisma/client": "^6.17.1" + "@prisma/client": "^7.1.0" } } diff --git a/packages/auth/README.md b/packages/auth/README.md new file mode 100644 index 0000000..c4aff8b --- /dev/null +++ b/packages/auth/README.md @@ -0,0 +1,84 @@ +# @cv/auth + +Authentication and authorization package for the CV Generator monorepo. + +## Overview + +This package provides reusable authentication and authorization infrastructure that can be used across multiple applications in the monorepo. It includes user management, token handling, guards, policies, and identity provider system. + +## Package Structure + +``` +src/ +├── auth.module.ts # Main module (global) +├── user/ # User domain entities and services +│ ├── user.entity.ts +│ ├── user.service.ts +│ ├── user.mapper.ts +│ ├── credentials.entity.ts +│ ├── credentials.service.ts +│ └── credentials.mapper.ts +├── token/ # Token management +│ ├── token.service.ts +│ ├── refresh-token.service.ts +│ ├── auth-cookie.service.ts +│ └── token-expiry.config.ts +├── guards/ # Authentication guards +│ ├── jwt-auth.guard.ts +│ └── verified-scope.guard.ts +├── authorization/ # Authorization system +│ ├── authorization.service.ts +│ ├── policy-registry.service.ts +│ ├── policy.interface.ts +│ └── *-resource.policy.ts +├── providers/ # Identity providers +│ └── password/ +│ ├── password-identity-provider.ts +│ ├── password-authentication.service.ts +│ └── credentials/ # Email templates and listeners +├── config/ # Configuration services +├── cookie/ # Cookie utilities +├── metadata/ # Device and location services +├── request/ # Request decorators +├── jwt/ # JWT utilities +└── errors/ # Authentication/authorization errors +``` + +## Usage + +### Installation + +This package is part of the monorepo and is automatically available to other packages via npm workspaces. + +### Importing + +```typescript +import { AuthModule, JwtAuthGuard, UserService } from "@cv/auth"; +``` + +### Global Module + +`AuthModule` is marked as `@Global()`, which means once imported in the root module, all its exports are available throughout the application without needing to import it in every module. + +### Key Exports + +- **Modules**: `AuthModule`, `UserModule`, `TokenModule`, `AuthorizationModule`, `PasswordProviderModule` +- **Services**: `UserService`, `TokenService`, `AuthorizationService`, `PasswordAuthenticationService` +- **Guards**: `JwtAuthGuard`, `VerifiedScopeGuard` +- **Entities**: `User`, `Credentials`, `RefreshToken` +- **Utilities**: `RequestMetadata`, `CookieService`, `AuthCookieService` + +## Dependencies + +- **Peer Dependencies**: `@cv/system`, `@nestjs/common`, `@nestjs/core`, `@nestjs/jwt`, `@nestjs/config` +- **Internal Dependencies**: Only `@cv/system` (infrastructure layer) + +## Architecture + +This package follows the three-layer architecture: +- **Domain Layer**: Entities (`User`, `Credentials`, `RefreshToken`) +- **Service Layer**: Business logic services +- **Guard/Policy Layer**: NestJS guards and authorization policies + +All entities are domain entities that contain business logic and rules, not Prisma models. + diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..05a16ff --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,52 @@ +{ + "name": "@cv/auth", + "version": "0.0.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "require": "./src/index.ts", + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "files": [ + "src/" + ], + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write .", + "typecheck": "tsc -b" + }, + "dependencies": { + "@cv/system": "*", + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/graphql": "^12.2.2", + "@nestjs/jwt": "^10.2.0", + "bcryptjs": "^2.4.3", + "graphql": "^16.12.0", + "reflect-metadata": "^0.2.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.6", + "@cv/biome-config": "*", + "@cv/tsconfig": "*", + "typescript": "^5.6.3" + }, + "peerDependencies": { + "@cv/system": "*", + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/graphql": "^12.2.2", + "@nestjs/jwt": "^10.2.0", + "bcryptjs": "^2.4.3", + "graphql": "^16.12.0" + } +} diff --git a/packages/auth/src/auth.module.ts b/packages/auth/src/auth.module.ts new file mode 100644 index 0000000..11261c7 --- /dev/null +++ b/packages/auth/src/auth.module.ts @@ -0,0 +1,23 @@ +import { Global, Module } from "@nestjs/common"; +import { DiscoveryModule } from "@nestjs/core"; +import { AuthorizationModule } from "./authorization/authorization.module"; +import { JwtAuthGuard } from "./guards/jwt-auth.guard"; +import { VerifiedScopeGuard } from "./guards/verified-scope.guard"; +import { IdentityProviderRegistry } from "./identity-provider-registry.service"; +import { TokenModule } from "./token/token.module"; +import { UserModule } from "./user/user.module"; + +@Global() +@Module({ + imports: [DiscoveryModule, UserModule, TokenModule, AuthorizationModule], + providers: [IdentityProviderRegistry, JwtAuthGuard, VerifiedScopeGuard], + exports: [ + IdentityProviderRegistry, + UserModule, + TokenModule, + AuthorizationModule, + JwtAuthGuard, + VerifiedScopeGuard, + ], +}) +export class AuthModule {} diff --git a/packages/auth/src/authorization/authorization.module.ts b/packages/auth/src/authorization/authorization.module.ts new file mode 100644 index 0000000..d109399 --- /dev/null +++ b/packages/auth/src/authorization/authorization.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { DiscoveryModule } from "@nestjs/core"; +import { AuthorizationService } from "./authorization.service"; +import { PolicyRegistry } from "./policy-registry.service"; + +@Module({ + imports: [DiscoveryModule], + providers: [PolicyRegistry, AuthorizationService], + exports: [AuthorizationService, PolicyRegistry], +}) +export class AuthorizationModule {} diff --git a/packages/auth/src/authorization/authorization.service.ts b/packages/auth/src/authorization/authorization.service.ts new file mode 100644 index 0000000..7583f89 --- /dev/null +++ b/packages/auth/src/authorization/authorization.service.ts @@ -0,0 +1,70 @@ +import type { BaseEntity } from "@cv/system"; +import { Injectable, type Type } from "@nestjs/common"; +import { + CannotCreateError, + CannotDeleteError, + CannotUpdateError, + CannotViewError, +} from "../errors/authorization.error"; +import type { User } from "../user/user.entity"; +import type { PolicyRegistry } from "./policy-registry.service"; + +@Injectable() +export class AuthorizationService { + constructor(private readonly policyRegistry: PolicyRegistry) {} + + async canView( + userContext: User, + resource: TResource, + resourceType?: Type, + ): Promise { + const type = resourceType ?? (resource.constructor as Type); + const policy = this.policyRegistry.getPolicy(type); + const allowed = await policy.view(userContext, resource); + if (!allowed) { + throw new CannotViewError(type.name); + } + } + + async canCreate( + userContext: User, + resourceType: Type, + resource?: Partial, + ): Promise { + const policy = this.policyRegistry.getPolicy(resourceType); + const allowed = await policy.create(userContext, resource); + if (!allowed) { + throw new CannotCreateError(resourceType.name); + } + } + + async canUpdate( + userContext: User, + resource: TResource, + resourceType?: Type, + ): Promise { + const type = resourceType ?? (resource.constructor as Type); + const policy = this.policyRegistry.getPolicy(type); + const allowed = await policy.update(userContext, resource); + if (!allowed) { + throw new CannotUpdateError(type.name); + } + } + + async canDelete( + userContext: User, + resource: TResource, + resourceType?: Type, + ): Promise { + const type = resourceType ?? (resource.constructor as Type); + const policy = this.policyRegistry.getPolicy(type); + const allowed = await policy.delete(userContext, resource); + if (!allowed) { + throw new CannotDeleteError(type.name); + } + } + + isSameUser(userContext: User, userId: string): boolean { + return userContext.id === userId; + } +} diff --git a/packages/auth/src/authorization/index.ts b/packages/auth/src/authorization/index.ts new file mode 100644 index 0000000..41543f3 --- /dev/null +++ b/packages/auth/src/authorization/index.ts @@ -0,0 +1,8 @@ +export * from "./authorization.module"; +export * from "./authorization.service"; +export * from "./owner-owned-resource.policy"; +export { Policy } from "./policy.decorator"; +export type { Policy as IPolicy } from "./policy.interface"; +export * from "./policy-registry.service"; +export * from "./public-resource.policy"; +export * from "./user-owned-resource.policy"; diff --git a/packages/auth/src/authorization/owner-owned-resource.policy.ts b/packages/auth/src/authorization/owner-owned-resource.policy.ts new file mode 100644 index 0000000..87f9890 --- /dev/null +++ b/packages/auth/src/authorization/owner-owned-resource.policy.ts @@ -0,0 +1,32 @@ +import type { User } from "../user/user.entity"; +import type { Policy } from "./policy.interface"; + +export abstract class OwnerOwnedResourcePolicy< + TResource extends { ownerId: string }, +> implements Policy +{ + view(user: User, resource: TResource): boolean | Promise { + return resource.ownerId === user.id; + } + + create( + user: User, + resource?: Partial, + ): boolean | Promise { + if (!resource) { + return true; + } + if ("ownerId" in resource && typeof resource.ownerId === "string") { + return resource.ownerId === user.id; + } + return false; + } + + update(user: User, resource: TResource): boolean | Promise { + return resource.ownerId === user.id; + } + + delete(user: User, resource: TResource): boolean | Promise { + return resource.ownerId === user.id; + } +} diff --git a/packages/auth/src/authorization/policy-registry.service.ts b/packages/auth/src/authorization/policy-registry.service.ts new file mode 100644 index 0000000..8363d8f --- /dev/null +++ b/packages/auth/src/authorization/policy-registry.service.ts @@ -0,0 +1,72 @@ +import { raise } from "@cv/system"; +import { Injectable, type OnModuleInit, type Type } from "@nestjs/common"; +import type { DiscoveryService, Reflector } from "@nestjs/core"; +import { POLICY_RESOURCE_KEY } from "./policy.decorator"; +import type { Policy } from "./policy.interface"; + +@Injectable() +export class PolicyRegistry implements OnModuleInit { + private readonly policies = new Map, Policy>(); + + constructor( + private readonly discoveryService: DiscoveryService, + private readonly reflector: Reflector, + ) {} + + onModuleInit(): void { + const providers = this.discoveryService.getProviders(); + + for (const provider of providers) { + const { instance, metatype } = provider; + + if (!instance) { + continue; + } + + if (!metatype) { + continue; + } + + const resourceType = this.reflector.get>( + POLICY_RESOURCE_KEY, + metatype, + ); + + if (!resourceType) { + continue; + } + + const policy = instance as Policy; + + if (this.isPolicy(policy)) { + this.policies.set(resourceType, policy); + } + } + } + + getPolicy(resourceType: Type): Policy { + return ( + (this.policies.get(resourceType) as Policy | undefined) ?? + raise(`No policy found for resource type: ${resourceType.name}`) + ); + } + + hasPolicy(resourceType: Type): boolean { + return this.policies.has(resourceType); + } + + private isPolicy(obj: unknown): obj is Policy { + if (!obj || typeof obj !== "object") { + return false; + } + + const prototype = Object.getPrototypeOf(obj); + const policyMethods = ["view", "create", "update", "delete"]; + + return policyMethods.every( + (method) => + typeof (obj as Record)[method] === "function" || + typeof prototype[method] === "function", + ); + } +} diff --git a/packages/auth/src/authorization/policy.decorator.ts b/packages/auth/src/authorization/policy.decorator.ts new file mode 100644 index 0000000..9bd28bc --- /dev/null +++ b/packages/auth/src/authorization/policy.decorator.ts @@ -0,0 +1,7 @@ +import type { Type } from "@nestjs/common"; +import { SetMetadata } from "@nestjs/common"; + +export const POLICY_RESOURCE_KEY = "policy:resource"; + +export const Policy = (resourceType: Type) => + SetMetadata(POLICY_RESOURCE_KEY, resourceType); diff --git a/packages/auth/src/authorization/policy.interface.ts b/packages/auth/src/authorization/policy.interface.ts new file mode 100644 index 0000000..6bb9964 --- /dev/null +++ b/packages/auth/src/authorization/policy.interface.ts @@ -0,0 +1,11 @@ +import type { User } from "../user/user.entity"; + +export interface Policy { + view(user: User, resource: TResource): boolean | Promise; + + create(user: User, resource?: Partial): boolean | Promise; + + update(user: User, resource: TResource): boolean | Promise; + + delete(user: User, resource: TResource): boolean | Promise; +} diff --git a/packages/auth/src/authorization/public-resource.policy.ts b/packages/auth/src/authorization/public-resource.policy.ts new file mode 100644 index 0000000..2e33e2d --- /dev/null +++ b/packages/auth/src/authorization/public-resource.policy.ts @@ -0,0 +1,25 @@ +import type { User } from "../user/user.entity"; +import type { Policy } from "./policy.interface"; + +export abstract class PublicResourcePolicy + implements Policy +{ + view(_user: User, _resource: TResource): boolean | Promise { + return true; + } + + create( + _user: User, + _resource?: Partial, + ): boolean | Promise { + return false; + } + + update(_user: User, _resource: TResource): boolean | Promise { + return false; + } + + delete(_user: User, _resource: TResource): boolean | Promise { + return false; + } +} diff --git a/packages/auth/src/authorization/user-owned-resource.policy.ts b/packages/auth/src/authorization/user-owned-resource.policy.ts new file mode 100644 index 0000000..a74eed9 --- /dev/null +++ b/packages/auth/src/authorization/user-owned-resource.policy.ts @@ -0,0 +1,32 @@ +import type { User } from "../user/user.entity"; +import type { Policy } from "./policy.interface"; + +export abstract class UserOwnedResourcePolicy< + TResource extends { userId: string }, +> implements Policy +{ + view(user: User, resource: TResource): boolean | Promise { + return resource.userId === user.id; + } + + create( + user: User, + resource?: Partial, + ): boolean | Promise { + if (!resource) { + return true; + } + if ("userId" in resource && typeof resource.userId === "string") { + return resource.userId === user.id; + } + return false; + } + + update(user: User, resource: TResource): boolean | Promise { + return resource.userId === user.id; + } + + delete(user: User, resource: TResource): boolean | Promise { + return resource.userId === user.id; + } +} diff --git a/packages/auth/src/config/index.ts b/packages/auth/src/config/index.ts new file mode 100644 index 0000000..397320d --- /dev/null +++ b/packages/auth/src/config/index.ts @@ -0,0 +1 @@ +export * from "./jwt.config"; diff --git a/packages/auth/src/config/jwt.config.ts b/packages/auth/src/config/jwt.config.ts new file mode 100644 index 0000000..9488386 --- /dev/null +++ b/packages/auth/src/config/jwt.config.ts @@ -0,0 +1,53 @@ +import { Injectable } from "@nestjs/common"; +import type { ConfigService } from "@nestjs/config"; + +@Injectable() +export class JwtConfigService { + constructor(private configService: ConfigService) {} + + getAccessTokenExpiry(): string { + return this.configService.getOrThrow("JWT_ACCESS_TOKEN_EXPIRY"); + } + + getRefreshTokenExpiry(): string { + return this.configService.getOrThrow("JWT_REFRESH_TOKEN_EXPIRY"); + } + + getSecret(): string { + return this.configService.getOrThrow("JWT_SECRET"); + } + + calculateExpiryDate(expiryString: string): Date { + const expires_at = new Date(); + + const match = expiryString.match(/^(\d+)([smhd])$/); + if (!(match?.[1] && match[2])) { + expires_at.setMinutes(expires_at.getMinutes() + 15); + return expires_at; + } + + const value = Number.parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "s": + expires_at.setSeconds(expires_at.getSeconds() + value); + break; + case "m": + expires_at.setMinutes(expires_at.getMinutes() + value); + break; + case "h": + expires_at.setHours(expires_at.getHours() + value); + break; + case "d": + expires_at.setDate(expires_at.getDate() + value); + break; + } + + return expires_at; + } + + calculateAccessTokenExpiryDate(): Date { + return this.calculateExpiryDate(this.getAccessTokenExpiry()); + } +} diff --git a/packages/auth/src/cookie/cookie.service.ts b/packages/auth/src/cookie/cookie.service.ts new file mode 100644 index 0000000..5ecd9ff --- /dev/null +++ b/packages/auth/src/cookie/cookie.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger } from "@nestjs/common"; +import type { ConfigService } from "@nestjs/config"; +import type { Response } from "express"; + +type CookieOptionsInput = { + httpOnly?: boolean; + secure?: boolean; + sameSite?: "lax" | "strict" | "none"; + maxAge?: number; + expires?: Date; + path?: string; +}; + +type CookieOptions = { + httpOnly: boolean; + secure: boolean; + sameSite: "lax" | "strict"; + maxAge?: number; + expires?: Date; + path: string; +}; + +@Injectable() +export class CookieService { + private readonly logger = new Logger(CookieService.name); + + constructor(private readonly configService: ConfigService) {} + + private getCookieOptions(options?: CookieOptionsInput): CookieOptions { + const isDevelopment = + this.configService.get("NODE_ENV") !== "production"; + const isSecure = options?.secure ?? !isDevelopment; + + const sameSiteOption = options?.sameSite; + const defaultSameSite = isDevelopment ? "lax" : "strict"; + const sameSite = + sameSiteOption === "none" + ? defaultSameSite + : (sameSiteOption ?? defaultSameSite); + + return { + httpOnly: options?.httpOnly ?? true, + secure: isSecure, + sameSite, + ...(options?.maxAge !== undefined && { maxAge: options.maxAge }), + ...(options?.expires !== undefined && { expires: options.expires }), + path: options?.path ?? "/", + }; + } + + setCookie( + res: Response, + name: string, + value: string, + options?: CookieOptionsInput, + ): void { + const cookieOptions = this.getCookieOptions(options); + res.cookie(name, value, cookieOptions); + this.logger.debug(`Set cookie: ${name}`, cookieOptions); + } + + clearCookie(res: Response, name: string, options?: CookieOptionsInput): void { + const clearOptions = this.getCookieOptions({ + ...options, + maxAge: 0, + expires: new Date(0), + }); + + res.clearCookie(name, clearOptions); + res.cookie(name, "", clearOptions); + this.logger.debug(`Cleared cookie: ${name}`, clearOptions); + } +} diff --git a/packages/auth/src/cookie/index.ts b/packages/auth/src/cookie/index.ts new file mode 100644 index 0000000..66de705 --- /dev/null +++ b/packages/auth/src/cookie/index.ts @@ -0,0 +1 @@ +export * from "./cookie.service"; diff --git a/packages/auth/src/errors/authentication.error.ts b/packages/auth/src/errors/authentication.error.ts new file mode 100644 index 0000000..41056ee --- /dev/null +++ b/packages/auth/src/errors/authentication.error.ts @@ -0,0 +1,102 @@ +import { DomainError } from "@cv/system"; + +export abstract class AuthenticationError extends DomainError {} + +export class NoTokenError extends AuthenticationError { + constructor() { + super("AUTHENTICATION_NO_TOKEN", "No authentication token provided"); + } +} + +export class InvalidCredentialsError extends AuthenticationError { + constructor() { + super("AUTHENTICATION_INVALID_CREDENTIALS", "Invalid credentials"); + } +} + +export class InvalidTokenError extends AuthenticationError { + constructor() { + super("AUTHENTICATION_INVALID_TOKEN", "Invalid token"); + } +} + +export class TokenExpiredError extends AuthenticationError { + constructor() { + super("AUTHENTICATION_TOKEN_EXPIRED", "Token expired"); + } +} + +export class InvalidRefreshTokenError extends AuthenticationError { + constructor() { + super( + "AUTHENTICATION_INVALID_REFRESH_TOKEN", + "Invalid or expired refresh token", + ); + } +} + +export class CurrentPasswordIncorrectError extends AuthenticationError { + constructor() { + super( + "AUTHENTICATION_CURRENT_PASSWORD_INCORRECT", + "Current password is incorrect", + ); + } +} + +export class PasswordIncorrectError extends AuthenticationError { + constructor() { + super("AUTHENTICATION_PASSWORD_INCORRECT", "Password is incorrect"); + } +} + +export class EmailAlreadyVerifiedError extends AuthenticationError { + constructor() { + super("AUTHENTICATION_EMAIL_ALREADY_VERIFIED", "Email is already verified"); + } +} + +export class InvalidVerificationTokenError extends AuthenticationError { + constructor() { + super( + "AUTHENTICATION_INVALID_VERIFICATION_TOKEN", + "Invalid verification token", + ); + } +} + +export class VerificationTokenExpiredError extends AuthenticationError { + constructor() { + super( + "AUTHENTICATION_VERIFICATION_TOKEN_EXPIRED", + "Verification token has expired", + ); + } +} + +export class InvalidPasswordResetTokenError extends AuthenticationError { + constructor() { + super( + "AUTHENTICATION_INVALID_PASSWORD_RESET_TOKEN", + "Invalid password reset token", + ); + } +} + +export class PasswordResetTokenExpiredError extends AuthenticationError { + constructor() { + super( + "AUTHENTICATION_PASSWORD_RESET_TOKEN_EXPIRED", + "Password reset token has expired", + ); + } +} + +export class EmailNotVerifiedError extends AuthenticationError { + constructor() { + super( + "AUTHENTICATION_EMAIL_NOT_VERIFIED", + "Email address has not been verified", + ); + } +} diff --git a/packages/auth/src/errors/authorization.error.ts b/packages/auth/src/errors/authorization.error.ts new file mode 100644 index 0000000..6f46a56 --- /dev/null +++ b/packages/auth/src/errors/authorization.error.ts @@ -0,0 +1,43 @@ +import { DomainError } from "@cv/system"; + +export abstract class AuthorizationError extends DomainError {} + +export class CannotViewError extends AuthorizationError { + constructor(resourceType: string) { + super( + "AUTHORIZATION_CANNOT_VIEW", + `You are not authorized to view this ${resourceType}`, + { resourceType }, + ); + } +} + +export class CannotCreateError extends AuthorizationError { + constructor(resourceType: string) { + super( + "AUTHORIZATION_CANNOT_CREATE", + `You are not authorized to create ${resourceType}`, + { resourceType }, + ); + } +} + +export class CannotUpdateError extends AuthorizationError { + constructor(resourceType: string) { + super( + "AUTHORIZATION_CANNOT_UPDATE", + `You are not authorized to update this ${resourceType}`, + { resourceType }, + ); + } +} + +export class CannotDeleteError extends AuthorizationError { + constructor(resourceType: string) { + super( + "AUTHORIZATION_CANNOT_DELETE", + `You are not authorized to delete this ${resourceType}`, + { resourceType }, + ); + } +} diff --git a/packages/auth/src/errors/index.ts b/packages/auth/src/errors/index.ts new file mode 100644 index 0000000..6286fb9 --- /dev/null +++ b/packages/auth/src/errors/index.ts @@ -0,0 +1,4 @@ +export * from "./authentication.error"; +export * from "./authorization.error"; +export * from "./not-found.util"; +export { EntityNotFoundError } from "./not-found.util"; diff --git a/packages/auth/src/errors/not-found.util.ts b/packages/auth/src/errors/not-found.util.ts new file mode 100644 index 0000000..9c7ceca --- /dev/null +++ b/packages/auth/src/errors/not-found.util.ts @@ -0,0 +1,19 @@ +import { DomainError } from "@cv/system"; + +export class EntityNotFoundError extends DomainError { + constructor(entityName: string, property: string, value: string) { + super( + "NOT_FOUND_ENTITY_NOT_FOUND", + `${entityName} with ${property} ${value} not found`, + { entityName, property, value }, + ); + } +} + +export const notFound = ( + entityName: string, + property: string, + value: string, +): never => { + throw new EntityNotFoundError(entityName, property, value); +}; diff --git a/packages/auth/src/guards/index.ts b/packages/auth/src/guards/index.ts new file mode 100644 index 0000000..fb9a64f --- /dev/null +++ b/packages/auth/src/guards/index.ts @@ -0,0 +1,2 @@ +export * from "./jwt-auth.guard"; +export * from "./verified-scope.guard"; diff --git a/packages/auth/src/guards/jwt-auth.guard.ts b/packages/auth/src/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..5be10a0 --- /dev/null +++ b/packages/auth/src/guards/jwt-auth.guard.ts @@ -0,0 +1,67 @@ +import { + type CanActivate, + type ExecutionContext, + Injectable, +} from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import type { JwtService } from "@nestjs/jwt"; +import { EntityNotFoundError } from "../errors"; +import { + InvalidTokenError, + NoTokenError, +} from "../errors/authentication.error"; +import type { UserService } from "../user/user.service"; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private userService: UserService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req; + + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new NoTokenError(); + } + + try { + const payload = await this.jwtService.verifyAsync(token); + const user = await this.userService.findById(payload.sub); + + if (!user) { + throw new EntityNotFoundError("User", "id", payload.sub); + } + + request.user = user; + request.jwtPayload = payload; + return true; + } catch (error) { + if ( + error instanceof EntityNotFoundError || + error instanceof InvalidTokenError + ) { + throw error; + } + throw new InvalidTokenError(); + } + } + + private extractTokenFromHeader(request: { + headers?: Record; + cookies?: { access_token?: string }; + }): string | undefined { + if (request.cookies?.access_token) { + return request.cookies.access_token; + } + const authHeader = request.headers?.["authorization"]; + if (!authHeader || typeof authHeader !== "string") { + return undefined; + } + const [type, token] = authHeader.split(" "); + return type === "Bearer" ? token : undefined; + } +} diff --git a/packages/auth/src/guards/verified-scope.guard.ts b/packages/auth/src/guards/verified-scope.guard.ts new file mode 100644 index 0000000..d2bd6e8 --- /dev/null +++ b/packages/auth/src/guards/verified-scope.guard.ts @@ -0,0 +1,58 @@ +import { + type CanActivate, + type ExecutionContext, + ForbiddenException, + Injectable, +} from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import type { JwtService } from "@nestjs/jwt"; +import { JwtScope } from "../jwt/jwt-scope.enum"; + +@Injectable() +export class VerifiedScopeGuard implements CanActivate { + constructor(private jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req; + + const token = this.extractTokenFromHeader(request); + if (!token) { + return false; + } + + try { + const payload = await this.jwtService.verifyAsync(token); + + if (payload.scope !== JwtScope.VERIFIED) { + throw new ForbiddenException( + "This action requires a verified email address", + ); + } + + return true; + } catch (error) { + if (error instanceof ForbiddenException) { + throw error; + } + throw new ForbiddenException( + "This action requires a verified email address", + ); + } + } + + private extractTokenFromHeader(request: { + headers?: Record; + cookies?: { access_token?: string }; + }): string | undefined { + if (request.cookies?.access_token) { + return request.cookies.access_token; + } + const authHeader = request.headers?.["authorization"]; + if (!authHeader || typeof authHeader !== "string") { + return undefined; + } + const [type, token] = authHeader.split(" "); + return type === "Bearer" ? token : undefined; + } +} diff --git a/packages/auth/src/identity-provider-registry.service.ts b/packages/auth/src/identity-provider-registry.service.ts new file mode 100644 index 0000000..30b160c --- /dev/null +++ b/packages/auth/src/identity-provider-registry.service.ts @@ -0,0 +1,142 @@ +import { raise } from "@cv/system"; +import { Injectable, type OnModuleInit } from "@nestjs/common"; +import type { DiscoveryService, Reflector } from "@nestjs/core"; +import { z } from "zod"; +import { + IDENTITY_PROVIDER_KEY, + type IdentityProviderMeta, +} from "./identity-provider.decorator"; +import type { IdentityProvider } from "./identity-provider.interface"; + +type ProviderEntry = { + meta: IdentityProviderMeta; + provider: IdentityProvider; +}; + +const identityProviderMetaSchema = z.object({ + name: z.custom((val) => typeof val === "symbol", { + message: "name must be a symbol", + }), + priority: z.number().optional(), +}); + +const identityProviderInstanceSchema = z + .object({ + authenticate: z.function(), + }) + .passthrough(); + +@Injectable() +export class IdentityProviderRegistry implements OnModuleInit { + private readonly providers = new Map>(); + + constructor( + private readonly discoveryService: DiscoveryService, + private readonly reflector: Reflector, + ) {} + + onModuleInit(): void { + const providers = this.discoveryService.getProviders(); + + for (const { instance, metatype } of providers) { + if (!(instance && metatype)) { + continue; + } + + const meta = this.reflector.get( + IDENTITY_PROVIDER_KEY, + metatype, + ); + + if (!meta) { + continue; + } + + const validatedMeta = this.validateMetadata(meta, metatype); + const { name } = validatedMeta; + const validatedProvider = this.validateInstance(instance, metatype); + + if (this.providers.has(name)) { + const className = this.getClassName(metatype); + throw new Error( + `Duplicate identity provider name: ${name.toString()}. Provider class: ${className}`, + ); + } + + this.providers.set(name, { + meta: validatedMeta, + provider: validatedProvider, + }); + } + } + + private validateMetadata( + meta: unknown, + metatype: unknown, + ): IdentityProviderMeta { + const className = this.getClassName(metatype); + + try { + const parsed = identityProviderMetaSchema.parse(meta); + const { name, priority } = parsed; + const result: IdentityProviderMeta = { name }; + if (priority !== undefined) { + result.priority = priority; + } + return result; + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map(({ path, message }) => `${path.join(".")}: ${message}`) + .join(", "); + throw new Error( + `Invalid identity provider metadata for class ${className}: ${errorMessages}`, + ); + } + throw error; + } + } + + private validateInstance( + instance: unknown, + metatype: unknown, + ): IdentityProvider { + const className = this.getClassName(metatype); + + try { + identityProviderInstanceSchema.parse(instance); + return instance as IdentityProvider; + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map((e) => `${e.path.join(".")}: ${e.message}`) + .join(", "); + throw new Error( + `Identity provider class ${className} has metadata but does not implement the authenticate method: ${errorMessages}`, + ); + } + throw new Error( + `Identity provider class ${className} has metadata but does not implement the authenticate method`, + ); + } + } + + private getClassName(metatype: unknown): string { + if (metatype && typeof metatype === "object" && "name" in metatype) { + const { name } = metatype as { name?: string }; + return name ?? "unknown"; + } + return "unknown"; + } + + getProvider(providerName: symbol): IdentityProvider { + const { provider } = + this.providers.get(providerName) ?? + raise(`No identity provider found: ${providerName.toString()}`); + return provider; + } + + hasProvider(providerName: symbol): boolean { + return this.providers.has(providerName); + } +} diff --git a/packages/auth/src/identity-provider.decorator.ts b/packages/auth/src/identity-provider.decorator.ts new file mode 100644 index 0000000..32004ae --- /dev/null +++ b/packages/auth/src/identity-provider.decorator.ts @@ -0,0 +1,14 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IDENTITY_PROVIDER_KEY = Symbol("IDENTITY_PROVIDER_META"); + +export type IdentityProviderMeta = { + name: symbol; + priority?: number; +}; + +export const IdentityProvider = (meta: IdentityProviderMeta) => + SetMetadata(IDENTITY_PROVIDER_KEY, { + ...meta, + priority: meta.priority ?? 0, + }); diff --git a/packages/auth/src/identity-provider.interface.ts b/packages/auth/src/identity-provider.interface.ts new file mode 100644 index 0000000..b1d15f9 --- /dev/null +++ b/packages/auth/src/identity-provider.interface.ts @@ -0,0 +1,3 @@ +export interface IdentityProvider { + authenticate(credentials: unknown | Record): Promise; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000..86b1ffd --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,19 @@ +export * from "./auth.module"; +export * from "./authorization"; +export * from "./config"; +export * from "./cookie"; +export * from "./errors"; +export * from "./guards"; +export { + IDENTITY_PROVIDER_KEY, + IdentityProvider as IdentityProviderDecorator, + type IdentityProviderMeta, +} from "./identity-provider.decorator"; +export type { IdentityProvider } from "./identity-provider.interface"; +export * from "./identity-provider-registry.service"; +export * from "./jwt"; +export * from "./metadata"; +export * from "./providers"; +export * from "./request"; +export * from "./token"; +export * from "./user"; diff --git a/packages/auth/src/jwt/index.ts b/packages/auth/src/jwt/index.ts new file mode 100644 index 0000000..c1a12a4 --- /dev/null +++ b/packages/auth/src/jwt/index.ts @@ -0,0 +1 @@ +export * from "./jwt-scope.enum"; diff --git a/packages/auth/src/jwt/jwt-scope.enum.ts b/packages/auth/src/jwt/jwt-scope.enum.ts new file mode 100644 index 0000000..29744bb --- /dev/null +++ b/packages/auth/src/jwt/jwt-scope.enum.ts @@ -0,0 +1,4 @@ +export enum JwtScope { + UNVERIFIED = "unverified", + VERIFIED = "verified", +} diff --git a/packages/auth/src/metadata/device-identification.service.ts b/packages/auth/src/metadata/device-identification.service.ts new file mode 100644 index 0000000..0d520b8 --- /dev/null +++ b/packages/auth/src/metadata/device-identification.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from "@nestjs/common"; + +export interface DeviceInfo { + name: string; + type: "desktop" | "mobile" | "tablet" | "unknown"; +} + +@Injectable() +export class DeviceIdentificationService { + private readonly browserNames: Record = { + msie: "Internet Explorer", + trident: "Internet Explorer", + edge: "Edge", + chrome: "Chrome", + firefox: "Firefox", + safari: "Safari", + opera: "Opera", + }; + + private readonly osNames: Record = { + macintosh: "macOS", + windows: "Windows", + linux: "Linux", + android: "Android", + ios: "iOS", + }; + + identifyDevice(userAgent: string | null): DeviceInfo { + if (!userAgent) { + return { name: "Unknown Device", type: "unknown" }; + } + + const ua = userAgent.toLowerCase(); + + const deviceType = this.getDeviceType(ua); + const deviceName = this.getDeviceName(ua, deviceType); + + return { name: deviceName, type: deviceType }; + } + + private getDeviceType(userAgent: string): DeviceInfo["type"] { + const mobilePattern = + /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i; + const tabletPattern = /tablet|ipad|playbook|silk/i; + + if (mobilePattern.test(userAgent)) { + return tabletPattern.test(userAgent) ? "tablet" : "mobile"; + } + return "desktop"; + } + + private getDeviceName(userAgent: string, type: DeviceInfo["type"]): string { + const ua = userAgent.toLowerCase(); + + return type === "mobile" || type === "tablet" + ? this.getMobileDeviceName(ua) + : this.getDesktopDeviceName(ua); + } + + private getMobileDeviceName(userAgent: string): string { + const mobileMatch = userAgent.match( + /(android|iphone|ipad|ipod|blackberry|windows phone|opera mini|mobile)/i, + ); + const device = mobileMatch?.[1]?.toLowerCase(); + + if (!device) { + return "Unknown Mobile Device"; + } + + const deviceHandlers: Record string> = { + android: (ua) => { + const version = ua.match(/android\s([0-9.]+)/)?.[1]; + return version ? `Android ${version}` : "Android Device"; + }, + iphone: (ua) => { + const version = ua.match(/os\s([0-9_]+)/)?.[1]; + return version ? `iPhone iOS ${version.replace(/_/g, ".")}` : "iPhone"; + }, + ipad: (ua) => { + const version = ua.match(/os\s([0-9_]+)/)?.[1]; + return version ? `iPad iOS ${version.replace(/_/g, ".")}` : "iPad"; + }, + ipod: (ua) => { + const version = ua.match(/os\s([0-9_]+)/)?.[1]; + return version ? `iPod iOS ${version.replace(/_/g, ".")}` : "iPod"; + }, + }; + + const handler = deviceHandlers[device]; + return handler + ? handler(userAgent) + : device.charAt(0).toUpperCase() + device.slice(1); + } + + private getDesktopDeviceName(userAgent: string): string { + const browserMatch = userAgent.match( + /(chrome|firefox|safari|edge|opera|msie|trident)/i, + ); + const osMatch = userAgent.match( + /(windows|macintosh|linux|android|ios|iphone|ipad)/i, + ); + + const browserKey = browserMatch?.[1]?.toLowerCase() ?? "unknown"; + const osKey = osMatch?.[1]?.toLowerCase() ?? "unknown"; + + const browserName = + this.browserNames[browserKey] ?? + browserKey.charAt(0).toUpperCase() + browserKey.slice(1); + const osName = + this.osNames[osKey] ?? osKey.charAt(0).toUpperCase() + osKey.slice(1); + + return `${osName} - ${browserName}`; + } +} diff --git a/packages/auth/src/metadata/index.ts b/packages/auth/src/metadata/index.ts new file mode 100644 index 0000000..fba2ca9 --- /dev/null +++ b/packages/auth/src/metadata/index.ts @@ -0,0 +1,2 @@ +export * from "./device-identification.service"; +export * from "./location.service"; diff --git a/packages/auth/src/metadata/location.service.ts b/packages/auth/src/metadata/location.service.ts new file mode 100644 index 0000000..f07b348 --- /dev/null +++ b/packages/auth/src/metadata/location.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from "@nestjs/common"; + +export interface LocationInfo { + country: string; + city: string; +} + +@Injectable() +export class LocationService { + getLocation(ipAddress: string | null): LocationInfo { + return { + country: "Unknown", + city: "Unknown", + }; + } +} diff --git a/packages/auth/src/providers/index.ts b/packages/auth/src/providers/index.ts new file mode 100644 index 0000000..df7d49b --- /dev/null +++ b/packages/auth/src/providers/index.ts @@ -0,0 +1 @@ +export * from "./password"; diff --git a/packages/auth/src/providers/password/credentials/email.config.ts b/packages/auth/src/providers/password/credentials/email.config.ts new file mode 100644 index 0000000..4b4f3eb --- /dev/null +++ b/packages/auth/src/providers/password/credentials/email.config.ts @@ -0,0 +1,15 @@ +export class EmailConfig { + constructor( + public readonly clientUrl: string, + public readonly fromEmail: string, + public readonly fromName?: string, + ) {} + + getFromAddress(): { email: string; name?: string } { + return this.fromName + ? { email: this.fromEmail, name: this.fromName } + : { email: this.fromEmail }; + } +} + +export const EMAIL_CONFIG_TOKEN = Symbol("EmailConfig"); diff --git a/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-html.hbs b/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-html.hbs new file mode 100644 index 0000000..5a3e5a3 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-html.hbs @@ -0,0 +1,6 @@ +

Reset your password

+

Click the link below to reset your password:

+

{{resetUrl}}

+

This link will expire in 1 hour.

+

If you did not request a password reset, please ignore this email.

+ diff --git a/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-subject.hbs b/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-subject.hbs new file mode 100644 index 0000000..2300b01 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-subject.hbs @@ -0,0 +1,2 @@ +Reset your password + diff --git a/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-text.hbs b/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-text.hbs new file mode 100644 index 0000000..604131d --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/password-reset-requested-text.hbs @@ -0,0 +1,2 @@ +To reset your password, click the following link: {{resetUrl}} + diff --git a/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-html.hbs b/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-html.hbs new file mode 100644 index 0000000..c8b1212 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-html.hbs @@ -0,0 +1,6 @@ +

Someone tried to create an account with your email address ({{email}}).

+ +

If this was you, you can ignore this email. If this was not you, you can also ignore this email - no account was created.

+ +

If you forgot your password, you can reset it here.

+ diff --git a/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-subject.hbs b/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-subject.hbs new file mode 100644 index 0000000..d709d89 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-subject.hbs @@ -0,0 +1,2 @@ +Someone tried to sign up with your email + diff --git a/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-text.hbs b/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-text.hbs new file mode 100644 index 0000000..a58e58e --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/registration-attempt-on-existing-email-text.hbs @@ -0,0 +1,6 @@ +Someone tried to create an account with your email address ({{email}}). + +If this was you, you can ignore this email. If this was not you, you can also ignore this email - no account was created. + +If you forgot your password, you can reset it here: {{resetPasswordUrl}} + diff --git a/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-html.hbs b/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-html.hbs new file mode 100644 index 0000000..a0becfd --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-html.hbs @@ -0,0 +1,5 @@ +

Verify your email address

+

Please click the link below to verify your email address:

+

{{verificationUrl}}

+

This link will expire in 24 hours.

+ diff --git a/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-subject.hbs b/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-subject.hbs new file mode 100644 index 0000000..5b1357d --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-subject.hbs @@ -0,0 +1,2 @@ +Verify your email address + diff --git a/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-text.hbs b/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-text.hbs new file mode 100644 index 0000000..50f568c --- /dev/null +++ b/packages/auth/src/providers/password/credentials/emails/templates/verification-email-requested-text.hbs @@ -0,0 +1,2 @@ +Please verify your email address by clicking the following link: {{verificationUrl}} + diff --git a/packages/auth/src/providers/password/credentials/events/index.ts b/packages/auth/src/providers/password/credentials/events/index.ts new file mode 100644 index 0000000..93331b9 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/events/index.ts @@ -0,0 +1,3 @@ +export * from "./password-reset-requested.event"; +export * from "./registration-attempt-on-existing-email.event"; +export * from "./verification-email-requested.event"; diff --git a/packages/auth/src/providers/password/credentials/events/password-reset-requested.event.ts b/packages/auth/src/providers/password/credentials/events/password-reset-requested.event.ts new file mode 100644 index 0000000..6c86556 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/events/password-reset-requested.event.ts @@ -0,0 +1,12 @@ +import type { Event } from "@cv/system"; + +export const PASSWORD_RESET_REQUESTED = Symbol("password-reset-requested"); + +export class PasswordResetRequestedEvent implements Event { + readonly eventName = PASSWORD_RESET_REQUESTED; + + constructor( + public readonly email: string, + public readonly resetToken: string, + ) {} +} diff --git a/packages/auth/src/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts b/packages/auth/src/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts new file mode 100644 index 0000000..cf2b8e4 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/events/registration-attempt-on-existing-email.event.ts @@ -0,0 +1,11 @@ +import type { Event } from "@cv/system"; + +export const REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL = Symbol( + "registration-attempt-on-existing-email", +); + +export class RegistrationAttemptOnExistingEmailEvent implements Event { + readonly eventName = REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL; + + constructor(public readonly email: string) {} +} diff --git a/packages/auth/src/providers/password/credentials/events/verification-email-requested.event.ts b/packages/auth/src/providers/password/credentials/events/verification-email-requested.event.ts new file mode 100644 index 0000000..049107e --- /dev/null +++ b/packages/auth/src/providers/password/credentials/events/verification-email-requested.event.ts @@ -0,0 +1,15 @@ +import type { Event } from "@cv/system"; + +export const VERIFICATION_EMAIL_REQUESTED = Symbol( + "verification-email-requested", +); + +export class VerificationEmailRequestedEvent implements Event { + readonly eventName = VERIFICATION_EMAIL_REQUESTED; + + constructor( + public readonly userId: string, + public readonly email: string, + public readonly verificationToken: string, + ) {} +} diff --git a/packages/auth/src/providers/password/credentials/index.ts b/packages/auth/src/providers/password/credentials/index.ts new file mode 100644 index 0000000..0be9b5a --- /dev/null +++ b/packages/auth/src/providers/password/credentials/index.ts @@ -0,0 +1,2 @@ +export * from "./email.config"; +export * from "./templated-email.service"; diff --git a/packages/auth/src/providers/password/credentials/listeners/index.ts b/packages/auth/src/providers/password/credentials/listeners/index.ts new file mode 100644 index 0000000..9a683df --- /dev/null +++ b/packages/auth/src/providers/password/credentials/listeners/index.ts @@ -0,0 +1,3 @@ +export * from "./password-reset-email.listener"; +export * from "./registration-attempt-email.listener"; +export * from "./verification-email.listener"; diff --git a/packages/auth/src/providers/password/credentials/listeners/password-reset-email.listener.ts b/packages/auth/src/providers/password/credentials/listeners/password-reset-email.listener.ts new file mode 100644 index 0000000..1b63c3e --- /dev/null +++ b/packages/auth/src/providers/password/credentials/listeners/password-reset-email.listener.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { + PASSWORD_RESET_REQUESTED, + type PasswordResetRequestedEvent, +} from "../events/password-reset-requested.event"; +import type { TemplatedEmailService } from "../templated-email.service"; + +@Injectable() +export class PasswordResetEmailListener { + constructor(private readonly templatedEmailService: TemplatedEmailService) {} + + @OnEvent(PASSWORD_RESET_REQUESTED) + async handlePasswordResetRequested({ + email, + resetToken, + }: PasswordResetRequestedEvent): Promise { + await this.templatedEmailService.send({ + to: email, + data: { + resetUrl: this.templatedEmailService.url("/reset-password", { + token: resetToken, + }), + }, + template: "password-reset-requested", + }); + } +} diff --git a/packages/auth/src/providers/password/credentials/listeners/registration-attempt-email.listener.ts b/packages/auth/src/providers/password/credentials/listeners/registration-attempt-email.listener.ts new file mode 100644 index 0000000..de7f818 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/listeners/registration-attempt-email.listener.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { + REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL, + type RegistrationAttemptOnExistingEmailEvent, +} from "../events/registration-attempt-on-existing-email.event"; +import type { TemplatedEmailService } from "../templated-email.service"; + +@Injectable() +export class RegistrationAttemptEmailListener { + constructor(private readonly templatedEmailService: TemplatedEmailService) {} + + @OnEvent(REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL) + async handleRegistrationAttemptOnExistingEmail({ + email, + }: RegistrationAttemptOnExistingEmailEvent): Promise { + await this.templatedEmailService.send({ + to: email, + data: { + email, + resetPasswordUrl: this.templatedEmailService.url( + "/auth/reset-password", + ), + }, + template: "registration-attempt-on-existing-email", + }); + } +} diff --git a/packages/auth/src/providers/password/credentials/listeners/verification-email.listener.ts b/packages/auth/src/providers/password/credentials/listeners/verification-email.listener.ts new file mode 100644 index 0000000..6811256 --- /dev/null +++ b/packages/auth/src/providers/password/credentials/listeners/verification-email.listener.ts @@ -0,0 +1,29 @@ +import { Injectable } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { + VERIFICATION_EMAIL_REQUESTED, + type VerificationEmailRequestedEvent, +} from "../events/verification-email-requested.event"; +import type { TemplatedEmailService } from "../templated-email.service"; + +@Injectable() +export class VerificationEmailListener { + constructor(private readonly templatedEmailService: TemplatedEmailService) {} + + @OnEvent(VERIFICATION_EMAIL_REQUESTED) + async handleVerificationEmailRequested({ + email, + verificationToken, + }: VerificationEmailRequestedEvent): Promise { + await this.templatedEmailService.send({ + to: email, + data: { + verificationUrl: this.templatedEmailService.url("/auth/verify-email", { + email, + token: verificationToken, + }), + }, + template: "verification-email-requested", + }); + } +} diff --git a/packages/auth/src/providers/password/credentials/templated-email.service.ts b/packages/auth/src/providers/password/credentials/templated-email.service.ts new file mode 100644 index 0000000..78ea78a --- /dev/null +++ b/packages/auth/src/providers/password/credentials/templated-email.service.ts @@ -0,0 +1,62 @@ +import { + HANDLEBARS_TEMPLATE_SERVICE_TOKEN, + type HandlebarsTemplateService, + MAIL_SERVICE_TOKEN, + type MailService, +} from "@cv/system"; +import { Inject, Injectable } from "@nestjs/common"; +import { EMAIL_CONFIG_TOKEN, type EmailConfig } from "./email.config"; + +export interface SendTemplatedEmailOptions { + to: string; + data?: Record; + template: string; +} + +@Injectable() +export class TemplatedEmailService { + constructor( + @Inject(MAIL_SERVICE_TOKEN) + private readonly mailService: MailService, + @Inject(EMAIL_CONFIG_TOKEN) + private readonly emailConfig: EmailConfig, + @Inject(HANDLEBARS_TEMPLATE_SERVICE_TOKEN) + private readonly templates: HandlebarsTemplateService, + ) {} + + url(path: string, params?: Record): string { + const baseUrl = this.emailConfig.clientUrl.replace(/\/$/, ""); + const cleanPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(`${baseUrl}${cleanPath}`); + + if (params) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + searchParams.append(key, value); + } + } + url.search = searchParams.toString(); + } + + return url.toString(); + } + + async send({ + to, + data = {}, + template, + }: SendTemplatedEmailOptions): Promise { + const subject = this.templates.render(`${template}-subject`, data); + const text = this.templates.render(`${template}-text`, data); + const html = this.templates.render(`${template}-html`, data); + + await this.mailService.sendMail({ + to: { email: to }, + from: this.emailConfig.getFromAddress(), + subject, + text, + html, + }); + } +} diff --git a/packages/auth/src/providers/password/index.ts b/packages/auth/src/providers/password/index.ts new file mode 100644 index 0000000..3178530 --- /dev/null +++ b/packages/auth/src/providers/password/index.ts @@ -0,0 +1,11 @@ +export * from "./credentials/email.config"; +export * from "./credentials/events/password-reset-requested.event"; +export * from "./credentials/events/registration-attempt-on-existing-email.event"; +export * from "./credentials/events/verification-email-requested.event"; +export * from "./credentials/listeners/password-reset-email.listener"; +export * from "./credentials/listeners/registration-attempt-email.listener"; +export * from "./credentials/listeners/verification-email.listener"; +export * from "./credentials/templated-email.service"; +export * from "./password-authentication.service"; +export * from "./password-identity-provider"; +export * from "./password-provider.module"; diff --git a/packages/auth/src/providers/password/password-authentication.service.ts b/packages/auth/src/providers/password/password-authentication.service.ts new file mode 100644 index 0000000..a6f4a6c --- /dev/null +++ b/packages/auth/src/providers/password/password-authentication.service.ts @@ -0,0 +1,315 @@ +import { Injectable } from "@nestjs/common"; +import type { EventEmitter2 } from "@nestjs/event-emitter"; +import * as bcrypt from "bcryptjs"; +import { + CurrentPasswordIncorrectError, + EmailAlreadyVerifiedError, + InvalidPasswordResetTokenError, + InvalidVerificationTokenError, + PasswordIncorrectError, +} from "../../errors/authentication.error"; +import type { IdentityProviderRegistry } from "../../identity-provider-registry.service"; +import { JwtScope } from "../../jwt/jwt-scope.enum"; +import type { RequestMetadata } from "../../request/request-metadata.decorator"; +import type { TokenService } from "../../token/token.service"; +import type { TokenExpiryConfigService } from "../../token/token-expiry.config"; +import type { CredentialsService } from "../../user/credentials.service"; +import { generateSecureToken } from "../../user/credentials-token.util"; +import type { User } from "../../user/user.entity"; +import type { UserService } from "../../user/user.service"; +import { + PASSWORD_RESET_REQUESTED, + PasswordResetRequestedEvent, +} from "./credentials/events/password-reset-requested.event"; +import { + REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL, + RegistrationAttemptOnExistingEmailEvent, +} from "./credentials/events/registration-attempt-on-existing-email.event"; +import { + VERIFICATION_EMAIL_REQUESTED, + VerificationEmailRequestedEvent, +} from "./credentials/events/verification-email-requested.event"; +import { PASSWORD_PROVIDER_NAME } from "./password-identity-provider"; + +export interface LoginDto { + email: string; + password: string; +} + +export interface RegisterDto { + email: string; + name: string; + password: string; +} + +export interface AuthenticationResponse { + access_token: string; + expires_at: Date; + user: User; +} + +export interface ChangePasswordDto { + currentPassword: string; + newPassword: string; +} + +export interface DeleteAccountDto { + password: string; +} + +type InternalAuthenticationResponse = AuthenticationResponse & { + refresh_token: string; +}; + +@Injectable() +export class PasswordAuthenticationService { + constructor( + private userService: UserService, + private credentialsService: CredentialsService, + private readonly eventEmitter: EventEmitter2, + private tokenExpiryConfig: TokenExpiryConfigService, + private tokenService: TokenService, + private identityProviderRegistry: IdentityProviderRegistry, + ) {} + + async register( + { email, name, password }: RegisterDto, + requestMetadata?: RequestMetadata, + ): Promise { + const emailExists = await this.credentialsService.exists(email); + + if (emailExists) { + this.eventEmitter.emit( + REGISTRATION_ATTEMPT_ON_EXISTING_EMAIL, + new RegistrationAttemptOnExistingEmailEvent(email), + ); + + const existingUser = + await this.credentialsService.findUserByEmailOrFail(email); + + const tokens = await this.tokenService.generateTokenPair( + existingUser, + undefined, + requestMetadata, + ); + + return { + access_token: tokens.access_token, + expires_at: tokens.expires_at, + user: existingUser, + refresh_token: tokens.refresh_token, + }; + } + + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await this.userService.create(name); + await this.credentialsService.create(user.id, email, hashedPassword); + + const verificationToken = generateSecureToken(); + const expiresAt = + this.tokenExpiryConfig.calculateEmailVerificationTokenExpiryDate(); + await this.credentialsService.setEmailVerificationToken( + user.id, + verificationToken, + expiresAt, + ); + this.eventEmitter.emit( + VERIFICATION_EMAIL_REQUESTED, + new VerificationEmailRequestedEvent(user.id, email, verificationToken), + ); + + const userWithCredentials = await this.userService.findByIdOrFail(user.id); + + const tokens = await this.tokenService.generateTokenPair( + userWithCredentials, + JwtScope.UNVERIFIED, + requestMetadata, + ); + + return { + access_token: tokens.access_token, + expires_at: tokens.expires_at, + user: userWithCredentials, + refresh_token: tokens.refresh_token, + }; + } + + async login( + { email, password }: LoginDto, + requestMetadata?: RequestMetadata, + ): Promise { + const passwordProvider = this.identityProviderRegistry.getProvider( + PASSWORD_PROVIDER_NAME, + ); + const user = await passwordProvider.authenticate({ + email, + password, + }); + + const isVerified = user.credentials?.emailVerifiedAt !== null; + const scope = isVerified ? JwtScope.VERIFIED : JwtScope.UNVERIFIED; + const tokens = await this.tokenService.generateTokenPair( + user, + scope, + requestMetadata, + ); + + return { + access_token: tokens.access_token, + expires_at: tokens.expires_at, + user, + refresh_token: tokens.refresh_token, + }; + } + + async changePassword( + userId: string, + { currentPassword, newPassword }: ChangePasswordDto, + ): Promise { + const user = await this.userService.findByIdOrFail(userId); + if (!user.credentials) { + throw new Error("User credentials not found"); + } + const passwordHash = await this.credentialsService.getPasswordHash( + user.credentials.email, + ); + + if ( + !(passwordHash && (await bcrypt.compare(currentPassword, passwordHash))) + ) { + throw new CurrentPasswordIncorrectError(); + } + + const hashedNewPassword = await bcrypt.hash(newPassword, 10); + await this.credentialsService.updatePassword(userId, hashedNewPassword); + } + + async deleteAccount( + userId: string, + { password }: DeleteAccountDto, + ): Promise { + const user = await this.userService.findByIdOrFail(userId); + if (!user.credentials) { + throw new Error("User credentials not found"); + } + const passwordHash = await this.credentialsService.getPasswordHash( + user.credentials.email, + ); + + if (!(passwordHash && (await bcrypt.compare(password, passwordHash)))) { + throw new PasswordIncorrectError(); + } + + await this.userService.delete(userId); + } + + async sendVerificationEmail(email: string): Promise { + const credentials = await this.credentialsService.findByEmailOrFail(email); + + if (credentials.emailVerifiedAt) { + throw new EmailAlreadyVerifiedError(); + } + + const verificationToken = generateSecureToken(); + const expiresAt = + this.tokenExpiryConfig.calculateEmailVerificationTokenExpiryDate(); + await this.credentialsService.setEmailVerificationToken( + credentials.userId, + verificationToken, + expiresAt, + ); + this.eventEmitter.emit( + VERIFICATION_EMAIL_REQUESTED, + new VerificationEmailRequestedEvent( + credentials.userId, + email, + verificationToken, + ), + ); + } + + async verifyEmail( + email: string, + token: string, + requestMetadata?: RequestMetadata, + ): Promise { + const credentials = await this.credentialsService.findByEmail(email); + + if (!credentials) { + throw new InvalidVerificationTokenError(); + } + + await this.credentialsService.verifyEmail(credentials.userId, token); + + const user = await this.userService.findByIdOrFail(credentials.userId); + + const tokens = await this.tokenService.generateTokenPair( + user, + JwtScope.VERIFIED, + requestMetadata, + ); + + return { + access_token: tokens.access_token, + expires_at: tokens.expires_at, + user, + refresh_token: tokens.refresh_token, + }; + } + + async requestPasswordReset(email: string): Promise { + const credentials = await this.credentialsService.findByEmail(email); + if (!credentials) { + return; + } + + const resetToken = generateSecureToken(); + const expiresAt = + this.tokenExpiryConfig.calculatePasswordResetTokenExpiryDate(); + await this.credentialsService.setPasswordResetToken( + email, + resetToken, + expiresAt, + ); + this.eventEmitter.emit( + PASSWORD_RESET_REQUESTED, + new PasswordResetRequestedEvent(email, resetToken), + ); + } + + async resetPassword( + token: string, + newPassword: string, + requestMetadata?: RequestMetadata, + ): Promise { + const credentials = + await this.credentialsService.findCredentialsByPasswordResetToken(token); + + if (!credentials) { + throw new InvalidPasswordResetTokenError(); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + await this.credentialsService.resetPassword( + credentials.email, + token, + hashedPassword, + ); + + const user = await this.userService.findByIdOrFail(credentials.userId); + + const tokens = await this.tokenService.generateTokenPair( + user, + JwtScope.VERIFIED, + requestMetadata, + ); + + return { + access_token: tokens.access_token, + expires_at: tokens.expires_at, + user, + refresh_token: tokens.refresh_token, + }; + } +} diff --git a/packages/auth/src/providers/password/password-identity-provider.ts b/packages/auth/src/providers/password/password-identity-provider.ts new file mode 100644 index 0000000..4cbf2c4 --- /dev/null +++ b/packages/auth/src/providers/password/password-identity-provider.ts @@ -0,0 +1,67 @@ +import { IdentityProviderDecorator } from "@cv/auth"; +import { Injectable } from "@nestjs/common"; +import * as bcrypt from "bcryptjs"; +import { InvalidCredentialsError } from "../../errors/authentication.error"; +import type { IdentityProvider as IIdentityProvider } from "../../identity-provider.interface"; +import type { CredentialsService } from "../../user/credentials.service"; +import type { User } from "../../user/user.entity"; + +export const PASSWORD_PROVIDER_NAME = Symbol("password"); + +interface PasswordCredentials { + email: string; + password: string; +} + +@Injectable() +@IdentityProviderDecorator({ + name: PASSWORD_PROVIDER_NAME, +}) +export class PasswordIdentityProvider implements IIdentityProvider { + constructor(private readonly credentialsService: CredentialsService) {} + + async authenticate( + credentials: unknown | Record, + ): Promise { + const passwordCredentials = this.validateCredentials(credentials); + + const passwordHash = await this.credentialsService.getPasswordHash( + passwordCredentials.email, + ); + + if ( + !( + passwordHash && + (await bcrypt.compare(passwordCredentials.password, passwordHash)) + ) + ) { + throw new InvalidCredentialsError(); + } + + return this.credentialsService.findUserByEmailOrFail( + passwordCredentials.email, + ); + } + + private validateCredentials( + credentials: unknown | Record, + ): PasswordCredentials { + if ( + !credentials || + typeof credentials !== "object" || + !("email" in credentials) || + !("password" in credentials) + ) { + throw new InvalidCredentialsError(); + } + + const email = credentials.email; + const password = credentials.password; + + if (typeof email !== "string" || typeof password !== "string") { + throw new InvalidCredentialsError(); + } + + return { email, password }; + } +} diff --git a/packages/auth/src/providers/password/password-provider.module.ts b/packages/auth/src/providers/password/password-provider.module.ts new file mode 100644 index 0000000..8c3cb16 --- /dev/null +++ b/packages/auth/src/providers/password/password-provider.module.ts @@ -0,0 +1,70 @@ +import { join } from "node:path"; +import { + ResendModule, + TemplateModule, + type TemplateRegistryService, +} from "@cv/system"; +import { Module, type OnModuleInit } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { AuthorizationModule } from "../../authorization/authorization.module"; +import { TokenModule } from "../../token/token.module"; +import { UserModule } from "../../user/user.module"; +import { EMAIL_CONFIG_TOKEN, EmailConfig } from "./credentials/email.config"; +import { PasswordResetEmailListener } from "./credentials/listeners/password-reset-email.listener"; +import { RegistrationAttemptEmailListener } from "./credentials/listeners/registration-attempt-email.listener"; +import { VerificationEmailListener } from "./credentials/listeners/verification-email.listener"; +import { TemplatedEmailService } from "./credentials/templated-email.service"; +import { PasswordAuthenticationService } from "./password-authentication.service"; +import { PasswordIdentityProvider } from "./password-identity-provider"; + +@Module({ + imports: [ + ConfigModule, + EventEmitterModule.forRoot(), + ResendModule, + TemplateModule, + UserModule, + AuthorizationModule, + TokenModule, + ], + providers: [ + { + provide: EMAIL_CONFIG_TOKEN, + useFactory: (configService: ConfigService): EmailConfig => { + const clientUrl = + configService.get("CLIENT_URL") ?? + configService.get("CLIENT_ORIGIN") ?? + "http://localhost:5173"; + const fromEmail = + configService.get("EMAIL_FROM_ADDRESS") ?? + "noreply@example.com"; + const fromName = configService.get("EMAIL_FROM_NAME"); + return new EmailConfig(clientUrl, fromEmail, fromName); + }, + inject: [ConfigService], + }, + TemplatedEmailService, + VerificationEmailListener, + PasswordResetEmailListener, + RegistrationAttemptEmailListener, + PasswordIdentityProvider, + PasswordAuthenticationService, + ], + exports: [ + PasswordAuthenticationService, + PasswordIdentityProvider, + TemplatedEmailService, + VerificationEmailListener, + PasswordResetEmailListener, + RegistrationAttemptEmailListener, + ], +}) +export class PasswordProviderModule implements OnModuleInit { + constructor(private readonly templateRegistry: TemplateRegistryService) {} + + onModuleInit(): void { + const templatesPath = join(__dirname, "credentials", "emails", "templates"); + this.templateRegistry.registerDirectory(templatesPath); + } +} diff --git a/packages/auth/src/request/index.ts b/packages/auth/src/request/index.ts new file mode 100644 index 0000000..8334077 --- /dev/null +++ b/packages/auth/src/request/index.ts @@ -0,0 +1 @@ +export * from "./request-metadata.decorator"; diff --git a/packages/auth/src/request/request-metadata.decorator.ts b/packages/auth/src/request/request-metadata.decorator.ts new file mode 100644 index 0000000..db26a24 --- /dev/null +++ b/packages/auth/src/request/request-metadata.decorator.ts @@ -0,0 +1,31 @@ +import { createParamDecorator, type ExecutionContext } from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import type { Request } from "express"; + +export interface RequestMetadata { + userAgent: string | null; + ipAddress: string | null; +} + +export const RequestMetadata = createParamDecorator( + (_data: unknown, context: ExecutionContext): RequestMetadata => { + const gqlContext = GqlExecutionContext.create(context); + const httpContext = context.switchToHttp(); + + const request: Request = gqlContext.getContext().req + ? gqlContext.getContext().req + : httpContext.getRequest(); + + const userAgent = request.headers["user-agent"] ?? null; + + const ipAddress = + (request.headers["x-forwarded-for"] as string | undefined) + ?.split(",")[0] + ?.trim() ?? + (request.headers["x-real-ip"] as string | undefined) ?? + request.socket.remoteAddress ?? + null; + + return { userAgent, ipAddress }; + }, +); diff --git a/packages/auth/src/token/auth-cookie.service.ts b/packages/auth/src/token/auth-cookie.service.ts new file mode 100644 index 0000000..d244277 --- /dev/null +++ b/packages/auth/src/token/auth-cookie.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger } from "@nestjs/common"; +import type { Response } from "express"; +import type { JwtConfigService } from "../config/jwt.config"; +import type { CookieService } from "../cookie/cookie.service"; + +@Injectable() +export class AuthCookieService { + private readonly logger = new Logger(AuthCookieService.name); + + constructor( + private readonly cookieService: CookieService, + private readonly jwtConfig: JwtConfigService, + ) {} + + private parseExpiryToSeconds(expiryString: string): number { + this.logger.debug(`Parsing expiry string: ${expiryString}`); + + if (!expiryString || typeof expiryString !== "string") { + throw new Error( + `Invalid expiry string: ${JSON.stringify(expiryString)}. Expected format like "15m", "1h", "7d"`, + ); + } + + const match = expiryString.match(/^(\d+)([smhd])$/); + if (!(match?.[1] && match[2])) { + throw new Error( + `Failed to parse expiry string: ${expiryString}. Expected format like "15m", "1h", "7d"`, + ); + } + + const value = Number.parseInt(match[1], 10); + const unit = match[2]; + + if (Number.isNaN(value) || value <= 0) { + throw new Error( + `Invalid expiry value: ${value}. Must be a positive number`, + ); + } + + let seconds: number; + switch (unit) { + case "s": + seconds = value; + break; + case "m": + seconds = value * 60; + break; + case "h": + seconds = value * 60 * 60; + break; + case "d": + seconds = value * 60 * 60 * 24; + break; + default: + throw new Error(`Invalid expiry unit: ${unit}. Expected s, m, h, or d`); + } + + this.logger.debug( + `Parsed expiry: ${expiryString} = ${seconds} seconds (${seconds / 60} minutes)`, + ); + + return seconds; + } + + setAuthCookies( + res: Response, + accessToken: string, + refreshToken: string, + ): void { + this.logger.log("Setting authentication cookies"); + const accessTokenExpiryString = this.jwtConfig.getAccessTokenExpiry(); + this.logger.debug(`Access token expiry string: ${accessTokenExpiryString}`); + this.logger.debug( + `Access token length: ${accessToken.length}, Refresh token length: ${refreshToken.length}`, + ); + + const accessTokenMaxAgeSeconds = this.parseExpiryToSeconds( + accessTokenExpiryString, + ); + + if (accessTokenMaxAgeSeconds <= 0) { + throw new Error( + `Invalid access token expiry: ${accessTokenExpiryString}. Calculated maxAge: ${accessTokenMaxAgeSeconds}s`, + ); + } + + const accessTokenMaxAgeMs = accessTokenMaxAgeSeconds * 1000; + const refreshTokenMaxAgeMs = 60 * 60 * 24 * 7 * 1000; // 7 days in milliseconds + + this.logger.debug( + `Access token maxAge: ${accessTokenMaxAgeSeconds}s (${accessTokenMaxAgeMs}ms)`, + ); + this.logger.debug( + `Refresh token maxAge: ${refreshTokenMaxAgeMs / 1000}s (${refreshTokenMaxAgeMs}ms)`, + ); + + this.cookieService.setCookie(res, "access_token", accessToken, { + maxAge: accessTokenMaxAgeMs, + }); + + this.cookieService.setCookie(res, "refresh_token", refreshToken, { + maxAge: refreshTokenMaxAgeMs, + path: "/api/auth/credentials/refresh", + }); + + this.logger.log("Authentication cookies set successfully"); + } + + clearAuthCookies(res: Response): void { + this.logger.log("Clearing authentication cookies"); + + this.cookieService.clearCookie(res, "access_token"); + this.cookieService.clearCookie(res, "refresh_token", { + path: "/api/auth/credentials/refresh", + }); + + const setCookieHeaders = res.getHeader("Set-Cookie"); + this.logger.debug( + `Set-Cookie headers after clearing: ${JSON.stringify(setCookieHeaders, null, 2)}`, + ); + + this.logger.log("Authentication cookies cleared successfully"); + } +} diff --git a/packages/auth/src/token/index.ts b/packages/auth/src/token/index.ts new file mode 100644 index 0000000..842a4bf --- /dev/null +++ b/packages/auth/src/token/index.ts @@ -0,0 +1,8 @@ +export * from "./auth-cookie.service"; +export * from "./refresh-token.entity"; +export * from "./refresh-token.mapper"; +export * from "./refresh-token.policy"; +export * from "./refresh-token.service"; +export * from "./token.module"; +export * from "./token.service"; +export * from "./token-expiry.config"; diff --git a/packages/auth/src/token/refresh-token.entity.ts b/packages/auth/src/token/refresh-token.entity.ts new file mode 100644 index 0000000..ce886a1 --- /dev/null +++ b/packages/auth/src/token/refresh-token.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from "@cv/system"; + +export class RefreshToken extends BaseEntity { + constructor( + id: string, + public token: string, + public userId: string, + public expiresAt: Date, + createdAt: Date, + updatedAt: Date, + public userAgent: string | null = null, + public ipAddress: string | null = null, + public deviceName: string | null = null, + public deviceType: string | null = null, + public country: string | null = null, + public city: string | null = null, + public usedAt: Date | null = null, + ) { + super(id, createdAt, updatedAt); + } + + get isExpired(): boolean { + return this.expiresAt < new Date(); + } + + get isUsed(): boolean { + return this.usedAt !== null; + } + + get isValid(): boolean { + return this.isExpired ? false : !this.isUsed; + } +} diff --git a/packages/auth/src/token/refresh-token.mapper.ts b/packages/auth/src/token/refresh-token.mapper.ts new file mode 100644 index 0000000..a3ceda1 --- /dev/null +++ b/packages/auth/src/token/refresh-token.mapper.ts @@ -0,0 +1,45 @@ +import { Injectable } from "@nestjs/common"; +import type { RefreshToken as PrismaRefreshToken } from "@prisma/client"; +import { RefreshToken } from "./refresh-token.entity"; + +@Injectable() +export class RefreshTokenMapper { + toDomain(prismaToken: null, decryptedToken?: string): null; + toDomain( + prismaToken: PrismaRefreshToken, + decryptedToken: string, + ): RefreshToken; + toDomain( + prismaToken: PrismaRefreshToken | null, + decryptedToken?: string, + ): RefreshToken | null { + if (prismaToken === null) { + return null; + } + + return new RefreshToken( + prismaToken.id, + decryptedToken ?? prismaToken.token, + prismaToken.userId, + prismaToken.expiresAt, + prismaToken.createdAt, + prismaToken.updatedAt, + prismaToken.userAgent, + prismaToken.ipAddress, + prismaToken.deviceName, + prismaToken.deviceType, + prismaToken.country, + prismaToken.city, + prismaToken.usedAt, + ); + } + + mapToDomain( + prismaTokens: PrismaRefreshToken[], + decryptedTokens: string[], + ): RefreshToken[] { + return prismaTokens.map((token, index) => + this.toDomain(token, decryptedTokens[index] ?? token.token), + ); + } +} diff --git a/packages/auth/src/token/refresh-token.policy.ts b/packages/auth/src/token/refresh-token.policy.ts new file mode 100644 index 0000000..a45502c --- /dev/null +++ b/packages/auth/src/token/refresh-token.policy.ts @@ -0,0 +1,8 @@ +import { Injectable } from "@nestjs/common"; +import { Policy } from "../authorization/policy.decorator"; +import { UserOwnedResourcePolicy } from "../authorization/user-owned-resource.policy"; +import { RefreshToken } from "./refresh-token.entity"; + +@Injectable() +@Policy(RefreshToken) +export class RefreshTokenPolicy extends UserOwnedResourcePolicy {} diff --git a/packages/auth/src/token/refresh-token.service.ts b/packages/auth/src/token/refresh-token.service.ts new file mode 100644 index 0000000..4dd203b --- /dev/null +++ b/packages/auth/src/token/refresh-token.service.ts @@ -0,0 +1,172 @@ +import type { PrismaService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import { InvalidRefreshTokenError, notFound } from "../errors"; +import type { DeviceIdentificationService } from "../metadata/device-identification.service"; +import type { LocationService } from "../metadata/location.service"; +import { hashToken } from "../user/credentials-token.util"; +import type { TokenEncryptionService } from "../user/token-encryption.service"; +import type { RefreshToken } from "./refresh-token.entity"; +import type { RefreshTokenMapper } from "./refresh-token.mapper"; + +@Injectable() +export class RefreshTokenService { + constructor( + private readonly prisma: PrismaService, + private readonly mapper: RefreshTokenMapper, + private readonly tokenEncryption: TokenEncryptionService, + private readonly deviceIdentification: DeviceIdentificationService, + private readonly locationService: LocationService, + ) {} + + async create( + token: string, + userId: string, + expiresAt: Date, + userAgent?: string | null, + ipAddress?: string | null, + ): Promise { + const tokenHash = hashToken(token); + const encryptedToken = this.tokenEncryption.encrypt(token); + + const deviceInfo = this.deviceIdentification.identifyDevice( + userAgent ?? null, + ); + const locationInfo = this.locationService.getLocation(ipAddress ?? null); + + const prismaToken = await this.prisma.refreshToken.create({ + data: { + token: tokenHash, + encryptedToken, + userId, + expiresAt, + userAgent: userAgent ?? null, + ipAddress: ipAddress ?? null, + deviceName: deviceInfo.name, + deviceType: deviceInfo.type, + country: locationInfo.country, + city: locationInfo.city, + }, + }); + + return this.mapper.toDomain(prismaToken, token); + } + + async findByToken(token: string): Promise { + const tokenHash = hashToken(token); + const prismaToken = await this.prisma.refreshToken.findUnique({ + where: { token: tokenHash }, + }); + + if (prismaToken === null) { + return null; + } + + const decryptedToken = this.tokenEncryption.decrypt( + prismaToken.encryptedToken, + ); + + return this.mapper.toDomain(prismaToken, decryptedToken); + } + + async findByTokenOrFail(token: string): Promise { + const refreshToken = await this.findByToken(token); + return refreshToken ?? notFound("RefreshToken", "token", token); + } + + async markAsUsed(tokenId: string): Promise { + await this.prisma.refreshToken.update({ + where: { id: tokenId }, + data: { usedAt: new Date() }, + }); + } + + async validateAndUse(token: string): Promise { + const refreshToken = await this.findByTokenOrFail(token); + + if (!refreshToken.isValid) { + throw new InvalidRefreshTokenError(); + } + + await this.markAsUsed(refreshToken.id); + + return refreshToken; + } + + async deleteExpiredTokens(): Promise { + const result = await this.prisma.refreshToken.deleteMany({ + where: { + expiresAt: { + lt: new Date(), + }, + }, + }); + + return result.count; + } + + async deleteByUserId(userId: string): Promise { + const result = await this.prisma.refreshToken.deleteMany({ + where: { userId }, + }); + + return result.count; + } + + async deleteByToken(token: string): Promise { + const tokenHash = hashToken(token); + const result = await this.prisma.refreshToken.deleteMany({ + where: { token: tokenHash }, + }); + + return result.count > 0; + } + + async findById(id: string): Promise { + const prismaToken = await this.prisma.refreshToken.findUnique({ + where: { id }, + }); + + if (prismaToken === null) { + return null; + } + + const decryptedToken = this.tokenEncryption.decrypt( + prismaToken.encryptedToken, + ); + + return this.mapper.toDomain(prismaToken, decryptedToken); + } + + async findByIdOrFail(id: string): Promise { + const refreshToken = await this.findById(id); + return refreshToken ?? notFound("RefreshToken", "id", id); + } + + async deleteById(id: string): Promise { + await this.prisma.refreshToken.delete({ + where: { id }, + }); + } + + async findActiveSessionsByUser(user: { + id: string; + }): Promise { + const prismaTokens = await this.prisma.refreshToken.findMany({ + where: { + userId: user.id, + usedAt: null, + expiresAt: { + gt: new Date(), + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return prismaTokens.map((token) => { + const decryptedToken = this.tokenEncryption.decrypt(token.encryptedToken); + return this.mapper.toDomain(token, decryptedToken); + }); + } +} diff --git a/packages/auth/src/token/token-expiry.config.ts b/packages/auth/src/token/token-expiry.config.ts new file mode 100644 index 0000000..11977c1 --- /dev/null +++ b/packages/auth/src/token/token-expiry.config.ts @@ -0,0 +1,56 @@ +import { Injectable } from "@nestjs/common"; +import type { ConfigService } from "@nestjs/config"; + +@Injectable() +export class TokenExpiryConfigService { + constructor(private readonly configService: ConfigService) {} + + getEmailVerificationTokenExpiry(): string { + return ( + this.configService.get("EMAIL_VERIFICATION_TOKEN_EXPIRY") ?? "24h" + ); + } + + getPasswordResetTokenExpiry(): string { + return ( + this.configService.get("PASSWORD_RESET_TOKEN_EXPIRY") ?? "1h" + ); + } + + private calculateExpiryDate(expiryString: string): Date { + const expiresAt = new Date(); + const match = expiryString.match(/^(\d+)([smhd])$/); + if (!(match?.[1] && match[2])) { + expiresAt.setHours(expiresAt.getHours() + 24); + return expiresAt; + } + + const value = Number.parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "s": + expiresAt.setSeconds(expiresAt.getSeconds() + value); + break; + case "m": + expiresAt.setMinutes(expiresAt.getMinutes() + value); + break; + case "h": + expiresAt.setHours(expiresAt.getHours() + value); + break; + case "d": + expiresAt.setDate(expiresAt.getDate() + value); + break; + } + + return expiresAt; + } + + calculateEmailVerificationTokenExpiryDate(): Date { + return this.calculateExpiryDate(this.getEmailVerificationTokenExpiry()); + } + + calculatePasswordResetTokenExpiryDate(): Date { + return this.calculateExpiryDate(this.getPasswordResetTokenExpiry()); + } +} diff --git a/packages/auth/src/token/token.module.ts b/packages/auth/src/token/token.module.ts new file mode 100644 index 0000000..89e1e83 --- /dev/null +++ b/packages/auth/src/token/token.module.ts @@ -0,0 +1,39 @@ +import { DatabaseModule } from "@cv/system"; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { JwtConfigService } from "../config/jwt.config"; +import { CookieService } from "../cookie/cookie.service"; +import { DeviceIdentificationService } from "../metadata/device-identification.service"; +import { LocationService } from "../metadata/location.service"; +import { UserModule } from "../user/user.module"; +import { AuthCookieService } from "./auth-cookie.service"; +import { RefreshTokenMapper } from "./refresh-token.mapper"; +import { RefreshTokenPolicy } from "./refresh-token.policy"; +import { RefreshTokenService } from "./refresh-token.service"; +import { TokenService } from "./token.service"; +import { TokenExpiryConfigService } from "./token-expiry.config"; + +@Module({ + imports: [ConfigModule, DatabaseModule, UserModule], + providers: [ + RefreshTokenMapper, + DeviceIdentificationService, + LocationService, + CookieService, + JwtConfigService, + TokenExpiryConfigService, + RefreshTokenService, + TokenService, + AuthCookieService, + RefreshTokenPolicy, + ], + exports: [ + JwtConfigService, + TokenService, + TokenExpiryConfigService, + RefreshTokenService, + RefreshTokenMapper, + AuthCookieService, + ], +}) +export class TokenModule {} diff --git a/packages/auth/src/token/token.service.ts b/packages/auth/src/token/token.service.ts new file mode 100644 index 0000000..6f29214 --- /dev/null +++ b/packages/auth/src/token/token.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from "@nestjs/common"; +import type { JwtService } from "@nestjs/jwt"; +import type { JwtConfigService } from "../config/jwt.config"; +import { InvalidRefreshTokenError } from "../errors/authentication.error"; +import { JwtScope } from "../jwt/jwt-scope.enum"; +import type { RequestMetadata } from "../request/request-metadata.decorator"; +import type { User } from "../user/user.entity"; +import type { UserService } from "../user/user.service"; +import type { RefreshTokenService } from "./refresh-token.service"; +import type { TokenExpiryConfigService } from "./token-expiry.config"; + +@Injectable() +export class TokenService { + constructor( + private readonly jwtService: JwtService, + private readonly jwtConfig: JwtConfigService, + readonly _tokenExpiryConfig: TokenExpiryConfigService, + private readonly refreshTokenService: RefreshTokenService, + private readonly userService: UserService, + ) {} + + async generateTokenPair( + user: User, + scope?: JwtScope, + requestMetadata?: RequestMetadata, + ): Promise<{ + access_token: string; + refresh_token: string; + expires_at: Date; + }> { + if (!user.credentials) { + throw new Error("User credentials not found"); + } + + const isVerified = user.credentials.emailVerifiedAt !== null; + const tokenScope = + scope ?? (isVerified ? JwtScope.VERIFIED : JwtScope.UNVERIFIED); + + const accessTokenExpiry = this.jwtConfig.getAccessTokenExpiry(); + const refreshTokenExpiry = this.jwtConfig.getRefreshTokenExpiry(); + + const refresh_token = this.jwtService.sign( + { sub: user.id, type: "refresh" }, + { expiresIn: refreshTokenExpiry }, + ); + + const expires_at = this.jwtConfig.calculateAccessTokenExpiryDate(); + const refresh_expires_at = + this.jwtConfig.calculateExpiryDate(refreshTokenExpiry); + + const refreshTokenEntity = await this.refreshTokenService.create( + refresh_token, + user.id, + refresh_expires_at, + requestMetadata?.userAgent, + requestMetadata?.ipAddress, + ); + + const payload = { + sub: user.id, + email: user.credentials.email, + scope: tokenScope, + refreshTokenId: refreshTokenEntity.id, + }; + + const access_token = this.jwtService.sign(payload, { + expiresIn: accessTokenExpiry, + }); + + return { access_token, refresh_token, expires_at }; + } + + async refreshTokenPair( + refreshToken: string, + requestMetadata?: RequestMetadata, + ): Promise<{ + access_token: string; + refresh_token: string; + expires_at: Date; + }> { + try { + const payload = await this.jwtService.verifyAsync(refreshToken); + + if (payload.type !== "refresh") { + throw new InvalidRefreshTokenError(); + } + + const refreshTokenEntity = + await this.refreshTokenService.validateAndUse(refreshToken); + + if (refreshTokenEntity.userId !== payload.sub) { + throw new InvalidRefreshTokenError(); + } + + const user = await this.userService.findByIdOrFail(payload.sub); + + return this.generateTokenPair(user, undefined, requestMetadata); + } catch (error) { + if (error instanceof InvalidRefreshTokenError) { + throw error; + } + throw new InvalidRefreshTokenError(); + } + } +} diff --git a/packages/auth/src/user/credentials-token.util.ts b/packages/auth/src/user/credentials-token.util.ts new file mode 100644 index 0000000..b91637c --- /dev/null +++ b/packages/auth/src/user/credentials-token.util.ts @@ -0,0 +1,17 @@ +import * as crypto from "node:crypto"; + +export const generateSecureToken = (): string => { + return crypto.randomBytes(32).toString("hex"); +}; + +export const hashToken = (token: string): string => { + return crypto.createHash("sha256").update(token).digest("hex"); +}; + +export const verifyToken = ( + providedToken: string, + hashedToken: string, +): boolean => { + const providedHash = hashToken(providedToken); + return providedHash === hashedToken; +}; diff --git a/packages/auth/src/user/credentials.entity.ts b/packages/auth/src/user/credentials.entity.ts new file mode 100644 index 0000000..033b1dc --- /dev/null +++ b/packages/auth/src/user/credentials.entity.ts @@ -0,0 +1,19 @@ +import { BaseEntity } from "@cv/system"; + +export class Credentials extends BaseEntity { + constructor( + id: string, + public userId: string, + public email: string, + public password: string, + createdAt: Date, + updatedAt: Date, + public emailVerifiedAt: Date | null = null, + public emailVerificationToken: string | null = null, + public emailVerificationTokenExpiresAt: Date | null = null, + public passwordResetToken: string | null = null, + public passwordResetTokenExpiresAt: Date | null = null, + ) { + super(id, createdAt, updatedAt); + } +} diff --git a/packages/auth/src/user/credentials.mapper.ts b/packages/auth/src/user/credentials.mapper.ts new file mode 100644 index 0000000..1ebac24 --- /dev/null +++ b/packages/auth/src/user/credentials.mapper.ts @@ -0,0 +1,35 @@ +import type { BaseMapper } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import type { Credentials as PrismaCredentials } from "@prisma/client"; +import { Credentials } from "./credentials.entity"; + +@Injectable() +export class CredentialsMapper + implements BaseMapper +{ + toDomain(prismaCredentials: null): null; + toDomain(prismaCredentials: PrismaCredentials): Credentials; + toDomain(prismaCredentials: PrismaCredentials | null): Credentials | null; + toDomain(prismaCredentials: PrismaCredentials | null): Credentials | null { + if (prismaCredentials === null) { + return null; + } + return new Credentials( + prismaCredentials.id, + prismaCredentials.userId, + prismaCredentials.email, + prismaCredentials.password, + prismaCredentials.createdAt, + prismaCredentials.updatedAt, + prismaCredentials.emailVerifiedAt ?? null, + prismaCredentials.emailVerificationToken ?? null, + prismaCredentials.emailVerificationTokenExpiresAt ?? null, + prismaCredentials.passwordResetToken ?? null, + prismaCredentials.passwordResetTokenExpiresAt ?? null, + ); + } + + mapToDomain(prismaCredentials: PrismaCredentials[]): Credentials[] { + return prismaCredentials.map((credentials) => this.toDomain(credentials)); + } +} diff --git a/packages/auth/src/user/credentials.service.ts b/packages/auth/src/user/credentials.service.ts new file mode 100644 index 0000000..e9ea79a --- /dev/null +++ b/packages/auth/src/user/credentials.service.ts @@ -0,0 +1,255 @@ +import type { PrismaService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import { notFound } from "../errors"; +import { + EmailAlreadyVerifiedError, + InvalidPasswordResetTokenError, + InvalidVerificationTokenError, + PasswordResetTokenExpiredError, + VerificationTokenExpiredError, +} from "../errors/authentication.error"; +import type { Credentials } from "./credentials.entity"; +import type { CredentialsMapper } from "./credentials.mapper"; +import { hashToken, verifyToken } from "./credentials-token.util"; +import type { TokenEncryptionService } from "./token-encryption.service"; +import type { User } from "./user.entity"; +import type { UserMapper } from "./user.mapper"; + +@Injectable() +export class CredentialsService { + constructor( + private prisma: PrismaService, + private credentialsMapper: CredentialsMapper, + private userMapper: UserMapper, + private tokenEncryption: TokenEncryptionService, + ) {} + + async create( + userId: string, + email: string, + password: string, + ): Promise { + const prismaCredentials = await this.prisma.credentials.create({ + data: { + userId, + email, + password, + }, + }); + + return this.credentialsMapper.toDomain(prismaCredentials); + } + + async findByEmail(email: string): Promise { + const prismaCredentials = await this.prisma.credentials.findUnique({ + where: { email }, + }); + + return this.credentialsMapper.toDomain(prismaCredentials); + } + + async findByEmailOrFail(email: string): Promise { + const credentials = await this.findByEmail(email); + return credentials ?? notFound("Credentials", "email", email); + } + + async findUserByEmail(email: string): Promise { + const prismaCredentials = await this.prisma.credentials.findUnique({ + where: { email }, + include: { user: { include: { credentials: true } } }, + }); + + if (!prismaCredentials?.user) { + return null; + } + + return this.userMapper.toDomain(prismaCredentials.user); + } + + async findUserByEmailOrFail(email: string): Promise { + const user = await this.findUserByEmail(email); + return user ?? notFound("User", "email", email); + } + + async exists(email: string): Promise { + const credentials = await this.prisma.credentials.findUnique({ + where: { email }, + select: { id: true }, + }); + + return credentials !== null; + } + + async getPasswordHash(email: string): Promise { + const prismaCredentials = await this.prisma.credentials.findUnique({ + where: { email }, + select: { password: true }, + }); + + return prismaCredentials?.password ?? null; + } + + async updatePassword(userId: string, hashedPassword: string): Promise { + await this.prisma.credentials.update({ + where: { userId }, + data: { + password: hashedPassword, + passwordResetToken: null, + passwordResetTokenExpiresAt: null, + }, + }); + } + + async setEmailVerificationToken( + userId: string, + token: string, + expiresAt: Date, + ): Promise { + const hashedToken = hashToken(token); + const encryptedToken = this.tokenEncryption.encrypt(hashedToken); + await this.prisma.credentials.update({ + where: { userId }, + data: { + emailVerificationToken: encryptedToken, + emailVerificationTokenExpiresAt: expiresAt, + }, + }); + } + + async verifyEmail(userId: string, token: string): Promise { + const credentials = await this.prisma.credentials.findUnique({ + where: { userId }, + select: { + emailVerifiedAt: true, + emailVerificationToken: true, + emailVerificationTokenExpiresAt: true, + }, + }); + + if (!credentials) { + throw notFound("Credentials", "userId", userId); + } + + if (credentials.emailVerifiedAt) { + throw new EmailAlreadyVerifiedError(); + } + + if ( + !( + credentials.emailVerificationToken && + credentials.emailVerificationTokenExpiresAt + ) + ) { + throw new InvalidVerificationTokenError(); + } + + if (credentials.emailVerificationTokenExpiresAt < new Date()) { + throw new VerificationTokenExpiredError(); + } + + const decryptedToken = this.tokenEncryption.decrypt( + credentials.emailVerificationToken, + ); + if (!verifyToken(token, decryptedToken)) { + throw new InvalidVerificationTokenError(); + } + + await this.prisma.credentials.update({ + where: { userId }, + data: { + emailVerifiedAt: new Date(), + emailVerificationToken: null, + emailVerificationTokenExpiresAt: null, + }, + }); + } + + async setPasswordResetToken( + email: string, + token: string, + expiresAt: Date, + ): Promise { + const hashedToken = hashToken(token); + const encryptedToken = this.tokenEncryption.encrypt(hashedToken); + await this.prisma.credentials.update({ + where: { email }, + data: { + passwordResetToken: encryptedToken, + passwordResetTokenExpiresAt: expiresAt, + }, + }); + } + + async resetPassword( + email: string, + token: string, + newPassword: string, + ): Promise { + const credentials = await this.prisma.credentials.findUnique({ + where: { email }, + select: { + passwordResetToken: true, + passwordResetTokenExpiresAt: true, + }, + }); + + if (!credentials) { + throw notFound("Credentials", "email", email); + } + + if ( + !( + credentials.passwordResetToken && + credentials.passwordResetTokenExpiresAt + ) + ) { + throw new InvalidPasswordResetTokenError(); + } + + if (credentials.passwordResetTokenExpiresAt < new Date()) { + throw new PasswordResetTokenExpiredError(); + } + + const decryptedToken = this.tokenEncryption.decrypt( + credentials.passwordResetToken, + ); + if (!verifyToken(token, decryptedToken)) { + throw new InvalidPasswordResetTokenError(); + } + + await this.prisma.credentials.update({ + where: { email }, + data: { + password: newPassword, + passwordResetToken: null, + passwordResetTokenExpiresAt: null, + }, + }); + } + + async findCredentialsByPasswordResetToken( + token: string, + ): Promise { + const allCredentials = await this.prisma.credentials.findMany({ + where: { + passwordResetToken: { not: null }, + passwordResetTokenExpiresAt: { gt: new Date() }, + }, + }); + + for (const cred of allCredentials) { + if (cred.passwordResetToken) { + try { + const decryptedToken = this.tokenEncryption.decrypt( + cred.passwordResetToken, + ); + if (verifyToken(token, decryptedToken)) { + return this.credentialsMapper.toDomain(cred); + } + } catch {} + } + } + + return null; + } +} diff --git a/packages/auth/src/user/index.ts b/packages/auth/src/user/index.ts new file mode 100644 index 0000000..cf3ad22 --- /dev/null +++ b/packages/auth/src/user/index.ts @@ -0,0 +1,9 @@ +export * from "./credentials.entity"; +export * from "./credentials.mapper"; +export * from "./credentials.service"; +export * from "./credentials-token.util"; +export * from "./token-encryption.service"; +export * from "./user.entity"; +export * from "./user.mapper"; +export * from "./user.module"; +export * from "./user.service"; diff --git a/packages/auth/src/user/token-encryption.service.ts b/packages/auth/src/user/token-encryption.service.ts new file mode 100644 index 0000000..10bdb34 --- /dev/null +++ b/packages/auth/src/user/token-encryption.service.ts @@ -0,0 +1,59 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, + scryptSync, +} from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import type { ConfigService } from "@nestjs/config"; + +@Injectable() +export class TokenEncryptionService { + private readonly algorithm = "aes-256-gcm"; + private readonly masterKey: string; + private readonly ivLength = 16; + private readonly saltLength = 16; + + constructor(private readonly configService: ConfigService) { + this.masterKey = this.configService.getOrThrow("ENCRYPTION_KEY"); + } + + encrypt(plaintext: string): string { + const iv = randomBytes(this.ivLength); + const salt = randomBytes(this.saltLength); + const key = scryptSync(this.masterKey, salt, 32) as Buffer; + const cipher = createCipheriv(this.algorithm, key, iv); + + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const tag = cipher.getAuthTag(); + + return `${salt.toString("hex")}:${iv.toString("hex")}:${tag.toString("hex")}:${encrypted}`; + } + + decrypt(encryptedData: string): string { + const parts = encryptedData.split(":"); + if (parts.length !== 4) { + throw new Error("Invalid encrypted data format"); + } + + const [saltHex, ivHex, tagHex, encrypted] = parts; + if (!(saltHex && ivHex && tagHex && encrypted)) { + throw new Error("Invalid encrypted data format"); + } + + const salt = Buffer.from(saltHex, "hex"); + const iv = Buffer.from(ivHex, "hex"); + const tag = Buffer.from(tagHex, "hex"); + const key = scryptSync(this.masterKey, salt, 32) as Buffer; + + const decipher = createDecipheriv(this.algorithm, key, iv); + decipher.setAuthTag(tag); + + let decrypted = decipher.update(encrypted, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } +} diff --git a/packages/auth/src/user/user.entity.ts b/packages/auth/src/user/user.entity.ts new file mode 100644 index 0000000..e2e45e6 --- /dev/null +++ b/packages/auth/src/user/user.entity.ts @@ -0,0 +1,14 @@ +import { BaseEntity } from "@cv/system"; +import type { Credentials } from "./credentials.entity"; + +export class User extends BaseEntity { + constructor( + id: string, + public name: string, + createdAt: Date, + updatedAt: Date, + public credentials: Credentials | null = null, + ) { + super(id, createdAt, updatedAt); + } +} diff --git a/packages/auth/src/user/user.mapper.ts b/packages/auth/src/user/user.mapper.ts new file mode 100644 index 0000000..01046aa --- /dev/null +++ b/packages/auth/src/user/user.mapper.ts @@ -0,0 +1,37 @@ +import type { BaseMapper } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import type { Prisma } from "@prisma/client"; +import type { CredentialsMapper } from "./credentials.mapper"; +import { User } from "./user.entity"; + +type PrismaUserWithCredentials = Prisma.UserGetPayload<{ + include: { credentials: true }; +}>; + +@Injectable() +export class UserMapper implements BaseMapper { + constructor(private readonly credentialsMapper: CredentialsMapper) {} + + toDomain(prismaUser: null): null; + toDomain(prismaUser: PrismaUserWithCredentials): User; + toDomain(prismaUser: PrismaUserWithCredentials | null): User | null; + toDomain(prismaUser: PrismaUserWithCredentials | null): User | null { + if (prismaUser === null) { + return null; + } + const credentials = prismaUser.credentials + ? this.credentialsMapper.toDomain(prismaUser.credentials) + : null; + return new User( + prismaUser.id, + prismaUser.name, + prismaUser.createdAt, + prismaUser.updatedAt, + credentials, + ); + } + + mapToDomain(prismaUsers: PrismaUserWithCredentials[]): User[] { + return prismaUsers.map((user) => this.toDomain(user)); + } +} diff --git a/packages/auth/src/user/user.module.ts b/packages/auth/src/user/user.module.ts new file mode 100644 index 0000000..225d87f --- /dev/null +++ b/packages/auth/src/user/user.module.ts @@ -0,0 +1,26 @@ +import { DatabaseModule } from "@cv/system"; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { CredentialsMapper } from "./credentials.mapper"; +import { CredentialsService } from "./credentials.service"; +import { TokenEncryptionService } from "./token-encryption.service"; +import { UserMapper } from "./user.mapper"; +import { UserService } from "./user.service"; + +@Module({ + imports: [ConfigModule, DatabaseModule], + providers: [ + CredentialsMapper, + UserMapper, + TokenEncryptionService, + CredentialsService, + UserService, + ], + exports: [ + UserService, + UserMapper, + CredentialsService, + TokenEncryptionService, + ], +}) +export class UserModule {} diff --git a/packages/auth/src/user/user.service.ts b/packages/auth/src/user/user.service.ts new file mode 100644 index 0000000..65d3af4 --- /dev/null +++ b/packages/auth/src/user/user.service.ts @@ -0,0 +1,48 @@ +import type { PrismaService } from "@cv/system"; +import { Injectable } from "@nestjs/common"; +import { notFound } from "../errors"; +import type { User } from "./user.entity"; +import type { UserMapper } from "./user.mapper"; + +@Injectable() +export class UserService { + private readonly userInclude = { + credentials: true, + }; + + constructor( + private prisma: PrismaService, + private userMapper: UserMapper, + ) {} + + async create(name: string): Promise { + const prismaUser = await this.prisma.user.create({ + data: { + name, + }, + include: this.userInclude, + }); + + return this.userMapper.toDomain(prismaUser); + } + + async findById(id: string): Promise { + const prismaUser = await this.prisma.user.findUnique({ + where: { id }, + include: this.userInclude, + }); + + return this.userMapper.toDomain(prismaUser); + } + + async findByIdOrFail(id: string): Promise { + const user = await this.findById(id); + return user ?? notFound("User", "id", id); + } + + async delete(id: string): Promise { + await this.prisma.user.delete({ + where: { id }, + }); + } +} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000..56296f7 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@cv/tsconfig/tsconfig.library.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "composite": true, + "paths": { + "@/*": ["./src/*"] + }, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "references": [{ "path": "../system" }, { "path": "../utils" }] +} diff --git a/packages/routing/package.json b/packages/routing/package.json new file mode 100644 index 0000000..c9fd2eb --- /dev/null +++ b/packages/routing/package.json @@ -0,0 +1,32 @@ +{ + "name": "@cv/routing", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "files": [ + "src/" + ], + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write .", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router-dom": "^7.9.4" + }, + "devDependencies": { + "@types/react": "^19.0.6", + "@types/react-dom": "^19.0.3", + "typescript": "^5.5.3" + } +} diff --git a/packages/routing/src/ExternalLink.tsx b/packages/routing/src/ExternalLink.tsx new file mode 100644 index 0000000..3053ebe --- /dev/null +++ b/packages/routing/src/ExternalLink.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from "react"; + +interface ExternalLinkProps { + href: string; + children: React.ReactNode; + className?: string; + prefetch?: boolean; +} + +export const ExternalLink = ({ + href, + children, + className, + prefetch = true, +}: ExternalLinkProps) => { + const [isNavigating, setIsNavigating] = useState(false); + const timeoutRef = useRef | null>(null); + const linkRef = useRef(null); + + useEffect(() => { + if (!(prefetch && linkRef.current)) return; + + const link = document.createElement("link"); + link.rel = "prefetch"; + link.href = href; + link.as = "document"; + + const handleMouseEnter = () => { + if (!document.querySelector(`link[href="${href}"]`)) { + document.head.appendChild(link); + } + }; + + const element = linkRef.current; + element.addEventListener("mouseenter", handleMouseEnter); + + return () => { + element.removeEventListener("mouseenter", handleMouseEnter); + link.remove(); + }; + }, [href, prefetch]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + setIsNavigating(true); + + if (document.startViewTransition) { + document.startViewTransition(() => { + window.location.href = href; + }); + } else { + document.body.style.transition = "opacity 0.2s ease-out"; + document.body.style.opacity = "0"; + + timeoutRef.current = setTimeout(() => { + window.location.href = href; + }, 200); + } + }; + + useEffect( + () => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, + [], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/routing/src/ViewTransitionLink.tsx b/packages/routing/src/ViewTransitionLink.tsx new file mode 100644 index 0000000..bec8829 --- /dev/null +++ b/packages/routing/src/ViewTransitionLink.tsx @@ -0,0 +1,46 @@ +import { type MouseEvent, type ReactNode, useState } from "react"; +import { Link, type LinkProps, useNavigate } from "react-router-dom"; + +interface ViewTransitionLinkProps extends Omit { + children: ReactNode; +} + +export const ViewTransitionLink = ({ + to, + children, + ...props +}: ViewTransitionLinkProps) => { + const navigate = useNavigate(); + const [isNavigating, setIsNavigating] = useState(false); + + const handleClick = (e: MouseEvent) => { + e.preventDefault(); + setIsNavigating(true); + + const targetPath = typeof to === "string" ? to : (to.pathname ?? ""); + + if (!document.startViewTransition) { + navigate(targetPath); + return; + } + + document.startViewTransition(() => { + navigate(targetPath); + }); + }; + + return ( + + {children} + + ); +}; diff --git a/packages/routing/src/index.ts b/packages/routing/src/index.ts new file mode 100644 index 0000000..c912242 --- /dev/null +++ b/packages/routing/src/index.ts @@ -0,0 +1,2 @@ +export { ExternalLink } from "./ExternalLink"; +export { ViewTransitionLink } from "./ViewTransitionLink"; diff --git a/packages/routing/tsconfig.json b/packages/routing/tsconfig.json new file mode 100644 index 0000000..4aa50d9 --- /dev/null +++ b/packages/routing/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@cv/tsconfig/tsconfig.library.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/system/README.md b/packages/system/README.md new file mode 100644 index 0000000..1d59c3c --- /dev/null +++ b/packages/system/README.md @@ -0,0 +1,66 @@ +# @cv/system + +Infrastructure and foundation package for the CV Generator monorepo. + +## Overview + +This package provides reusable infrastructure services and utilities that form the foundation layer of the application. It contains database services, mail services, base entities, and domain error classes. + +## Package Structure + +``` +src/ +├── database/ # Database services +│ ├── prisma.service.ts +│ └── database.module.ts +├── base/ # Base utilities +│ ├── base.entity.ts +│ ├── pagination.service.ts +│ ├── cursor.service.ts +│ ├── raise.util.ts +│ └── unauthorized.util.ts +├── mail/ # Email services +│ ├── mail.module.ts +│ ├── resend/ # Resend implementation +│ └── template/ # Email templating +├── errors/ # Domain errors +│ └── domain-error.ts +└── index.ts +``` + +## Usage + +### Installation + +This package is part of the monorepo and is automatically available to other packages via npm workspaces. + +### Importing + +```typescript +import { PrismaService, DatabaseModule, BaseEntity } from "@cv/system"; +``` + +### Key Exports + +- **Modules**: `DatabaseModule`, `ResendModule`, `TemplateModule`, `BaseModule` +- **Services**: `PrismaService`, `MailService`, `HandlebarsTemplateService`, `TemplateRegistryService` +- **Entities**: `BaseEntity` +- **Utilities**: `raise`, `unauthorized`, `PaginationService`, `CursorService` +- **Errors**: `DomainError` + +## Dependencies + +- **Peer Dependencies**: `@nestjs/common`, `@nestjs/core`, `@prisma/client`, `resend`, `handlebars` +- **Internal Dependencies**: None (foundation layer) + +## Architecture + +This package is the foundation layer and has no dependencies on other monorepo packages. It provides: +- Database access via Prisma +- Email sending and templating +- Base entity classes +- Pagination and cursor utilities +- Domain error base classes + +All packages in the monorepo depend on this package, but this package depends on nothing within the monorepo. + diff --git a/packages/system/package.json b/packages/system/package.json new file mode 100644 index 0000000..5b908cb --- /dev/null +++ b/packages/system/package.json @@ -0,0 +1,56 @@ +{ + "name": "@cv/system", + "version": "0.0.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "require": "./src/index.ts", + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "files": [ + "src/" + ], + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write .", + "typecheck": "tsc -b" + }, + "dependencies": { + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/graphql": "^12.2.2", + "@prisma/adapter-pg": "^7.1.0", + "@prisma/client": "^7.1.0", + "dataloader": "^2.2.3", + "graphql": "^16.12.0", + "handlebars": "^4.7.8", + "pg": "^8.16.3", + "reflect-metadata": "^0.2.2", + "resend": "^6.5.2" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.6", + "@cv/biome-config": "*", + "@cv/tsconfig": "*", + "typescript": "^5.6.3" + }, + "peerDependencies": { + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.4.7", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/graphql": "^12.2.2", + "@prisma/adapter-pg": "^7.1.0", + "@prisma/client": "^7.1.0", + "graphql": "^16.12.0", + "handlebars": "^4.7.8", + "pg": "^8.16.3", + "resend": "^6.5.2" + } +} diff --git a/packages/system/src/base/base.entity.ts b/packages/system/src/base/base.entity.ts new file mode 100644 index 0000000..6f7b494 --- /dev/null +++ b/packages/system/src/base/base.entity.ts @@ -0,0 +1,11 @@ +export abstract class BaseEntity { + id: string; + createdAt: Date; + updatedAt: Date; + + constructor(id: string, createdAt: Date, updatedAt: Date) { + this.id = id; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/packages/system/src/base/base.module.ts b/packages/system/src/base/base.module.ts new file mode 100644 index 0000000..b008db9 --- /dev/null +++ b/packages/system/src/base/base.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; +import { ClockService } from "./clock.service"; +import { CursorService } from "./cursor.service"; +import { PaginationService } from "./pagination.service"; +import { UuidFactoryService } from "./uuid-factory.service"; + +@Module({ + providers: [ + PaginationService, + CursorService, + UuidFactoryService, + ClockService, + ], + exports: [PaginationService, CursorService, UuidFactoryService, ClockService], +}) +export class BaseModule {} diff --git a/packages/system/src/base/clock.service.ts b/packages/system/src/base/clock.service.ts new file mode 100644 index 0000000..4cd30a6 --- /dev/null +++ b/packages/system/src/base/clock.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class ClockService { + now(): Date { + return new Date(); + } +} diff --git a/packages/system/src/base/connection.types.ts b/packages/system/src/base/connection.types.ts new file mode 100644 index 0000000..4f025c7 --- /dev/null +++ b/packages/system/src/base/connection.types.ts @@ -0,0 +1,25 @@ +export abstract class BaseEdge { + cursor: string; + node: T; + + constructor(cursor: string, node: T) { + this.cursor = cursor; + this.node = node; + } + + /** + * Static factory method to create an edge from pagination result + */ + static fromPaginationEdge>( + this: new ( + cursor: string, + node: T, + ) => TEdge, + edge: { node: T; cursor: string }, + ): TEdge { + // biome-ignore lint/complexity/noThisInStatic: this is intentional for the factory pattern + return new this(edge.cursor, edge.node); + } +} + +// Base classes for inheritance - concrete classes define their own GraphQL types diff --git a/packages/system/src/base/cursor.service.ts b/packages/system/src/base/cursor.service.ts new file mode 100644 index 0000000..64ef353 --- /dev/null +++ b/packages/system/src/base/cursor.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class CursorService { + /** + * Encode an ID to a cursor + */ + encode(id: string): string { + return Buffer.from(id).toString("base64"); + } + + /** + * Decode a cursor to get the ID + */ + decode(cursor: string): string { + return Buffer.from(cursor, "base64").toString("utf-8"); + } +} diff --git a/packages/system/src/base/dataloader.service.ts b/packages/system/src/base/dataloader.service.ts new file mode 100644 index 0000000..8f92736 --- /dev/null +++ b/packages/system/src/base/dataloader.service.ts @@ -0,0 +1,40 @@ +import { Injectable, Scope } from "@nestjs/common"; +import DataLoader from "dataloader"; +import type { BaseEntity } from "./base.entity"; + +export type { DataLoader }; + +export type BatchLoadFn = (keys: readonly K[]) => Promise<(V | null)[]>; + +@Injectable({ scope: Scope.REQUEST }) +export abstract class BaseDataLoaderService { + private loader: DataLoader; + + constructor(protected batchLoadFn: BatchLoadFn) { + this.loader = new DataLoader(async (keys: readonly K[]) => { + const results = await this.batchLoadFn(keys); + return results; + }); + } + + async load(key: K): Promise { + return this.loader.load(key); + } + + async loadMany(keys: readonly K[]): Promise<(V | null)[]> { + const results = await this.loader.loadMany(keys); + return results.map((result) => (result instanceof Error ? null : result)); + } + + clear(key: K): void { + this.loader.clear(key); + } + + clearAll(): void { + this.loader.clearAll(); + } + + prime(key: K, value: V | null): void { + this.loader.prime(key, value); + } +} diff --git a/packages/system/src/base/entity-service.interface.ts b/packages/system/src/base/entity-service.interface.ts new file mode 100644 index 0000000..dfcced4 --- /dev/null +++ b/packages/system/src/base/entity-service.interface.ts @@ -0,0 +1,13 @@ +import type { BaseEntity } from "./base.entity"; + +export interface EntityService< + T extends BaseEntity, + TWhereInput = Record, +> { + findById(id: string): Promise; + findByIdOrFail(id: string): Promise; + findMany(filters?: TWhereInput): Promise; + count(filters?: TWhereInput): Promise; + save(entity: T): Promise; + destroy(entity: T): Promise; +} diff --git a/packages/system/src/base/event.interface.ts b/packages/system/src/base/event.interface.ts new file mode 100644 index 0000000..d47260a --- /dev/null +++ b/packages/system/src/base/event.interface.ts @@ -0,0 +1,3 @@ +export interface Event { + readonly eventName: symbol; +} diff --git a/packages/system/src/base/factory.interface.ts b/packages/system/src/base/factory.interface.ts new file mode 100644 index 0000000..394882c --- /dev/null +++ b/packages/system/src/base/factory.interface.ts @@ -0,0 +1,5 @@ +import type { BaseEntity } from "./base.entity"; + +export interface Factory { + create(data: TData): TEntity; +} diff --git a/packages/system/src/base/index.ts b/packages/system/src/base/index.ts new file mode 100644 index 0000000..45cac08 --- /dev/null +++ b/packages/system/src/base/index.ts @@ -0,0 +1,18 @@ +export * from "./base.entity"; +export * from "./base.module"; +export * from "./clock.service"; +export * from "./connection.types"; +export * from "./cursor.service"; +export * from "./dataloader.service"; +export * from "./entity-service.interface"; +export * from "./event.interface"; +export * from "./factory.interface"; +export * from "./mapper.interface"; +export * from "./named-entity"; +export * from "./named-entity.mapper"; +export * from "./named-entity.service"; +export * from "./pagination.service"; +export * from "./pagination.types"; +export * from "./raise.util"; +export * from "./unauthorized.util"; +export * from "./uuid-factory.service"; diff --git a/packages/system/src/base/mapper.interface.ts b/packages/system/src/base/mapper.interface.ts new file mode 100644 index 0000000..eb085c8 --- /dev/null +++ b/packages/system/src/base/mapper.interface.ts @@ -0,0 +1,14 @@ +/** + * Base interface for mapping between Prisma entities and domain entities + */ +export interface BaseMapper { + /** + * Maps a single Prisma entity to a domain entity, handling null input + */ + toDomain(prismaEntity: TPrismaEntity | null): TDomainEntity | null; + + /** + * Maps an array of Prisma entities to domain entities + */ + mapToDomain(prismaEntities: TPrismaEntity[]): TDomainEntity[]; +} diff --git a/packages/system/src/base/named-entity.mapper.ts b/packages/system/src/base/named-entity.mapper.ts new file mode 100644 index 0000000..b3ed882 --- /dev/null +++ b/packages/system/src/base/named-entity.mapper.ts @@ -0,0 +1,46 @@ +import type { BaseMapper } from "./mapper.interface"; +import type { NamedEntity } from "./named-entity"; + +/** + * Creates a mapper for NamedEntity types + * Eliminates boilerplate for simple entities with name/description + */ +export function createNamedEntityMapper< + TPrisma extends { + id: string; + name: string; + createdAt: Date; + updatedAt: Date; + description: string | null; + }, + TDomain extends NamedEntity, +>( + EntityClass: new ( + id: string, + name: string, + createdAt: Date, + updatedAt: Date, + description?: string, + ) => TDomain, +): BaseMapper { + return { + toDomain(prismaEntity: TPrisma | null): TDomain | null { + if (prismaEntity === null) { + return null; + } + return new EntityClass( + prismaEntity.id, + prismaEntity.name, + prismaEntity.createdAt, + prismaEntity.updatedAt, + prismaEntity.description ?? undefined, + ); + }, + + mapToDomain(prismaEntities: TPrisma[]): TDomain[] { + return prismaEntities + .map((entity) => this.toDomain(entity)) + .filter((e): e is TDomain => e !== null); + }, + }; +} diff --git a/packages/system/src/base/named-entity.service.ts b/packages/system/src/base/named-entity.service.ts new file mode 100644 index 0000000..a5fa235 --- /dev/null +++ b/packages/system/src/base/named-entity.service.ts @@ -0,0 +1,140 @@ +import type { PrismaService } from "../database/prisma.service"; +import type { EntityService } from "./entity-service.interface"; +import type { BaseMapper } from "./mapper.interface"; +import type { NamedEntity } from "./named-entity"; + +/** + * Base filters for NamedEntity services + */ +export type NamedEntityFilters = { + searchTerm?: string | undefined; + id?: string | string[]; +}; + +/** + * Base where input for Prisma queries + */ +export type NamedEntityWhereInput = { + name?: { contains: string; mode: "insensitive" }; + id?: { in: string[] } | string; +}; + +/** + * Minimal interface for Prisma model delegates + * Captures the methods used by NamedEntityService without coupling to generated types + * + * Note: Uses 'object' for args to allow Prisma's specific types to be passed through. + * This is intentionally loose - the concrete service knows the actual Prisma types. + */ +export interface PrismaModelDelegate { + findUnique(args: object): Promise; + findMany(args: object): Promise; + upsert(args: object): Promise; + delete(args: object): Promise; + count(args: object): Promise; +} + +/** + * Abstract base service for NamedEntity types + * Provides standard CRUD operations with search and filtering + */ +export abstract class NamedEntityService< + TEntity extends NamedEntity, + TFilters extends NamedEntityFilters = NamedEntityFilters, + TWhereInput extends NamedEntityWhereInput = NamedEntityWhereInput, +> implements EntityService +{ + constructor( + protected readonly prisma: PrismaService, + protected readonly mapper: BaseMapper, + protected readonly prismaModel: PrismaModelDelegate, + protected readonly entityName: string, + ) {} + + async findById(id: string): Promise { + const prismaEntity = await this.prismaModel.findUnique({ + where: { id }, + }); + return this.mapper.toDomain(prismaEntity); + } + + async findByIdOrFail(id: string): Promise { + const entity = await this.findById(id); + if (!entity) { + throw new Error(`${this.entityName} with id ${id} not found`); + } + return entity; + } + + /** + * Build where clause for filtering + * Override this method to add custom filters + */ + protected buildWhere(filters: TFilters = {} as TFilters): TWhereInput { + const where: Partial = {}; + + const namedFilters = filters as NamedEntityFilters; + + if (namedFilters.searchTerm) { + where.name = { + contains: namedFilters.searchTerm, + mode: "insensitive" as const, + }; + } + + if (namedFilters.id !== undefined) { + where.id = Array.isArray(namedFilters.id) + ? { in: namedFilters.id } + : namedFilters.id; + } + + return where as TWhereInput; + } + + async findMany(filters: TFilters = {} as TFilters): Promise { + const prismaEntities = await this.prismaModel.findMany({ + where: this.buildWhere(filters), + orderBy: { name: "asc" }, + }); + + return this.mapper.mapToDomain(prismaEntities); + } + + async count(filters: TFilters = {} as TFilters): Promise { + return this.prismaModel.count({ where: this.buildWhere(filters) }); + } + + /** + * Build Prisma data for create/update + * Override this method if entity has additional fields + */ + protected buildPrismaData(entity: TEntity): { + name: string; + description: string | null; + } { + return { + name: entity.name, + description: entity.description ?? null, + }; + } + + async save(entity: TEntity): Promise { + const data = this.buildPrismaData(entity); + const prismaEntity = await this.prismaModel.upsert({ + where: { id: entity.id }, + create: { id: entity.id, ...data }, + update: data, + }); + const mapped = this.mapper.toDomain(prismaEntity); + if (mapped === null) { + throw new Error(`Failed to map ${this.entityName} entity after save`); + } + return mapped; + } + + async destroy(entity: TEntity): Promise { + await this.prismaModel.delete({ + where: { id: entity.id }, + }); + } +} diff --git a/packages/system/src/base/named-entity.ts b/packages/system/src/base/named-entity.ts new file mode 100644 index 0000000..09ba6d7 --- /dev/null +++ b/packages/system/src/base/named-entity.ts @@ -0,0 +1,17 @@ +import { BaseEntity } from "./base.entity"; + +/** + * Base class for entities with name and description fields + * Provides common structure for reference data entities + */ +export abstract class NamedEntity extends BaseEntity { + constructor( + id: string, + public name: string, + createdAt: Date, + updatedAt: Date, + public description?: string, + ) { + super(id, createdAt, updatedAt); + } +} diff --git a/packages/system/src/base/pagination.service.ts b/packages/system/src/base/pagination.service.ts new file mode 100644 index 0000000..3a71c63 --- /dev/null +++ b/packages/system/src/base/pagination.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from "@nestjs/common"; +import type { BaseEntity } from "./base.entity"; +import type { CursorService } from "./cursor.service"; +import { + PageInfo, + type PaginationOptions, + type PaginationResult, +} from "./pagination.types"; + +@Injectable() +export class PaginationService { + constructor(private cursorService: CursorService) {} + + /** + * Parse pagination arguments from GraphQL + */ + parsePaginationArgs(args: { + first?: number | null; + after?: string | null; + last?: number | null; + before?: string | null; + searchTerm?: string | null; + }): PaginationOptions { + const options: PaginationOptions = {}; + + if (args.first) { + options.first = args.first; + } + if (args.after) { + options.after = args.after; + } + if (args.last) { + options.last = args.last; + } + if (args.before) { + options.before = args.before; + } + + return options; + } + + /** + * Build Prisma query options with cursor-based pagination + * This generic method handles all cursor logic for any Prisma model + */ + buildQueryOptions>( + where: T, + orderBy: Record, + options: PaginationOptions, + ): { + where: T & { id?: { gt?: string; lt?: string } }; + orderBy: Record; + take?: number; + } { + const queryOptions: { + where: T & { id?: { gt?: string; lt?: string } }; + orderBy: Record; + take?: number; + } = { + where: { ...where }, + orderBy, + }; + + // Apply cursor-based pagination + if (options.after) { + const afterId = this.cursorService.decode(options.after); + queryOptions.where = { + ...where, + id: { gt: afterId }, + } as T & { id: { gt: string } }; + } + + if (options.before) { + const beforeId = this.cursorService.decode(options.before); + queryOptions.where = { + ...where, + id: { lt: beforeId }, + } as T & { id: { lt: string } }; + } + + // Apply limit + if (options.first) { + queryOptions.take = options.first; + } + + if (options.last) { + queryOptions.take = options.last; + } + + return queryOptions; + } + + /** + * Build pagination result + */ + buildPaginationResult( + items: T[], + totalCount: number, + options: PaginationOptions, + ): PaginationResult { + const edges = items.map((item) => ({ + node: item, + cursor: this.cursorService.encode(item.id), + })); + + // Use early returns for boolean logic + const hasNextPage = options.first ? items.length === options.first : false; + const hasPreviousPage = options.last + ? items.length === options.last + : false; + + // Use early returns for cursor logic + if (edges.length === 0) { + const pageInfo = new PageInfo(hasNextPage, hasPreviousPage, null, null); + return { edges, pageInfo, totalCount }; + } + + const startCursor = edges[0]?.cursor ?? null; + const endCursor = edges[edges.length - 1]?.cursor ?? null; + const pageInfo = new PageInfo( + hasNextPage, + hasPreviousPage, + startCursor, + endCursor, + ); + + return { edges, pageInfo, totalCount }; + } +} diff --git a/packages/system/src/base/pagination.types.ts b/packages/system/src/base/pagination.types.ts new file mode 100644 index 0000000..0a100e0 --- /dev/null +++ b/packages/system/src/base/pagination.types.ts @@ -0,0 +1,78 @@ +import { ArgsType, Field, InputType, Int, ObjectType } from "@nestjs/graphql"; +import { GraphQLString } from "graphql"; + +@ObjectType() +export class PageInfo { + @Field(() => Boolean) + hasNextPage: boolean; + + @Field(() => Boolean) + hasPreviousPage: boolean; + + @Field(() => GraphQLString, { nullable: true }) + startCursor: string | null; + + @Field(() => GraphQLString, { nullable: true }) + endCursor: string | null; + + constructor( + hasNextPage: boolean, + hasPreviousPage: boolean, + startCursor: string | null, + endCursor: string | null, + ) { + this.hasNextPage = hasNextPage; + this.hasPreviousPage = hasPreviousPage; + this.startCursor = startCursor; + this.endCursor = endCursor; + } +} + +@InputType() +export abstract class BasePaginationArgs { + @Field(() => Int, { nullable: true }) + first?: number | null; + + @Field(() => GraphQLString, { nullable: true }) + after?: string | null; + + @Field(() => Int, { nullable: true }) + last?: number | null; + + @Field(() => GraphQLString, { nullable: true }) + before?: string | null; +} + +@ArgsType() +export class PaginationArgs extends BasePaginationArgs {} + +@InputType() +export abstract class SearchablePaginationArgs extends BasePaginationArgs { + @Field(() => GraphQLString, { nullable: true }) + searchTerm?: string | null; +} + +@InputType() +export abstract class SortablePaginationArgs extends SearchablePaginationArgs { + @Field(() => GraphQLString, { nullable: true }) + sortBy?: string | null; + + @Field(() => GraphQLString, { nullable: true, defaultValue: "asc" }) + sortOrder?: string | null; +} + +export interface PaginationResult { + edges: Array<{ + node: T; + cursor: string; + }>; + pageInfo: PageInfo; + totalCount: number; +} + +export interface PaginationOptions { + first?: number; + after?: string; + last?: number; + before?: string; +} diff --git a/packages/system/src/base/raise.util.ts b/packages/system/src/base/raise.util.ts new file mode 100644 index 0000000..1e72816 --- /dev/null +++ b/packages/system/src/base/raise.util.ts @@ -0,0 +1,17 @@ +/** + * Utility function to throw an Error with the given message. + * This is useful for creating never-returning functions that help with TypeScript's control flow analysis. + * + * @param message - The error message to throw + * @throws {Error} Always throws an Error + * @returns {never} This function never returns + * + * @example + * ```typescript + * const value = getValue() ?? raise("Value not found"); + * // TypeScript knows value is not null/undefined after this line + * ``` + */ +export const raise = (message: string): never => { + throw new Error(message); +}; diff --git a/packages/system/src/base/unauthorized.util.ts b/packages/system/src/base/unauthorized.util.ts new file mode 100644 index 0000000..56aa8c9 --- /dev/null +++ b/packages/system/src/base/unauthorized.util.ts @@ -0,0 +1,19 @@ +import { UnauthorizedException } from "@nestjs/common"; + +/** + * Throws an UnauthorizedException with the given message. + * This is useful for creating never-returning functions that help with TypeScript's control flow analysis. + * + * @param message - The error message to throw + * @throws {UnauthorizedException} Always throws an UnauthorizedException + * @returns {never} This function never returns + * + * @example + * ```typescript + * const token = getToken() ?? unauthorized("Token not found"); + * // TypeScript knows token is not null/undefined after this line + * ``` + */ +export const unauthorized = (message: string): never => { + throw new UnauthorizedException(message); +}; diff --git a/packages/system/src/base/uuid-factory.service.ts b/packages/system/src/base/uuid-factory.service.ts new file mode 100644 index 0000000..3321836 --- /dev/null +++ b/packages/system/src/base/uuid-factory.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class UuidFactoryService { + generate(): string { + return crypto.randomUUID(); + } +} diff --git a/packages/system/src/database/database.module.ts b/packages/system/src/database/database.module.ts new file mode 100644 index 0000000..c42527c --- /dev/null +++ b/packages/system/src/database/database.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { PrismaService } from "./prisma.service"; + +@Module({ + imports: [ConfigModule], + providers: [PrismaService], + exports: [PrismaService], +}) +export class DatabaseModule {} diff --git a/packages/system/src/database/index.ts b/packages/system/src/database/index.ts new file mode 100644 index 0000000..570a387 --- /dev/null +++ b/packages/system/src/database/index.ts @@ -0,0 +1,3 @@ +export * from "./database.module"; +export type { TypedPrismaService } from "./prisma.service"; +export * from "./prisma.service"; diff --git a/packages/system/src/database/prisma.service.ts b/packages/system/src/database/prisma.service.ts new file mode 100644 index 0000000..ada6ed3 --- /dev/null +++ b/packages/system/src/database/prisma.service.ts @@ -0,0 +1,33 @@ +import { + Injectable, + type OnModuleDestroy, + type OnModuleInit, +} from "@nestjs/common"; +import type { ConfigService } from "@nestjs/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "@prisma/client"; +import { Pool } from "pg"; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + constructor(readonly configService: ConfigService) { + const connectionString = configService.getOrThrow("DATABASE_URL"); + const pool = new Pool({ connectionString }); + const adapter = new PrismaPg(pool); + super({ adapter }); + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} + +export type { PrismaClient }; +export type TypedPrismaService = PrismaService & PrismaClient; diff --git a/packages/system/src/errors/domain-error.ts b/packages/system/src/errors/domain-error.ts new file mode 100644 index 0000000..9559629 --- /dev/null +++ b/packages/system/src/errors/domain-error.ts @@ -0,0 +1,15 @@ +export interface ErrorVariables { + [key: string]: string | number | string[] | number[]; +} + +export abstract class DomainError extends Error { + public readonly code: string; + public readonly variables: ErrorVariables; + + constructor(code: string, message: string, variables: ErrorVariables = {}) { + super(message); + this.code = code; + this.variables = variables; + this.name = this.constructor.name; + } +} diff --git a/packages/system/src/errors/index.ts b/packages/system/src/errors/index.ts new file mode 100644 index 0000000..68c92a4 --- /dev/null +++ b/packages/system/src/errors/index.ts @@ -0,0 +1 @@ +export * from "./domain-error"; diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts new file mode 100644 index 0000000..4402fa0 --- /dev/null +++ b/packages/system/src/index.ts @@ -0,0 +1,4 @@ +export * from "./base"; +export * from "./database"; +export * from "./errors"; +export * from "./mail"; diff --git a/packages/system/src/mail/index.ts b/packages/system/src/mail/index.ts new file mode 100644 index 0000000..c3777fb --- /dev/null +++ b/packages/system/src/mail/index.ts @@ -0,0 +1,6 @@ +export * from "./mail.module"; +export * from "./mail.service.interface"; +export * from "./providers/console-mail.service"; +export * from "./providers/example-mail.service"; +export * from "./resend"; +export * from "./template"; diff --git a/packages/system/src/mail/mail.module.ts b/packages/system/src/mail/mail.module.ts new file mode 100644 index 0000000..a523642 --- /dev/null +++ b/packages/system/src/mail/mail.module.ts @@ -0,0 +1,23 @@ +import { type DynamicModule, Module } from "@nestjs/common"; +import { MAIL_SERVICE_TOKEN, type MailService } from "./mail.service.interface"; + +export interface MailModuleOptions { + provider: new (...args: unknown[]) => MailService; +} + +@Module({}) +export class MailModule { + static forRoot(options: MailModuleOptions): DynamicModule { + return { + module: MailModule, + providers: [ + { + provide: MAIL_SERVICE_TOKEN, + useClass: options.provider, + }, + ], + exports: [MAIL_SERVICE_TOKEN], + global: true, + }; + } +} diff --git a/packages/system/src/mail/mail.service.interface.ts b/packages/system/src/mail/mail.service.interface.ts new file mode 100644 index 0000000..f431b6b --- /dev/null +++ b/packages/system/src/mail/mail.service.interface.ts @@ -0,0 +1,25 @@ +export interface MailAddress { + email: string; + name?: string; +} + +export interface MailAttachment { + filename: string; + content: string | Buffer; + contentType?: string; +} + +export interface SendMailOptions { + to: MailAddress | MailAddress[]; + from?: MailAddress; + subject: string; + text?: string; + html?: string; + attachments?: MailAttachment[]; +} + +export interface MailService { + sendMail(options: SendMailOptions): Promise; +} + +export const MAIL_SERVICE_TOKEN = Symbol("MailService"); diff --git a/packages/system/src/mail/providers/console-mail.service.ts b/packages/system/src/mail/providers/console-mail.service.ts new file mode 100644 index 0000000..a614bb6 --- /dev/null +++ b/packages/system/src/mail/providers/console-mail.service.ts @@ -0,0 +1,33 @@ +import { Injectable, Logger } from "@nestjs/common"; +import type { MailService, SendMailOptions } from "../mail.service.interface"; + +@Injectable() +export class ConsoleMailService implements MailService { + private readonly logger = new Logger(ConsoleMailService.name); + + async sendMail(options: SendMailOptions): Promise { + const to = Array.isArray(options.to) ? options.to : [options.to]; + const toEmails = to.map((addr) => addr.email).join(", "); + + this.logger.log("=".repeat(80)); + this.logger.log("Email sent (console provider)"); + this.logger.log("=".repeat(80)); + this.logger.log(`From: ${options.from?.email ?? "noreply@example.com"}`); + this.logger.log(`To: ${toEmails}`); + this.logger.log(`Subject: ${options.subject}`); + if (options.text) { + this.logger.log("Text Content:"); + this.logger.log(options.text); + } + if (options.html) { + this.logger.log("HTML Content:"); + this.logger.log(options.html); + } + if (options.attachments && options.attachments.length > 0) { + this.logger.log( + `Attachments: ${options.attachments.map((a) => a.filename).join(", ")}`, + ); + } + this.logger.log("=".repeat(80)); + } +} diff --git a/packages/system/src/mail/providers/example-mail.service.ts b/packages/system/src/mail/providers/example-mail.service.ts new file mode 100644 index 0000000..14b5050 --- /dev/null +++ b/packages/system/src/mail/providers/example-mail.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from "@nestjs/common"; +import type { MailService, SendMailOptions } from "../mail.service.interface"; + +@Injectable() +export class ExampleMailService implements MailService { + async sendMail(_options: SendMailOptions): Promise { + // Implement your mail provider logic here + // Example: + // const client = new SendGridClient(process.env["SENDGRID_API_KEY"]); + // await client.send({ ... }); + throw new Error("ExampleMailService is not implemented"); + } +} diff --git a/packages/system/src/mail/resend/index.ts b/packages/system/src/mail/resend/index.ts new file mode 100644 index 0000000..f35030f --- /dev/null +++ b/packages/system/src/mail/resend/index.ts @@ -0,0 +1,2 @@ +export * from "./resend.module"; +export * from "./resend-mail.service"; diff --git a/packages/system/src/mail/resend/resend-mail.service.ts b/packages/system/src/mail/resend/resend-mail.service.ts new file mode 100644 index 0000000..f9c4c44 --- /dev/null +++ b/packages/system/src/mail/resend/resend-mail.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from "@nestjs/common"; +import { Resend } from "resend"; +import type { + MailAddress, + MailService, + SendMailOptions, +} from "../mail.service.interface"; + +@Injectable() +export class ResendMailService implements MailService { + private readonly resend: Resend; + + constructor(apiKey: string) { + this.resend = new Resend(apiKey); + } + + async sendMail(options: SendMailOptions): Promise { + const to = Array.isArray(options.to) ? options.to : [options.to]; + const toEmails = to.map((addr) => this.formatAddress(addr)); + + const from = options.from + ? this.formatAddress(options.from) + : "noreply@example.com"; + + const emailOptions: { + from: string; + to: string[]; + subject: string; + text?: string; + html?: string; + attachments?: Array<{ + filename: string; + content: Buffer; + type?: string; + }>; + } = { + from, + to: toEmails, + subject: options.subject, + }; + + if (options.text !== undefined) { + emailOptions.text = options.text; + } + + if (options.html !== undefined) { + emailOptions.html = options.html; + } + + if (options.attachments && options.attachments.length > 0) { + emailOptions.attachments = options.attachments.map((attachment) => { + const attachmentData: { + filename: string; + content: Buffer; + type?: string; + } = { + filename: attachment.filename, + content: + typeof attachment.content === "string" + ? Buffer.from(attachment.content) + : attachment.content, + }; + + if (attachment.contentType !== undefined) { + attachmentData.type = attachment.contentType; + } + + return attachmentData; + }); + } + + await this.resend.emails.send( + emailOptions as Parameters[0], + ); + } + + private formatAddress(address: MailAddress): string { + if (address.name) { + return `${address.name} <${address.email}>`; + } + return address.email; + } +} diff --git a/packages/system/src/mail/resend/resend.module.ts b/packages/system/src/mail/resend/resend.module.ts new file mode 100644 index 0000000..5f3735c --- /dev/null +++ b/packages/system/src/mail/resend/resend.module.ts @@ -0,0 +1,23 @@ +import { Global, Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + MAIL_SERVICE_TOKEN, + type MailService, +} from "../mail.service.interface"; +import { ResendMailService } from "./resend-mail.service"; + +@Global() +@Module({ + providers: [ + { + provide: MAIL_SERVICE_TOKEN, + useFactory: (configService: ConfigService): MailService => { + const apiKey = configService.getOrThrow("RESEND_API_KEY"); + return new ResendMailService(apiKey); + }, + inject: [ConfigService], + }, + ], + exports: [MAIL_SERVICE_TOKEN], +}) +export class ResendModule {} diff --git a/packages/system/src/mail/template/handlebars-template.service.ts b/packages/system/src/mail/template/handlebars-template.service.ts new file mode 100644 index 0000000..bfabd0a --- /dev/null +++ b/packages/system/src/mail/template/handlebars-template.service.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Injectable } from "@nestjs/common"; +import Handlebars from "handlebars"; +import type { TemplateRegistryService } from "./template-registry.service"; + +@Injectable() +export class HandlebarsTemplateService { + private readonly templateCache = new Map< + string, + Handlebars.TemplateDelegate + >(); + + constructor(private readonly registry: TemplateRegistryService) {} + + private resolveTemplatePath(templateName: string): string | null { + const pathWithExtension = templateName.endsWith(".hbs") + ? templateName + : `${templateName}.hbs`; + + const directories = this.registry.getDirectories(); + for (const directory of directories) { + const fullPath = join(directory, pathWithExtension); + if (existsSync(fullPath)) { + return fullPath; + } + } + + return null; + } + + private loadTemplate(templateName: string): Handlebars.TemplateDelegate { + const cached = this.templateCache.get(templateName); + if (cached) { + return cached; + } + + const fullPath = this.resolveTemplatePath(templateName); + if (!fullPath) { + const directories = this.registry.getDirectories().join(", "); + throw new Error( + `Template not found: ${templateName} (searched in: ${directories})`, + ); + } + + const templateContent = readFileSync(fullPath, "utf-8"); + const compiled = Handlebars.compile(templateContent, { strict: true }); + this.templateCache.set(templateName, compiled); + return compiled; + } + + render(templateName: string, data: Record): string { + const template = this.loadTemplate(templateName); + return template(data); + } +} diff --git a/packages/system/src/mail/template/index.ts b/packages/system/src/mail/template/index.ts new file mode 100644 index 0000000..22c9c9a --- /dev/null +++ b/packages/system/src/mail/template/index.ts @@ -0,0 +1,4 @@ +export * from "./handlebars-template.service"; +export * from "./template.module"; +export { HANDLEBARS_TEMPLATE_SERVICE_TOKEN } from "./template.module"; +export * from "./template-registry.service"; diff --git a/packages/system/src/mail/template/template-registry.service.ts b/packages/system/src/mail/template/template-registry.service.ts new file mode 100644 index 0000000..09b92ee --- /dev/null +++ b/packages/system/src/mail/template/template-registry.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class TemplateRegistryService { + private readonly directories: string[] = []; + + registerDirectory(path: string): void { + if (!this.directories.includes(path)) { + this.directories.push(path); + } + } + + getDirectories(): readonly string[] { + return [...this.directories]; + } + + clear(): void { + this.directories.length = 0; + } +} diff --git a/packages/system/src/mail/template/template.module.ts b/packages/system/src/mail/template/template.module.ts new file mode 100644 index 0000000..bfc010a --- /dev/null +++ b/packages/system/src/mail/template/template.module.ts @@ -0,0 +1,25 @@ +import { Global, Module } from "@nestjs/common"; +import { HandlebarsTemplateService } from "./handlebars-template.service"; +import { TemplateRegistryService } from "./template-registry.service"; + +export const HANDLEBARS_TEMPLATE_SERVICE_TOKEN = Symbol( + "HandlebarsTemplateService", +); + +@Global() +@Module({ + providers: [ + TemplateRegistryService, + HandlebarsTemplateService, + { + provide: HANDLEBARS_TEMPLATE_SERVICE_TOKEN, + useExisting: HandlebarsTemplateService, + }, + ], + exports: [ + HANDLEBARS_TEMPLATE_SERVICE_TOKEN, + HandlebarsTemplateService, + TemplateRegistryService, + ], +}) +export class TemplateModule {} diff --git a/packages/system/tsconfig.json b/packages/system/tsconfig.json new file mode 100644 index 0000000..25dc8ba --- /dev/null +++ b/packages/system/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@cv/tsconfig/tsconfig.library.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "composite": true, + "declaration": true, + "emitDeclarationOnly": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/packages/tsconfig/tsconfig.base.json b/packages/tsconfig/tsconfig.base.json index 7b0998e..8b9e89f 100644 --- a/packages/tsconfig/tsconfig.base.json +++ b/packages/tsconfig/tsconfig.base.json @@ -23,7 +23,9 @@ "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, - "noImplicitAny": true + "noImplicitAny": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "exclude": ["dist", "node_modules"] } diff --git a/packages/ui/package.json b/packages/ui/package.json index fe925f4..eba02dd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,11 +18,12 @@ ], "scripts": { "lint": "biome check .", - "lint:fix": "biome check --write ." + "lint:fix": "biome check --write .", + "typecheck": "tsc --noEmit" }, "peerDependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^19.2.3", + "react-dom": "^19.2.3" }, "dependencies": { "@cv/utils": "*", @@ -30,13 +31,14 @@ "@tanstack/react-virtual": "^3.11.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "tailwind-merge": "^2.5.3" + "tailwind-merge": "^2.5.3", + "zod": "^3.25.76" }, "devDependencies": { "@tailwindcss/cli": "^4.1.16", "@tailwindcss/postcss": "^4.0.0", - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.0.6", + "@types/react-dom": "^19.0.3", "tailwindcss": "^4.0.0", "typescript": "^5.5.3" } diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 028b269..0ebe46a 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -28,7 +28,7 @@ const buttonVariants = cva( interface ButtonProps extends VariantProps { children: React.ReactNode; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; disabled?: boolean; className?: string; type?: "button" | "submit" | "reset"; diff --git a/apps/client/src/components/ConfirmationModal.tsx b/packages/ui/src/components/ConfirmationModal.tsx similarity index 94% rename from apps/client/src/components/ConfirmationModal.tsx rename to packages/ui/src/components/ConfirmationModal.tsx index 3d31283..e6e4aee 100644 --- a/apps/client/src/components/ConfirmationModal.tsx +++ b/packages/ui/src/components/ConfirmationModal.tsx @@ -28,9 +28,6 @@ interface ConfirmationModalProps { variant?: "danger" | "warning" | "info"; } -/** - * Reusable confirmation modal component - */ export const ConfirmationModal = ({ isOpen, onClose, @@ -60,7 +57,6 @@ export const ConfirmationModal = ({ return ( - {/* Backdrop */}