diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e1fd181 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.env +.git +.gitignore +README.md +npm-debug.log +.DS_Store +*.md +.vscode +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5b1fd08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Build stage +FROM node:24-slim AS builder + +WORKDIR /app + +# Install OpenSSL for Prisma +RUN apt-get update -y && apt-get install -y openssl + +# Copy package files +COPY package*.json ./ +COPY prisma ./prisma/ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Generate Prisma Client +RUN npx prisma generate + +# Build the application +RUN npm run build + +# Production stage +FROM node:24-slim + +WORKDIR /app + +# Install OpenSSL for Prisma +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* + +# Copy package files +COPY package*.json ./ +COPY prisma ./prisma/ + +# Install production dependencies only +RUN npm ci --only=production + +# Generate Prisma Client in production stage +RUN npx prisma generate + +# Copy built application from builder +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma + +# Copy data.json for seeding +COPY --from=builder /app/data.json ./data.json + +# Copy entrypoint script +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +# Expose the application port +EXPOSE 3000 + +# Start the application with migrations +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/data.json b/data.json index 4e1a12f..979929a 100644 --- a/data.json +++ b/data.json @@ -1,63 +1,239 @@ { - "roles": [ - { - "name": "admin" - }, - { - "name": "user" - } - ], - "permissions": [ - { - "action": "create", - "resource": "employee" - }, - { - "action": "read", - "resource": "employee" - }, - { - "action": "update", - "resource": "employee" - }, - { - "action": "delete", - "resource": "employee" - } - ], - "rolePermissions": [ - { - "roleName": "admin", - "permissionActions": [ - "create:employee", - "read:employee", - "update:employee", - "delete:employee" - ] - }, - { - "roleName": "user", - "permissionActions": [ - "read:employee" - ] - } - ], - "users": [ - { - "email": "admin@example.com", - "password": "adminpassword", - "isSuperAdmin": true, - "roleNames": [ - "admin" - ] - }, - { - "email": "user@example.com", - "password": "userpassword", - "isSuperAdmin": false, - "roleNames": [ - "user" - ] - } - ] + "roles": [ + { + "name": "admin" + }, + { + "name": "receptionist" + }, + { + "name": "doctor" + }, + { + "name": "technician" + }, + { + "name": "applicator" + }, + { + "name": "user" + } + ], + "permissions": [ + {"action": "create", "resource": "patient", "description": "Create new patient records"}, + {"action": "read", "resource": "patient", "description": "View patient information and medical history"}, + {"action": "update", "resource": "patient", "description": "Modify existing patient records"}, + {"action": "delete", "resource": "patient", "description": "Remove patient records from the system"}, + + {"action": "create", "resource": "employee", "description": "Add new employees to the system"}, + {"action": "read", "resource": "employee", "description": "View employee information and profiles"}, + {"action": "update", "resource": "employee", "description": "Update employee details and assignments"}, + {"action": "delete", "resource": "employee", "description": "Remove employees from the system"}, + + {"action": "create", "resource": "device", "description": "Register new orthopedic devices"}, + {"action": "read", "resource": "device", "description": "View device specifications and inventory"}, + {"action": "update", "resource": "device", "description": "Modify device information and pricing"}, + {"action": "delete", "resource": "device", "description": "Remove devices from the catalog"}, + + {"action": "create", "resource": "component", "description": "Add new device components to inventory"}, + {"action": "read", "resource": "component", "description": "View component details and availability"}, + {"action": "update", "resource": "component", "description": "Update component specifications and pricing"}, + {"action": "delete", "resource": "component", "description": "Remove components from inventory"}, + + {"action": "create", "resource": "quotations", "description": "Create new quotations for patients"}, + {"action": "read", "resource": "quotations", "description": "View quotation details and status"}, + {"action": "update", "resource": "quotations", "description": "Modify quotation information and progress status"}, + {"action": "delete", "resource": "quotations", "description": "Cancel and remove quotations"}, + + {"action": "create", "resource": "diagnosis", "description": "Create medical diagnosis reports"}, + {"action": "read", "resource": "diagnosis", "description": "View patient diagnosis and medical reports"}, + {"action": "update", "resource": "diagnosis", "description": "Update diagnosis information and recommendations"}, + {"action": "delete", "resource": "diagnosis", "description": "Remove diagnosis records"}, + + {"action": "create", "resource": "fabrication-order", "description": "Create new fabrication orders"}, + {"action": "read", "resource": "fabrication-order", "description": "View fabrication order details and status"}, + {"action": "update", "resource": "fabrication-order", "description": "Update fabrication order progress and assignments"}, + {"action": "delete", "resource": "fabrication-order", "description": "Cancel fabrication orders"}, + + {"action": "create", "resource": "execution-order", "description": "Create device execution orders"}, + {"action": "read", "resource": "execution-order", "description": "View execution order details and components"}, + {"action": "update", "resource": "execution-order", "description": "Update execution order status and assignments"}, + {"action": "delete", "resource": "execution-order", "description": "Cancel execution orders"}, + + {"action": "create", "resource": "finalized-device", "description": "Register completed devices"}, + {"action": "read", "resource": "finalized-device", "description": "View finalized device information and photos"}, + {"action": "update", "resource": "finalized-device", "description": "Update finalized device details"}, + {"action": "delete", "resource": "finalized-device", "description": "Remove finalized device records"}, + + {"action": "create", "resource": "notification", "description": "Create system notifications"}, + {"action": "read", "resource": "notification", "description": "View notifications and alerts"}, + {"action": "update", "resource": "notification", "description": "Mark notifications as read or acknowledged"}, + {"action": "delete", "resource": "notification", "description": "Remove notifications"}, + + {"action": "create", "resource": "role", "description": "Create new user roles"}, + {"action": "read", "resource": "role", "description": "View role definitions and permissions"}, + {"action": "update", "resource": "role", "description": "Modify role permissions and settings"}, + {"action": "delete", "resource": "role", "description": "Remove roles from the system"}, + + {"action": "create", "resource": "permission", "description": "Define new system permissions"}, + {"action": "read", "resource": "permission", "description": "View available permissions"}, + {"action": "update", "resource": "permission", "description": "Modify permission definitions"}, + {"action": "delete", "resource": "permission", "description": "Remove permissions from the system"}, + + {"action": "read", "resource": "dashboard", "description": "Access the main dashboard and overview"}, + {"action": "read", "resource": "analytics", "description": "View analytics, reports, and statistics"}, + {"action": "read", "resource": "search", "description": "Search across all system resources"}, + + {"action": "create", "resource": "upload", "description": "Upload files, images, and documents"}, + {"action": "read", "resource": "upload", "description": "View and download uploaded files"}, + {"action": "delete", "resource": "upload", "description": "Remove uploaded files"}, + + {"action": "manage", "resource": "workflow", "description": "Manage and control quotation workflow progression"}, + {"action": "read", "resource": "workflow", "description": "View workflow status and history"}, + + {"action": "read", "resource": "audit", "description": "View system audit logs and activity"}, + {"action": "create", "resource": "audit", "description": "Create audit log entries"} + ], + "rolePermissions": [ + { + "roleName": "admin", + "permissionActions": [ + "create:patient", "read:patient", "update:patient", "delete:patient", + "create:employee", "read:employee", "update:employee", "delete:employee", + "create:device", "read:device", "update:device", "delete:device", + "create:component", "read:component", "update:component", "delete:component", + "create:quotations", "read:quotations", "update:quotations", "delete:quotations", + "create:diagnosis", "read:diagnosis", "update:diagnosis", "delete:diagnosis", + "create:fabrication-order", "read:fabrication-order", "update:fabrication-order", "delete:fabrication-order", + "create:execution-order", "read:execution-order", "update:execution-order", "delete:execution-order", + "create:finalized-device", "read:finalized-device", "update:finalized-device", "delete:finalized-device", + "create:notification", "read:notification", "update:notification", "delete:notification", + "create:role", "read:role", "update:role", "delete:role", + "create:permission", "read:permission", "update:permission", "delete:permission", + "read:dashboard", "read:analytics", "read:search", + "create:upload", "read:upload", "delete:upload", + "manage:workflow", "read:workflow", + "read:audit", "create:audit" + ] + }, + { + "roleName": "receptionist", + "permissionActions": [ + "create:patient", "read:patient", "update:patient", + "read:employee", + "read:device", "read:component", + "create:quotations", "read:quotations", "update:quotations", + "read:diagnosis", + "read:fabrication-order", + "read:execution-order", + "read:finalized-device", + "read:notification", "update:notification", + "read:dashboard", "read:search", + "create:upload", "read:upload", + "read:workflow" + ] + }, + { + "roleName": "doctor", + "permissionActions": [ + "read:patient", "update:patient", + "read:employee", + "read:device", "read:component", + "read:quotations", "update:quotations", + "create:diagnosis", "read:diagnosis", "update:diagnosis", + "read:fabrication-order", + "read:execution-order", + "read:finalized-device", + "read:notification", "update:notification", + "read:dashboard", "read:analytics", "read:search", + "create:upload", "read:upload", + "manage:workflow", "read:workflow" + ] + }, + { + "roleName": "technician", + "permissionActions": [ + "read:patient", + "read:employee", + "read:device", "update:device", + "read:component", "update:component", + "read:quotations", + "read:diagnosis", + "create:fabrication-order", "read:fabrication-order", "update:fabrication-order", + "create:execution-order", "read:execution-order", "update:execution-order", + "create:finalized-device", "read:finalized-device", "update:finalized-device", + "read:notification", "update:notification", + "read:dashboard", "read:search", + "create:upload", "read:upload", + "read:workflow" + ] + }, + { + "roleName": "applicator", + "permissionActions": [ + "read:patient", + "read:employee", + "read:device", + "read:component", + "read:quotations", + "read:diagnosis", + "read:fabrication-order", + "read:execution-order", "update:execution-order", + "create:finalized-device", "read:finalized-device", "update:finalized-device", + "read:notification", "update:notification", + "read:dashboard", "read:search", + "create:upload", "read:upload", + "read:workflow" + ] + }, + { + "roleName": "user", + "permissionActions": [ + "read:patient", + "read:employee", + "read:device", + "read:component", + "read:quotations", + "read:diagnosis", + "read:fabrication-order", + "read:execution-order", + "read:finalized-device", + "read:notification", + "read:dashboard", + "read:search" + ] + } + ], + "users": [ + { + "email": "admin@example.com", + "password": "adminpassword", + "isSuperAdmin": true, + "roleNames": ["admin"] + }, + { + "email": "receptionist@example.com", + "password": "receptionist123", + "isSuperAdmin": false, + "roleNames": ["receptionist"] + }, + { + "email": "doctor@example.com", + "password": "doctor123", + "isSuperAdmin": false, + "roleNames": ["doctor"] + }, + { + "email": "technician@example.com", + "password": "technician123", + "isSuperAdmin": false, + "roleNames": ["technician"] + }, + { + "email": "user@example.com", + "password": "userpassword", + "isSuperAdmin": false, + "roleNames": ["user"] + } + ] } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..1693a4b --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +npx prisma migrate deploy + +echo "Starting application..." +exec node dist/main diff --git a/docs/QUOTATION_CODE_GENERATION.md b/docs/QUOTATION_CODE_GENERATION.md new file mode 100644 index 0000000..44f19f9 --- /dev/null +++ b/docs/QUOTATION_CODE_GENERATION.md @@ -0,0 +1,238 @@ +# Quotation Code Generation + +## Overview +Every quotation in the system is automatically assigned a unique sequential code for easy identification and tracking in the UI. + +## Code Format +``` +CA-YYYYMM0000000001 +``` + +### Components: +- **CA**: Fixed prefix (Chiffre d'Affaires / Quotation) +- **YYYY**: 4-digit year (e.g., 2025) +- **MM**: 2-digit month (01-12) +- **0000000001**: 10-digit sequential number (padded with leading zeros) + +## Examples +- First quotation in November 2025: `CA-2025110000000001` +- Second quotation in November 2025: `CA-2025110000000002` +- 100th quotation in November 2025: `CA-2025110000000100` +- First quotation in December 2025: `CA-2025120000000001` (sequence resets) + +## Behavior + +### Sequential Numbering +- Sequence starts at 1 for each new month +- Sequence resets to 1 when a new month begins +- Numbers are padded with leading zeros to maintain a 10-digit format +- Maximum sequence per month: 9,999,999,999 (10 billion quotations) + +### Automatic Generation +The code is automatically generated when creating a quotation: +1. System extracts current year and month +2. Queries database for the last quotation in current month +3. Increments the sequence number +4. Formats the code with proper padding +5. Saves the quotation with the unique code + +### Uniqueness +- The `code` field is **unique** across the entire database +- Database enforces uniqueness constraint at the schema level +- Indexed for fast lookups and sorting + +## Database Schema + +```prisma +model Quotation { + id String @id @default(uuid()) + code String @unique // CA-YYYYMM0000000001 + patient Patient @relation(fields: [patientId], references: [id]) + patientId String + createdBy Employee @relation(fields: [createdById], references: [id]) + createdById String + status QuotationStatus @default(created) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([code]) + @@index([createdAt]) +} +``` + +## API Response Example + +When creating a quotation via POST `/quotation`: + +**Request:** +```json +{ + "patientId": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**Response:** +```json +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "code": "CA-2025110000000042", + "patientId": "550e8400-e29b-41d4-a716-446655440000", + "createdById": "e4f5g6h7-i8j9-0123-4567-890abcdef123", + "status": "created", + "createdAt": "2025-11-21T14:30:25.123Z", + "updatedAt": "2025-11-21T14:30:25.123Z", + "patient": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "firstName": "John", + "lastName": "Doe", + // ... other patient fields + }, + "createdBy": { + "id": "e4f5g6h7-i8j9-0123-4567-890abcdef123", + "firstName": "Jane", + "lastName": "Smith", + // ... other employee fields + } +} +``` + +## Implementation Details + +### Service Method: `generateQuotationCode()` + +```typescript +private async generateQuotationCode(): Promise { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const prefix = `CA-${year}${month}`; + + // Find the last quotation for this year-month + const lastQuotation = await this.prisma.quotation.findFirst({ + where: { + code: { + startsWith: prefix, + }, + }, + orderBy: { + code: 'desc', + }, + }); + + let sequence = 1; + if (lastQuotation && lastQuotation.code) { + // Extract the last 10 digits and increment + const lastSequence = parseInt(lastQuotation.code.slice(-10), 10); + sequence = lastSequence + 1; + } + + // Format: CA-YYYYMM0000000001 (10 digits for sequence) + const sequenceStr = String(sequence).padStart(10, '0'); + const code = `${prefix}${sequenceStr}`; + + return code; +} +``` + +## Migration + +Existing quotations are automatically assigned codes during migration: +- Sorted by `createdAt` in ascending order +- Codes generated based on original creation month +- Sequential numbering within each month +- No duplicate codes + +**Migration SQL** (excerpt): +```sql +-- Add code column +ALTER TABLE "Quotation" ADD COLUMN "code" TEXT; + +-- Generate codes for existing quotations +DO $$ +DECLARE + quotation_record RECORD; + year_month TEXT; + sequence_num INT; + new_code TEXT; +BEGIN + -- Loop through quotations by creation date + FOR quotation_record IN + SELECT id, "createdAt" + FROM "Quotation" + ORDER BY "createdAt" ASC + LOOP + -- Generate sequential code + -- ... + END LOOP; +END $$; + +-- Make code NOT NULL and unique +ALTER TABLE "Quotation" ALTER COLUMN "code" SET NOT NULL; +CREATE UNIQUE INDEX "Quotation_code_key" ON "Quotation"("code"); +``` + +## Benefits + +### For Users +1. **Human-readable**: Easy to communicate over phone/email +2. **Sortable**: Natural chronological ordering +3. **Trackable**: Month-based organization +4. **Predictable**: Sequential numbering is intuitive + +### For System +1. **Unique**: Guaranteed uniqueness via database constraint +2. **Indexed**: Fast queries and lookups +3. **Scalable**: Supports billions of quotations per month +4. **Automatic**: No manual intervention required + +## Best Practices + +### Display in UI +- **Show code prominently** in quotation lists and detail views +- Use code as primary identifier for users (UUID for system) +- Sort by code for chronological view +- Include code in search functionality + +### Example UI Display +``` +Quotation #CA-2025110000000042 +Patient: John Doe +Status: Created +Date: Nov 21, 2025 +``` + +### Search and Filter +- Search by code: `CA-202511*` (all Nov 2025 quotations) +- Filter by month: Extract YYYYMM from code +- Sort by code: Natural chronological ordering + +## Error Handling + +The system handles edge cases gracefully: +- **Concurrent creation**: Database unique constraint prevents duplicates +- **Missing codes in sequence**: System continues from last code +- **Month rollover**: Automatic sequence reset +- **Large numbers**: Supports up to 10 billion per month + +## Testing + +Test scenarios: +1. Create first quotation in a new month +2. Create multiple quotations in same month (verify sequential increment) +3. Create quotation in new month (verify sequence reset) +4. Retrieve quotation by code +5. Filter quotations by code prefix + +## Future Enhancements + +Potential improvements: +- Custom prefixes per branch/location (e.g., `CA-LOC1-202511-0000000001`) +- Year-based sequential numbering (reset yearly instead of monthly) +- Configurable sequence padding (e.g., 6 digits instead of 10) +- Code generation strategy selection (monthly, yearly, continuous) + +--- + +**Created**: November 21, 2025 +**Last Updated**: November 21, 2025 +**Author**: GenSpark AI Developer diff --git a/package-lock.json b/package-lock.json index dbccd95..ec362a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,14 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "mrz": "^5.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sharp": "^0.34.5", "swagger-ui-express": "^5.0.1", + "tesseract.js": "^6.0.1", "uuid": "^11.1.0" }, "devDependencies": { @@ -768,6 +771,16 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -988,6 +1001,471 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/checkbox": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.9.tgz", @@ -5379,6 +5857,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -6260,6 +6744,15 @@ "node": ">= 0.8" } }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -7752,6 +8245,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -7994,6 +8493,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9388,6 +9893,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mrz": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mrz/-/mrz-5.0.0.tgz", + "integrity": "sha512-BNU5DfMLg6WG2l4IRVT0HhlICzt9jMIlAOeE8DsplUjEtK3GDEpdglotvILRYIUfkAhY8FlJOpnEVi3HtYYddQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9514,6 +10025,26 @@ "lodash": "^4.17.21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -9633,6 +10164,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10343,6 +10883,12 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -10611,9 +11157,9 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10704,6 +11250,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11487,6 +12077,30 @@ "dev": true, "license": "MIT" }, + "node_modules/tesseract.js": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz", + "integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^6.0.0", + "wasm-feature-detect": "^1.2.11", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.0.0.tgz", + "integrity": "sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==", + "license": "Apache-2.0" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -11616,6 +12230,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -12090,6 +12710,12 @@ "makeerror": "1.0.12" } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -12127,6 +12753,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack": { "version": "5.100.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.2.tgz", @@ -12328,6 +12960,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12565,6 +13207,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } } } } diff --git a/package.json b/package.json index 163063b..2d27aa6 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,14 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "mrz": "^5.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sharp": "^0.34.5", "swagger-ui-express": "^5.0.1", + "tesseract.js": "^6.0.1", "uuid": "^11.1.0" }, "devDependencies": { diff --git a/prisma/migrations/20251107180637_update_patient_schema_with_full_details/migration.sql b/prisma/migrations/20251107180637_update_patient_schema_with_full_details/migration.sql new file mode 100644 index 0000000..b8a7d86 --- /dev/null +++ b/prisma/migrations/20251107180637_update_patient_schema_with_full_details/migration.sql @@ -0,0 +1,37 @@ +-- AlterTable +ALTER TABLE "Patient" DROP CONSTRAINT IF EXISTS "Patient_nationalId_key"; +ALTER TABLE "Patient" DROP CONSTRAINT IF EXISTS "Patient_socialSecurityNumber_key"; + +-- AlterTable Patient - Add new columns +ALTER TABLE "Patient" + ADD COLUMN IF NOT EXISTS "email" TEXT, + ADD COLUMN IF NOT EXISTS "phone" TEXT, + ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "gender" TEXT, + ADD COLUMN IF NOT EXISTS "address" TEXT, + ADD COLUMN IF NOT EXISTS "city" TEXT, + ADD COLUMN IF NOT EXISTS "bloodType" TEXT, + ADD COLUMN IF NOT EXISTS "allergies" TEXT[] DEFAULT ARRAY[]::TEXT[], + ADD COLUMN IF NOT EXISTS "currentMedications" TEXT[] DEFAULT ARRAY[]::TEXT[], + ADD COLUMN IF NOT EXISTS "emergencyContactName" TEXT, + ADD COLUMN IF NOT EXISTS "emergencyContactRelationship" TEXT, + ADD COLUMN IF NOT EXISTS "emergencyContactPhone" TEXT; + +-- AlterTable Patient - Modify existing columns +ALTER TABLE "Patient" + ALTER COLUMN "nationalId" TYPE TEXT, + ALTER COLUMN "socialSecurityNumber" TYPE TEXT, + ALTER COLUMN "insuranceType" DROP NOT NULL; + +-- Make new required fields NOT NULL (after data migration if needed) +ALTER TABLE "Patient" + ALTER COLUMN "email" SET NOT NULL, + ALTER COLUMN "phone" SET NOT NULL, + ALTER COLUMN "dateOfBirth" SET NOT NULL, + ALTER COLUMN "gender" SET NOT NULL, + ALTER COLUMN "address" SET NOT NULL, + ALTER COLUMN "city" SET NOT NULL, + ALTER COLUMN "bloodType" SET NOT NULL, + ALTER COLUMN "emergencyContactName" SET NOT NULL, + ALTER COLUMN "emergencyContactRelationship" SET NOT NULL, + ALTER COLUMN "emergencyContactPhone" SET NOT NULL; diff --git a/prisma/migrations/20251107181622_change_emergency_contact_to_json/migration.sql b/prisma/migrations/20251107181622_change_emergency_contact_to_json/migration.sql new file mode 100644 index 0000000..cc72819 --- /dev/null +++ b/prisma/migrations/20251107181622_change_emergency_contact_to_json/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable Patient - Change emergency contact from separate columns to JSON +-- First, migrate existing data to JSON format (if any exists) +ALTER TABLE "Patient" ADD COLUMN IF NOT EXISTS "emergencyContact" JSONB; + +-- Migrate existing data to JSON (for patients that already have emergency contact data) +UPDATE "Patient" +SET "emergencyContact" = jsonb_build_object( + 'name', COALESCE("emergencyContactName", ''), + 'relationship', COALESCE("emergencyContactRelationship", ''), + 'phone', COALESCE("emergencyContactPhone", '') +) +WHERE "emergencyContactName" IS NOT NULL; + +-- Drop old columns +ALTER TABLE "Patient" DROP COLUMN IF EXISTS "emergencyContactName"; +ALTER TABLE "Patient" DROP COLUMN IF EXISTS "emergencyContactRelationship"; +ALTER TABLE "Patient" DROP COLUMN IF EXISTS "emergencyContactPhone"; + +-- Make emergencyContact required +ALTER TABLE "Patient" ALTER COLUMN "emergencyContact" SET NOT NULL; diff --git a/prisma/migrations/20251107184917_add_description_to_permission/migration.sql b/prisma/migrations/20251107184917_add_description_to_permission/migration.sql new file mode 100644 index 0000000..218f02e --- /dev/null +++ b/prisma/migrations/20251107184917_add_description_to_permission/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable Permission - Add description column +ALTER TABLE "Permission" ADD COLUMN IF NOT EXISTS "description" TEXT; + +-- Set default descriptions for existing permissions (if any) +UPDATE "Permission" SET "description" = + CASE + WHEN action = 'create' THEN 'Create ' || resource || ' resources' + WHEN action = 'read' THEN 'View ' || resource || ' information' + WHEN action = 'update' THEN 'Modify ' || resource || ' details' + WHEN action = 'delete' THEN 'Remove ' || resource || ' from system' + WHEN action = 'manage' THEN 'Manage ' || resource || ' operations' + ELSE action || ' permission for ' || resource + END +WHERE "description" IS NULL; + +-- Make description required +ALTER TABLE "Permission" ALTER COLUMN "description" SET NOT NULL; diff --git a/prisma/migrations/20251108055950_create_audit_log_table/migration.sql b/prisma/migrations/20251108055950_create_audit_log_table/migration.sql new file mode 100644 index 0000000..3de0ef2 --- /dev/null +++ b/prisma/migrations/20251108055950_create_audit_log_table/migration.sql @@ -0,0 +1,38 @@ +-- CreateEnum +CREATE TYPE "AuditAction" AS ENUM ('CREATE', 'READ', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'ACCESS_DENIED', 'UNAUTHORIZED_ATTEMPT'); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "userEmail" TEXT, + "userRole" TEXT, + "action" "AuditAction" NOT NULL, + "resource" TEXT NOT NULL, + "resourceId" TEXT, + "method" TEXT, + "endpoint" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "status" TEXT NOT NULL, + "message" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId"); + +-- CreateIndex +CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action"); + +-- CreateIndex +CREATE INDEX "AuditLog_resource_idx" ON "AuditLog"("resource"); + +-- CreateIndex +CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt"); + +-- CreateIndex +CREATE INDEX "AuditLog_status_idx" ON "AuditLog"("status"); diff --git a/prisma/migrations/20251108064430_add_patient_documents_table/migration.sql b/prisma/migrations/20251108064430_add_patient_documents_table/migration.sql new file mode 100644 index 0000000..ec9ea07 --- /dev/null +++ b/prisma/migrations/20251108064430_add_patient_documents_table/migration.sql @@ -0,0 +1,30 @@ +-- CreateEnum +CREATE TYPE "DocumentType" AS ENUM ('ID_CARD', 'CHIFA_CARD', 'PRESCRIPTION', 'OTHER'); + +-- CreateTable +CREATE TABLE "PatientDocument" ( + "id" TEXT NOT NULL, + "patientId" TEXT NOT NULL, + "quotationId" TEXT, + "type" "DocumentType" NOT NULL, + "fileName" TEXT NOT NULL, + "fileUrl" TEXT NOT NULL, + "fileSize" INTEGER, + "mimeType" TEXT, + "extractedData" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PatientDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PatientDocument_patientId_idx" ON "PatientDocument"("patientId"); + +-- CreateIndex +CREATE INDEX "PatientDocument_quotationId_idx" ON "PatientDocument"("quotationId"); + +-- AddForeignKey +ALTER TABLE "PatientDocument" ADD CONSTRAINT "PatientDocument_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PatientDocument" ADD CONSTRAINT "PatientDocument_quotationId_fkey" FOREIGN KEY ("quotationId") REFERENCES "Quotation"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20251121221533_add_quotation_code_field/migration.sql b/prisma/migrations/20251121221533_add_quotation_code_field/migration.sql new file mode 100644 index 0000000..cc7dca6 --- /dev/null +++ b/prisma/migrations/20251121221533_add_quotation_code_field/migration.sql @@ -0,0 +1,55 @@ +-- AlterTable +ALTER TABLE "Quotation" ADD COLUMN "code" TEXT; + +-- Create temporary function to generate sequential codes +DO $$ +DECLARE + quotation_record RECORD; + year_month TEXT; + sequence_num INT; + new_code TEXT; + current_year_month TEXT; + current_sequence INT; +BEGIN + current_year_month := ''; + current_sequence := 0; + + -- Loop through all existing quotations ordered by creation date + FOR quotation_record IN + SELECT id, "createdAt" + FROM "Quotation" + ORDER BY "createdAt" ASC + LOOP + -- Extract year-month from createdAt + year_month := TO_CHAR(quotation_record."createdAt", 'YYYYMM'); + + -- Reset sequence if we've moved to a new month + IF year_month != current_year_month THEN + current_year_month := year_month; + current_sequence := 0; + END IF; + + -- Increment sequence + current_sequence := current_sequence + 1; + + -- Generate code: CA-YYYYMM0000000001 + new_code := 'CA-' || year_month || LPAD(current_sequence::TEXT, 10, '0'); + + -- Update the quotation with generated code + UPDATE "Quotation" + SET code = new_code + WHERE id = quotation_record.id; + END LOOP; +END $$; + +-- Make code column NOT NULL after populating +ALTER TABLE "Quotation" ALTER COLUMN "code" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Quotation_code_key" ON "Quotation"("code"); + +-- CreateIndex +CREATE INDEX "Quotation_code_idx" ON "Quotation"("code"); + +-- CreateIndex +CREATE INDEX "Quotation_createdAt_idx" ON "Quotation"("createdAt"); diff --git a/prisma/migrations/20251122161956_add_doctor_receptionist_applicator/migration.sql b/prisma/migrations/20251122161956_add_doctor_receptionist_applicator/migration.sql new file mode 100644 index 0000000..6f6ea5b --- /dev/null +++ b/prisma/migrations/20251122161956_add_doctor_receptionist_applicator/migration.sql @@ -0,0 +1,122 @@ +-- CreateEnum +CREATE TYPE "EmployeeStatus" AS ENUM ('active', 'inactive', 'on_leave'); + +-- CreateEnum +CREATE TYPE "Shift" AS ENUM ('morning', 'afternoon', 'evening', 'night'); + +-- CreateTable +CREATE TABLE "Doctor" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "specialization" TEXT NOT NULL, + "licenseNumber" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "gender" TEXT NOT NULL, + "address" TEXT NOT NULL, + "city" TEXT NOT NULL, + "hireDate" TIMESTAMP(3) NOT NULL, + "status" "EmployeeStatus" NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Doctor_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Receptionist" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "gender" TEXT NOT NULL, + "address" TEXT NOT NULL, + "city" TEXT NOT NULL, + "hireDate" TIMESTAMP(3) NOT NULL, + "shift" "Shift" NOT NULL, + "status" "EmployeeStatus" NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Receptionist_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Applicator" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "specialization" TEXT NOT NULL, + "certificationNumber" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "gender" TEXT NOT NULL, + "address" TEXT NOT NULL, + "city" TEXT NOT NULL, + "hireDate" TIMESTAMP(3) NOT NULL, + "experienceYears" INTEGER NOT NULL, + "status" "EmployeeStatus" NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Applicator_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Doctor_userId_key" ON "Doctor"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Doctor_licenseNumber_key" ON "Doctor"("licenseNumber"); + +-- CreateIndex +CREATE INDEX "Doctor_userId_idx" ON "Doctor"("userId"); + +-- CreateIndex +CREATE INDEX "Doctor_email_idx" ON "Doctor"("email"); + +-- CreateIndex +CREATE INDEX "Doctor_status_idx" ON "Doctor"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "Receptionist_userId_key" ON "Receptionist"("userId"); + +-- CreateIndex +CREATE INDEX "Receptionist_userId_idx" ON "Receptionist"("userId"); + +-- CreateIndex +CREATE INDEX "Receptionist_email_idx" ON "Receptionist"("email"); + +-- CreateIndex +CREATE INDEX "Receptionist_status_idx" ON "Receptionist"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "Applicator_userId_key" ON "Applicator"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Applicator_certificationNumber_key" ON "Applicator"("certificationNumber"); + +-- CreateIndex +CREATE INDEX "Applicator_userId_idx" ON "Applicator"("userId"); + +-- CreateIndex +CREATE INDEX "Applicator_email_idx" ON "Applicator"("email"); + +-- CreateIndex +CREATE INDEX "Applicator_status_idx" ON "Applicator"("status"); + +-- AddForeignKey +ALTER TABLE "Doctor" ADD CONSTRAINT "Doctor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Receptionist" ADD CONSTRAINT "Receptionist_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Applicator" ADD CONSTRAINT "Applicator_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251123225312_restructure_employee_schema/migration.sql b/prisma/migrations/20251123225312_restructure_employee_schema/migration.sql new file mode 100644 index 0000000..a562eb1 --- /dev/null +++ b/prisma/migrations/20251123225312_restructure_employee_schema/migration.sql @@ -0,0 +1,170 @@ +-- Step 1: Restructure Employee table to use userId as primary key +-- Drop existing Employee table (it's not being used yet based on the old schema) +DROP TABLE IF EXISTS "Employee" CASCADE; + +-- Create new Employee table with userId as PK +CREATE TABLE "Employee" ( + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Employee_pkey" PRIMARY KEY ("userId") +); + +-- Create index on type +CREATE INDEX "Employee_type_idx" ON "Employee"("type"); + +-- Add foreign key to User +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Step 2: Restructure Doctor table - remove id, use userId as PK +-- First, we need to handle existing data if any +DO $$ +DECLARE + doctor_record RECORD; +BEGIN + -- Create temporary table to store old data + CREATE TEMP TABLE temp_doctors AS + SELECT * FROM "Doctor"; + + -- Drop the old table + DROP TABLE "Doctor"; + + -- Create new Doctor table with userId as PK + CREATE TABLE "Doctor" ( + "userId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "specialization" TEXT NOT NULL, + "licenseNumber" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "gender" TEXT NOT NULL, + "address" TEXT NOT NULL, + "city" TEXT NOT NULL, + "hireDate" TIMESTAMP(3) NOT NULL, + "status" "EmployeeStatus" NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Doctor_pkey" PRIMARY KEY ("userId") + ); + + -- Recreate indexes + CREATE UNIQUE INDEX "Doctor_licenseNumber_key" ON "Doctor"("licenseNumber"); + CREATE INDEX "Doctor_email_idx" ON "Doctor"("email"); + CREATE INDEX "Doctor_status_idx" ON "Doctor"("status"); + + -- Add foreign key + ALTER TABLE "Doctor" ADD CONSTRAINT "Doctor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + -- Migrate data from temp table if any exists + FOR doctor_record IN SELECT * FROM temp_doctors LOOP + INSERT INTO "Doctor" ("userId", "firstName", "lastName", "email", "phone", "specialization", "licenseNumber", "dateOfBirth", "gender", "address", "city", "hireDate", "status", "createdAt", "updatedAt") + VALUES (doctor_record."userId", doctor_record."firstName", doctor_record."lastName", doctor_record."email", doctor_record."phone", doctor_record."specialization", doctor_record."licenseNumber", doctor_record."dateOfBirth", doctor_record."gender", doctor_record."address", doctor_record."city", doctor_record."hireDate", doctor_record."status", doctor_record."createdAt", doctor_record."updatedAt"); + + -- Create corresponding Employee record + INSERT INTO "Employee" ("userId", "type", "createdAt", "updatedAt") + VALUES (doctor_record."userId", 'doctor', doctor_record."createdAt", doctor_record."updatedAt") + ON CONFLICT ("userId") DO NOTHING; + END LOOP; + + DROP TABLE temp_doctors; +END $$; + +-- Step 3: Restructure Receptionist table +DO $$ +DECLARE + receptionist_record RECORD; +BEGIN + CREATE TEMP TABLE temp_receptionists AS + SELECT * FROM "Receptionist"; + + DROP TABLE "Receptionist"; + + CREATE TABLE "Receptionist" ( + "userId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "gender" TEXT NOT NULL, + "address" TEXT NOT NULL, + "city" TEXT NOT NULL, + "hireDate" TIMESTAMP(3) NOT NULL, + "shift" "Shift" NOT NULL, + "status" "EmployeeStatus" NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Receptionist_pkey" PRIMARY KEY ("userId") + ); + + CREATE INDEX "Receptionist_email_idx" ON "Receptionist"("email"); + CREATE INDEX "Receptionist_status_idx" ON "Receptionist"("status"); + + ALTER TABLE "Receptionist" ADD CONSTRAINT "Receptionist_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + FOR receptionist_record IN SELECT * FROM temp_receptionists LOOP + INSERT INTO "Receptionist" ("userId", "firstName", "lastName", "email", "phone", "dateOfBirth", "gender", "address", "city", "hireDate", "shift", "status", "createdAt", "updatedAt") + VALUES (receptionist_record."userId", receptionist_record."firstName", receptionist_record."lastName", receptionist_record."email", receptionist_record."phone", receptionist_record."dateOfBirth", receptionist_record."gender", receptionist_record."address", receptionist_record."city", receptionist_record."hireDate", receptionist_record."shift", receptionist_record."status", receptionist_record."createdAt", receptionist_record."updatedAt"); + + INSERT INTO "Employee" ("userId", "type", "createdAt", "updatedAt") + VALUES (receptionist_record."userId", 'receptionist', receptionist_record."createdAt", receptionist_record."updatedAt") + ON CONFLICT ("userId") DO NOTHING; + END LOOP; + + DROP TABLE temp_receptionists; +END $$; + +-- Step 4: Restructure Applicator table +DO $$ +DECLARE + applicator_record RECORD; +BEGIN + CREATE TEMP TABLE temp_applicators AS + SELECT * FROM "Applicator"; + + DROP TABLE "Applicator"; + + CREATE TABLE "Applicator" ( + "userId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "specialization" TEXT NOT NULL, + "certificationNumber" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3) NOT NULL, + "gender" TEXT NOT NULL, + "address" TEXT NOT NULL, + "city" TEXT NOT NULL, + "hireDate" TIMESTAMP(3) NOT NULL, + "experienceYears" INTEGER NOT NULL, + "status" "EmployeeStatus" NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Applicator_pkey" PRIMARY KEY ("userId") + ); + + CREATE UNIQUE INDEX "Applicator_certificationNumber_key" ON "Applicator"("certificationNumber"); + CREATE INDEX "Applicator_email_idx" ON "Applicator"("email"); + CREATE INDEX "Applicator_status_idx" ON "Applicator"("status"); + + ALTER TABLE "Applicator" ADD CONSTRAINT "Applicator_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + FOR applicator_record IN SELECT * FROM temp_applicators LOOP + INSERT INTO "Applicator" ("userId", "firstName", "lastName", "email", "phone", "specialization", "certificationNumber", "dateOfBirth", "gender", "address", "city", "hireDate", "experienceYears", "status", "createdAt", "updatedAt") + VALUES (applicator_record."userId", applicator_record."firstName", applicator_record."lastName", applicator_record."email", applicator_record."phone", applicator_record."specialization", applicator_record."certificationNumber", applicator_record."dateOfBirth", applicator_record."gender", applicator_record."address", applicator_record."city", applicator_record."hireDate", applicator_record."experienceYears", applicator_record."status", applicator_record."createdAt", applicator_record."updatedAt"); + + INSERT INTO "Employee" ("userId", "type", "createdAt", "updatedAt") + VALUES (applicator_record."userId", 'applicator', applicator_record."createdAt", applicator_record."updatedAt") + ON CONFLICT ("userId") DO NOTHING; + END LOOP; + + DROP TABLE temp_applicators; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2053ba4..30d48c3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,12 +8,16 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique - password String - roles UserRole[] - createdAt DateTime @default(now()) - isSuperAdmin Boolean @default(false) + id String @id @default(uuid()) + email String @unique + password String + roles UserRole[] + createdAt DateTime @default(now()) + isSuperAdmin Boolean @default(false) + employee Employee? + doctor Doctor? + receptionist Receptionist? + applicator Applicator? } model Role { @@ -27,6 +31,7 @@ model Permission { id String @id @default(uuid()) action String resource String + description String rolePermissions RolePermission[] @@unique([action, resource], name: "action_resource") } @@ -48,17 +53,13 @@ model UserRole { } model Employee { - id String @id @default(uuid()) - firstName String - lastName String - nationalId String @db.Char(18) - dateOfBirth DateTime - placeOfBirth String - photos String[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Reverse relations + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // "doctor", "receptionist", "applicator", etc. + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Old fields for backward compatibility with existing relations diagnosesAssignedTo Diagnosis[] @relation("DiagnosisAssignedTo") diagnosesAssignedBy Diagnosis[] @relation("DiagnosisAssignedBy") fabricationOrdersAssignedTo FabricationOrder[] @relation("FabricationOrderAssignedTo") @@ -66,20 +67,137 @@ model Employee { executionOrdersAssignedTo ExecutionOrder[] @relation("ExecutionOrderAssignedTo") executionOrdersAssignedBy ExecutionOrder[] @relation("ExecutionOrderAssignedBy") Quotation Quotation[] + + @@index([type]) +} + +enum EmployeeStatus { + active + inactive + on_leave +} + +enum Shift { + morning + afternoon + evening + night +} + +model Doctor { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + firstName String + lastName String + email String + phone String + specialization String + licenseNumber String @unique + dateOfBirth DateTime + gender String // "Male" or "Female" + address String + city String + hireDate DateTime + status EmployeeStatus @default(active) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([email]) + @@index([status]) +} + +model Receptionist { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + firstName String + lastName String + email String + phone String + dateOfBirth DateTime + gender String // "Male" or "Female" + address String + city String + hireDate DateTime + shift Shift + status EmployeeStatus @default(active) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([email]) + @@index([status]) +} + +model Applicator { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + firstName String + lastName String + email String + phone String + specialization String + certificationNumber String @unique + dateOfBirth DateTime + gender String // "Male" or "Female" + address String + city String + hireDate DateTime + experienceYears Int + status EmployeeStatus @default(active) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([email]) + @@index([status]) } model Patient { - id String @id @default(uuid()) + id String @id @default(uuid()) firstName String lastName String - nationalId String @db.Char(18) - socialSecurityNumber String @db.Char(15) - insuranceType String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + email String + phone String + dateOfBirth DateTime + gender String // "Male" or "Female" + address String + city String + nationalId String + socialSecurityNumber String + insuranceType String? + bloodType String // A+, A-, B+, B-, AB+, AB-, O+, O- + allergies String[] @default([]) + currentMedications String[] @default([]) + emergencyContact Json // { name, relationship, phone } + documents PatientDocument[] // ID card, Chifa card, etc. + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt Quotation Quotation[] } +enum DocumentType { + ID_CARD + CHIFA_CARD + PRESCRIPTION + OTHER +} + +model PatientDocument { + id String @id @default(uuid()) + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + patientId String + quotation Quotation? @relation(fields: [quotationId], references: [id], onDelete: SetNull) + quotationId String? + type DocumentType + fileName String + fileUrl String + fileSize Int? + mimeType String? + extractedData Json? // MRZ data, OCR results, etc. + createdAt DateTime @default(now()) + + @@index([patientId]) + @@index([quotationId]) +} + model Device { id String @id @default(uuid()) name String @@ -115,18 +233,23 @@ enum QuotationStatus { } model Quotation { - id String @id @default(uuid()) - patient Patient @relation(fields: [patientId], references: [id]) + id String @id @default(uuid()) + code String @unique // CA-YYYYMM0000000001 format + patient Patient @relation(fields: [patientId], references: [id]) patientId String - createdBy Employee @relation(fields: [createdById], references: [id]) + createdBy Employee @relation(fields: [createdById], references: [userId]) createdById String - status QuotationStatus @default(created) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + status QuotationStatus @default(created) + documents PatientDocument[] // Related documents + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt Diagnosis Diagnosis? FabricationOrder FabricationOrder? ExecutionOrder ExecutionOrder[] FinalizedDevice FinalizedDevice? + + @@index([code]) + @@index([createdAt]) } model Diagnosis { @@ -137,10 +260,10 @@ model Diagnosis { medicalDevice String photos String[] patientTestimonial String? - assignedTo Employee? @relation("DiagnosisAssignedTo", fields: [assignedToId], references: [id]) + assignedTo Employee? @relation("DiagnosisAssignedTo", fields: [assignedToId], references: [userId]) assignedToId String? assignedAt DateTime? - assignedBy Employee? @relation("DiagnosisAssignedBy", fields: [assignedById], references: [id]) + assignedBy Employee? @relation("DiagnosisAssignedBy", fields: [assignedById], references: [userId]) assignedById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -156,10 +279,10 @@ model FabricationOrder { id String @id @default(uuid()) quotation Quotation @relation(fields: [quotationId], references: [id]) quotationId String @unique - assignedTo Employee? @relation("FabricationOrderAssignedTo", fields: [assignedToId], references: [id]) + assignedTo Employee? @relation("FabricationOrderAssignedTo", fields: [assignedToId], references: [userId]) assignedToId String? assignedAt DateTime? - assignedBy Employee? @relation("FabricationOrderAssignedBy", fields: [assignedById], references: [id]) + assignedBy Employee? @relation("FabricationOrderAssignedBy", fields: [assignedById], references: [userId]) assignedById String? status FabricationOrderStatus @default(created) createdAt DateTime @default(now()) @@ -201,9 +324,9 @@ model ExecutionOrder { deviceId String components ExecutionOrderComponents[] eta DateTime? - assignedTo Employee? @relation("ExecutionOrderAssignedTo", fields: [assignedToId], references: [id]) + assignedTo Employee? @relation("ExecutionOrderAssignedTo", fields: [assignedToId], references: [userId]) assignedToId String? - assignedBy Employee? @relation("ExecutionOrderAssignedBy", fields: [assignedById], references: [id]) + assignedBy Employee? @relation("ExecutionOrderAssignedBy", fields: [assignedById], references: [userId]) assignedById String? assignedAt DateTime status ExecutionOrderStatus @default(created) @@ -241,3 +364,38 @@ model UserGroup { userId Int @id groupName String } + +enum AuditAction { + CREATE + READ + UPDATE + DELETE + LOGIN + LOGOUT + ACCESS_DENIED + UNAUTHORIZED_ATTEMPT +} + +model AuditLog { + id String @id @default(uuid()) + userId String? // Nullable for anonymous attempts + userEmail String? + userRole String? + action AuditAction + resource String // e.g., "patient", "quotations", "device" + resourceId String? // ID of the affected resource + method String? // HTTP method (GET, POST, PUT, DELETE) + endpoint String? // API endpoint + ipAddress String? + userAgent String? + status String // success, denied, unauthorized, error + message String // Description of what happened + metadata Json? // Additional context (before/after values, etc.) + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([action]) + @@index([resource]) + @@index([createdAt]) + @@index([status]) +} diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts index 940b93a..82c3027 100644 --- a/src/analytics/analytics.controller.ts +++ b/src/analytics/analytics.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { AnalyticsService } from './analytics.service'; @ApiTags('analytics') +@ApiBearerAuth() @Controller('analytics') export class AnalyticsController { constructor(private readonly analyticsService: AnalyticsService) {} diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index 6befa55..77ec80e 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -17,7 +17,7 @@ export class AnalyticsService { include: { patient: true }, }).then(quotations => { const grouped = quotations.reduce((acc, q) => { - const type = q.patient.insuranceType; + const type = q.patient.insuranceType || 'None'; acc[type] = (acc[type] || 0) + 1; return acc; }, {} as Record); @@ -61,6 +61,13 @@ export class AnalyticsService { try { const employees = await this.prisma.employee.findMany({ include: { + user: { + select: { + doctor: { select: { firstName: true, lastName: true } }, + receptionist: { select: { firstName: true, lastName: true } }, + applicator: { select: { firstName: true, lastName: true } }, + }, + }, diagnosesAssignedTo: true, fabricationOrdersAssignedTo: true, executionOrdersAssignedTo: true, @@ -68,15 +75,32 @@ export class AnalyticsService { }, }); - return employees.map(emp => ({ - id: emp.id, - name: `${emp.firstName} ${emp.lastName}`, - diagnosesCount: emp.diagnosesAssignedTo.length, - fabricationOrdersCount: emp.fabricationOrdersAssignedTo.length, - executionOrdersCount: emp.executionOrdersAssignedTo.length, - quotationsCreated: emp.Quotation.length, - totalTasks: emp.diagnosesAssignedTo.length + emp.fabricationOrdersAssignedTo.length + emp.executionOrdersAssignedTo.length, - })); + return employees.map(emp => { + let firstName = 'Unknown'; + let lastName = 'User'; + + if (emp.user.doctor) { + firstName = emp.user.doctor.firstName; + lastName = emp.user.doctor.lastName; + } else if (emp.user.receptionist) { + firstName = emp.user.receptionist.firstName; + lastName = emp.user.receptionist.lastName; + } else if (emp.user.applicator) { + firstName = emp.user.applicator.firstName; + lastName = emp.user.applicator.lastName; + } + + return { + userId: emp.userId, + name: `${firstName} ${lastName}`, + type: emp.type, + diagnosesCount: emp.diagnosesAssignedTo.length, + fabricationOrdersCount: emp.fabricationOrdersAssignedTo.length, + executionOrdersCount: emp.executionOrdersAssignedTo.length, + quotationsCreated: emp.Quotation.length, + totalTasks: emp.diagnosesAssignedTo.length + emp.fabricationOrdersAssignedTo.length + emp.executionOrdersAssignedTo.length, + }; + }); } catch (error) { this.logger.error(`Failed to fetch employee performance: ${error.message}`, error.stack); throw error; diff --git a/src/app.module.ts b/src/app.module.ts index f9c48bb..a392e3b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { PrismaModule } from './prisma/prisma.module'; @@ -8,7 +9,7 @@ import { PermissionsModule } from './permissions/permissions.module'; import { ConfigModule } from '@nestjs/config'; import { EmployeesModule } from './employees/employees.module'; import { SeederModule } from './seeder/seeder.module'; -import { QuotationModule } from './quotation/quotation.module'; +import { QuotationsModule } from './quotations/quotations.module'; import { PatientsModule } from './patients/patients.module'; import { DiagnosisModule } from './diagnosis/diagnosis.module'; import { FabricationOrdersModule } from './fabrication-orders/fabrication-orders.module'; @@ -22,6 +23,12 @@ import { DashboardModule } from './dashboard/dashboard.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { SearchModule } from './search/search.module'; import { UploadsModule } from './uploads/uploads.module'; +import { AuditModule } from './audit/audit.module'; +import { AuditLoggingInterceptor } from './common/interceptors/audit-logging.interceptor'; +import { CommonModule } from './common/common.module'; +import { DoctorsModule } from './doctors/doctors.module'; +import { ReceptionistsModule } from './receptionists/receptionists.module'; +import { ApplicatorsModule } from './applicators/applicators.module'; @Module({ imports: [ @@ -29,12 +36,14 @@ import { UploadsModule } from './uploads/uploads.module'; isGlobal: true, }), PrismaModule, + CommonModule, + AuditModule, AuthModule, RolesModule, PermissionsModule, EmployeesModule, SeederModule, - QuotationModule, + QuotationsModule, PatientsModule, DiagnosisModule, FabricationOrdersModule, @@ -48,8 +57,17 @@ import { UploadsModule } from './uploads/uploads.module'; AnalyticsModule, SearchModule, UploadsModule, + DoctorsModule, + ReceptionistsModule, + ApplicatorsModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_INTERCEPTOR, + useClass: AuditLoggingInterceptor, + }, + ], }) export class AppModule {} diff --git a/src/applicators/applicators.controller.spec.ts b/src/applicators/applicators.controller.spec.ts new file mode 100644 index 0000000..cd3d50a --- /dev/null +++ b/src/applicators/applicators.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ApplicatorsController } from './applicators.controller'; + +describe('ApplicatorsController', () => { + let controller: ApplicatorsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ApplicatorsController], + }).compile(); + + controller = module.get(ApplicatorsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/applicators/applicators.controller.ts b/src/applicators/applicators.controller.ts new file mode 100644 index 0000000..0279418 --- /dev/null +++ b/src/applicators/applicators.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApplicatorsService } from './applicators.service'; +import { CreateApplicatorDto } from './dto/create-applicator.dto'; +import { UpdateApplicatorDto } from './dto/update-applicator.dto'; +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; +import { ResourceAccessGuard } from '@/guards/resource-access.guard'; +import { RequireResourceRoles } from '@/decorators/resource-roles.decorator'; + +@ApiTags('applicators') +@ApiBearerAuth() +@Controller('applicators') +@UseGuards(JwtAuthGuard) +export class ApplicatorsController { + constructor(private readonly applicatorsService: ApplicatorsService) {} + + @Post() + @UseGuards(ResourceAccessGuard) + @RequireResourceRoles('employee', 'create', ['admin']) + @ApiOperation({ summary: 'Create a new applicator (Admin only)' }) + @ApiResponse({ status: 201, description: 'Applicator created successfully' }) + @ApiResponse({ status: 403, description: 'Access denied - Only admin can create applicators' }) + @ApiResponse({ status: 409, description: 'Email or certification number already exists' }) + create(@Body() createApplicatorDto: CreateApplicatorDto) { + return this.applicatorsService.create(createApplicatorDto); + } + + @Get() + @ApiOperation({ summary: 'Get all applicators' }) + @ApiResponse({ status: 200, description: 'List of all applicators' }) + findAll() { + return this.applicatorsService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get applicator by ID' }) + @ApiResponse({ status: 200, description: 'Applicator details' }) + @ApiResponse({ status: 404, description: 'Applicator not found' }) + findOne(@Param('id') id: string) { + return this.applicatorsService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update applicator' }) + @ApiResponse({ status: 200, description: 'Applicator updated successfully' }) + @ApiResponse({ status: 404, description: 'Applicator not found' }) + @ApiResponse({ status: 409, description: 'Email or certification number already exists' }) + update(@Param('id') id: string, @Body() updateApplicatorDto: UpdateApplicatorDto) { + return this.applicatorsService.update(id, updateApplicatorDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete applicator' }) + @ApiResponse({ status: 200, description: 'Applicator deleted successfully' }) + @ApiResponse({ status: 404, description: 'Applicator not found' }) + remove(@Param('id') id: string) { + return this.applicatorsService.remove(id); + } +} diff --git a/src/applicators/applicators.module.ts b/src/applicators/applicators.module.ts new file mode 100644 index 0000000..3e01cf7 --- /dev/null +++ b/src/applicators/applicators.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ApplicatorsService } from './applicators.service'; +import { ApplicatorsController } from './applicators.controller'; +import { PrismaModule } from '@/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ApplicatorsController], + providers: [ApplicatorsService], + exports: [ApplicatorsService], +}) +export class ApplicatorsModule {} diff --git a/src/applicators/applicators.service.spec.ts b/src/applicators/applicators.service.spec.ts new file mode 100644 index 0000000..c7aeb8c --- /dev/null +++ b/src/applicators/applicators.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ApplicatorsService } from './applicators.service'; + +describe('ApplicatorsService', () => { + let service: ApplicatorsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ApplicatorsService], + }).compile(); + + service = module.get(ApplicatorsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/applicators/applicators.service.ts b/src/applicators/applicators.service.ts new file mode 100644 index 0000000..0587304 --- /dev/null +++ b/src/applicators/applicators.service.ts @@ -0,0 +1,195 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '@/prisma/prisma.service'; +import { CreateApplicatorDto } from './dto/create-applicator.dto'; +import { UpdateApplicatorDto } from './dto/update-applicator.dto'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class ApplicatorsService { + constructor(private readonly prisma: PrismaService) {} + + async create(createApplicatorDto: CreateApplicatorDto) { + // Check if email already exists + const existingUser = await this.prisma.user.findUnique({ + where: { email: createApplicatorDto.email }, + }); + if (existingUser) { + throw new ConflictException('Email already exists'); + } + + // Check if certification number already exists + const existingApplicator = await this.prisma.applicator.findUnique({ + where: { certificationNumber: createApplicatorDto.certificationNumber }, + }); + if (existingApplicator) { + throw new ConflictException('Certification number already exists'); + } + + // Generate password if not provided (email prefix + random 4 digits) + const password = createApplicatorDto.password || `${createApplicatorDto.email.split('@')[0]}${Math.floor(1000 + Math.random() * 9000)}`; + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Get applicator role + const applicatorRole = await this.prisma.role.findUnique({ + where: { name: 'applicator' }, + }); + if (!applicatorRole) { + throw new NotFoundException('Applicator role not found'); + } + + // Create user and applicator in transaction + const result = await this.prisma.$transaction(async (tx) => { + // Create user + const user = await tx.user.create({ + data: { + email: createApplicatorDto.email, + password: hashedPassword, + }, + }); + + // Assign applicator role + await tx.userRole.create({ + data: { + userId: user.id, + roleId: applicatorRole.id, + }, + }); + + // Create employee record + await tx.employee.create({ + data: { + userId: user.id, + type: 'applicator', + }, + }); + + // Create applicator profile + const applicator = await tx.applicator.create({ + data: { + userId: user.id, + firstName: createApplicatorDto.firstName, + lastName: createApplicatorDto.lastName, + email: createApplicatorDto.email, + phone: createApplicatorDto.phone, + specialization: createApplicatorDto.specialization, + certificationNumber: createApplicatorDto.certificationNumber, + dateOfBirth: new Date(createApplicatorDto.dateOfBirth), + gender: createApplicatorDto.gender, + address: createApplicatorDto.address, + city: createApplicatorDto.city, + hireDate: new Date(createApplicatorDto.hireDate), + experienceYears: createApplicatorDto.experienceYears, + status: createApplicatorDto.status || 'active', + }, + }); + + return applicator; + }); + + return result; + } + + async findAll() { + const applicators = await this.prisma.applicator.findMany({ + orderBy: { createdAt: 'desc' }, + }); + return applicators; + } + + async findOne(userId: string) { + const applicator = await this.prisma.applicator.findUnique({ + where: { userId }, + }); + if (!applicator) { + throw new NotFoundException(`Applicator with ID ${userId} not found`); + } + return applicator; + } + + async update(userId: string, updateApplicatorDto: UpdateApplicatorDto) { + const applicator = await this.prisma.applicator.findUnique({ + where: { userId }, + }); + if (!applicator) { + throw new NotFoundException(`Applicator with ID ${userId} not found`); + } + + // Check if email is being changed and already exists + if (updateApplicatorDto.email && updateApplicatorDto.email !== applicator.email) { + const existingUser = await this.prisma.user.findUnique({ + where: { email: updateApplicatorDto.email }, + }); + if (existingUser && existingUser.id !== applicator.userId) { + throw new ConflictException('Email already exists'); + } + } + + // Check if certification number is being changed and already exists + if (updateApplicatorDto.certificationNumber && updateApplicatorDto.certificationNumber !== applicator.certificationNumber) { + const existingApplicator = await this.prisma.applicator.findUnique({ + where: { certificationNumber: updateApplicatorDto.certificationNumber }, + }); + if (existingApplicator && existingApplicator.userId !== userId) { + throw new ConflictException('Certification number already exists'); + } + } + + const result = await this.prisma.$transaction(async (tx) => { + // Update user if email or password changed + if (updateApplicatorDto.email || updateApplicatorDto.password) { + const userData: any = {}; + if (updateApplicatorDto.email) { + userData.email = updateApplicatorDto.email; + } + if (updateApplicatorDto.password) { + userData.password = await bcrypt.hash(updateApplicatorDto.password, 10); + } + await tx.user.update({ + where: { id: applicator.userId }, + data: userData, + }); + } + + // Update applicator profile + const updateData: any = {}; + if (updateApplicatorDto.firstName) updateData.firstName = updateApplicatorDto.firstName; + if (updateApplicatorDto.lastName) updateData.lastName = updateApplicatorDto.lastName; + if (updateApplicatorDto.email) updateData.email = updateApplicatorDto.email; + if (updateApplicatorDto.phone) updateData.phone = updateApplicatorDto.phone; + if (updateApplicatorDto.specialization) updateData.specialization = updateApplicatorDto.specialization; + if (updateApplicatorDto.certificationNumber) updateData.certificationNumber = updateApplicatorDto.certificationNumber; + if (updateApplicatorDto.dateOfBirth) updateData.dateOfBirth = new Date(updateApplicatorDto.dateOfBirth); + if (updateApplicatorDto.gender) updateData.gender = updateApplicatorDto.gender; + if (updateApplicatorDto.address) updateData.address = updateApplicatorDto.address; + if (updateApplicatorDto.city) updateData.city = updateApplicatorDto.city; + if (updateApplicatorDto.hireDate) updateData.hireDate = new Date(updateApplicatorDto.hireDate); + if (updateApplicatorDto.experienceYears !== undefined) updateData.experienceYears = updateApplicatorDto.experienceYears; + if (updateApplicatorDto.status) updateData.status = updateApplicatorDto.status; + + return await tx.applicator.update({ + where: { userId }, + data: updateData, + }); + }); + + return result; + } + + async remove(userId: string) { + const applicator = await this.prisma.applicator.findUnique({ + where: { userId }, + }); + if (!applicator) { + throw new NotFoundException(`Applicator with ID ${userId} not found`); + } + + // Delete user (cascade will delete applicator profile and employee record) + await this.prisma.user.delete({ + where: { id: userId }, + }); + + return { message: 'Applicator deleted successfully' }; + } +} diff --git a/src/applicators/dto/create-applicator.dto.ts b/src/applicators/dto/create-applicator.dto.ts new file mode 100644 index 0000000..75154c7 --- /dev/null +++ b/src/applicators/dto/create-applicator.dto.ts @@ -0,0 +1,63 @@ +import { IsString, IsEmail, IsEnum, IsDateString, IsOptional, IsInt, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateApplicatorDto { + @ApiProperty() + @IsString() + firstName: string; + + @ApiProperty() + @IsString() + lastName: string; + + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsString() + phone: string; + + @ApiProperty() + @IsString() + specialization: string; + + @ApiProperty() + @IsString() + certificationNumber: string; + + @ApiProperty() + @IsDateString() + dateOfBirth: string; + + @ApiProperty({ enum: ['Male', 'Female'] }) + @IsEnum(['Male', 'Female']) + gender: 'Male' | 'Female'; + + @ApiProperty() + @IsString() + address: string; + + @ApiProperty() + @IsString() + city: string; + + @ApiProperty() + @IsDateString() + hireDate: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + experienceYears: number; + + @ApiPropertyOptional({ enum: ['active', 'inactive', 'on_leave'], default: 'active' }) + @IsOptional() + @IsEnum(['active', 'inactive', 'on_leave']) + status?: 'active' | 'inactive' | 'on_leave'; + + @ApiPropertyOptional({ description: 'Password for the user account (optional - will be auto-generated if not provided)' }) + @IsOptional() + @IsString() + password?: string; +} diff --git a/src/applicators/dto/update-applicator.dto.ts b/src/applicators/dto/update-applicator.dto.ts new file mode 100644 index 0000000..828dfcd --- /dev/null +++ b/src/applicators/dto/update-applicator.dto.ts @@ -0,0 +1,11 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateApplicatorDto } from './create-applicator.dto'; +import { IsOptional, IsString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateApplicatorDto extends PartialType(CreateApplicatorDto) { + @ApiPropertyOptional() + @IsOptional() + @IsString() + password?: string; +} diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts new file mode 100644 index 0000000..931475b --- /dev/null +++ b/src/audit/audit.controller.ts @@ -0,0 +1,71 @@ +import { Controller, Get, Query, UseGuards, Request } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; +import { Roles } from '@/decorators/roles.decorator'; +import { AuditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; + +@ApiTags('audit') +@ApiBearerAuth() +@Controller('audit') +@UseGuards(JwtAuthGuard) +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get('logs') + @Roles('admin') + @ApiOperation({ summary: 'Get audit logs (admin only)' }) + @ApiQuery({ name: 'userId', required: false }) + @ApiQuery({ name: 'resource', required: false }) + @ApiQuery({ name: 'action', required: false, enum: AuditAction }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getAuditLogs( + @Query('userId') userId?: string, + @Query('resource') resource?: string, + @Query('action') action?: AuditAction, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.auditService.getAuditLogs({ + userId, + resource, + action, + status, + page: page ? parseInt(page) : undefined, + limit: limit ? parseInt(limit) : undefined, + }); + } + + @Get('my-activity') + @ApiOperation({ summary: 'Get current user activity history' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getMyActivity(@Request() req, @Query('limit') limit?: string) { + return this.auditService.getUserActivity( + req.user.userId, + limit ? parseInt(limit) : 20, + ); + } + + @Get('resource-history') + @Roles('admin', 'receptionist', 'doctor') + @ApiOperation({ summary: 'Get resource history' }) + @ApiQuery({ name: 'resource', required: true }) + @ApiQuery({ name: 'resourceId', required: true }) + async getResourceHistory( + @Query('resource') resource: string, + @Query('resourceId') resourceId: string, + ) { + return this.auditService.getResourceHistory(resource, resourceId); + } + + @Get('security-events') + @Roles('admin') + @ApiOperation({ summary: 'Get security events (unauthorized attempts, access denied)' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getSecurityEvents(@Query('limit') limit?: string) { + return this.auditService.getSecurityEvents(limit ? parseInt(limit) : 100); + } +} diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts new file mode 100644 index 0000000..f5b1e91 --- /dev/null +++ b/src/audit/audit.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuditService } from './audit.service'; +import { AuditController } from './audit.controller'; +import { PrismaModule } from '@/prisma/prisma.module'; + +import { Global } from '@nestjs/common'; + +@Global() +@Module({ + imports: [PrismaModule], + controllers: [AuditController], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts new file mode 100644 index 0000000..d63a64e --- /dev/null +++ b/src/audit/audit.service.ts @@ -0,0 +1,199 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '@/prisma/prisma.service'; +import { AuditAction } from '@prisma/client'; + +export interface AuditLogData { + userId?: string; + userEmail?: string; + userRole?: string; + action: AuditAction; + resource: string; + resourceId?: string; + method?: string; + endpoint?: string; + ipAddress?: string; + userAgent?: string; + status: 'success' | 'denied' | 'unauthorized' | 'error'; + message: string; + metadata?: any; +} + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor(private readonly prisma: PrismaService) {} + + async log(data: AuditLogData): Promise { + try { + await this.prisma.auditLog.create({ + data: { + userId: data.userId, + userEmail: data.userEmail, + userRole: data.userRole, + action: data.action, + resource: data.resource, + resourceId: data.resourceId, + method: data.method, + endpoint: data.endpoint, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + status: data.status, + message: data.message, + metadata: data.metadata || {}, + }, + }); + + // Also log to console for immediate visibility + this.logger.log( + `[${data.status.toUpperCase()}] ${data.action} on ${data.resource} by ${data.userEmail || 'anonymous'}: ${data.message}` + ); + } catch (error) { + this.logger.error(`Failed to create audit log: ${error.message}`, error.stack); + } + } + + async logUnauthorizedAttempt( + userId: string, + userEmail: string, + userRole: string, + resource: string, + action: string, + endpoint: string, + ipAddress?: string, + ): Promise { + await this.log({ + userId, + userEmail, + userRole, + action: AuditAction.UNAUTHORIZED_ATTEMPT, + resource, + endpoint, + ipAddress, + status: 'unauthorized', + message: `User ${userEmail} (${userRole}) attempted to ${action} ${resource} without permission`, + metadata: { attemptedAction: action }, + }); + } + + async logAccessDenied( + userId: string, + userEmail: string, + userRole: string, + resource: string, + requiredRoles: string[], + endpoint: string, + ipAddress?: string, + ): Promise { + await this.log({ + userId, + userEmail, + userRole, + action: AuditAction.ACCESS_DENIED, + resource, + endpoint, + ipAddress, + status: 'denied', + message: `Access denied: User ${userEmail} (${userRole}) attempted to access ${resource}. Required roles: ${requiredRoles.join(', ')}`, + metadata: { requiredRoles, actualRole: userRole }, + }); + } + + async logSuccess( + userId: string, + userEmail: string, + action: AuditAction, + resource: string, + resourceId?: string, + message?: string, + metadata?: any, + ): Promise { + await this.log({ + userId, + userEmail, + action, + resource, + resourceId, + status: 'success', + message: message || `Successfully ${action.toLowerCase()} ${resource}`, + metadata, + }); + } + + async getAuditLogs(filters?: { + userId?: string; + resource?: string; + action?: AuditAction; + status?: string; + startDate?: Date; + endDate?: Date; + page?: number; + limit?: number; + }) { + const page = filters?.page || 1; + const limit = filters?.limit || 50; + const skip = (page - 1) * limit; + + const where: any = {}; + if (filters?.userId) where.userId = filters.userId; + if (filters?.resource) where.resource = filters.resource; + if (filters?.action) where.action = filters.action; + if (filters?.status) where.status = filters.status; + if (filters?.startDate || filters?.endDate) { + where.createdAt = {}; + if (filters.startDate) where.createdAt.gte = filters.startDate; + if (filters.endDate) where.createdAt.lte = filters.endDate; + } + + const [logs, total] = await Promise.all([ + this.prisma.auditLog.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.auditLog.count({ where }), + ]); + + return { + data: logs, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getUserActivity(userId: string, limit = 20) { + return this.prisma.auditLog.findMany({ + where: { userId }, + take: limit, + orderBy: { createdAt: 'desc' }, + }); + } + + async getResourceHistory(resource: string, resourceId: string) { + return this.prisma.auditLog.findMany({ + where: { + resource, + resourceId, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async getSecurityEvents(limit = 100) { + return this.prisma.auditLog.findMany({ + where: { + OR: [ + { action: AuditAction.UNAUTHORIZED_ATTEMPT }, + { action: AuditAction.ACCESS_DENIED }, + { status: 'denied' }, + { status: 'unauthorized' }, + ], + }, + take: limit, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 7a2f5a4..54e1921 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,19 +1,84 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Post, Get, UseGuards, Request } from '@nestjs/common'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; import { SignupDto } from './dto/signup.dto'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +@ApiTags('auth') @Controller('auth') export class AuthController { constructor(private authService: AuthService) {} @Post('signup') + @ApiOperation({ summary: 'Sign up a new user' }) + @ApiResponse({ status: 201, description: 'User created successfully' }) signup(@Body() dto: SignupDto) { return this.authService.signup(dto.email, dto.password); } @Post('login') + @ApiOperation({ summary: 'Login with email and password' }) + @ApiResponse({ status: 200, description: 'Login successful' }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) login(@Body() dto: LoginDto) { return this.authService.login(dto.email, dto.password); } + + @Get('who') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current authenticated user details (with employee profile if applicable)' }) + @ApiResponse({ + status: 200, + description: 'User details with employee profile if user is an employee', + schema: { + oneOf: [ + { + title: 'Employee User', + example: { + id: 'user-uuid', + email: 'doctor@example.com', + roles: ['doctor'], + isSuperAdmin: false, + createdAt: '2025-01-01T00:00:00.000Z', + isEmployee: true, + employeeType: 'doctor', + employee: { + type: 'doctor', + userId: 'user-uuid', + firstName: 'John', + lastName: 'Doe', + email: 'doctor@example.com', + phone: '+1234567890', + specialization: 'Orthopedics', + licenseNumber: 'DOC-12345', + dateOfBirth: '1980-01-15T00:00:00.000Z', + gender: 'Male', + address: '123 Main St', + city: 'Boston', + hireDate: '2020-01-01T00:00:00.000Z', + status: 'active', + createdAt: '2025-01-01T00:00:00.000Z' + } + } + }, + { + title: 'Regular User', + example: { + id: 'user-uuid', + email: 'user@example.com', + roles: ['user'], + isSuperAdmin: false, + createdAt: '2025-01-01T00:00:00.000Z', + isEmployee: false + } + } + ] + } + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + who(@Request() req) { + return this.authService.getAuthenticatedUserDetails(req.user.userId); + } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index b088fac..dab05c3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -57,4 +57,70 @@ export class AuthService { }), }; } + + async getAuthenticatedUserDetails(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + roles: { include: { role: true } }, + employee: true, + doctor: true, + receptionist: true, + applicator: true, + }, + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + const roles = user.roles?.map((r: any) => r.role.name) || []; + + // Check if user is an employee and get profile details + if (user.employee) { + let employeeProfile: any = null; + const employeeType = user.employee.type; + + if (employeeType === 'doctor' && user.doctor) { + const { userId: _, updatedAt, ...doctorData } = user.doctor; + employeeProfile = { + type: 'doctor', + ...doctorData, + }; + } else if (employeeType === 'receptionist' && user.receptionist) { + const { userId: _, updatedAt, ...receptionistData } = user.receptionist; + employeeProfile = { + type: 'receptionist', + ...receptionistData, + }; + } else if (employeeType === 'applicator' && user.applicator) { + const { userId: _, updatedAt, ...applicatorData } = user.applicator; + employeeProfile = { + type: 'applicator', + ...applicatorData, + }; + } + + return { + id: user.id, + email: user.email, + roles, + isSuperAdmin: user.isSuperAdmin, + createdAt: user.createdAt, + isEmployee: true, + employeeType: user.employee.type, + employee: employeeProfile, + }; + } + + // Return basic user details if not an employee + return { + id: user.id, + email: user.email, + roles, + isSuperAdmin: user.isSuperAdmin, + createdAt: user.createdAt, + isEmployee: false, + }; + } } diff --git a/src/common/common.module.ts b/src/common/common.module.ts new file mode 100644 index 0000000..aa50863 --- /dev/null +++ b/src/common/common.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { ResourceAccessGuard } from '@/guards/resource-access.guard'; +import { AuditModule } from '@/audit/audit.module'; + +@Global() +@Module({ + imports: [AuditModule], + providers: [ResourceAccessGuard], + exports: [ResourceAccessGuard], +}) +export class CommonModule {} diff --git a/src/common/interceptors/audit-logging.interceptor.ts b/src/common/interceptors/audit-logging.interceptor.ts new file mode 100644 index 0000000..6b2f79d --- /dev/null +++ b/src/common/interceptors/audit-logging.interceptor.ts @@ -0,0 +1,103 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap, catchError } from 'rxjs/operators'; +import { AuditService } from '@/audit/audit.service'; +import { AuditAction } from '@prisma/client'; + +@Injectable() +export class AuditLoggingInterceptor implements NestInterceptor { + constructor(private readonly auditService: AuditService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const { method, url, user, ip, body, params } = request; + const userAgent = request.get('user-agent') || ''; + + // Extract resource from URL (e.g., /api/patients -> patients) + const urlParts = url.split('/').filter(Boolean); + const resource = urlParts[0] || 'unknown'; + + // Map HTTP methods to audit actions + const actionMap: Record = { + POST: AuditAction.CREATE, + GET: AuditAction.READ, + PUT: AuditAction.UPDATE, + PATCH: AuditAction.UPDATE, + DELETE: AuditAction.DELETE, + }; + + const action = actionMap[method] || AuditAction.READ; + + // Skip logging for certain endpoints (health checks, etc.) + const skipEndpoints = ['/health', '/healthz', '/docs', '/favicon.ico']; + if (skipEndpoints.some((endpoint) => url.includes(endpoint))) { + return next.handle(); + } + + const startTime = Date.now(); + + return next.handle().pipe( + tap(async (response) => { + const duration = Date.now() - startTime; + + // Log successful operations + if (user) { + const resourceId = params?.id || body?.id || response?.id; + + await this.auditService.log({ + userId: user.userId, + userEmail: user.email, + userRole: user.roles?.join(', '), + action, + resource, + resourceId, + method, + endpoint: url, + ipAddress: ip, + userAgent, + status: 'success', + message: `${method} ${url} completed successfully`, + metadata: { + duration, + resourceId, + statusCode: 200, + }, + }); + } + }), + catchError(async (error) => { + const duration = Date.now() - startTime; + + // Log failed operations + if (user) { + await this.auditService.log({ + userId: user.userId, + userEmail: user.email, + userRole: user.roles?.join(', '), + action, + resource, + method, + endpoint: url, + ipAddress: ip, + userAgent, + status: 'error', + message: `${method} ${url} failed: ${error.message}`, + metadata: { + duration, + error: error.message, + statusCode: error.status || 500, + stack: error.stack, + }, + }); + } + + throw error; + }), + ); + } +} diff --git a/src/components/components.controller.ts b/src/components/components.controller.ts index 0d9e594..d9eb4cb 100644 --- a/src/components/components.controller.ts +++ b/src/components/components.controller.ts @@ -8,12 +8,13 @@ import { Delete, Query, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; import { ComponentsService } from './components.service'; import { CreateComponentDto } from './dto/create-component.dto'; import { UpdateComponentDto } from './dto/update-component.dto'; @ApiTags('components') +@ApiBearerAuth() @Controller('components') export class ComponentsController { constructor(private readonly componentsService: ComponentsService) {} diff --git a/src/dashboard/dashboard.controller.ts b/src/dashboard/dashboard.controller.ts index 2eacd77..e581feb 100644 --- a/src/dashboard/dashboard.controller.ts +++ b/src/dashboard/dashboard.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { DashboardService } from './dashboard.service'; @ApiTags('dashboard') +@ApiBearerAuth() @Controller('dashboard') export class DashboardController { constructor(private readonly dashboardService: DashboardService) {} diff --git a/src/decorators/resource-roles.decorator.ts b/src/decorators/resource-roles.decorator.ts new file mode 100644 index 0000000..c6d2a68 --- /dev/null +++ b/src/decorators/resource-roles.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; +import { RESOURCE_ROLES_KEY, ResourceRoleConfig } from '@/guards/resource-access.guard'; + +export const RequireResourceRoles = ( + resource: string, + action: string, + allowedRoles: string[], +) => SetMetadata(RESOURCE_ROLES_KEY, { resource, action, allowedRoles } as ResourceRoleConfig); diff --git a/src/devices/devices.controller.ts b/src/devices/devices.controller.ts index c34906c..cb96d8d 100644 --- a/src/devices/devices.controller.ts +++ b/src/devices/devices.controller.ts @@ -8,12 +8,13 @@ import { Delete, Query, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; import { DevicesService } from './devices.service'; import { CreateDeviceDto } from './dto/create-device.dto'; import { UpdateDeviceDto } from './dto/update-device.dto'; @ApiTags('devices') +@ApiBearerAuth() @Controller('devices') export class DevicesController { constructor(private readonly devicesService: DevicesService) {} diff --git a/src/diagnosis/diagnosis.controller.ts b/src/diagnosis/diagnosis.controller.ts index 247a56e..138b9fc 100644 --- a/src/diagnosis/diagnosis.controller.ts +++ b/src/diagnosis/diagnosis.controller.ts @@ -6,13 +6,14 @@ import { Patch, Param, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { DiagnosisService } from './diagnosis.service'; import { CreateDiagnosisDto } from './dto/create-diagnosis.dto'; import { UpdateDiagnosisDto } from './dto/update-diagnosis.dto'; import { AssignDiagnosisDto } from './dto/assign-diagnosis.dto'; @ApiTags('diagnosis') +@ApiBearerAuth() @Controller('diagnosis') export class DiagnosisController { constructor(private readonly diagnosisService: DiagnosisService) {} diff --git a/src/diagnosis/diagnosis.service.ts b/src/diagnosis/diagnosis.service.ts index 2779c56..3ee757b 100644 --- a/src/diagnosis/diagnosis.service.ts +++ b/src/diagnosis/diagnosis.service.ts @@ -121,8 +121,8 @@ export class DiagnosisService { // Validate employees exist const [assignedTo, assignedBy] = await Promise.all([ - this.prisma.employee.findUnique({ where: { id: assignDiagnosisDto.assignedToId } }), - this.prisma.employee.findUnique({ where: { id: assignDiagnosisDto.assignedById } }), + this.prisma.employee.findUnique({ where: { userId: assignDiagnosisDto.assignedToId } }), + this.prisma.employee.findUnique({ where: { userId: assignDiagnosisDto.assignedById } }), ]); if (!assignedTo) { diff --git a/src/doctors/doctors.controller.spec.ts b/src/doctors/doctors.controller.spec.ts new file mode 100644 index 0000000..e404667 --- /dev/null +++ b/src/doctors/doctors.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DoctorsController } from './doctors.controller'; + +describe('DoctorsController', () => { + let controller: DoctorsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DoctorsController], + }).compile(); + + controller = module.get(DoctorsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/doctors/doctors.controller.ts b/src/doctors/doctors.controller.ts new file mode 100644 index 0000000..bcbf4d4 --- /dev/null +++ b/src/doctors/doctors.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { DoctorsService } from './doctors.service'; +import { CreateDoctorDto } from './dto/create-doctor.dto'; +import { UpdateDoctorDto } from './dto/update-doctor.dto'; +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; +import { ResourceAccessGuard } from '@/guards/resource-access.guard'; +import { RequireResourceRoles } from '@/decorators/resource-roles.decorator'; + +@ApiTags('doctors') +@ApiBearerAuth() +@Controller('doctors') +@UseGuards(JwtAuthGuard) +export class DoctorsController { + constructor(private readonly doctorsService: DoctorsService) {} + + @Post() + @UseGuards(ResourceAccessGuard) + @RequireResourceRoles('employee', 'create', ['admin']) + @ApiOperation({ summary: 'Create a new doctor (Admin only)' }) + @ApiResponse({ status: 201, description: 'Doctor created successfully' }) + @ApiResponse({ status: 403, description: 'Access denied - Only admin can create doctors' }) + @ApiResponse({ status: 409, description: 'Email or license number already exists' }) + create(@Body() createDoctorDto: CreateDoctorDto) { + return this.doctorsService.create(createDoctorDto); + } + + @Get() + @ApiOperation({ summary: 'Get all doctors' }) + @ApiResponse({ status: 200, description: 'List of all doctors' }) + findAll() { + return this.doctorsService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get doctor by ID' }) + @ApiResponse({ status: 200, description: 'Doctor details' }) + @ApiResponse({ status: 404, description: 'Doctor not found' }) + findOne(@Param('id') id: string) { + return this.doctorsService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update doctor' }) + @ApiResponse({ status: 200, description: 'Doctor updated successfully' }) + @ApiResponse({ status: 404, description: 'Doctor not found' }) + @ApiResponse({ status: 409, description: 'Email or license number already exists' }) + update(@Param('id') id: string, @Body() updateDoctorDto: UpdateDoctorDto) { + return this.doctorsService.update(id, updateDoctorDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete doctor' }) + @ApiResponse({ status: 200, description: 'Doctor deleted successfully' }) + @ApiResponse({ status: 404, description: 'Doctor not found' }) + remove(@Param('id') id: string) { + return this.doctorsService.remove(id); + } +} diff --git a/src/doctors/doctors.module.ts b/src/doctors/doctors.module.ts new file mode 100644 index 0000000..3d8617c --- /dev/null +++ b/src/doctors/doctors.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DoctorsService } from './doctors.service'; +import { DoctorsController } from './doctors.controller'; +import { PrismaModule } from '@/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [DoctorsController], + providers: [DoctorsService], + exports: [DoctorsService], +}) +export class DoctorsModule {} diff --git a/src/quotation/quotation.service.spec.ts b/src/doctors/doctors.service.spec.ts similarity index 53% rename from src/quotation/quotation.service.spec.ts rename to src/doctors/doctors.service.spec.ts index a0f7b35..97db507 100644 --- a/src/quotation/quotation.service.spec.ts +++ b/src/doctors/doctors.service.spec.ts @@ -1,15 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { QuotationService } from './quotation.service'; +import { DoctorsService } from './doctors.service'; -describe('QuotationService', () => { - let service: QuotationService; +describe('DoctorsService', () => { + let service: DoctorsService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [QuotationService], + providers: [DoctorsService], }).compile(); - service = module.get(QuotationService); + service = module.get(DoctorsService); }); it('should be defined', () => { diff --git a/src/doctors/doctors.service.ts b/src/doctors/doctors.service.ts new file mode 100644 index 0000000..00543b7 --- /dev/null +++ b/src/doctors/doctors.service.ts @@ -0,0 +1,195 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '@/prisma/prisma.service'; +import { CreateDoctorDto } from './dto/create-doctor.dto'; +import { UpdateDoctorDto } from './dto/update-doctor.dto'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class DoctorsService { + constructor(private readonly prisma: PrismaService) {} + + async create(createDoctorDto: CreateDoctorDto) { + // Check if email already exists + const existingUser = await this.prisma.user.findUnique({ + where: { email: createDoctorDto.email }, + }); + if (existingUser) { + throw new ConflictException('Email already exists'); + } + + // Check if license number already exists + const existingDoctor = await this.prisma.doctor.findUnique({ + where: { licenseNumber: createDoctorDto.licenseNumber }, + }); + if (existingDoctor) { + throw new ConflictException('License number already exists'); + } + + // Generate password if not provided (email prefix + random 4 digits) + const password = createDoctorDto.password || `${createDoctorDto.email.split('@')[0]}${Math.floor(1000 + Math.random() * 9000)}`; + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Get doctor role + const doctorRole = await this.prisma.role.findUnique({ + where: { name: 'doctor' }, + }); + if (!doctorRole) { + throw new NotFoundException('Doctor role not found'); + } + + // Create user and doctor in transaction + const result = await this.prisma.$transaction(async (tx) => { + // Create user + const user = await tx.user.create({ + data: { + email: createDoctorDto.email, + password: hashedPassword, + }, + }); + + // Assign doctor role + await tx.userRole.create({ + data: { + userId: user.id, + roleId: doctorRole.id, + }, + }); + + // Create employee record + await tx.employee.create({ + data: { + userId: user.id, + type: 'doctor', + }, + }); + + // Create doctor profile + const doctor = await tx.doctor.create({ + data: { + userId: user.id, + firstName: createDoctorDto.firstName, + lastName: createDoctorDto.lastName, + email: createDoctorDto.email, + phone: createDoctorDto.phone, + specialization: createDoctorDto.specialization, + licenseNumber: createDoctorDto.licenseNumber, + dateOfBirth: new Date(createDoctorDto.dateOfBirth), + gender: createDoctorDto.gender, + address: createDoctorDto.address, + city: createDoctorDto.city, + hireDate: new Date(createDoctorDto.hireDate), + status: createDoctorDto.status || 'active', + }, + }); + + return doctor; + }); + + // Remove userId from response + const { userId, ...doctorData } = result; + return doctorData; + } + + async findAll() { + const doctors = await this.prisma.doctor.findMany({ + orderBy: { createdAt: 'desc' }, + }); + return doctors; + } + + async findOne(userId: string) { + const doctor = await this.prisma.doctor.findUnique({ + where: { userId }, + }); + if (!doctor) { + throw new NotFoundException(`Doctor with ID ${userId} not found`); + } + return doctor; + } + + async update(userId: string, updateDoctorDto: UpdateDoctorDto) { + const doctor = await this.prisma.doctor.findUnique({ + where: { userId }, + }); + if (!doctor) { + throw new NotFoundException(`Doctor with ID ${userId} not found`); + } + + // Check if email is being changed and already exists + if (updateDoctorDto.email && updateDoctorDto.email !== doctor.email) { + const existingUser = await this.prisma.user.findUnique({ + where: { email: updateDoctorDto.email }, + }); + if (existingUser && existingUser.id !== doctor.userId) { + throw new ConflictException('Email already exists'); + } + } + + // Check if license number is being changed and already exists + if (updateDoctorDto.licenseNumber && updateDoctorDto.licenseNumber !== doctor.licenseNumber) { + const existingDoctor = await this.prisma.doctor.findUnique({ + where: { licenseNumber: updateDoctorDto.licenseNumber }, + }); + if (existingDoctor && existingDoctor.userId !== userId) { + throw new ConflictException('License number already exists'); + } + } + + const result = await this.prisma.$transaction(async (tx) => { + // Update user if email or password changed + if (updateDoctorDto.email || updateDoctorDto.password) { + const userData: any = {}; + if (updateDoctorDto.email) { + userData.email = updateDoctorDto.email; + } + if (updateDoctorDto.password) { + userData.password = await bcrypt.hash(updateDoctorDto.password, 10); + } + await tx.user.update({ + where: { id: doctor.userId }, + data: userData, + }); + } + + // Update doctor profile + const updateData: any = {}; + if (updateDoctorDto.firstName) updateData.firstName = updateDoctorDto.firstName; + if (updateDoctorDto.lastName) updateData.lastName = updateDoctorDto.lastName; + if (updateDoctorDto.email) updateData.email = updateDoctorDto.email; + if (updateDoctorDto.phone) updateData.phone = updateDoctorDto.phone; + if (updateDoctorDto.specialization) updateData.specialization = updateDoctorDto.specialization; + if (updateDoctorDto.licenseNumber) updateData.licenseNumber = updateDoctorDto.licenseNumber; + if (updateDoctorDto.dateOfBirth) updateData.dateOfBirth = new Date(updateDoctorDto.dateOfBirth); + if (updateDoctorDto.gender) updateData.gender = updateDoctorDto.gender; + if (updateDoctorDto.address) updateData.address = updateDoctorDto.address; + if (updateDoctorDto.city) updateData.city = updateDoctorDto.city; + if (updateDoctorDto.hireDate) updateData.hireDate = new Date(updateDoctorDto.hireDate); + if (updateDoctorDto.status) updateData.status = updateDoctorDto.status; + + return await tx.doctor.update({ + where: { userId }, + data: updateData, + }); + }); + + return result; + } + + async remove(userId: string) { + const doctor = await this.prisma.doctor.findUnique({ + where: { userId }, + }); + if (!doctor) { + throw new NotFoundException(`Doctor with ID ${userId} not found`); + } + + // Delete user (cascade will delete doctor profile and employee record) + await this.prisma.user.delete({ + where: { id: userId }, + }); + + return { message: 'Doctor deleted successfully' }; + } +} diff --git a/src/doctors/dto/create-doctor.dto.ts b/src/doctors/dto/create-doctor.dto.ts new file mode 100644 index 0000000..26b71ae --- /dev/null +++ b/src/doctors/dto/create-doctor.dto.ts @@ -0,0 +1,58 @@ +import { IsString, IsEmail, IsEnum, IsDateString, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateDoctorDto { + @ApiProperty() + @IsString() + firstName: string; + + @ApiProperty() + @IsString() + lastName: string; + + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsString() + phone: string; + + @ApiProperty() + @IsString() + specialization: string; + + @ApiProperty() + @IsString() + licenseNumber: string; + + @ApiProperty() + @IsDateString() + dateOfBirth: string; + + @ApiProperty({ enum: ['Male', 'Female'] }) + @IsEnum(['Male', 'Female']) + gender: 'Male' | 'Female'; + + @ApiProperty() + @IsString() + address: string; + + @ApiProperty() + @IsString() + city: string; + + @ApiProperty() + @IsDateString() + hireDate: string; + + @ApiPropertyOptional({ enum: ['active', 'inactive', 'on_leave'], default: 'active' }) + @IsOptional() + @IsEnum(['active', 'inactive', 'on_leave']) + status?: 'active' | 'inactive' | 'on_leave'; + + @ApiPropertyOptional({ description: 'Password for the user account (optional - will be auto-generated if not provided)' }) + @IsOptional() + @IsString() + password?: string; +} diff --git a/src/doctors/dto/update-doctor.dto.ts b/src/doctors/dto/update-doctor.dto.ts new file mode 100644 index 0000000..995f875 --- /dev/null +++ b/src/doctors/dto/update-doctor.dto.ts @@ -0,0 +1,11 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateDoctorDto } from './create-doctor.dto'; +import { IsOptional, IsString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateDoctorDto extends PartialType(CreateDoctorDto) { + @ApiPropertyOptional() + @IsOptional() + @IsString() + password?: string; +} diff --git a/src/employees/employees.controller.ts b/src/employees/employees.controller.ts index 6ca5944..89a6b86 100644 --- a/src/employees/employees.controller.ts +++ b/src/employees/employees.controller.ts @@ -1,53 +1,37 @@ import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; -import { Roles } from '@/decorators/roles.decorator'; import { - Body, Controller, - Delete, Get, Param, - ParseIntPipe, - Patch, - Post, + Query, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth } from '@nestjs/swagger'; -import { CreateEmployeeDto } from './dto/create-employee.dto'; -import { UpdateEmployeeDto } from './dto/update-employee.dto'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { EmployeesService } from './employees.service'; +@ApiTags('employees') @Controller('employees') @ApiBearerAuth() @UseGuards(JwtAuthGuard) -@Roles('admin') export class EmployeesController { constructor(private readonly employeesService: EmployeesService) {} - @Post() - create(@Body() createEmployeeDto: CreateEmployeeDto) { - return this.employeesService.create(createEmployeeDto); - } - @Get() - findAll() { + @ApiOperation({ summary: 'Get all employees (doctors, receptionists, applicators)' }) + @ApiQuery({ name: 'type', required: false, enum: ['doctor', 'receptionist', 'applicator'], description: 'Filter by employee type' }) + @ApiResponse({ status: 200, description: 'List of all employees with their profiles' }) + findAll(@Query('type') type?: string) { + if (type) { + return this.employeesService.findByType(type); + } return this.employeesService.findAll(); } - @Get(':id') - findOne(@Param('id', ParseIntPipe) id: string) { - return this.employeesService.findOne(id); - } - - @Patch(':id') - update( - @Param('id', ParseIntPipe) id: string, - @Body() updateEmployeeDto: UpdateEmployeeDto, - ) { - return this.employeesService.update(id, updateEmployeeDto); - } - - @Delete(':id') - remove(@Param('id', ParseIntPipe) id: string) { - return this.employeesService.remove(id); + @Get(':userId') + @ApiOperation({ summary: 'Get employee by userId' }) + @ApiResponse({ status: 200, description: 'Employee details with profile' }) + @ApiResponse({ status: 404, description: 'Employee not found' }) + findOne(@Param('userId') userId: string) { + return this.employeesService.findOne(userId); } } diff --git a/src/employees/employees.service.ts b/src/employees/employees.service.ts index 5f25876..ead4a4b 100644 --- a/src/employees/employees.service.ts +++ b/src/employees/employees.service.ts @@ -1,61 +1,122 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { CreateEmployeeDto } from './dto/create-employee.dto'; -import { UpdateEmployeeDto } from './dto/update-employee.dto'; @Injectable() export class EmployeesService { constructor(private prisma: PrismaService) {} - async create(createEmployeeDto: CreateEmployeeDto) { - return this.prisma.employee.create({ - data: { - firstName: createEmployeeDto.firstName, - lastName: createEmployeeDto.lastName, - nationalId: createEmployeeDto.nationalId, - dateOfBirth: createEmployeeDto.dateOfBirth, - placeOfBirth: createEmployeeDto.placeOfBirth, - photos: createEmployeeDto.photos ?? [], + async findAll() { + // Get all employees with their type + const employees = await this.prisma.employee.findMany({ + include: { + user: { + select: { + email: true, + doctor: true, + receptionist: true, + applicator: true, + }, + }, }, + orderBy: { createdAt: 'desc' }, }); - } - async findAll() { - return this.prisma.employee.findMany(); - } + // Map to unified employee format + return employees.map((emp) => { + const type = emp.type; + let profile: any = null; - async findOne(id: string) { - const employee = await this.prisma.employee.findUnique({ - where: { id }, + if (type === 'doctor' && emp.user.doctor) { + profile = emp.user.doctor; + } else if (type === 'receptionist' && emp.user.receptionist) { + profile = emp.user.receptionist; + } else if (type === 'applicator' && emp.user.applicator) { + profile = emp.user.applicator; + } + + return { + userId: emp.userId, + type: emp.type, + email: emp.user.email, + createdAt: emp.createdAt, + ...profile, + }; }); - if (!employee) { - throw new NotFoundException(`Employee with ID ${id} not found`); - } - return employee; } - async update(id: string, updateEmployeeDto: UpdateEmployeeDto) { + async findOne(userId: string) { const employee = await this.prisma.employee.findUnique({ - where: { id }, + where: { userId }, + include: { + user: { + select: { + email: true, + doctor: true, + receptionist: true, + applicator: true, + }, + }, + }, }); + if (!employee) { - throw new NotFoundException(`Employee with ID ${id} not found`); + throw new NotFoundException(`Employee with ID ${userId} not found`); } - return this.prisma.employee.update({ - where: { id }, - data: updateEmployeeDto, - }); + + const type = employee.type; + let profile: any = null; + + if (type === 'doctor' && employee.user.doctor) { + profile = employee.user.doctor; + } else if (type === 'receptionist' && employee.user.receptionist) { + profile = employee.user.receptionist; + } else if (type === 'applicator' && employee.user.applicator) { + profile = employee.user.applicator; + } + + return { + userId: employee.userId, + type: employee.type, + email: employee.user.email, + createdAt: employee.createdAt, + ...profile, + }; } - async remove(id: string) { - const employee = await this.prisma.employee.findUnique({ - where: { id }, + async findByType(type: string) { + const employees = await this.prisma.employee.findMany({ + where: { type }, + include: { + user: { + select: { + email: true, + doctor: true, + receptionist: true, + applicator: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, }); - if (!employee) { - throw new NotFoundException(`Employee with ID ${id} not found`); - } - return this.prisma.employee.delete({ - where: { id }, + + return employees.map((emp) => { + let profile: any = null; + + if (type === 'doctor' && emp.user.doctor) { + profile = emp.user.doctor; + } else if (type === 'receptionist' && emp.user.receptionist) { + profile = emp.user.receptionist; + } else if (type === 'applicator' && emp.user.applicator) { + profile = emp.user.applicator; + } + + return { + userId: emp.userId, + type: emp.type, + email: emp.user.email, + createdAt: emp.createdAt, + ...profile, + }; }); } } diff --git a/src/execution-orders/execution-orders.controller.ts b/src/execution-orders/execution-orders.controller.ts index c6379ce..ccc223c 100644 --- a/src/execution-orders/execution-orders.controller.ts +++ b/src/execution-orders/execution-orders.controller.ts @@ -8,7 +8,7 @@ import { Query, Delete, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; import { ExecutionOrdersService } from './execution-orders.service'; import { CreateExecutionOrderDto } from './dto/create-execution-order.dto'; import { UpdateExecutionOrderDto } from './dto/update-execution-order.dto'; @@ -17,6 +17,7 @@ import { AddComponentsDto } from './dto/add-components.dto'; import { ExecutionOrderStatus } from '@prisma/client'; @ApiTags('execution-orders') +@ApiBearerAuth() @Controller('execution-orders') export class ExecutionOrdersController { constructor(private readonly executionOrdersService: ExecutionOrdersService) {} diff --git a/src/execution-orders/execution-orders.service.ts b/src/execution-orders/execution-orders.service.ts index b62e7e8..f682d28 100644 --- a/src/execution-orders/execution-orders.service.ts +++ b/src/execution-orders/execution-orders.service.ts @@ -247,8 +247,8 @@ export class ExecutionOrdersService { // Validate employees exist const [assignedTo, assignedBy] = await Promise.all([ - this.prisma.employee.findUnique({ where: { id: assignDto.assignedToId } }), - this.prisma.employee.findUnique({ where: { id: assignDto.assignedById } }), + this.prisma.employee.findUnique({ where: { userId: assignDto.assignedToId } }), + this.prisma.employee.findUnique({ where: { userId: assignDto.assignedById } }), ]); if (!assignedTo) { diff --git a/src/fabrication-orders/fabrication-orders.controller.ts b/src/fabrication-orders/fabrication-orders.controller.ts index 19d1d63..8ae78af 100644 --- a/src/fabrication-orders/fabrication-orders.controller.ts +++ b/src/fabrication-orders/fabrication-orders.controller.ts @@ -7,7 +7,7 @@ import { Param, Query, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; import { FabricationOrdersService } from './fabrication-orders.service'; import { CreateFabricationOrderDto } from './dto/create-fabrication-order.dto'; import { UpdateFabricationOrderDto } from './dto/update-fabrication-order.dto'; @@ -15,6 +15,7 @@ import { AssignFabricationOrderDto } from './dto/assign-fabrication-order.dto'; import { FabricationOrderStatus } from '@prisma/client'; @ApiTags('fabrication-orders') +@ApiBearerAuth() @Controller('fabrication-orders') export class FabricationOrdersController { constructor(private readonly fabricationOrdersService: FabricationOrdersService) {} diff --git a/src/fabrication-orders/fabrication-orders.service.ts b/src/fabrication-orders/fabrication-orders.service.ts index 49e2e53..234ca6c 100644 --- a/src/fabrication-orders/fabrication-orders.service.ts +++ b/src/fabrication-orders/fabrication-orders.service.ts @@ -216,8 +216,8 @@ export class FabricationOrdersService { // Validate employees exist const [assignedTo, assignedBy] = await Promise.all([ - this.prisma.employee.findUnique({ where: { id: assignDto.assignedToId } }), - this.prisma.employee.findUnique({ where: { id: assignDto.assignedById } }), + this.prisma.employee.findUnique({ where: { userId: assignDto.assignedToId } }), + this.prisma.employee.findUnique({ where: { userId: assignDto.assignedById } }), ]); if (!assignedTo) { diff --git a/src/finalized-devices/finalized-devices.controller.ts b/src/finalized-devices/finalized-devices.controller.ts index 731ce17..7d482e6 100644 --- a/src/finalized-devices/finalized-devices.controller.ts +++ b/src/finalized-devices/finalized-devices.controller.ts @@ -6,12 +6,13 @@ import { Patch, Param, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { FinalizedDevicesService } from './finalized-devices.service'; import { CreateFinalizedDeviceDto } from './dto/create-finalized-device.dto'; import { UpdateFinalizedDeviceDto } from './dto/update-finalized-device.dto'; @ApiTags('finalized-devices') +@ApiBearerAuth() @Controller('finalized-devices') export class FinalizedDevicesController { constructor(private readonly finalizedDevicesService: FinalizedDevicesService) {} diff --git a/src/guards/resource-access.guard.ts b/src/guards/resource-access.guard.ts new file mode 100644 index 0000000..a86ea7e --- /dev/null +++ b/src/guards/resource-access.guard.ts @@ -0,0 +1,67 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuditService } from '@/audit/audit.service'; + +export const RESOURCE_ROLES_KEY = 'resourceRoles'; + +export interface ResourceRoleConfig { + resource: string; + action: string; + allowedRoles: string[]; +} + +@Injectable() +export class ResourceAccessGuard implements CanActivate { + constructor( + private reflector: Reflector, + private auditService: AuditService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const config = this.reflector.get( + RESOURCE_ROLES_KEY, + context.getHandler(), + ); + + if (!config) { + return true; // No specific resource role requirements + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) { + return false; + } + + // Get user roles from the user object + const userRoles = user.roles || []; + const hasRequiredRole = config.allowedRoles.some((role) => + userRoles.includes(role), + ); + + if (!hasRequiredRole) { + // Log the unauthorized attempt + await this.auditService.logAccessDenied( + user.userId, + user.email, + userRoles.join(', '), + config.resource, + config.allowedRoles, + request.url, + request.ip, + ); + + throw new ForbiddenException( + `Access denied. Only ${config.allowedRoles.join(', ')} can ${config.action} ${config.resource}. Your role(s): ${userRoles.join(', ')}`, + ); + } + + return true; + } +} diff --git a/src/main.ts b/src/main.ts index 21360b3..19432ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,15 @@ import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + + // Enable CORS + app.enableCors({ + origin: true, // Allow all origins in development (configure for production) + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], + }); + const config = new DocumentBuilder() .setTitle('GPS Auth API') .setDescription('Authentication, RBAC, user & center management API') diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts index 69ebb4c..bb7867f 100644 --- a/src/notifications/notifications.controller.ts +++ b/src/notifications/notifications.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Patch, Post, Param, Body, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; @ApiTags('notifications') +@ApiBearerAuth() @Controller('notifications') export class NotificationsController { constructor(private readonly notificationsService: NotificationsService) {} diff --git a/src/patients/dto/create-patient.dto.ts b/src/patients/dto/create-patient.dto.ts index 58a068a..5f49ac1 100644 --- a/src/patients/dto/create-patient.dto.ts +++ b/src/patients/dto/create-patient.dto.ts @@ -1,40 +1,143 @@ -import { IsString, IsNotEmpty, Length } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsEmail, + IsDateString, + IsEnum, + IsOptional, + IsArray, + ValidateNested, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +class EmergencyContactDto { + @ApiProperty({ example: 'Billal Boumaad', description: 'Emergency contact name' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ example: 'Sibling', description: 'Relationship to patient' }) + @IsString() + @IsNotEmpty() + relationship: string; + + @ApiProperty({ example: '+2323232323', description: 'Emergency contact phone' }) + @IsString() + @IsNotEmpty() + phone: string; +} export class CreatePatientDto { - @ApiProperty({ example: 'John', description: 'Patient first name' }) + @ApiProperty({ example: 'Oussama', description: 'Patient first name' }) @IsString() @IsNotEmpty() firstName: string; - @ApiProperty({ example: 'Doe', description: 'Patient last name' }) + @ApiProperty({ example: 'Boumaad', description: 'Patient last name' }) @IsString() @IsNotEmpty() lastName: string; @ApiProperty({ - example: '123456789012345678', - description: 'National ID (18 characters)', + example: 'boumaadoussama@gmail.com', + description: 'Patient email', + }) + @IsEmail() + @IsNotEmpty() + email: string; + + @ApiProperty({ example: '+213553213139', description: 'Patient phone number' }) + @IsString() + @IsNotEmpty() + phone: string; + + @ApiProperty({ + example: '1992-07-31', + description: 'Date of birth (ISO format)', + }) + @IsDateString() + @IsNotEmpty() + dateOfBirth: string; + + @ApiProperty({ + example: 'Male', + description: 'Patient gender', + enum: ['Male', 'Female'], + }) + @IsEnum(['Male', 'Female']) + @IsNotEmpty() + gender: 'Male' | 'Female'; + + @ApiProperty({ example: 'Rue 1 er novembre', description: 'Patient address' }) + @IsString() + @IsNotEmpty() + address: string; + + @ApiProperty({ example: 'Khemisti', description: 'Patient city' }) + @IsString() + @IsNotEmpty() + city: string; + + @ApiProperty({ + example: '1013241234124134', + description: 'National ID', }) @IsString() @IsNotEmpty() - @Length(18, 18) nationalId: string; @ApiProperty({ - example: '123456789012345', - description: 'Social Security Number (15 characters)', + example: '234234234234', + description: 'Social Security Number', }) @IsString() @IsNotEmpty() - @Length(15, 15) socialSecurityNumber: string; - @ApiProperty({ - example: 'Public', - description: 'Insurance type (e.g., Public, Private)', + @ApiPropertyOptional({ + example: null, + description: 'Insurance type (nullable)', }) @IsString() + @IsOptional() + insuranceType?: string | null; + + @ApiProperty({ + example: 'A+', + description: 'Blood type', + enum: ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-'], + }) + @IsEnum(['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-']) + @IsNotEmpty() + bloodType: string; + + @ApiPropertyOptional({ + example: [], + description: 'List of allergies', + type: [String], + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + allergies?: string[]; + + @ApiPropertyOptional({ + example: [], + description: 'List of current medications', + type: [String], + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + currentMedications?: string[]; + + @ApiProperty({ + description: 'Emergency contact information', + type: EmergencyContactDto, + }) + @ValidateNested() + @Type(() => EmergencyContactDto) @IsNotEmpty() - insuranceType: string; + emergencyContact: EmergencyContactDto; } diff --git a/src/patients/patients.controller.ts b/src/patients/patients.controller.ts index 066370d..0cc3c52 100644 --- a/src/patients/patients.controller.ts +++ b/src/patients/patients.controller.ts @@ -7,20 +7,28 @@ import { Param, Delete, Query, + UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; import { PatientsService } from './patients.service'; import { CreatePatientDto } from './dto/create-patient.dto'; import { UpdatePatientDto } from './dto/update-patient.dto'; +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; +import { ResourceAccessGuard } from '@/guards/resource-access.guard'; +import { RequireResourceRoles } from '@/decorators/resource-roles.decorator'; @ApiTags('patients') +@ApiBearerAuth() @Controller('patients') export class PatientsController { constructor(private readonly patientsService: PatientsService) {} @Post() - @ApiOperation({ summary: 'Create a new patient' }) + @UseGuards(JwtAuthGuard, ResourceAccessGuard) + @RequireResourceRoles('patient', 'create', ['admin', 'receptionist']) + @ApiOperation({ summary: 'Create a new patient (Admin & Receptionist only)' }) @ApiResponse({ status: 201, description: 'Patient created successfully' }) + @ApiResponse({ status: 403, description: 'Access denied - Only admin and receptionist can create patients' }) create(@Body() createPatientDto: CreatePatientDto) { return this.patientsService.create(createPatientDto); } diff --git a/src/patients/patients.service.ts b/src/patients/patients.service.ts index 74b7acb..a53a9ad 100644 --- a/src/patients/patients.service.ts +++ b/src/patients/patients.service.ts @@ -13,8 +13,16 @@ export class PatientsService { async create(createPatientDto: CreatePatientDto): Promise { this.logger.log(`Creating patient: ${createPatientDto.firstName} ${createPatientDto.lastName}`); try { + const { allergies, currentMedications, emergencyContact, ...patientData } = createPatientDto; + const patient = await this.prisma.patient.create({ - data: createPatientDto, + data: { + ...patientData, + allergies: allergies || [], + currentMedications: currentMedications || [], + emergencyContact: emergencyContact as any, // Store as JSON + dateOfBirth: new Date(createPatientDto.dateOfBirth), + }, }); this.logger.log(`Patient created with ID: ${patient.id}`); return patient; @@ -35,6 +43,8 @@ export class PatientsService { { lastName: { contains: search, mode: 'insensitive' as const } }, { nationalId: { contains: search, mode: 'insensitive' as const } }, { socialSecurityNumber: { contains: search, mode: 'insensitive' as const } }, + { email: { contains: search, mode: 'insensitive' as const } }, + { phone: { contains: search, mode: 'insensitive' as const } }, ], } : {}; @@ -93,9 +103,21 @@ export class PatientsService { throw new NotFoundException(`Patient with ID ${id} not found`); } + // Handle nested objects and date conversion + const { emergencyContact, dateOfBirth, ...patientData } = updatePatientDto as any; + const updateData: any = { ...patientData }; + + if (emergencyContact) { + updateData.emergencyContact = emergencyContact; // Store as JSON + } + + if (dateOfBirth) { + updateData.dateOfBirth = new Date(dateOfBirth); + } + const updatedPatient = await this.prisma.patient.update({ where: { id }, - data: updatePatientDto, + data: updateData, }); this.logger.log(`Patient updated: ${id}`); diff --git a/src/permissions/permissions.controller.ts b/src/permissions/permissions.controller.ts index c15e527..18771ee 100644 --- a/src/permissions/permissions.controller.ts +++ b/src/permissions/permissions.controller.ts @@ -12,8 +12,8 @@ export class PermissionsController { constructor(private permissionsService: PermissionsService) {} @Post() - create(@Body() body: { action: string; resource: string }) { - return this.permissionsService.createPermission(body.action, body.resource); + create(@Body() body: { action: string; resource: string; description: string }) { + return this.permissionsService.createPermission(body.action, body.resource, body.description); } @Post('assign') diff --git a/src/permissions/permissions.service.ts b/src/permissions/permissions.service.ts index fd7b0ab..0bb9644 100644 --- a/src/permissions/permissions.service.ts +++ b/src/permissions/permissions.service.ts @@ -9,8 +9,8 @@ import { PrismaService } from '../prisma/prisma.service'; export class PermissionsService { constructor(private prisma: PrismaService) {} - createPermission(action: string, resource: string) { - return this.prisma.permission.create({ data: { action, resource } }); + createPermission(action: string, resource: string, description: string) { + return this.prisma.permission.create({ data: { action, resource, description } }); } assignPermissionToRole(permissionId: string, roleId: string) { diff --git a/src/quotation/dto/create-quotation.dto.ts b/src/quotation/dto/create-quotation.dto.ts deleted file mode 100644 index 23dcde7..0000000 --- a/src/quotation/dto/create-quotation.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { QuotationStatus } from '@prisma/client'; -import { IsEnum, IsUUID } from 'class-validator'; - -export class CreateQuotationDto { - @IsUUID() - patientId: string; - - @IsEnum(QuotationStatus) - status?: QuotationStatus = QuotationStatus.created; -} diff --git a/src/quotation/quotation.controller.spec.ts b/src/quotation/quotation.controller.spec.ts deleted file mode 100644 index 7386a16..0000000 --- a/src/quotation/quotation.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { QuotationController } from './quotation.controller'; -import { QuotationService } from './quotation.service'; - -describe('QuotationController', () => { - let controller: QuotationController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [QuotationController], - providers: [QuotationService], - }).compile(); - - controller = module.get(QuotationController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/quotation/quotation.controller.ts b/src/quotation/quotation.controller.ts deleted file mode 100644 index cf3bcef..0000000 --- a/src/quotation/quotation.controller.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; -import { QuotationsService } from './quotation.service'; -import { CreateQuotationDto } from './dto/create-quotation.dto'; -import { UpdateQuotationDto } from './dto/update-quotation.dto'; - -@Controller('quotation') -export class QuotationController { - constructor(private readonly quotationService: QuotationsService) {} - - @Post() - create(@Body() createQuotationDto: CreateQuotationDto) { - // TODO: Get createdById from authenticated user - const createdById = 'temp-employee-id'; - return this.quotationService.create(createQuotationDto, createdById); - } - - @Get() - findAll() { - return this.quotationService.findAll(); - } - - @Get(':id') - findOne(@Param('id') id: string) { - return this.quotationService.findOne(id); - } - - @Patch(':id') - update(@Param('id') id: string, @Body() updateQuotationDto: UpdateQuotationDto) { - return this.quotationService.update(id, updateQuotationDto); - } - - @Delete(':id') - remove(@Param('id') id: string) { - return this.quotationService.remove(id); - } -} diff --git a/src/quotations/dto/create-quotations.dto.ts b/src/quotations/dto/create-quotations.dto.ts new file mode 100644 index 0000000..16df49f --- /dev/null +++ b/src/quotations/dto/create-quotations.dto.ts @@ -0,0 +1,20 @@ +import { QuotationStatus } from '@prisma/client'; +import { IsEnum, IsUUID, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateQuotationDto { + @ApiProperty({ description: 'Patient UUID' }) + @IsUUID() + patientId: string; + + @ApiPropertyOptional({ + description: 'Employee UUID who creates the quotation (optional - defaults to authenticated user)', + }) + @IsOptional() + @IsUUID() + createdById?: string; + + @ApiPropertyOptional({ enum: QuotationStatus, default: QuotationStatus.created }) + @IsEnum(QuotationStatus) + status?: QuotationStatus = QuotationStatus.created; +} diff --git a/src/quotation/dto/update-quotation.dto.ts b/src/quotations/dto/update-quotations.dto.ts similarity index 85% rename from src/quotation/dto/update-quotation.dto.ts rename to src/quotations/dto/update-quotations.dto.ts index f4e0d3b..a212bc2 100644 --- a/src/quotation/dto/update-quotation.dto.ts +++ b/src/quotations/dto/update-quotations.dto.ts @@ -1,5 +1,5 @@ import { PartialType } from '@nestjs/swagger'; -import { CreateQuotationDto } from './create-quotation.dto'; +import { CreateQuotationDto } from './create-quotations.dto'; import { QuotationStatus } from '@prisma/client'; import { IsEnum, IsOptional, IsUUID } from 'class-validator'; diff --git a/src/quotation/entities/quotation.entity.ts b/src/quotations/entities/quotation.entity.ts similarity index 100% rename from src/quotation/entities/quotation.entity.ts rename to src/quotations/entities/quotation.entity.ts diff --git a/src/quotations/quotations.controller.spec.ts b/src/quotations/quotations.controller.spec.ts new file mode 100644 index 0000000..d786b9e --- /dev/null +++ b/src/quotations/quotations.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { QuotationsController } from './quotations.controller'; +import { QuotationsService } from './quotations.service'; + +describe('QuotationsController', () => { + let controller: QuotationsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [QuotationsController], + providers: [QuotationsService], + }).compile(); + + controller = module.get(QuotationsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/quotations/quotations.controller.ts b/src/quotations/quotations.controller.ts new file mode 100644 index 0000000..ac77656 --- /dev/null +++ b/src/quotations/quotations.controller.ts @@ -0,0 +1,104 @@ +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; +import { RequireResourceRoles } from '@/decorators/resource-roles.decorator'; +import { ResourceAccessGuard } from '@/guards/resource-access.guard'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Request, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CreateQuotationDto } from './dto/create-quotations.dto'; +import { UpdateQuotationDto } from './dto/update-quotations.dto'; +import { QuotationsService } from './quotations.service'; + +@ApiTags('quotations') +@ApiBearerAuth() +@Controller('quotations') +export class QuotationsController { + constructor(private readonly quotationsService: QuotationsService) {} + + @Post() + @UseGuards(JwtAuthGuard, ResourceAccessGuard) + @RequireResourceRoles('quotations', 'create', ['admin', 'receptionist']) + @ApiOperation({ + summary: 'Create a new quotation (Admin & Receptionist only)', + description: 'Creates a quotation with auto-generated sequential code (format: CA-YYYYMM0000000001). The createdById is automatically set from the authenticated user. If createdById is provided in body, it will be used instead.' + }) + @ApiResponse({ + status: 201, + description: 'Quotation created successfully with unique code', + schema: { + example: { + id: 'uuid-here', + code: 'CA-2025110000000001', + patientId: 'patient-uuid', + createdById: 'employee-uuid', + status: 'created', + createdAt: '2025-11-21T10:30:00.000Z', + updatedAt: '2025-11-21T10:30:00.000Z' + } + } + }) + @ApiResponse({ status: 403, description: 'Access denied - Only admin and receptionist can create quotations' }) + create(@Body() createQuotationDto: CreateQuotationDto, @Request() req) { + // Use createdById from body if provided, otherwise use authenticated user's ID + const createdById = createQuotationDto.createdById || req.user.userId; + return this.quotationsService.create(createQuotationDto, createdById); + } + + @Get('count') + count() { + return this.quotationsService.count(); + } + + @Get() + @ApiOperation({ summary: 'Get all quotations' }) + @ApiResponse({ + status: 200, + description: 'List of all quotations with codes', + schema: { + example: [{ + id: 'uuid-here', + code: 'CA-2025110000000001', + patientId: 'patient-uuid', + createdById: 'employee-uuid', + status: 'created', + createdAt: '2025-11-21T10:30:00.000Z', + updatedAt: '2025-11-21T10:30:00.000Z' + }] + } + }) + findAll() { + return this.quotationsService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get quotation by ID' }) + @ApiResponse({ + status: 200, + description: 'Quotation details including code', + schema: { + example: { + id: 'uuid-here', + code: 'CA-2025110000000001', + patientId: 'patient-uuid', + createdById: 'employee-uuid', + status: 'created', + createdAt: '2025-11-21T10:30:00.000Z', + updatedAt: '2025-11-21T10:30:00.000Z', + patient: {}, + createdBy: {} + } + } + }) + @ApiResponse({ status: 404, description: 'Quotation not found' }) + findOne(@Param('id') id: string) { + return this.quotationsService.findOne(id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateQuotationDto: UpdateQuotationDto) { + return this.quotationsService.update(id, updateQuotationDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.quotationsService.remove(id); + } +} diff --git a/src/quotation/quotation.module.ts b/src/quotations/quotations.module.ts similarity index 51% rename from src/quotation/quotation.module.ts rename to src/quotations/quotations.module.ts index db94986..d002b19 100644 --- a/src/quotation/quotation.module.ts +++ b/src/quotations/quotations.module.ts @@ -1,12 +1,12 @@ -import { Module } from '@nestjs/common'; -import { QuotationsService } from './quotation.service'; -import { QuotationController } from './quotation.controller'; import { PrismaModule } from '@/prisma/prisma.module'; +import { Module } from '@nestjs/common'; +import { QuotationsController } from './quotations.controller'; +import { QuotationsService } from './quotations.service'; @Module({ imports: [PrismaModule], - controllers: [QuotationController], + controllers: [QuotationsController], providers: [QuotationsService], exports: [QuotationsService], }) -export class QuotationModule {} +export class QuotationsModule {} diff --git a/src/quotations/quotations.service.spec.ts b/src/quotations/quotations.service.spec.ts new file mode 100644 index 0000000..43dc6ab --- /dev/null +++ b/src/quotations/quotations.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { QuotationsService } from './quotations.service'; + +describe('QuotationsService', () => { + let service: QuotationsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [QuotationsService], + }).compile(); + + service = module.get(QuotationsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/quotation/quotation.service.ts b/src/quotations/quotations.service.ts similarity index 68% rename from src/quotation/quotation.service.ts rename to src/quotations/quotations.service.ts index 227e3b5..b181a29 100644 --- a/src/quotation/quotation.service.ts +++ b/src/quotations/quotations.service.ts @@ -3,8 +3,8 @@ import { PrismaService } from '@/prisma/prisma.service'; import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Quotation } from '@prisma/client'; -import { CreateQuotationDto } from './dto/create-quotation.dto'; -import { UpdateQuotationDto } from './dto/update-quotation.dto'; +import { CreateQuotationDto } from './dto/create-quotations.dto'; +import { UpdateQuotationDto } from './dto/update-quotations.dto'; @Injectable() export class QuotationsService { @@ -12,14 +12,56 @@ export class QuotationsService { constructor(private readonly prisma: PrismaService) {} + /** + * Generate sequential quotation code in format: CA-YYYYMM0000000001 + * Example: CA-202501000000001 (January 2025, sequence 1) + */ + private async generateQuotationCode(): Promise { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const prefix = `CA-${year}${month}`; + + // Find the last quotation for this year-month + const lastQuotation = await this.prisma.quotation.findFirst({ + where: { + code: { + startsWith: prefix, + }, + }, + orderBy: { + code: 'desc', + }, + }); + + let sequence = 1; + if (lastQuotation && lastQuotation.code) { + // Extract the last 10 digits and increment + const lastSequence = parseInt(lastQuotation.code.slice(-10), 10); + sequence = lastSequence + 1; + } + + // Format: CA-YYYYMM0000000001 (10 digits for sequence) + const sequenceStr = String(sequence).padStart(10, '0'); + const code = `${prefix}${sequenceStr}`; + + this.logger.log(`Generated quotation code: ${code}`); + return code; + } + async create( createQuotationDto: CreateQuotationDto, - createdById: string, + userId: string, ): Promise { this.logger.log( - `Creating quotation for patient ID: ${createQuotationDto.patientId}`, + `Creating quotation for patient ID: ${createQuotationDto.patientId}, userId: ${userId}`, ); try { + // Validate userId is provided + if (!userId) { + throw new NotFoundException('User ID is required (should come from authentication token)'); + } + // Validate patient exists const patient = await this.prisma.patient.findUnique({ where: { id: createQuotationDto.patientId }, @@ -31,18 +73,37 @@ export class QuotationsService { ); } + // Validate that user is an employee (has employee record) + const employee = await this.prisma.employee.findUnique({ + where: { userId }, + }); + + if (!employee) { + throw new NotFoundException( + `User with ID ${userId} is not registered as an employee. Only employees can create quotations.`, + ); + } + + // Generate sequential code + const code = await this.generateQuotationCode(); + const quotation = await this.prisma.quotation.create({ data: { - patientId: createQuotationDto.patientId, - createdById, - status: createQuotationDto.status, + code, + patient: { + connect: { id: createQuotationDto.patientId } + }, + createdBy: { + connect: { userId } + }, + status: createQuotationDto.status || 'created', }, include: { patient: true, createdBy: true, }, }); - this.logger.log(`Quotation created with ID: ${quotation.id}`); + this.logger.log(`Quotation created with ID: ${quotation.id}, code: ${quotation.code}`); return quotation; } catch (error) { this.logger.error( @@ -53,6 +114,10 @@ export class QuotationsService { } } + async count(): Promise { + return await this.prisma.quotation.count(); + } + async findAll(): Promise { this.logger.log('Fetching all quotations'); try { diff --git a/src/receptionists/dto/create-receptionist.dto.ts b/src/receptionists/dto/create-receptionist.dto.ts new file mode 100644 index 0000000..051e6dd --- /dev/null +++ b/src/receptionists/dto/create-receptionist.dto.ts @@ -0,0 +1,54 @@ +import { IsString, IsEmail, IsEnum, IsDateString, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateReceptionistDto { + @ApiProperty() + @IsString() + firstName: string; + + @ApiProperty() + @IsString() + lastName: string; + + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsString() + phone: string; + + @ApiProperty() + @IsDateString() + dateOfBirth: string; + + @ApiProperty({ enum: ['Male', 'Female'] }) + @IsEnum(['Male', 'Female']) + gender: 'Male' | 'Female'; + + @ApiProperty() + @IsString() + address: string; + + @ApiProperty() + @IsString() + city: string; + + @ApiProperty() + @IsDateString() + hireDate: string; + + @ApiProperty({ enum: ['morning', 'afternoon', 'evening', 'night'] }) + @IsEnum(['morning', 'afternoon', 'evening', 'night']) + shift: 'morning' | 'afternoon' | 'evening' | 'night'; + + @ApiPropertyOptional({ enum: ['active', 'inactive', 'on_leave'], default: 'active' }) + @IsOptional() + @IsEnum(['active', 'inactive', 'on_leave']) + status?: 'active' | 'inactive' | 'on_leave'; + + @ApiPropertyOptional({ description: 'Password for the user account (optional - will be auto-generated if not provided)' }) + @IsOptional() + @IsString() + password?: string; +} diff --git a/src/receptionists/dto/update-receptionist.dto.ts b/src/receptionists/dto/update-receptionist.dto.ts new file mode 100644 index 0000000..5089b41 --- /dev/null +++ b/src/receptionists/dto/update-receptionist.dto.ts @@ -0,0 +1,11 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateReceptionistDto } from './create-receptionist.dto'; +import { IsOptional, IsString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateReceptionistDto extends PartialType(CreateReceptionistDto) { + @ApiPropertyOptional() + @IsOptional() + @IsString() + password?: string; +} diff --git a/src/receptionists/receptionists.controller.spec.ts b/src/receptionists/receptionists.controller.spec.ts new file mode 100644 index 0000000..439820b --- /dev/null +++ b/src/receptionists/receptionists.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReceptionistsController } from './receptionists.controller'; + +describe('ReceptionistsController', () => { + let controller: ReceptionistsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReceptionistsController], + }).compile(); + + controller = module.get(ReceptionistsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/receptionists/receptionists.controller.ts b/src/receptionists/receptionists.controller.ts new file mode 100644 index 0000000..727156b --- /dev/null +++ b/src/receptionists/receptionists.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ReceptionistsService } from './receptionists.service'; +import { CreateReceptionistDto } from './dto/create-receptionist.dto'; +import { UpdateReceptionistDto } from './dto/update-receptionist.dto'; +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; +import { ResourceAccessGuard } from '@/guards/resource-access.guard'; +import { RequireResourceRoles } from '@/decorators/resource-roles.decorator'; + +@ApiTags('receptionists') +@ApiBearerAuth() +@Controller('receptionists') +@UseGuards(JwtAuthGuard) +export class ReceptionistsController { + constructor(private readonly receptionistsService: ReceptionistsService) {} + + @Post() + @UseGuards(ResourceAccessGuard) + @RequireResourceRoles('employee', 'create', ['admin']) + @ApiOperation({ summary: 'Create a new receptionist (Admin only)' }) + @ApiResponse({ status: 201, description: 'Receptionist created successfully' }) + @ApiResponse({ status: 403, description: 'Access denied - Only admin can create receptionists' }) + @ApiResponse({ status: 409, description: 'Email already exists' }) + create(@Body() createReceptionistDto: CreateReceptionistDto) { + return this.receptionistsService.create(createReceptionistDto); + } + + @Get() + @ApiOperation({ summary: 'Get all receptionists' }) + @ApiResponse({ status: 200, description: 'List of all receptionists' }) + findAll() { + return this.receptionistsService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get receptionist by ID' }) + @ApiResponse({ status: 200, description: 'Receptionist details' }) + @ApiResponse({ status: 404, description: 'Receptionist not found' }) + findOne(@Param('id') id: string) { + return this.receptionistsService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update receptionist' }) + @ApiResponse({ status: 200, description: 'Receptionist updated successfully' }) + @ApiResponse({ status: 404, description: 'Receptionist not found' }) + @ApiResponse({ status: 409, description: 'Email already exists' }) + update(@Param('id') id: string, @Body() updateReceptionistDto: UpdateReceptionistDto) { + return this.receptionistsService.update(id, updateReceptionistDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete receptionist' }) + @ApiResponse({ status: 200, description: 'Receptionist deleted successfully' }) + @ApiResponse({ status: 404, description: 'Receptionist not found' }) + remove(@Param('id') id: string) { + return this.receptionistsService.remove(id); + } +} diff --git a/src/receptionists/receptionists.module.ts b/src/receptionists/receptionists.module.ts new file mode 100644 index 0000000..c6aa2e7 --- /dev/null +++ b/src/receptionists/receptionists.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ReceptionistsService } from './receptionists.service'; +import { ReceptionistsController } from './receptionists.controller'; +import { PrismaModule } from '@/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ReceptionistsController], + providers: [ReceptionistsService], + exports: [ReceptionistsService], +}) +export class ReceptionistsModule {} diff --git a/src/receptionists/receptionists.service.spec.ts b/src/receptionists/receptionists.service.spec.ts new file mode 100644 index 0000000..06537d4 --- /dev/null +++ b/src/receptionists/receptionists.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReceptionistsService } from './receptionists.service'; + +describe('ReceptionistsService', () => { + let service: ReceptionistsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReceptionistsService], + }).compile(); + + service = module.get(ReceptionistsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/receptionists/receptionists.service.ts b/src/receptionists/receptionists.service.ts new file mode 100644 index 0000000..119059d --- /dev/null +++ b/src/receptionists/receptionists.service.ts @@ -0,0 +1,167 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '@/prisma/prisma.service'; +import { CreateReceptionistDto } from './dto/create-receptionist.dto'; +import { UpdateReceptionistDto } from './dto/update-receptionist.dto'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class ReceptionistsService { + constructor(private readonly prisma: PrismaService) {} + + async create(createReceptionistDto: CreateReceptionistDto) { + // Check if email already exists + const existingUser = await this.prisma.user.findUnique({ + where: { email: createReceptionistDto.email }, + }); + if (existingUser) { + throw new ConflictException('Email already exists'); + } + + // Generate password if not provided (email prefix + random 4 digits) + const password = createReceptionistDto.password || `${createReceptionistDto.email.split('@')[0]}${Math.floor(1000 + Math.random() * 9000)}`; + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Get receptionist role + const receptionistRole = await this.prisma.role.findUnique({ + where: { name: 'receptionist' }, + }); + if (!receptionistRole) { + throw new NotFoundException('Receptionist role not found'); + } + + // Create user and receptionist in transaction + const result = await this.prisma.$transaction(async (tx) => { + // Create user + const user = await tx.user.create({ + data: { + email: createReceptionistDto.email, + password: hashedPassword, + }, + }); + + // Assign receptionist role + await tx.userRole.create({ + data: { + userId: user.id, + roleId: receptionistRole.id, + }, + }); + + // Create receptionist profile + const receptionist = await tx.receptionist.create({ + data: { + userId: user.id, + firstName: createReceptionistDto.firstName, + lastName: createReceptionistDto.lastName, + email: createReceptionistDto.email, + phone: createReceptionistDto.phone, + dateOfBirth: new Date(createReceptionistDto.dateOfBirth), + gender: createReceptionistDto.gender, + address: createReceptionistDto.address, + city: createReceptionistDto.city, + hireDate: new Date(createReceptionistDto.hireDate), + shift: createReceptionistDto.shift, + status: createReceptionistDto.status || 'active', + }, + }); + + return receptionist; + }); + + // Remove userId from response + const { userId, ...receptionistData } = result; + return receptionistData; + } + + async findAll() { + const receptionists = await this.prisma.receptionist.findMany({ + orderBy: { createdAt: 'desc' }, + }); + return receptionists; + } + + async findOne(userId: string) { + const receptionist = await this.prisma.receptionist.findUnique({ + where: { userId }, + }); + if (!receptionist) { + throw new NotFoundException(`Receptionist with ID ${userId} not found`); + } + return receptionist; + } + + async update(userId: string, updateReceptionistDto: UpdateReceptionistDto) { + const receptionist = await this.prisma.receptionist.findUnique({ + where: { userId }, + }); + if (!receptionist) { + throw new NotFoundException(`Receptionist with ID ${userId} not found`); + } + + // Check if email is being changed and already exists + if (updateReceptionistDto.email && updateReceptionistDto.email !== receptionist.email) { + const existingUser = await this.prisma.user.findUnique({ + where: { email: updateReceptionistDto.email }, + }); + if (existingUser && existingUser.id !== receptionist.userId) { + throw new ConflictException('Email already exists'); + } + } + + const result = await this.prisma.$transaction(async (tx) => { + // Update user if email or password changed + if (updateReceptionistDto.email || updateReceptionistDto.password) { + const userData: any = {}; + if (updateReceptionistDto.email) { + userData.email = updateReceptionistDto.email; + } + if (updateReceptionistDto.password) { + userData.password = await bcrypt.hash(updateReceptionistDto.password, 10); + } + await tx.user.update({ + where: { id: receptionist.userId }, + data: userData, + }); + } + + // Update receptionist profile + const updateData: any = {}; + if (updateReceptionistDto.firstName) updateData.firstName = updateReceptionistDto.firstName; + if (updateReceptionistDto.lastName) updateData.lastName = updateReceptionistDto.lastName; + if (updateReceptionistDto.email) updateData.email = updateReceptionistDto.email; + if (updateReceptionistDto.phone) updateData.phone = updateReceptionistDto.phone; + if (updateReceptionistDto.dateOfBirth) updateData.dateOfBirth = new Date(updateReceptionistDto.dateOfBirth); + if (updateReceptionistDto.gender) updateData.gender = updateReceptionistDto.gender; + if (updateReceptionistDto.address) updateData.address = updateReceptionistDto.address; + if (updateReceptionistDto.city) updateData.city = updateReceptionistDto.city; + if (updateReceptionistDto.hireDate) updateData.hireDate = new Date(updateReceptionistDto.hireDate); + if (updateReceptionistDto.shift) updateData.shift = updateReceptionistDto.shift; + if (updateReceptionistDto.status) updateData.status = updateReceptionistDto.status; + + return await tx.receptionist.update({ + where: { userId }, + data: updateData, + }); + }); + + return result; + } + + async remove(userId: string) { + const receptionist = await this.prisma.receptionist.findUnique({ + where: { userId }, + }); + if (!receptionist) { + throw new NotFoundException(`Receptionist with ID ${userId} not found`); + } + + // Delete user (cascade will delete receptionist profile and employee record) + await this.prisma.user.delete({ + where: { id: userId }, + }); + + return { message: 'Receptionist deleted successfully' }; + } +} diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index 514ea6c..cc14761 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; import { SearchService } from './search.service'; @ApiTags('search') +@ApiBearerAuth() @Controller('search') export class SearchController { constructor(private readonly searchService: SearchService) {} diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 2a57166..12f8ceb 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -52,15 +52,7 @@ export class SearchService { }, take: 5, }), - this.prisma.employee.findMany({ - where: { - OR: [ - { firstName: { contains: query, mode: 'insensitive' } }, - { lastName: { contains: query, mode: 'insensitive' } }, - ], - }, - take: 5, - }), + this.searchEmployees(query), ]); return { patients, quotations, devices, components, employees }; @@ -69,4 +61,45 @@ export class SearchService { throw error; } } + + private async searchEmployees(query: string) { + const [doctors, receptionists, applicators] = await Promise.all([ + this.prisma.doctor.findMany({ + where: { + OR: [ + { firstName: { contains: query, mode: 'insensitive' } }, + { lastName: { contains: query, mode: 'insensitive' } }, + { email: { contains: query, mode: 'insensitive' } }, + ], + }, + take: 5, + }), + this.prisma.receptionist.findMany({ + where: { + OR: [ + { firstName: { contains: query, mode: 'insensitive' } }, + { lastName: { contains: query, mode: 'insensitive' } }, + { email: { contains: query, mode: 'insensitive' } }, + ], + }, + take: 5, + }), + this.prisma.applicator.findMany({ + where: { + OR: [ + { firstName: { contains: query, mode: 'insensitive' } }, + { lastName: { contains: query, mode: 'insensitive' } }, + { email: { contains: query, mode: 'insensitive' } }, + ], + }, + take: 5, + }), + ]); + + return [ + ...doctors.map(d => ({ ...d, type: 'doctor' })), + ...receptionists.map(r => ({ ...r, type: 'receptionist' })), + ...applicators.map(a => ({ ...a, type: 'applicator' })) + ].slice(0, 5); + } } diff --git a/src/seeder/seeder.service.ts b/src/seeder/seeder.service.ts index 7285915..faa2a2f 100644 --- a/src/seeder/seeder.service.ts +++ b/src/seeder/seeder.service.ts @@ -13,6 +13,7 @@ interface SeedPermission { id?: string; // Optional since it’s generated in create action: string; resource: string; + description: string; } interface SeedRole { @@ -64,6 +65,7 @@ export class SeederService implements OnModuleInit { id: string; action: string; resource: string; + description: string; }[] = []; for (const permission of seedData.permissions) { const record = await this.prisma.permission.upsert({ @@ -73,11 +75,14 @@ export class SeederService implements OnModuleInit { resource: permission.resource, }, }, - update: {}, + update: { + description: permission.description, + }, create: { id: uuidv4(), action: permission.action, resource: permission.resource, + description: permission.description, }, }); permissionRecords.push(record); @@ -121,13 +126,23 @@ export class SeederService implements OnModuleInit { }) .filter((p) => p); // Filter out undefined permissions - await this.prisma.rolePermission.createMany({ - data: permissionsToConnect.map((permission) => ({ - id: uuidv4(), - roleId: role.id, - permissionId: permission!.id, - })), - }); + // Create role-permission associations + for (const permission of permissionsToConnect) { + try { + await this.prisma.rolePermission.create({ + data: { + id: uuidv4(), + roleId: role.id, + permissionId: permission!.id, + }, + }); + } catch (error) { + // Ignore duplicate key errors (relationship already exists) + if (!error.code || error.code !== 'P2002') { + throw error; + } + } + } } } @@ -149,30 +164,49 @@ export class SeederService implements OnModuleInit { .map((roleName) => roleRecords.find((r) => r.name === roleName)) .filter((r): r is { id: string; name: string } => !!r); // Type guard to filter out undefined roles - await this.prisma.user.upsert({ + // Check if user exists + const existingUser = await this.prisma.user.findUnique({ where: { email: user.email }, - update: { - isSuperAdmin: user.isSuperAdmin, - roles: { - create: rolesToConnect.map((role) => ({ - id: uuidv4(), - roleId: role.id, - })), + include: { roles: true }, + }); + + if (existingUser) { + // Delete existing user roles first + await this.prisma.userRole.deleteMany({ + where: { userId: existingUser.id }, + }); + + // Update user and create new role associations + await this.prisma.user.update({ + where: { email: user.email }, + data: { + isSuperAdmin: user.isSuperAdmin, + password: hashedPassword, + roles: { + create: rolesToConnect.map((role) => ({ + id: uuidv4(), + roleId: role.id, + })), + }, }, - }, - create: { - id: uuidv4(), - email: user.email, - password: hashedPassword, - isSuperAdmin: user.isSuperAdmin, - roles: { - create: rolesToConnect.map((role) => ({ - id: uuidv4(), - roleId: role.id, - })), + }); + } else { + // Create new user with roles + await this.prisma.user.create({ + data: { + id: uuidv4(), + email: user.email, + password: hashedPassword, + isSuperAdmin: user.isSuperAdmin, + roles: { + create: rolesToConnect.map((role) => ({ + id: uuidv4(), + roleId: role.id, + })), + }, }, - }, - }); + }); + } } this.logger.log('Seeding completed successfully from JSON file'); diff --git a/src/uploads/dto/upload-documents.dto.ts b/src/uploads/dto/upload-documents.dto.ts new file mode 100644 index 0000000..170d666 --- /dev/null +++ b/src/uploads/dto/upload-documents.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UploadDocumentsDto { + @ApiProperty({ type: 'string', format: 'binary', description: 'ID Card image' }) + idCard: any; + + @ApiProperty({ type: 'string', format: 'binary', description: 'Chifa (Social Security) Card image' }) + chifaCard: any; + + @ApiProperty({ type: 'string', format: 'binary', description: 'Prescription image', required: false }) + prescription?: any; + + @ApiProperty({ description: 'Patient ID (required)' }) + @IsUUID() + patientId: string; +} + +export class MRZExtractionResult { + success: boolean; + documentType: string; + extractedData: { + // ID Card fields + firstName?: string; + lastName?: string; + nationalId?: string; + dateOfBirth?: string; + gender?: string; + address?: string; + city?: string; + + // Chifa Card fields + socialSecurityNumber?: string; + + // Raw MRZ data + mrzLines?: string[]; + rawText?: string; + }; + confidence?: number; + fileUrl?: string; +} diff --git a/src/uploads/uploads.controller.ts b/src/uploads/uploads.controller.ts index d0391bd..f623e04 100644 --- a/src/uploads/uploads.controller.ts +++ b/src/uploads/uploads.controller.ts @@ -6,16 +6,124 @@ import { Param, UseInterceptors, UploadedFiles, + Body, + Query, } from '@nestjs/common'; -import { FilesInterceptor } from '@nestjs/platform-express'; -import { ApiTags, ApiOperation, ApiResponse, ApiConsumes } from '@nestjs/swagger'; +import { FileFieldsInterceptor, FilesInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiResponse, ApiConsumes, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { UploadsService } from './uploads.service'; +import { UploadDocumentsDto } from './dto/upload-documents.dto'; @ApiTags('uploads') +@ApiBearerAuth() @Controller('uploads') export class UploadsController { constructor(private readonly uploadsService: UploadsService) {} + @Post('extract-mrz') + @ApiOperation({ + summary: 'Extract MRZ data from ID card and Chifa card', + description: 'Upload ID card, Chifa card (social security), and optional prescription for a patient. Returns extracted patient data from MRZ. Documents are saved and linked to the patient. Quotation should be created separately after this step.', + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + idCard: { + type: 'string', + format: 'binary', + description: 'ID Card image (required)', + }, + chifaCard: { + type: 'string', + format: 'binary', + description: 'Chifa/Social Security Card image (required)', + }, + prescription: { + type: 'string', + format: 'binary', + description: 'Prescription image (optional)', + }, + patientId: { + type: 'string', + description: 'Patient UUID (required - must create patient first)', + }, + }, + required: ['idCard', 'chifaCard', 'patientId'], + }, + }) + @ApiResponse({ + status: 201, + description: 'MRZ data extracted successfully', + schema: { + type: 'object', + properties: { + idCardData: { + type: 'object', + properties: { + success: { type: 'boolean' }, + documentType: { type: 'string' }, + extractedData: { + type: 'object', + properties: { + firstName: { type: 'string' }, + lastName: { type: 'string' }, + nationalId: { type: 'string' }, + dateOfBirth: { type: 'string' }, + gender: { type: 'string' }, + address: { type: 'string' }, + city: { type: 'string' }, + }, + }, + fileUrl: { type: 'string' }, + }, + }, + chifaCardData: { + type: 'object', + properties: { + success: { type: 'boolean' }, + documentType: { type: 'string' }, + extractedData: { + type: 'object', + properties: { + socialSecurityNumber: { type: 'string' }, + }, + }, + fileUrl: { type: 'string' }, + }, + }, + prescriptionUrl: { type: 'string' }, + patientData: { + type: 'object', + description: 'Combined patient data ready for patient creation', + }, + savedDocuments: { + type: 'array', + description: 'Array of saved document records (if patientId was provided)', + }, + }, + }, + }) + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'idCard', maxCount: 1 }, + { name: 'chifaCard', maxCount: 1 }, + { name: 'prescription', maxCount: 1 }, + ]), + ) + async extractMRZ( + @UploadedFiles() + files: { + idCard?: Express.Multer.File[]; + chifaCard?: Express.Multer.File[]; + prescription?: Express.Multer.File[]; + }, + @Body('patientId') patientId: string, + ) { + return this.uploadsService.extractMRZ(files, patientId); + } + @Post('photos') @ApiOperation({ summary: 'Upload multiple photos' }) @ApiConsumes('multipart/form-data') @@ -25,16 +133,43 @@ export class UploadsController { return this.uploadsService.uploadPhotos(files); } + @Get('patient/:patientId') + @ApiOperation({ summary: 'Get all documents for a patient' }) + @ApiResponse({ status: 200, description: 'List of patient documents' }) + getPatientDocuments(@Param('patientId') patientId: string) { + return this.uploadsService.getPatientDocuments(patientId); + } + + @Get('quotation/:quotationId') + @ApiOperation({ summary: 'Get all documents for a quotation' }) + @ApiResponse({ status: 200, description: 'List of quotation documents' }) + getQuotationDocuments(@Param('quotationId') quotationId: string) { + return this.uploadsService.getQuotationDocuments(quotationId); + } + @Get(':id') - @ApiOperation({ summary: 'Get file by ID' }) - @ApiResponse({ status: 200, description: 'File details' }) + @ApiOperation({ summary: 'Get document by ID' }) + @ApiResponse({ status: 200, description: 'Document details' }) getFile(@Param('id') id: string) { return this.uploadsService.getFile(id); } + @Post('link-to-quotation') + @ApiOperation({ + summary: 'Link existing documents to a quotation', + description: 'After creating a quotation, link previously uploaded patient documents to it', + }) + @ApiResponse({ status: 200, description: 'Documents linked successfully' }) + linkDocumentsToQuotation( + @Body('documentIds') documentIds: string[], + @Body('quotationId') quotationId: string, + ) { + return this.uploadsService.linkDocumentsToQuotation(documentIds, quotationId); + } + @Delete(':id') - @ApiOperation({ summary: 'Delete file by ID' }) - @ApiResponse({ status: 200, description: 'File deleted successfully' }) + @ApiOperation({ summary: 'Delete document by ID' }) + @ApiResponse({ status: 200, description: 'Document deleted successfully' }) deleteFile(@Param('id') id: string) { return this.uploadsService.deleteFile(id); } diff --git a/src/uploads/uploads.module.ts b/src/uploads/uploads.module.ts index 6352bb4..3d0e250 100644 --- a/src/uploads/uploads.module.ts +++ b/src/uploads/uploads.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { UploadsService } from './uploads.service'; import { UploadsController } from './uploads.controller'; +import { PrismaModule } from '@/prisma/prisma.module'; @Module({ + imports: [PrismaModule], controllers: [UploadsController], providers: [UploadsService], exports: [UploadsService], diff --git a/src/uploads/uploads.service.ts b/src/uploads/uploads.service.ts index 11dc8b7..3463c9b 100644 --- a/src/uploads/uploads.service.ts +++ b/src/uploads/uploads.service.ts @@ -1,20 +1,321 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '@/prisma/prisma.service'; +import { DocumentType } from '@prisma/client'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { MRZExtractionResult } from './dto/upload-documents.dto'; +import { createWorker } from 'tesseract.js'; +import { parse } from 'mrz'; +import * as sharp from 'sharp'; @Injectable() export class UploadsService { private readonly logger = new Logger(UploadsService.name); + private readonly uploadDir = path.join(process.cwd(), 'uploads'); + + constructor(private readonly prisma: PrismaService) { + this.ensureUploadDir(); + } + + private async ensureUploadDir() { + try { + await fs.mkdir(this.uploadDir, { recursive: true }); + } catch (error) { + this.logger.error(`Failed to create upload directory: ${error.message}`); + } + } + + /** + * Extract MRZ (Machine Readable Zone) data from ID card using OCR + */ + private async extractMRZFromIDCard(file: Express.Multer.File): Promise { + this.logger.log(`Extracting MRZ from ID card: ${file.originalname}`); + + try { + // Preprocess image for better OCR + const processedBuffer = await sharp(file.buffer) + .grayscale() + .normalize() + .threshold(128) + .toBuffer(); + + // Initialize Tesseract worker + const worker = await createWorker('eng'); + + // Perform OCR + const { data: { text } } = await worker.recognize(processedBuffer); + await worker.terminate(); + + this.logger.log('OCR completed, analyzing text for MRZ...'); + + // Extract MRZ lines (typically last 2-3 lines of ID card) + const lines = text.split('\n').map(line => line.trim()).filter(Boolean); + const mrzLines = lines.slice(-3); // Get last 3 lines, MRZ is usually at bottom + + this.logger.log(`Found ${mrzLines.length} potential MRZ lines`); + + // Try to parse MRZ + try { + // MRZ format for ID cards (TD1, TD2, TD3) + const mrzString = mrzLines.join('\n'); + const parsedMRZ = parse(mrzString); + + if (parsedMRZ && parsedMRZ.valid) { + this.logger.log('Successfully parsed MRZ data'); + + // Extract data from parsed MRZ + return { + success: true, + documentType: 'ID_CARD', + extractedData: { + firstName: parsedMRZ.fields.firstName || '', + lastName: parsedMRZ.fields.lastName || '', + nationalId: parsedMRZ.fields.documentNumber || '', + dateOfBirth: this.formatMRZDate(parsedMRZ.fields.birthDate), + gender: parsedMRZ.fields.sex === 'M' ? 'Male' : parsedMRZ.fields.sex === 'F' ? 'Female' : '', + nationality: parsedMRZ.fields.nationality || 'DZA', + expiryDate: this.formatMRZDate(parsedMRZ.fields.expirationDate), + documentNumber: parsedMRZ.fields.documentNumber || '', + mrzLines: mrzLines, + rawText: text, + }, + confidence: 1.0, + }; + } + } catch (mrzError: any) { + this.logger.warn(`MRZ parsing failed: ${mrzError.message}, falling back to text extraction`); + } + + // Fallback: Extract data from raw text if MRZ parsing fails + return { + success: false, + documentType: 'ID_CARD', + extractedData: { + rawText: text, + mrzLines: mrzLines, + message: 'MRZ parsing failed. Please verify the document quality and try again.', + }, + confidence: 0.5, + }; + } catch (error) { + this.logger.error(`Failed to extract MRZ: ${error.message}`, error.stack); + return { + success: false, + documentType: 'ID_CARD', + extractedData: { + error: error.message, + message: 'OCR extraction failed. Please ensure the image is clear and well-lit.', + }, + confidence: 0, + }; + } + } + + /** + * Extract Social Security Number from Chifa card using OCR + */ + private async extractChifaCardData(file: Express.Multer.File): Promise { + this.logger.log(`Extracting data from Chifa card: ${file.originalname}`); + + try { + // Preprocess image + const processedBuffer = await sharp(file.buffer) + .grayscale() + .normalize() + .threshold(128) + .toBuffer(); + + // Initialize Tesseract worker + const worker = await createWorker('eng+fra'); // English and French for Algerian cards + + // Perform OCR + const { data: { text } } = await worker.recognize(processedBuffer); + await worker.terminate(); + + this.logger.log('OCR completed for Chifa card'); + + // Extract social security number + // Algerian SSN format: typically 15 digits + const ssnMatch = text.match(/\b\d{15}\b/); + const socialSecurityNumber = ssnMatch ? ssnMatch[0] : ''; + + // Extract other identifiable numbers + const numbers = text.match(/\d+/g) || []; + + return { + success: !!socialSecurityNumber, + documentType: 'CHIFA_CARD', + extractedData: { + socialSecurityNumber: socialSecurityNumber || '', + cardNumber: numbers.length > 0 ? numbers[0] : '', + rawText: text, + message: socialSecurityNumber + ? 'Social security number extracted successfully' + : 'Could not extract SSN automatically. Please enter manually.', + }, + confidence: socialSecurityNumber ? 0.85 : 0.3, + }; + } catch (error) { + this.logger.error(`Failed to extract Chifa data: ${error.message}`, error.stack); + return { + success: false, + documentType: 'CHIFA_CARD', + extractedData: { + error: error.message, + message: 'OCR extraction failed. Please ensure the image is clear.', + }, + confidence: 0, + }; + } + } + + /** + * Format MRZ date (YYMMDD) to ISO format + */ + private formatMRZDate(mrzDate: string | null | undefined): string { + if (!mrzDate || mrzDate.length !== 6) return ''; + + const year = parseInt(mrzDate.substring(0, 2)); + const month = mrzDate.substring(2, 4); + const day = mrzDate.substring(4, 6); + + // Assume 2000s for years < 50, 1900s for >= 50 + const fullYear = year < 50 ? 2000 + year : 1900 + year; + + return `${fullYear}-${month}-${day}`; + } + + /** + * Save file to disk and return URL + */ + private async saveFile(file: Express.Multer.File): Promise<{ url: string; path: string }> { + const timestamp = Date.now(); + const sanitizedFilename = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + const filename = `${timestamp}-${sanitizedFilename}`; + const filepath = path.join(this.uploadDir, filename); + + await fs.writeFile(filepath, file.buffer); + + return { + url: `/uploads/${filename}`, + path: filepath, + }; + } + + /** + * Main endpoint for extracting MRZ from uploaded documents + */ + async extractMRZ(files: { + idCard?: Express.Multer.File[]; + chifaCard?: Express.Multer.File[]; + prescription?: Express.Multer.File[]; + }, patientId: string): Promise<{ + idCardData?: MRZExtractionResult; + chifaCardData?: MRZExtractionResult; + prescriptionUrl?: string; + patientData: any; + savedDocuments: any[]; + }> { + this.logger.log(`Starting MRZ extraction process for patient: ${patientId}`); + + if (!patientId) { + throw new BadRequestException('Patient ID is required'); + } + + let idCardData: any = null; + let chifaCardData: any = null; + let prescriptionUrl: string | null = null; + const savedDocuments: any[] = []; + + // Process ID Card + if (files.idCard && files.idCard[0]) { + const idCardFile = files.idCard[0]; + idCardData = await this.extractMRZFromIDCard(idCardFile); + const { url } = await this.saveFile(idCardFile); + idCardData.fileUrl = url; + + const doc = await this.prisma.patientDocument.create({ + data: { + patientId, + type: DocumentType.ID_CARD, + fileName: idCardFile.originalname, + fileUrl: url, + fileSize: idCardFile.size, + mimeType: idCardFile.mimetype, + extractedData: idCardData.extractedData, + }, + }); + savedDocuments.push(doc); + } + + // Process Chifa Card + if (files.chifaCard && files.chifaCard[0]) { + const chifaCardFile = files.chifaCard[0]; + chifaCardData = await this.extractChifaCardData(chifaCardFile); + const { url } = await this.saveFile(chifaCardFile); + chifaCardData.fileUrl = url; + + const doc = await this.prisma.patientDocument.create({ + data: { + patientId, + type: DocumentType.CHIFA_CARD, + fileName: chifaCardFile.originalname, + fileUrl: url, + fileSize: chifaCardFile.size, + mimeType: chifaCardFile.mimetype, + extractedData: chifaCardData.extractedData, + }, + }); + savedDocuments.push(doc); + } + + // Process Prescription (no extraction, just save) + if (files.prescription && files.prescription[0]) { + const prescriptionFile = files.prescription[0]; + const { url } = await this.saveFile(prescriptionFile); + prescriptionUrl = url; + + const doc = await this.prisma.patientDocument.create({ + data: { + patientId, + type: DocumentType.PRESCRIPTION, + fileName: prescriptionFile.originalname, + fileUrl: url, + fileSize: prescriptionFile.size, + mimeType: prescriptionFile.mimetype, + }, + }); + savedDocuments.push(doc); + } + + // Combine extracted data for patient creation/update + const patientData = { + firstName: idCardData?.extractedData?.firstName || '', + lastName: idCardData?.extractedData?.lastName || '', + nationalId: idCardData?.extractedData?.nationalId || '', + dateOfBirth: idCardData?.extractedData?.dateOfBirth || '', + gender: idCardData?.extractedData?.gender || '', + socialSecurityNumber: chifaCardData?.extractedData?.socialSecurityNumber || '', + }; + + return { + idCardData, + chifaCardData, + prescriptionUrl: prescriptionUrl || undefined, + patientData, + savedDocuments, + }; + } async uploadPhotos(files: Express.Multer.File[]) { this.logger.log(`Uploading ${files.length} photos`); try { - // In a real implementation, you would: - // 1. Upload files to cloud storage (S3, Azure Blob, etc.) - // 2. Generate URLs for the uploaded files - // 3. Return the URLs + const photoUrls: string[] = []; - const photoUrls = files.map((file, index) => { - return `/uploads/${Date.now()}-${index}-${file.originalname}`; - }); + for (const file of files) { + const { url } = await this.saveFile(file); + photoUrls.push(url); + } this.logger.log(`Photos uploaded successfully: ${photoUrls.length}`); return { photos: photoUrls }; @@ -26,13 +327,77 @@ export class UploadsService { async getFile(id: string) { this.logger.log(`Fetching file: ${id}`); - // In a real implementation, retrieve file from storage - return { id, url: `/uploads/${id}` }; + const doc = await this.prisma.patientDocument.findUnique({ + where: { id }, + include: { patient: true }, + }); + + if (!doc) { + throw new BadRequestException(`Document ${id} not found`); + } + + return doc; } async deleteFile(id: string) { this.logger.log(`Deleting file: ${id}`); - // In a real implementation, delete file from storage + const doc = await this.prisma.patientDocument.findUnique({ + where: { id }, + }); + + if (!doc) { + throw new BadRequestException(`Document ${id} not found`); + } + + // Delete from disk + try { + const filename = doc.fileUrl.split('/').pop(); + const filepath = path.join(this.uploadDir, filename!); + await fs.unlink(filepath); + } catch (error) { + this.logger.warn(`Could not delete physical file: ${error.message}`); + } + + // Delete from database + await this.prisma.patientDocument.delete({ where: { id } }); + return { success: true, message: 'File deleted successfully' }; } + + async getPatientDocuments(patientId: string) { + return this.prisma.patientDocument.findMany({ + where: { patientId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async getQuotationDocuments(quotationId: string) { + return this.prisma.patientDocument.findMany({ + where: { quotationId }, + orderBy: { createdAt: 'desc' }, + }); + } + + /** + * Link existing patient documents to a quotation + * Called after quotation is created + */ + async linkDocumentsToQuotation(documentIds: string[], quotationId: string) { + this.logger.log(`Linking ${documentIds.length} documents to quotation ${quotationId}`); + + const updated = await this.prisma.patientDocument.updateMany({ + where: { + id: { in: documentIds }, + }, + data: { + quotationId, + }, + }); + + return { + success: true, + count: updated.count, + message: `${updated.count} documents linked to quotation`, + }; + } } diff --git a/src/workflow/workflow.controller.ts b/src/workflow/workflow.controller.ts index 453fcc4..0fae330 100644 --- a/src/workflow/workflow.controller.ts +++ b/src/workflow/workflow.controller.ts @@ -1,8 +1,9 @@ import { Controller, Post, Get, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { WorkflowService } from './workflow.service'; @ApiTags('workflow') +@ApiBearerAuth() @Controller('workflow') export class WorkflowController { constructor(private readonly workflowService: WorkflowService) {}