diff --git a/PRINTING_SYSTEM_ARCHITECTURE.md b/PRINTING_SYSTEM_ARCHITECTURE.md new file mode 100644 index 0000000..437ab32 --- /dev/null +++ b/PRINTING_SYSTEM_ARCHITECTURE.md @@ -0,0 +1,513 @@ +# Printing System Architecture + +## Overview + +The printing system has been redesigned to use MongoDB as the single source of truth, eliminating API communication between the website and printing server. Both systems read from and write to the same MongoDB database. + +## Architecture Diagram + +``` +┌─────────────────┐ ┌─────────────────┐ +│ Website │ │ Printing Server │ +│ (Next.js) │ │ (Node.js) │ +└────────┬────────┘ └────────┬────────┘ + │ │ + │ │ + └──────────┬─────────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ MongoDB Database │ + │ (Single Source of │ + │ Truth) │ + └──────────────────────┘ + │ + ┌──────────┼──────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────┐ ┌────────┐ ┌────────┐ + │ orders │ │printers│ │print_ │ + │ │ │ │ │ logs │ + └────────┘ └────────┘ └────────┘ +``` + +## Data Flow + +### Order Processing Flow + +```mermaid +flowchart TD + A[Order Created] -->|paymentStatus: completed| B[Set printStatus: pending] + B --> C[Printing Server Polls MongoDB] + C -->|findOneAndUpdate atomic| D[Set printStatus: printing] + D --> E[Download File from Cloudinary] + E --> F[Send to Printer] + F -->|Success| G[Set printStatus: printed] + F -->|Failure| H[Set printStatus: pending + error] + H --> C +``` + +### State Machine + +``` +pending → printing → printed + ↑ │ + └─────────┘ (on failure) +``` + +**State Rules:** +- Only `pending` orders can be claimed +- State transitions are atomic (using `findOneAndUpdate`) +- Duplicate printing is impossible: + - Atomic claim prevents race conditions + - `printJobId` idempotency check prevents re-printing + - Worker ownership ensures only one worker processes each job + +## Database Schema + +### Orders Collection + +```typescript +{ + _id: ObjectId, + orderId: string, + paymentStatus: 'pending' | 'completed' | 'failed', + printStatus: 'pending' | 'printing' | 'printed', + printError?: string, + printerId?: string, + printerName?: string, + printStartedAt?: Date, + printCompletedAt?: Date, + // Production-critical fields + printJobId?: string, // UUID for idempotency (unique, indexed) + printAttempt?: number, // Number of print attempts (default: 0) + printingBy?: string, // Worker ID that owns this job (indexed) + fileURL: string, + printingOptions: {...}, + // ... other fields +} +``` + +**Indexes:** +- `{ printStatus: 1, paymentStatus: 1, createdAt: 1 }` +- `{ printStatus: 1, createdAt: -1 }` +- `{ printerId: 1, printStatus: 1 }` +- `{ printJobId: 1 }` (unique, sparse) +- `{ printingBy: 1, printStatus: 1 }` + +### Printers Collection + +```typescript +{ + _id: ObjectId, + name: string, + printer_id: string, // NEW + printer_name: string, // NEW + status: 'online' | 'offline' | 'busy' | 'error', + last_seen_at: Date, // NEW + last_successful_print_at?: Date, // NEW + queue_length: number, // NEW + error_message?: string, // NEW + driver_name?: string, // NEW + system_name: 'Windows' | 'Linux', // NEW + connectionType: 'usb' | 'network' | 'wireless', + isActive: boolean, + autoPrintEnabled: boolean, + // ... other fields +} +``` + +**Indexes:** +- `{ status: 1, isActive: 1 }` +- `{ printer_id: 1 }` (unique) +- `{ last_seen_at: -1 }` + +### Print Logs Collection + +```typescript +{ + _id: ObjectId, + action: string, // 'reprint', 'cancel', 'reset_state', 'force_printed', 'server_shutdown' + orderId: string, + printJobId?: string, // Print job ID for idempotency tracking + adminId?: string, + adminEmail?: string, + previousStatus?: string, + newStatus?: string, + reason?: string, + timestamp: Date, + metadata?: object +} +``` + +**Indexes:** +- `{ orderId: 1, timestamp: -1 }` +- `{ timestamp: -1 }` +- `{ action: 1, timestamp: -1 }` + +### Metrics Collection + +```typescript +{ + _id: ObjectId, + timestamp: Date, + prints_per_hour: number, + failures_per_hour: number, + average_print_start_delay: number, // seconds + printer_offline_duration: number, // seconds + workerId: string, + createdAt: Date, + updatedAt: Date +} +``` + +**Indexes:** +- `{ timestamp: -1 }` +- `{ workerId: 1, timestamp: -1 }` + +## Printing Server Behavior + +### Order Processing + +1. **Poll MongoDB** every 5 seconds (configurable) for orders with: + - `printStatus: 'pending'` + - `paymentStatus: 'completed'` + - Has file URL(s) + +2. **Validate Capabilities:** + - Check order requirements against printer capabilities + - Skip orders that don't match (page size, color, duplex, copies) + +3. **Atomically Claim Order:** + ```typescript + const printJobId = randomUUID(); + const workerId = getWorkerId(); + const order = await Order.findOneAndUpdate( + { + _id: orderId, + printStatus: 'pending', + paymentStatus: 'completed' + }, + { + $set: { + printStatus: 'printing', + printStartedAt: new Date(), + printJobId: printJobId, + printingBy: workerId, + printAttempt: 1, + ... + } + }, + { new: true } + ); + ``` + +4. **Check Idempotency:** + - Verify `printJobId` has not been printed before + - Skip printing if already printed + +4. **Process Order:** + - Download file from Cloudinary + - Validate file (existence, size, PDF header) + - Check idempotency: skip if `printJobId` already printed + - Send to printer + - On success: Update to `printStatus: 'printed'` (only if owned by this worker) + - On failure: Reset to `printStatus: 'pending'` with error message (only if owned by this worker) + - Increment `printAttempt` on failure + +### Printer Health Monitoring + +1. **Check Health** every 30 seconds (configurable): + - Query Windows Print Spooler for printer status + - Update MongoDB `printers` collection with: + - Status (online/offline/busy/error) + - Queue length + - Last seen timestamp + - Error messages + +2. **Only Process Orders** if printer is online + +3. **Auto-Recovery**: When printer comes back online, processing resumes automatically + +### Stuck Order Detection + +- Orders in `printing` state for > 30 minutes are automatically reset to `pending` +- Logged to `print_logs` for admin review + +## Admin UI + +### Real-time Updates + +The admin monitor page (`/admin/printer-monitor`) uses: + +1. **MongoDB Change Streams** (if replica set available) +2. **Polling Fallback** (every 2-3 seconds) if replica set not available + +### Features + +1. **Order Groups:** + - Pending Orders + - Printing Orders + - Printed Orders (last 24 hours) + +2. **Printer Health Panel:** + - Status indicator (color-coded) + - Queue length + - Last successful print + - Error messages + +3. **Admin Actions:** + - Reprint failed order + - Cancel pending order + - Reset stuck order + - Force mark as printed + +All actions are logged to `print_logs` collection. + +## Atomic Operations + +### Preventing Duplicate Printing + +The system uses multiple layers of protection: + +1. **Atomic Claim with Ownership:** +```typescript +const printJobId = randomUUID(); +const workerId = getWorkerId(); + +const order = await Order.findOneAndUpdate( + { + _id: orderId, + printStatus: 'pending', // Condition: only update if still pending + paymentStatus: 'completed' + }, + { + $set: { + printStatus: 'printing', + printStartedAt: new Date(), + printJobId: printJobId, // Unique UUID + printingBy: workerId, // Worker ownership + printAttempt: 1, + printerId: printerId, + printerName: printerName + } + }, + { new: true } +); +``` + +2. **Idempotency Check:** +```typescript +// Before printing, check if printJobId already printed +const alreadyPrinted = await Order.findOne({ + printJobId: printJobId, + printStatus: 'printed' +}); + +if (alreadyPrinted) { + // Skip printing - already done + return; +} +``` + +3. **Ownership Verification:** +```typescript +// Only allow owning worker to complete/reset +const result = await Order.findOneAndUpdate( + { + _id: orderId, + printingBy: workerId, // Must be owned by this worker + printStatus: 'printing' + }, + { $set: { printStatus: 'printed', ... } } +); +``` + +This ensures: +- Only one printing server can claim an order (atomic claim) +- No duplicate printing (printJobId idempotency) +- Safe concurrent access (worker ownership) +- Graceful handling of server restarts (ownership-based recovery) + +## Error Handling + +### Printer Offline + +- Orders remain in `pending` state +- Printer status updated to `offline` in MongoDB +- Admin UI shows printer as offline +- Processing resumes when printer comes back online +- Offline duration tracked in metrics + +### Print Failure + +- Order reset to `pending` with error message (only by owning worker) +- Error logged in `printError` field +- `printAttempt` incremented +- `printJobId` cleared to allow new attempt +- Printing server will retry on next poll +- Admin can manually reprint if needed +- Failures tracked in metrics + +### Stuck Orders + +- Auto-detected after 30 minutes (only orders owned by this worker) +- Automatically reset to `pending` +- Logged to `print_logs` with reason +- Other workers' orders are ignored + +### Server Shutdown + +- On shutdown (SIGTERM, SIGINT): + - Find all orders with `printStatus: 'printing'` AND `printingBy: workerId` + - Reset them to `'pending'` with reason "Server shutdown" + - Log each reset to `print_logs` + - Ensures no jobs are left stuck + +### Capability Mismatch + +- Orders that don't match printer capabilities are skipped +- Marked as failed with capability mismatch error +- Admin can see capability requirements in error message + +### File Validation Failure + +- Invalid files (missing, too small, invalid PDF) are rejected +- Order marked as failed with validation error +- Admin can see specific validation error + +## Security + +### Admin Actions + +- All admin actions require authentication +- Actions are logged with: + - Admin email + - Timestamp + - Previous and new status + - Reason + +### MongoDB Access + +- Website and printing server use same MongoDB connection +- No API keys or authentication between systems +- MongoDB handles access control + +## Performance + +### Indexes + +Critical indexes for performance: +- `orders.printStatus + paymentStatus + createdAt` +- `orders.printStatus + createdAt` +- `orders.printJobId` (unique, sparse) +- `orders.printingBy + printStatus` +- `printers.status + isActive` +- `print_logs.orderId + timestamp` +- `metrics.timestamp` (for metrics queries) +- `metrics.workerId + timestamp` + +### Polling Intervals + +- Order polling: 5 seconds (configurable) +- Health checks: 30 seconds (configurable) +- Metrics storage: 5 minutes +- Stuck order check: 5 minutes +- Admin UI polling: 2-3 seconds (if Change Streams not available) + +## Production-Critical Features + +### Print Job Idempotency + +- Each print job has unique `printJobId` (UUID) +- System checks if `printJobId` already printed before printing +- Prevents duplicate physical prints +- `printAttempt` counter tracks retry attempts + +### Worker Ownership + +- Each server has unique `workerId` (UUID + hostname + timestamp) +- Orders claimed with `printingBy: workerId` +- Only owning worker can complete/reset job +- Supports multiple servers safely +- Prevents interference between workers + +### Graceful Shutdown + +- On shutdown, resets orders owned by this worker +- Logs all shutdown actions +- Ensures no stuck jobs + +### Capability Validation + +- Validates orders against printer capabilities +- Checks: page size, color, duplex, max copies +- Skips incompatible orders + +### File Integrity + +- Validates files before printing +- Checks: existence, size, PDF header +- Rejects invalid files safely + +### Observability + +- Tracks metrics: prints/hour, failures/hour, delays, offline duration +- Stores metrics in MongoDB every 5 minutes +- Viewable in admin UI +- Helps identify performance issues + +## Deployment + +### Printing Server + +1. Install on Windows machine with printer +2. Configure MongoDB connection +3. Set printer name +4. Run as service (PM2 recommended) + +### Website + +1. Deploy to Vercel/cloud +2. Ensure MongoDB connection string is set +3. Run migration script to add `printStatus` to existing orders + +## Migration + +Run migration script to: +1. Add `printStatus` field to existing orders +2. Set `printStatus: 'pending'` for completed orders +3. Set `printStatus: 'printed'` for delivered orders +4. Create required indexes +5. Initialize printer records + +```bash +npm run migrate-print-status +``` + +## Monitoring + +### Key Metrics + +- Orders pending/printing/printed +- Printer online/offline status +- Queue lengths +- Error rates +- Processing times + +### Logs + +- All state transitions logged +- Admin actions logged +- Errors logged with context +- Stuck order detection logged + +## Future Enhancements + +- Support for multiple printers +- Priority queue +- Print job scheduling +- Advanced error recovery +- Print preview +- Batch printing + diff --git a/Printing-Server/.gitignore b/Printing-Server/.gitignore new file mode 100644 index 0000000..1d4edf7 --- /dev/null +++ b/Printing-Server/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +.env.local +*.log +.DS_Store + diff --git a/Printing-Server/Printing-Server/package-lock.json b/Printing-Server/Printing-Server/package-lock.json new file mode 100644 index 0000000..c21f9ab --- /dev/null +++ b/Printing-Server/Printing-Server/package-lock.json @@ -0,0 +1,38 @@ +{ + "name": "Printing-Server", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "printing-server": "file:.." + } + }, + "..": { + "name": "printing-server", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^1.7.9", + "dotenv": "^17.2.1", + "mongoose": "^8.18.0", + "node-fetch": "^2.7.0", + "pdf-lib": "^1.17.1", + "pdf-to-printer": "^5.6.0", + "pdfkit": "^0.15.0", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@types/node": "^20.19.13", + "@types/pdfkit": "^0.13.0", + "@types/uuid": "^10.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.2" + } + }, + "node_modules/printing-server": { + "resolved": "..", + "link": true + } + } +} diff --git a/Printing-Server/Printing-Server/package.json b/Printing-Server/Printing-Server/package.json new file mode 100644 index 0000000..fb74683 --- /dev/null +++ b/Printing-Server/Printing-Server/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "printing-server": "file:.." + } +} diff --git a/Printing-Server/README.md b/Printing-Server/README.md new file mode 100644 index 0000000..357c410 --- /dev/null +++ b/Printing-Server/README.md @@ -0,0 +1,439 @@ +# Printing Server + +MongoDB-based printing server for funPrinting. This server polls MongoDB for pending orders and processes them directly, eliminating the need for API communication between the website and printing server. + +## Architecture + +- **No API Communication**: Website and printing server both use MongoDB as the single source of truth +- **Atomic State Transitions**: Uses MongoDB `findOneAndUpdate` for safe concurrent access +- **Real-time Monitoring**: Printer health and order status tracked in MongoDB +- **Windows Support**: Designed for Windows printers using `pdf-to-printer` +- **Print Job Idempotency**: UUID-based printJobId prevents duplicate physical prints +- **Worker Ownership**: Multi-server support with worker ownership tracking +- **Graceful Shutdown**: Automatically resets orders on server shutdown +- **Capability Validation**: Validates orders against printer capabilities before printing +- **File Integrity**: Validates files before printing (existence, size, PDF header) +- **Observability**: Tracks metrics (prints/hour, failures/hour, delays, offline duration) + +## Prerequisites + +- Node.js 18+ and npm +- MongoDB database (shared with website) +- Windows OS with printer drivers installed +- Printer connected and configured + +## Installation + +1. **Clone the repository:** + ```bash + git clone https://github.com/AdityaPandey-DEV/Printing-Server.git + cd Printing-Server + ``` + +2. **Install dependencies:** + ```bash + npm install + ``` + +3. **Configure environment:** + ```bash + cp env.example .env + ``` + +4. **Edit `.env` file:** + ```env + MONGODB_URI=mongodb://localhost:27017/print-service + PRINTER_NAME=Your Printer Name + PRINTER_ID=printer_001 + SYSTEM_NAME=Windows + ORDER_POLL_INTERVAL=5000 + HEALTH_CHECK_INTERVAL=30000 + MAX_RETRIES=3 + RETRY_DELAY=5000 + LOG_LEVEL=info + ``` + +## Configuration + +### MongoDB Connection + +Set `MONGODB_URI` to your MongoDB connection string. This must be the same database used by the website. + +### Printer Configuration + +1. **Find your printer name:** + ```powershell + wmic printer get name + ``` + +2. **Set `PRINTER_NAME`** in `.env` to match your printer name exactly. + +3. **Set `PRINTER_ID`** to a unique identifier for this printer (e.g., `printer_001`). + +### Polling Intervals + +- `ORDER_POLL_INTERVAL`: How often to check for pending orders (default: 5000ms = 5 seconds) +- `HEALTH_CHECK_INTERVAL`: How often to check printer health (default: 30000ms = 30 seconds) + +## Running the Server + +### Development Mode + +```bash +npm run dev +``` + +### Production Mode + +1. **Build the project:** + ```bash + npm run build + ``` + +2. **Start the server:** + ```bash + npm start + ``` + +## How It Works + +1. **Order Processing:** + - Server polls MongoDB for orders with `printStatus: 'pending'` and `paymentStatus: 'completed'` + - Validates order against printer capabilities (page size, color, duplex, copies) + - Atomically claims order by updating `printStatus: 'pending' → 'printing'` with: + - Unique `printJobId` (UUID) for idempotency + - `printingBy` (workerId) for ownership tracking + - `printAttempt: 1` for retry tracking + - Downloads file from Cloudinary + - Validates file (existence, size, PDF header) + - Checks idempotency: skips if `printJobId` already printed + - Sends to printer + - Updates `printStatus: 'printing' → 'printed'` on success (only if owned by this worker) + - Resets to `'pending'` with error message on failure (only if owned by this worker) + +2. **Printer Health Monitoring:** + - Checks printer status every 30 seconds (configurable) + - Updates MongoDB `printers` collection with: + - Status (online/offline/busy/error) + - Queue length + - Last seen timestamp + - Error messages + +3. **Stuck Order Detection:** + - Automatically resets orders stuck in 'printing' state for > 30 minutes + - Logs to MongoDB for admin review + +## Print Status States + +Orders have exactly three print states: + +- **`pending`**: Order waiting to be printed +- **`printing`**: Order currently being printed (owned by a specific worker) +- **`printed`**: Order successfully printed + +State transitions are atomic and prevent duplicate printing. + +### Idempotency + +Each print job has a unique `printJobId` (UUID). The same `printJobId` will never be printed twice, even if: +- Server restarts mid-print +- Network errors occur +- Multiple workers attempt to process the same order + +### Worker Ownership + +Each printing server has a unique `workerId`. When an order is claimed: +- `printingBy` is set to the worker's ID +- Only that worker can complete or reset the job +- Other workers ignore orders owned by different workers +- On shutdown, orders owned by this worker are reset to `pending` + +## Failure-Proofing Layers + +The printing system includes comprehensive failure-proofing to ensure ZERO duplicate physical prints and ZERO stuck orders, even in the face of crashes, power failures, network issues, and human errors. + +### State Machine Enforcement + +- **Strict State Transitions**: Only allowed transitions are: + - `pending → printing` (automatic) + - `printing → printed` (on success) + - `printing → pending` (on failure/reset) + - `printed → pending` (admin override only) +- **Validation**: All state transitions are validated before execution +- **Logging**: Every transition is logged to `print_logs` + +### Print Attempt & Retry Control + +- **Max Attempts**: Default limit of 3 attempts per order +- **Automatic Stop**: Orders exceeding max attempts stop automatic retries +- **Admin Action Required**: Orders with max attempts reached require manual admin intervention +- **Increment Tracking**: `printAttempt` counter tracks retry attempts + +### Heartbeat & Stale Job Detection + +- **Heartbeat Updates**: `printingHeartbeatAt` updated every 10 seconds during printing +- **Stale Detection**: Jobs with stale heartbeats (>5 minutes) are automatically recovered +- **Crash Recovery**: Detects crashed servers and resets orphaned jobs +- **Auto-Recovery**: No manual intervention required for stale jobs + +### Power Failure & Crash Recovery + +- **Startup Recovery**: On server startup, automatically recovers: + - Orders owned by this worker (`printingBy = workerId`) + - Orphaned orders (no `printingBy`) +- **Increment Attempts**: Recovered orders have `printAttempt` incremented +- **Logging**: All recovery actions logged to `print_logs` + +### Printer-Level Failure Handling + +- **Auto-Pause**: Printing automatically pauses when printer has hard errors: + - Offline + - Paper jam + - Out of paper + - Out of ink +- **Auto-Resume**: Printing automatically resumes when printer recovers +- **Status Tracking**: `autoPrintEnabled` flag controls automatic printing + +### File-Level Failure Safety + +- **Existence Check**: Verifies file exists before printing +- **Size Validation**: Minimum 500 bytes, maximum 100 MB +- **PDF Header Validation**: Validates PDF magic bytes (`%PDF`) +- **Readability Check**: Ensures file is readable +- **Fail-Safe**: Invalid files are rejected, order marked as failed + +### Duplicate Physical Print Prevention + +- **Idempotency**: Each print job has unique `printJobId` (UUID) +- **Double Check**: Checks both `orders` and `print_logs` collections +- **Never Reprint**: Same `printJobId` is never printed twice, even if state reset +- **Absolute Guarantee**: Physical duplicates are impossible + +### Admin Safety Guards + +- **State Validation**: All admin actions validate state transitions +- **Required Reasons**: Reset and force-printed actions require reason +- **Confirmation**: Force-printed requires explicit confirmation +- **Logging**: All admin overrides logged with full context + +### Fail-Safe Defaults + +- **Conservative Approach**: When in doubt, don't print +- **Comprehensive Checks**: Before every print: + - Printer online and not in error + - File validation passed + - Capability validation passed + - State transition valid + - Print attempt < max attempts + - PrintJobId not already printed +- **Error Handling**: All unexpected errors reset to pending, don't print + +## Production-Critical Features + +### Print Job Idempotency + +- Each print job has a unique `printJobId` (UUID) +- System checks if `printJobId` already printed before printing +- Prevents duplicate physical prints even on retries or server restarts +- `printAttempt` counter tracks retry attempts + +### Worker Ownership + +- Each server instance has a unique `workerId` (UUID + hostname + timestamp) +- Orders are claimed with `printingBy: workerId` +- Only the owning worker can complete or reset a job +- Supports multiple servers running simultaneously +- Prevents interference between workers + +### Graceful Shutdown + +- On shutdown (SIGTERM, SIGINT), server: + - Finds all orders with `printStatus: 'printing'` AND `printingBy: workerId` + - Resets them to `'pending'` with reason "Server shutdown" + - Logs each reset to `print_logs` + - Ensures no jobs are left stuck + +### Capability Validation + +- Validates orders against printer capabilities before claiming: + - Page size (A3 vs A4) + - Color support + - Duplex support + - Max copies +- Orders that don't match capabilities are skipped with error message + +### File Integrity Validation + +- Validates files before printing: + - File exists + - Minimum file size (100 bytes) + - Valid PDF header (%PDF) +- Invalid files are rejected with clear error messages + +### Observability & Metrics + +- Tracks system metrics: + - `prints_per_hour`: Number of successful prints in last hour + - `failures_per_hour`: Number of failed prints in last hour + - `average_print_start_delay`: Average time from order creation to printing start (seconds) + - `printer_offline_duration`: Total time printer was offline in last hour (seconds) +- Metrics stored in MongoDB every 5 minutes +- Old metrics cleaned up after 7 days +- Viewable in admin monitor UI + +## Troubleshooting + +### Printer Not Found + +1. Verify printer name matches exactly (case-sensitive): + ```powershell + wmic printer get name + ``` + +2. Check printer is online in Windows Settings + +3. Verify printer drivers are installed + +### MongoDB Connection Error + +1. Check `MONGODB_URI` is correct +2. Verify MongoDB is running +3. Check network connectivity +4. Verify database user has read/write permissions + +### Orders Not Processing + +1. Check printer status in MongoDB: + ```javascript + db.printers.find({ isActive: true }) + ``` + +2. Verify orders have `printStatus: 'pending'` and `paymentStatus: 'completed'` + +3. Check server logs for errors + +4. Verify printer is online and not in error state + +5. Check for capability mismatches (page size, color, duplex) + +6. Verify file URLs are accessible and valid + +### Print Jobs Failing + +1. Check printer error messages in MongoDB +2. Verify file URLs are accessible +3. Check printer has paper and ink +4. Review server logs for detailed error messages +5. Check file validation errors (size, PDF header) +6. Verify printer capabilities match order requirements + +### Duplicate Prints + +- System prevents duplicates using `printJobId` +- If you see duplicates, check: + - Are multiple `printJobId` values for the same order? + - Is idempotency check working? + - Review `print_logs` for duplicate print attempts + +### Stuck Orders + +- Orders stuck in `printing` state for > 30 minutes are auto-reset +- On server shutdown, orders owned by that worker are reset +- Check `print_logs` for reset reasons +- Verify worker ownership: `db.orders.find({ printStatus: 'printing' })` + +### Metrics Not Updating + +- Metrics are stored every 5 minutes +- Check MongoDB `metrics` collection: + ```javascript + db.metrics.find().sort({ timestamp: -1 }).limit(1) + ``` +- Verify metrics collector is running (check server logs) + +## Monitoring + +### View Printer Status + +Check MongoDB `printers` collection: +```javascript +db.printers.find().pretty() +``` + +### View Order Status + +Check MongoDB `orders` collection: +```javascript +db.orders.find({ printStatus: 'pending' }).pretty() +``` + +### View Print Logs + +Check MongoDB `print_logs` collection: +```javascript +db.print_logs.find().sort({ timestamp: -1 }).limit(10).pretty() +``` + +## Admin Actions + +Admins can perform actions via the website admin panel: + +- **Reprint**: Reset failed order to pending +- **Cancel**: Cancel pending order +- **Reset**: Reset stuck printing order to pending +- **Force Printed**: Manually mark order as printed + +All actions are logged to `print_logs` collection. + +## Auto-Restart + +For production, use a process manager like PM2: + +```bash +npm install -g pm2 +pm2 start dist/index.js --name printing-server +pm2 save +pm2 startup +``` + +## Development + +### Project Structure + +``` +Printing-Server/ +├── src/ +│ ├── index.ts # Main entry point +│ ├── config/ +│ │ └── mongodb.ts # MongoDB connection +│ ├── services/ +│ │ ├── orderProcessor.ts # Process pending orders +│ │ ├── printerHealth.ts # Health monitoring +│ │ └── printExecutor.ts # Actual printing logic +│ ├── models/ +│ │ ├── Order.ts # Order model +│ │ └── Printer.ts # Printer model +│ └── utils/ +│ ├── atomicUpdate.ts # Atomic state transitions +│ └── printerDriver.ts # OS-specific printer access +├── package.json +├── tsconfig.json +└── README.md +``` + +### Building + +```bash +npm run build +``` + +Output will be in `dist/` directory. + +## License + +MIT + +## Support + +For issues or questions, contact the development team. + diff --git a/Printing-Server/env.example b/Printing-Server/env.example new file mode 100644 index 0000000..c7b533a --- /dev/null +++ b/Printing-Server/env.example @@ -0,0 +1,19 @@ +# MongoDB Connection +MONGODB_URI=mongodb://localhost:27017/print-service + +# Printer Configuration +PRINTER_NAME=Default Printer +PRINTER_ID=printer_001 +SYSTEM_NAME=Windows + +# Polling Intervals (in milliseconds) +ORDER_POLL_INTERVAL=5000 +HEALTH_CHECK_INTERVAL=30000 + +# Retry Configuration +MAX_RETRIES=3 +RETRY_DELAY=5000 + +# Logging +LOG_LEVEL=info + diff --git a/Printing-Server/package-lock.json b/Printing-Server/package-lock.json new file mode 100644 index 0000000..e506e29 --- /dev/null +++ b/Printing-Server/package-lock.json @@ -0,0 +1,1773 @@ +{ + "name": "printing-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "printing-server", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^1.7.9", + "dotenv": "^17.2.1", + "mongoose": "^8.18.0", + "node-fetch": "^2.7.0", + "pdf-lib": "^1.17.1", + "pdf-to-printer": "^5.6.0", + "pdfkit": "^0.15.0", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@types/node": "^20.19.13", + "@types/pdfkit": "^0.13.0", + "@types/uuid": "^10.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.0.tgz", + "integrity": "sha512-ZHzx7Z3rdlWL1mECydvpryWN/ETXJiCxdgQKTAH+djzIPe77HdnSizKBDi1TVDXZjXyOj2IqEG/vPw71ULF06w==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", + "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@swc/helpers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pdfkit": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.13.9.tgz", + "integrity": "sha512-RDG8Yb1zT7I01FfpwK7nMSA433XWpblMqSCtA5vJlSyavWZb303HUYPCel6JTiDDFqwGLvtAnYbH8N/e0Cb89g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fontkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz", + "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.3.13", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "deep-equal": "^2.0.5", + "dfa": "^1.2.0", + "restructure": "^2.0.1", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "license": "MIT" + }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.20.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.20.2.tgz", + "integrity": "sha512-U0TPupnqBOAI3p9H9qdShX8/nJUBylliRcHFKuhbewEkM7Y0qc9BbrQR9h4q6+1easoZqej7cq2Ee36AZ0gMzQ==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "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-fetch/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/node-fetch/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/node-fetch/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/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-to-printer": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/pdf-to-printer/-/pdf-to-printer-5.6.1.tgz", + "integrity": "sha512-VlwOPVv8LmCGVnJKnPZaCqUSB0wm/eTmdrsXqHG2lpkIbwnlsBXIVIAipQgdsubIssbJAutyN5yOCB1CnYcMVw==", + "license": "MIT" + }, + "node_modules/pdfkit": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz", + "integrity": "sha512-s3GjpdBFSCaeDSX/v73MI5UsPqH1kjKut2AXCgxQ5OH10lPVOu5q5vLAG0OCpz/EYqKsTSw1WHpENqMvp43RKg==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^1.8.1", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.0.2", + "png-js": "^1.0.0" + } + }, + "node_modules/png-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", + "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/restructure": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", + "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==", + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/Printing-Server/package.json b/Printing-Server/package.json new file mode 100644 index 0000000..a9177c2 --- /dev/null +++ b/Printing-Server/package.json @@ -0,0 +1,37 @@ +{ + "name": "printing-server", + "version": "1.0.0", + "description": "MongoDB-based printing server for funPrinting", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "watch": "tsc --watch" + }, + "keywords": [ + "printing", + "mongodb", + "windows" + ], + "author": "", + "license": "MIT", + "dependencies": { + "dotenv": "^17.2.1", + "mongoose": "^8.18.0", + "pdf-to-printer": "^5.6.0", + "axios": "^1.7.9", + "node-fetch": "^2.7.0", + "uuid": "^11.1.0", + "pdf-lib": "^1.17.1", + "pdfkit": "^0.15.0" + }, + "devDependencies": { + "@types/node": "^20.19.13", + "@types/uuid": "^10.0.0", + "@types/pdfkit": "^0.13.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.2" + } +} + diff --git a/Printing-Server/src/config/mongodb.ts b/Printing-Server/src/config/mongodb.ts new file mode 100644 index 0000000..5fc2e49 --- /dev/null +++ b/Printing-Server/src/config/mongodb.ts @@ -0,0 +1,52 @@ +import mongoose from 'mongoose'; + +const MONGODB_URI = process.env.MONGODB_URI; + +if (!MONGODB_URI) { + throw new Error('Please define the MONGODB_URI environment variable'); +} + +let isConnected = false; + +export async function connectMongoDB() { + if (isConnected && mongoose.connection.readyState === 1) { + return mongoose.connection; + } + + try { + await mongoose.connect(MONGODB_URI as string); + isConnected = true; + console.log('✅ Connected to MongoDB'); + + // Handle connection events + mongoose.connection.on('error', (error) => { + console.error('❌ MongoDB connection error:', error); + isConnected = false; + }); + + mongoose.connection.on('disconnected', () => { + console.warn('⚠️ MongoDB disconnected'); + isConnected = false; + }); + + mongoose.connection.on('reconnected', () => { + console.log('✅ MongoDB reconnected'); + isConnected = true; + }); + + return mongoose.connection; + } catch (error) { + console.error('❌ Error connecting to MongoDB:', error); + isConnected = false; + throw error; + } +} + +export async function disconnectMongoDB() { + if (isConnected) { + await mongoose.disconnect(); + isConnected = false; + console.log('🔌 Disconnected from MongoDB'); + } +} + diff --git a/Printing-Server/src/index.ts b/Printing-Server/src/index.ts new file mode 100644 index 0000000..e1d21ba --- /dev/null +++ b/Printing-Server/src/index.ts @@ -0,0 +1,126 @@ +/** + * Printing Server - Main Entry Point + * + * MongoDB-based printing server for funPrinting + * Polls MongoDB for pending orders and processes them + */ + +import dotenv from 'dotenv'; +import { connectMongoDB, disconnectMongoDB } from './config/mongodb'; +import { startOrderProcessing, checkStuckOrders } from './services/orderProcessor'; +import { startHealthMonitoring, initializePrinter } from './services/printerHealth'; +import { handleShutdown } from './services/shutdownHandler'; +import { getWorkerId } from './utils/workerId'; +import { startMetricsCollection } from './services/metricsCollector'; +import { recoverCrashedJobs } from './services/startupRecovery'; +import { detectAndRecoverStaleJobs } from './services/staleJobDetector'; +import { startHeartbeatService, stopHeartbeatService } from './services/heartbeatService'; + +// Load environment variables +dotenv.config(); + +let isShuttingDown = false; + +/** + * Graceful shutdown handler + */ +async function gracefulShutdown(signal: string) { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + console.log(`\n🛑 Received ${signal}. Starting graceful shutdown...`); + + try { + // Stop heartbeat service + stopHeartbeatService(); + + // Reset orders owned by this worker + const resetCount = await handleShutdown(); + console.log(`📊 Reset ${resetCount} order(s) during shutdown`); + + // Disconnect from MongoDB + await disconnectMongoDB(); + console.log('✅ Graceful shutdown completed'); + process.exit(0); + } catch (error) { + console.error('❌ Error during shutdown:', error); + process.exit(1); + } +} + +/** + * Main function + */ +async function main() { + try { + console.log('🚀 Starting Printing Server...'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // Connect to MongoDB + await connectMongoDB(); + + // Initialize worker ID + const workerId = getWorkerId(); + console.log(`🆔 Worker ID: ${workerId}`); + + // Recover crashed jobs on startup + console.log('🔄 Running startup recovery...'); + const recoveredCount = await recoverCrashedJobs(); + if (recoveredCount > 0) { + console.log(`✅ Recovered ${recoveredCount} crashed job(s) on startup`); + } + + // Initialize printer + await initializePrinter(); + + // Start health monitoring + startHealthMonitoring(); + + // Start heartbeat service + startHeartbeatService(); + + // Start order processing + startOrderProcessing(); + + // Start metrics collection + startMetricsCollection(); + + // Check for stuck orders every 5 minutes + setInterval(checkStuckOrders, 5 * 60 * 1000); + + // Check for stale jobs every 2-3 minutes + setInterval(async () => { + await detectAndRecoverStaleJobs(); + }, 2 * 60 * 1000); // Every 2 minutes + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('✅ Printing Server started successfully'); + console.log('📊 Monitoring MongoDB for pending orders...'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // Handle shutdown signals + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + console.error('❌ Uncaught Exception:', error); + gracefulShutdown('uncaughtException'); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason); + gracefulShutdown('unhandledRejection'); + }); + + } catch (error) { + console.error('❌ Failed to start Printing Server:', error); + process.exit(1); + } +} + +// Start the server +main(); + diff --git a/Printing-Server/src/models/Metrics.ts b/Printing-Server/src/models/Metrics.ts new file mode 100644 index 0000000..16f91f6 --- /dev/null +++ b/Printing-Server/src/models/Metrics.ts @@ -0,0 +1,31 @@ +import mongoose from 'mongoose'; + +export interface IMetrics { + _id: string; + timestamp: Date; + prints_per_hour: number; + failures_per_hour: number; + average_print_start_delay: number; // seconds + printer_offline_duration: number; // seconds + workerId: string; + createdAt: Date; + updatedAt: Date; +} + +const metricsSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, default: Date.now }, + prints_per_hour: { type: Number, default: 0 }, + failures_per_hour: { type: Number, default: 0 }, + average_print_start_delay: { type: Number, default: 0 }, // seconds + printer_offline_duration: { type: Number, default: 0 }, // seconds + workerId: { type: String, required: true, index: true }, +}, { + timestamps: true, +}); + +// Indexes for efficient queries +metricsSchema.index({ timestamp: -1 }); +metricsSchema.index({ workerId: 1, timestamp: -1 }); + +export const Metrics = mongoose.models.Metrics || mongoose.model('Metrics', metricsSchema); + diff --git a/Printing-Server/src/models/Order.ts b/Printing-Server/src/models/Order.ts new file mode 100644 index 0000000..93b3183 --- /dev/null +++ b/Printing-Server/src/models/Order.ts @@ -0,0 +1,130 @@ +import mongoose from 'mongoose'; + +export interface IOrder { + _id: string; + orderId: string; + customerInfo: { + name: string; + phone: string; + email: string; + }; + orderType: 'file' | 'template'; + fileURL?: string; + fileURLs?: string[]; + originalFileName?: string; + originalFileNames?: string[]; + printingOptions: { + pageSize: 'A4' | 'A3'; + color: 'color' | 'bw' | 'mixed'; + sided: 'single' | 'double'; + copies: number; + pageCount?: number; + pageColors?: { + colorPages?: number[]; + bwPages?: number[]; + } | Array<{ + colorPages?: number[]; + bwPages?: number[]; + }>; + }; + paymentStatus: 'pending' | 'completed' | 'failed'; + printStatus?: 'pending' | 'printing' | 'printed'; + printError?: string; + printerId?: string; + printerName?: string; + printStartedAt?: Date; + printCompletedAt?: Date; + // Production-critical: Idempotency and ownership + printJobId?: string; // UUID for print job idempotency + printAttempt?: number; // Number of print attempts (default: 0) + maxPrintAttempts?: number; // Maximum print attempts before requiring admin action (default: 3) + printingBy?: string; // Worker ID that owns this print job + printingHeartbeatAt?: Date; // Last heartbeat update while printing (for stale job detection) + printSegments?: Array<{ // For mixed printing (color/BW segments) + segmentId: string; + pageRange?: { + start: number; + end: number; + }; + printMode?: 'color' | 'bw'; + copies?: number; + paperSize?: 'A4' | 'A3'; + duplex?: boolean; + status: 'pending' | 'printing' | 'completed' | 'failed'; + printJobId?: string; + startedAt?: Date; + completedAt?: Date; + error?: string; + }>; + createdAt: Date; + updatedAt: Date; +} + +const orderSchema = new mongoose.Schema({ + orderId: { type: String, required: true, index: true }, + customerInfo: { + name: { type: String, required: true }, + phone: { type: String, required: true }, + email: { type: String, required: true }, + }, + orderType: { type: String, enum: ['file', 'template'], required: true }, + fileURL: String, + fileURLs: [String], + originalFileName: String, + originalFileNames: [String], + printingOptions: { + pageSize: { type: String, enum: ['A4', 'A3'], required: true }, + color: { type: String, enum: ['color', 'bw', 'mixed'], required: true }, + sided: { type: String, enum: ['single', 'double'], required: true }, + copies: { type: Number, required: true, min: 1 }, + pageCount: { type: Number, default: 1 }, + }, + paymentStatus: { + type: String, + enum: ['pending', 'completed', 'failed'], + default: 'pending', + }, + printStatus: { + type: String, + enum: ['pending', 'printing', 'printed'], + index: true, + }, + printError: String, + printerId: String, + printerName: String, + printStartedAt: Date, + printCompletedAt: Date, + // Production-critical: Idempotency and ownership + printJobId: { type: String, index: true, sparse: true, unique: true }, // UUID for print job idempotency + printAttempt: { type: Number, default: 0 }, // Number of print attempts + maxPrintAttempts: { type: Number, default: 3 }, // Maximum print attempts before requiring admin action + printingBy: { type: String, index: true }, // Worker ID that owns this print job + printingHeartbeatAt: { type: Date, index: true }, // Last heartbeat update while printing (for stale job detection) + printSegments: [{ // For mixed printing (color/BW segments) + segmentId: String, + pageRange: { + start: Number, + end: Number, + }, + printMode: { type: String, enum: ['color', 'bw'] }, + copies: Number, + paperSize: { type: String, enum: ['A4', 'A3'] }, + duplex: Boolean, + status: { type: String, enum: ['pending', 'printing', 'completed', 'failed'] }, + printJobId: String, + startedAt: Date, + completedAt: Date, + error: String, + }], +}, { + timestamps: true, +}); + +// Indexes for efficient queries +orderSchema.index({ printJobId: 1 }, { unique: true, sparse: true }); +orderSchema.index({ printingBy: 1, printStatus: 1 }); +orderSchema.index({ printingHeartbeatAt: 1 }); // For stale job detection +orderSchema.index({ printStatus: 1, printAttempt: 1 }); // For retry limit queries + +export const Order = mongoose.models.Order || mongoose.model('Order', orderSchema); + diff --git a/Printing-Server/src/models/PrintLog.ts b/Printing-Server/src/models/PrintLog.ts new file mode 100644 index 0000000..b0dc939 --- /dev/null +++ b/Printing-Server/src/models/PrintLog.ts @@ -0,0 +1,35 @@ +import mongoose from 'mongoose'; + +export interface IPrintLog { + _id: string; + action: string; + orderId: string; + printJobId?: string; + adminId?: string; + adminEmail?: string; + previousStatus?: string; + newStatus?: string; + reason?: string; + timestamp: Date; + metadata?: Record; + createdAt: Date; + updatedAt: Date; +} + +const printLogSchema = new mongoose.Schema({ + action: { type: String, required: true, index: true }, + orderId: { type: String, required: true, index: true }, + printJobId: String, + adminId: String, + adminEmail: String, + previousStatus: String, + newStatus: String, + reason: String, + timestamp: { type: Date, default: Date.now, index: true }, + metadata: mongoose.Schema.Types.Mixed, +}, { + timestamps: true, +}); + +export const PrintLog = mongoose.models.PrintLog || mongoose.model('PrintLog', printLogSchema); + diff --git a/Printing-Server/src/models/Printer.ts b/Printing-Server/src/models/Printer.ts new file mode 100644 index 0000000..5fc0005 --- /dev/null +++ b/Printing-Server/src/models/Printer.ts @@ -0,0 +1,76 @@ +import mongoose from 'mongoose'; + +export interface IPrinter { + _id: string; + name: string; + printer_id?: string; + printer_name?: string; + status: 'online' | 'offline' | 'error' | 'maintenance' | 'busy'; + last_seen_at?: Date; + last_successful_print_at?: Date; + queue_length?: number; + error_message?: string; + driver_name?: string; + system_name?: 'Windows' | 'Linux'; + connectionType: 'usb' | 'network' | 'wireless'; + connectionString: string; + capabilities?: { + supportedPageSizes?: string[]; + supportsColor?: boolean; + supportsDuplex?: boolean; + maxCopies?: number; + supportedFileTypes?: string[]; + // Enhanced capabilities + maxPaperSize?: string; // e.g., "A3", "A4", "Letter" + recommendedDPI?: number; // e.g., 300, 600 + supportsPostScript?: boolean; + supportsPCL?: boolean; + }; + isActive: boolean; + autoPrintEnabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +const printerSchema = new mongoose.Schema({ + name: { type: String, required: true }, + printer_id: { type: String, unique: true, sparse: true }, + printer_name: String, + status: { + type: String, + enum: ['online', 'offline', 'error', 'maintenance', 'busy'], + default: 'offline', + index: true, + }, + last_seen_at: Date, + last_successful_print_at: Date, + queue_length: { type: Number, default: 0 }, + error_message: String, + driver_name: String, + system_name: { type: String, enum: ['Windows', 'Linux'] }, + connectionType: { + type: String, + enum: ['usb', 'network', 'wireless'], + required: true, + }, + connectionString: { type: String, required: true }, + capabilities: { + supportedPageSizes: [{ type: String }], + supportsColor: { type: Boolean, default: false }, + supportsDuplex: { type: Boolean, default: false }, + maxCopies: { type: Number, default: 1 }, + supportedFileTypes: [{ type: String }], + // Enhanced capabilities + maxPaperSize: String, // e.g., "A3", "A4", "Letter" + recommendedDPI: Number, // e.g., 300, 600 + supportsPostScript: { type: Boolean, default: false }, + supportsPCL: { type: Boolean, default: false }, + }, + isActive: { type: Boolean, default: true, index: true }, + autoPrintEnabled: { type: Boolean, default: true, index: true }, +}, { + timestamps: true, +}); + +export const Printer = mongoose.models.Printer || mongoose.model('Printer', printerSchema); + diff --git a/Printing-Server/src/services/alertService.ts b/Printing-Server/src/services/alertService.ts new file mode 100644 index 0000000..ec2dfaa --- /dev/null +++ b/Printing-Server/src/services/alertService.ts @@ -0,0 +1,166 @@ +/** + * Alert Service + * Detects critical conditions and logs alerts + */ + +import { Order } from '../models/Order'; +import { Printer } from '../models/Printer'; +import { PrintLog } from '../models/PrintLog'; +import { getStaleJobCount } from './staleJobDetector'; + +const PRINTER_OFFLINE_THRESHOLD_MINUTES = parseInt(process.env.PRINTER_OFFLINE_THRESHOLD_MINUTES || '10', 10); +const QUEUE_BACKLOG_THRESHOLD = parseInt(process.env.QUEUE_BACKLOG_THRESHOLD || '20', 10); + +export interface Alert { + type: 'repeated_failure' | 'printer_offline' | 'stale_job' | 'admin_override' | 'queue_backlog'; + severity: 'warning' | 'error' | 'critical'; + message: string; + metadata?: Record; + timestamp: Date; +} + +/** + * Check for alert conditions + */ +export async function checkAlertConditions(): Promise { + const alerts: Alert[] = []; + + try { + // 1. Check for orders that failed > 2 times + const repeatedFailures = await Order.find({ + printStatus: 'pending', + printError: { $exists: true, $ne: null }, + printAttempt: { $gte: 3 }, + }).limit(10); + + if (repeatedFailures.length > 0) { + alerts.push({ + type: 'repeated_failure', + severity: 'warning', + message: `${repeatedFailures.length} order(s) have failed 3+ times and require admin action`, + metadata: { + orderIds: repeatedFailures.map(o => o.orderId), + count: repeatedFailures.length, + }, + timestamp: new Date(), + }); + } + + // 2. Check for printers offline > threshold + const printers = await Printer.find({ isActive: true }); + const now = new Date(); + + for (const printer of printers) { + if (printer.status === 'offline' || printer.status === 'error') { + if (printer.last_seen_at) { + const offlineDuration = (now.getTime() - printer.last_seen_at.getTime()) / (1000 * 60); // minutes + if (offlineDuration > PRINTER_OFFLINE_THRESHOLD_MINUTES) { + alerts.push({ + type: 'printer_offline', + severity: printer.status === 'error' ? 'error' : 'warning', + message: `Printer ${printer.name} has been ${printer.status} for ${Math.round(offlineDuration)} minutes`, + metadata: { + printerId: printer._id.toString(), + printerName: printer.name, + status: printer.status, + offlineDurationMinutes: Math.round(offlineDuration), + errorMessage: printer.error_message, + }, + timestamp: new Date(), + }); + } + } + } + } + + // 3. Check for stale jobs + const staleJobCount = await getStaleJobCount(); + if (staleJobCount > 0) { + alerts.push({ + type: 'stale_job', + severity: 'warning', + message: `${staleJobCount} stale printing job(s) detected`, + metadata: { + count: staleJobCount, + }, + timestamp: new Date(), + }); + } + + // 4. Check for recent admin overrides + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + const recentOverrides = await PrintLog.find({ + action: { $in: ['force_printed', 'reset_state', 'reprint'] }, + timestamp: { $gte: oneHourAgo }, + }).limit(10); + + if (recentOverrides.length > 0) { + alerts.push({ + type: 'admin_override', + severity: 'warning', + message: `${recentOverrides.length} admin override(s) in the last hour`, + metadata: { + actions: recentOverrides.map(log => ({ + action: log.action, + orderId: log.orderId, + adminEmail: log.adminEmail, + timestamp: log.timestamp, + })), + count: recentOverrides.length, + }, + timestamp: new Date(), + }); + } + + // 5. Check for queue backlog + const pendingCount = await Order.countDocuments({ + printStatus: 'pending', + paymentStatus: 'completed', + }); + + if (pendingCount > QUEUE_BACKLOG_THRESHOLD) { + alerts.push({ + type: 'queue_backlog', + severity: pendingCount > QUEUE_BACKLOG_THRESHOLD * 2 ? 'error' : 'warning', + message: `Print queue backlog: ${pendingCount} pending order(s)`, + metadata: { + pendingCount, + threshold: QUEUE_BACKLOG_THRESHOLD, + }, + timestamp: new Date(), + }); + } + + // Log alerts to print_logs + for (const alert of alerts) { + try { + await PrintLog.create({ + action: 'alert', + orderId: 'system', + reason: alert.message, + timestamp: alert.timestamp, + metadata: { + alertType: alert.type, + severity: alert.severity, + ...alert.metadata, + }, + }); + } catch (logError) { + console.error('Error logging alert:', logError); + } + } + + return alerts; + } catch (error) { + console.error('❌ Error checking alert conditions:', error); + return alerts; + } +} + +/** + * Get active alerts + */ +export async function getActiveAlerts(): Promise { + return await checkAlertConditions(); +} + diff --git a/Printing-Server/src/services/heartbeatService.ts b/Printing-Server/src/services/heartbeatService.ts new file mode 100644 index 0000000..6bd6b80 --- /dev/null +++ b/Printing-Server/src/services/heartbeatService.ts @@ -0,0 +1,100 @@ +/** + * Heartbeat Service + * Updates printingHeartbeatAt for active printing jobs to detect stale/crashed jobs + */ + +import { Order } from '../models/Order'; +import { getWorkerId } from '../utils/workerId'; + +const HEARTBEAT_INTERVAL = parseInt(process.env.HEARTBEAT_INTERVAL || '10000', 10); // Default: 10 seconds + +let heartbeatInterval: NodeJS.Timeout | null = null; + +/** + * Update heartbeat for all orders currently being printed by this worker + */ +async function updateHeartbeats(): Promise { + try { + const workerId = getWorkerId(); + const now = new Date(); + + // Update heartbeat for all orders owned by this worker that are in 'printing' state + const result = await Order.updateMany( + { + printStatus: 'printing', + printingBy: workerId, + }, + { + $set: { + printingHeartbeatAt: now, + }, + } + ); + + if (result.modifiedCount > 0) { + console.log(`💓 Heartbeat updated for ${result.modifiedCount} active print job(s)`); + } + } catch (error) { + console.error('❌ Error updating heartbeats:', error); + } +} + +/** + * Start the heartbeat service + * Updates heartbeats at regular intervals + */ +export function startHeartbeatService(): void { + if (heartbeatInterval) { + console.log('⚠️ Heartbeat service already running'); + return; + } + + console.log(`💓 Starting heartbeat service (interval: ${HEARTBEAT_INTERVAL}ms)`); + + // Update immediately + updateHeartbeats(); + + // Then update at intervals + heartbeatInterval = setInterval(updateHeartbeats, HEARTBEAT_INTERVAL); +} + +/** + * Stop the heartbeat service + */ +export function stopHeartbeatService(): void { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + console.log('💓 Heartbeat service stopped'); + } +} + +/** + * Update heartbeat for a specific order + * Called during active printing to keep heartbeat fresh + */ +export async function updateHeartbeat(orderId: string): Promise { + try { + const workerId = getWorkerId(); + const now = new Date(); + + const result = await Order.findOneAndUpdate( + { + _id: orderId, + printStatus: 'printing', + printingBy: workerId, + }, + { + $set: { + printingHeartbeatAt: now, + }, + } + ); + + return !!result; + } catch (error) { + console.error(`❌ Error updating heartbeat for order ${orderId}:`, error); + return false; + } +} + diff --git a/Printing-Server/src/services/metricsCollector.ts b/Printing-Server/src/services/metricsCollector.ts new file mode 100644 index 0000000..2fbbae0 --- /dev/null +++ b/Printing-Server/src/services/metricsCollector.ts @@ -0,0 +1,235 @@ +/** + * Metrics Collector Service + * Tracks and stores system metrics for monitoring + */ + +import { Metrics } from '../models/Metrics'; +import { Order } from '../models/Order'; +import { Printer } from '../models/Printer'; +import { getWorkerId } from '../utils/workerId'; + +const METRICS_STORAGE_INTERVAL = 5 * 60 * 1000; // 5 minutes +const METRICS_RETENTION_DAYS = 7; + +interface PrintEvent { + timestamp: Date; + orderId: string; + success: boolean; + delay?: number; // seconds from pending to printing +} + +interface PrinterOfflineEvent { + startTime: Date; + endTime?: Date; +} + +// In-memory tracking +const printEvents: PrintEvent[] = []; +const printerOfflineEvents: PrinterOfflineEvent[] = []; +let lastPrinterOfflineTime: Date | null = null; + +/** + * Record a print event + */ +export function recordPrintEvent(orderId: string, success: boolean, delay?: number): void { + printEvents.push({ + timestamp: new Date(), + orderId, + success, + delay, + }); + + // Keep only last hour of events + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + while (printEvents.length > 0 && printEvents[0].timestamp < oneHourAgo) { + printEvents.shift(); + } +} + +/** + * Record printer offline event + */ +export function recordPrinterOffline(): void { + if (!lastPrinterOfflineTime) { + lastPrinterOfflineTime = new Date(); + } +} + +/** + * Record printer online event + */ +export function recordPrinterOnline(): void { + if (lastPrinterOfflineTime) { + printerOfflineEvents.push({ + startTime: lastPrinterOfflineTime, + endTime: new Date(), + }); + lastPrinterOfflineTime = null; + + // Keep only last 24 hours of events + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + while (printerOfflineEvents.length > 0 && printerOfflineEvents[0].startTime < oneDayAgo) { + printerOfflineEvents.shift(); + } + } +} + +/** + * Calculate metrics from tracked events + */ +async function calculateMetrics(): Promise<{ + prints_per_hour: number; + failures_per_hour: number; + average_print_start_delay: number; + printer_offline_duration: number; +}> { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + + // Calculate prints per hour + const recentPrints = printEvents.filter(e => e.timestamp >= oneHourAgo); + const prints_per_hour = recentPrints.length; + + // Calculate failures per hour + const recentFailures = recentPrints.filter(e => !e.success); + const failures_per_hour = recentFailures.length; + + // Calculate average print start delay + const delays = recentPrints + .filter(e => e.delay !== undefined) + .map(e => e.delay!); + const average_print_start_delay = delays.length > 0 + ? delays.reduce((a, b) => a + b, 0) / delays.length + : 0; + + // Calculate printer offline duration (last hour) + const recentOfflineEvents = printerOfflineEvents.filter(e => { + const eventEnd = e.endTime || new Date(); + return eventEnd >= oneHourAgo; + }); + + let printer_offline_duration = 0; + for (const event of recentOfflineEvents) { + const start = event.startTime < oneHourAgo ? oneHourAgo : event.startTime; + const end = event.endTime || new Date(); + printer_offline_duration += (end.getTime() - start.getTime()) / 1000; // seconds + } + + // Add current offline duration if printer is currently offline + if (lastPrinterOfflineTime) { + const start = lastPrinterOfflineTime < oneHourAgo ? oneHourAgo : lastPrinterOfflineTime; + const now = new Date(); + printer_offline_duration += (now.getTime() - start.getTime()) / 1000; // seconds + } + + return { + prints_per_hour, + failures_per_hour, + average_print_start_delay, + printer_offline_duration, + }; +} + +/** + * Store metrics to MongoDB + */ +async function storeMetrics(): Promise { + try { + const workerId = getWorkerId(); + const metrics = await calculateMetrics(); + + const metricsDoc = new Metrics({ + timestamp: new Date(), + prints_per_hour: metrics.prints_per_hour, + failures_per_hour: metrics.failures_per_hour, + average_print_start_delay: metrics.average_print_start_delay, + printer_offline_duration: metrics.printer_offline_duration, + workerId, + }); + + await metricsDoc.save(); + console.log(`📊 Metrics stored: ${metrics.prints_per_hour} prints/h, ${metrics.failures_per_hour} failures/h`); + } catch (error) { + console.error('❌ Error storing metrics:', error); + } +} + +/** + * Clean up old metrics (keep last 7 days) + */ +async function cleanupOldMetrics(): Promise { + try { + const retentionDate = new Date(Date.now() - METRICS_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const result = await Metrics.deleteMany({ + timestamp: { $lt: retentionDate }, + }); + + if (result.deletedCount > 0) { + console.log(`🗑️ Cleaned up ${result.deletedCount} old metrics`); + } + } catch (error) { + console.error('❌ Error cleaning up old metrics:', error); + } +} + +/** + * Calculate delay from order creation to printing start + */ +export async function calculatePrintDelay(orderId: string): Promise { + try { + const order = await Order.findOne({ orderId }); + if (!order || !order.printStartedAt || !order.createdAt) { + return undefined; + } + + const delay = (order.printStartedAt.getTime() - order.createdAt.getTime()) / 1000; // seconds + return delay; + } catch (error) { + console.error('Error calculating print delay:', error); + return undefined; + } +} + +/** + * Start metrics collection + */ +export function startMetricsCollection(): void { + console.log(`📊 Starting metrics collection (interval: ${METRICS_STORAGE_INTERVAL}ms)`); + + // Store metrics immediately + storeMetrics(); + + // Then store at intervals + setInterval(storeMetrics, METRICS_STORAGE_INTERVAL); + + // Clean up old metrics daily + setInterval(cleanupOldMetrics, 24 * 60 * 60 * 1000); +} + +/** + * Get latest metrics + */ +export async function getLatestMetrics(): Promise { + try { + const workerId = getWorkerId(); + const latest = await Metrics.findOne({ workerId }) + .sort({ timestamp: -1 }) + .limit(1); + + if (!latest) { + // Return current calculated metrics if no stored metrics + return await calculateMetrics(); + } + + return { + prints_per_hour: latest.prints_per_hour, + failures_per_hour: latest.failures_per_hour, + average_print_start_delay: latest.average_print_start_delay, + printer_offline_duration: latest.printer_offline_duration, + timestamp: latest.timestamp, + }; + } catch (error) { + console.error('Error getting latest metrics:', error); + return await calculateMetrics(); + } +} + diff --git a/Printing-Server/src/services/orderProcessor.ts b/Printing-Server/src/services/orderProcessor.ts new file mode 100644 index 0000000..ccf7f1e --- /dev/null +++ b/Printing-Server/src/services/orderProcessor.ts @@ -0,0 +1,371 @@ +/** + * Order Processor Service + * Handles polling MongoDB for pending orders and processing them + */ + +import { Order } from '../models/Order'; +import { Printer } from '../models/Printer'; +import { claimOrderForPrinting, markOrderAsPrinted, markOrderAsFailed, isPrintJobIdAlreadyPrinted } from '../utils/atomicUpdate'; +import { executePrint, executeSegmentedPrint } from './printExecutor'; +import { getAvailablePrinter } from './printerHealth'; +import { getWorkerId } from '../utils/workerId'; +import { validateCapabilities } from '../utils/capabilityValidator'; +import { recordPrintEvent, calculatePrintDelay } from './metricsCollector'; +import { updateHeartbeat } from './heartbeatService'; +import { analyzeDocumentSegments, PrintSegment, getExecutionOrder } from './segmentAnalyzer'; +import fetch from 'node-fetch'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const ORDER_POLL_INTERVAL = parseInt(process.env.ORDER_POLL_INTERVAL || '5000', 10); +const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '3', 10); + +let isProcessing = false; + +/** + * Poll MongoDB for pending orders and process them + */ +export async function pollPendingOrders(): Promise { + if (isProcessing) { + return; // Skip if already processing + } + + try { + isProcessing = true; + + // Get available printer (fail-safe: must be online and auto-print enabled) + const printer = await getAvailablePrinter(); + if (!printer) { + console.log('⚠️ No available printer found. Skipping order processing.'); + return; + } + + // Fail-safe check: Printer must be online and auto-print enabled + if (printer.status !== 'online' && printer.status !== 'busy') { + console.log(`⚠️ Printer ${printer.name} is not available (status: ${printer.status}). Skipping order processing.`); + return; + } + + if (printer.autoPrintEnabled !== true) { + console.log(`⚠️ Printer ${printer.name} has auto-print disabled. Skipping order processing.`); + return; + } + + // Find pending orders (excluding those that exceeded max attempts) + const pendingOrders = await Order.find({ + printStatus: 'pending', + paymentStatus: 'completed', + $or: [ + { fileURL: { $exists: true, $ne: null } }, + { fileURLs: { $exists: true, $ne: null, $not: { $size: 0 } } }, + ], + // Exclude orders that exceeded max attempts + $expr: { + $lt: [ + { $ifNull: ['$printAttempt', 0] }, + { $ifNull: ['$maxPrintAttempts', 3] } + ] + }, + }) + .sort({ createdAt: 1 }) // Process oldest first + .limit(1); // Process one at a time + + if (pendingOrders.length === 0) { + return; // No pending orders + } + + const order = pendingOrders[0]; + console.log(`📋 Processing order: ${order.orderId}`); + + // Validate printer capabilities before claiming + const capabilityValidation = validateCapabilities(order, printer); + if (!capabilityValidation.valid) { + console.log(`⚠️ Order ${order.orderId} does not match printer capabilities: ${capabilityValidation.errors.join(', ')}`); + // Mark order as failed with capability mismatch + await markOrderAsFailed( + order._id.toString(), + `Printer capability mismatch: ${capabilityValidation.errors.join(', ')}` + ); + return; + } + + // Atomically claim the order + const claimResult = await claimOrderForPrinting( + order._id.toString(), + printer._id.toString(), + printer.name + ); + + if (!claimResult.success) { + console.log(`⚠️ Failed to claim order ${order.orderId}: ${claimResult.error}`); + return; + } + + // Process the order + await processOrder(claimResult.order!, printer, claimResult.printJobId!); + } catch (error) { + console.error('❌ Error in pollPendingOrders:', error); + } finally { + isProcessing = false; + } +} + +/** + * Process a single order + */ +async function processOrder(order: any, printer: any, printJobId: string): Promise { + try { + const workerId = getWorkerId(); + + // Fail-safe checks before processing + // 1. Verify ownership + if (order.printingBy !== workerId) { + console.warn(`⚠️ Order ${order.orderId} is owned by different worker. Skipping.`); + return; + } + + // 2. Check idempotency: has this printJobId already been printed? + const alreadyPrinted = await isPrintJobIdAlreadyPrinted(printJobId); + if (alreadyPrinted) { + console.log(`⏭️ Print job ${printJobId} already printed. Skipping duplicate.`); + // Mark as printed if it was already printed + await markOrderAsPrinted(order._id.toString()); + return; + } + + // 3. Fail-safe: Check print attempt limit (double-check) + const maxAttempts = order.maxPrintAttempts || 3; + const currentAttempt = order.printAttempt || 0; + if (currentAttempt >= maxAttempts) { + console.log(`⚠️ Order ${order.orderId} exceeded max attempts (${currentAttempt}/${maxAttempts}). Skipping.`); + return; + } + + // 4. Fail-safe: Verify printer is still available + const currentPrinter = await Printer.findById(printer._id); + if (!currentPrinter || currentPrinter.status === 'error' || currentPrinter.status === 'offline' || currentPrinter.autoPrintEnabled !== true) { + console.log(`⚠️ Printer ${printer.name} is no longer available. Resetting order.`); + await markOrderAsFailed(order._id.toString(), 'Printer became unavailable during processing'); + return; + } + + console.log(`🖨️ Starting print job for order: ${order.orderId}, printJobId: ${printJobId}`); + + // Update heartbeat at start of printing + await updateHeartbeat(order._id.toString()); + + // Determine file URLs + const fileURLs = order.fileURLs && order.fileURLs.length > 0 + ? order.fileURLs + : order.fileURL + ? [order.fileURL] + : []; + + if (fileURLs.length === 0) { + throw new Error('No files to print'); + } + + // Determine file names + const fileNames = order.originalFileNames && order.originalFileNames.length > 0 + ? order.originalFileNames + : order.originalFileName + ? [order.originalFileName] + : fileURLs.map((url: string, index: number) => `file_${index + 1}.pdf`); + + // Analyze segments if mixed printing or if segments not already analyzed + let segments: PrintSegment[] = []; + if (order.printingOptions?.color === 'mixed' || (order.printSegments && order.printSegments.length === 0)) { + console.log(`📊 Analyzing document segments...`); + segments = await analyzeDocumentSegments(order); + + // Store segments in order document + await Order.findByIdAndUpdate(order._id, { + $set: { + printSegments: segments.map(seg => ({ + segmentId: seg.segmentId, + pageRange: seg.pageRange, + printMode: seg.printMode, + copies: seg.copies, + paperSize: seg.paperSize, + duplex: seg.duplex, + status: 'pending', + })), + }, + }); + console.log(`✅ Analyzed ${segments.length} segment(s)`); + } else if (order.printSegments && order.printSegments.length > 0) { + // Use existing segments, but only process pending/failed ones + segments = order.printSegments + .filter((seg: any) => seg.status === 'pending' || seg.status === 'failed') + .map((seg: any) => ({ + segmentId: seg.segmentId, + pageRange: seg.pageRange || { start: 1, end: order.printingOptions?.pageCount || 1 }, + printMode: seg.printMode || (order.printingOptions?.color === 'color' ? 'color' : 'bw'), + copies: seg.copies || order.printingOptions?.copies || 1, + paperSize: seg.paperSize || order.printingOptions?.pageSize || 'A4', + duplex: seg.duplex || order.printingOptions?.sided === 'double', + status: seg.status as 'pending' | 'printing' | 'completed' | 'failed', + printJobId: seg.printJobId, + startedAt: seg.startedAt ? new Date(seg.startedAt) : undefined, + completedAt: seg.completedAt ? new Date(seg.completedAt) : undefined, + error: seg.error, + })); + console.log(`📊 Using existing segments: ${segments.length} pending/failed segment(s) to process`); + } + + // Execute print for each file + for (let i = 0; i < fileURLs.length; i++) { + const fileURL = fileURLs[i]; + const fileName = fileNames[i] || `file_${i + 1}.pdf`; + + console.log(`📄 Printing file ${i + 1}/${fileURLs.length}: ${fileName}`); + + // Get file-specific printing options if available + const fileOptions = order.printingOptions?.fileOptions?.[i]; + const printingOptions = fileOptions || order.printingOptions; + + // Update heartbeat before printing each file + await updateHeartbeat(order._id.toString()); + + // Check if we should use segmented printing + if (segments.length > 0 && order.printingOptions?.color === 'mixed') { + // Download file for segmented printing + const tempDir = os.tmpdir(); + const tempFilePath = path.join(tempDir, `doc_${order.orderId}_${Date.now()}.pdf`); + + try { + const response = await fetch(fileURL); + if (!response.ok) { + throw new Error(`Failed to download file: ${response.statusText}`); + } + const buffer = await response.buffer(); + fs.writeFileSync(tempFilePath, buffer); + + // Execute segmented print (status updates handled inside executeSegmentedPrint) + await executeSegmentedPrint(order, segments, buffer, printer.name); + } finally { + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + } + } else { + // Standard printing with order summary + await executePrint({ + fileURL, + fileName, + printingOptions, + printerName: printer.name, + orderId: order.orderId, + printJobId: printJobId, + order: order, // Pass order for order summary generation + segments: segments.length > 0 ? segments : undefined, + }); + } + + // Update heartbeat after printing each file + await updateHeartbeat(order._id.toString()); + } + + // Mark order as printed + const success = await markOrderAsPrinted(order._id.toString()); + if (success) { + console.log(`✅ Order ${order.orderId} printed successfully`); + + // Record successful print event + const delay = await calculatePrintDelay(order.orderId); + recordPrintEvent(order.orderId, true, delay); + + // Update printer last successful print time + await Printer.findByIdAndUpdate(printer._id, { + $set: { + last_successful_print_at: new Date(), + }, + }); + } else { + throw new Error('Failed to mark order as printed'); + } + } catch (error) { + console.error(`❌ Error processing order ${order.orderId}:`, error); + + // Record failed print event + recordPrintEvent(order.orderId, false); + + // Mark failed segments + if (order.printSegments && order.printSegments.length > 0) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + for (const segment of order.printSegments) { + if (segment.status === 'printing') { + await Order.findOneAndUpdate( + { _id: order._id, 'printSegments.segmentId': segment.segmentId }, + { + $set: { + 'printSegments.$.status': 'failed', + 'printSegments.$.error': errorMessage, + }, + } + ); + } + } + } + + // Mark order as failed + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await markOrderAsFailed(order._id.toString(), errorMessage); + + // Update printer error status if needed + if (errorMessage.includes('printer') || errorMessage.includes('offline')) { + await Printer.findByIdAndUpdate(printer._id, { + $set: { + status: 'error', + error_message: errorMessage, + }, + }); + } + } +} + +/** + * Start the order processing loop + */ +export function startOrderProcessing(): void { + console.log(`🔄 Starting order processing loop (interval: ${ORDER_POLL_INTERVAL}ms)`); + + // Process immediately + pollPendingOrders(); + + // Then process at intervals + setInterval(pollPendingOrders, ORDER_POLL_INTERVAL); +} + +/** + * Check for stuck orders (in 'printing' state for too long) + * Only resets orders owned by this worker + */ +export async function checkStuckOrders(): Promise { + try { + const workerId = getWorkerId(); + const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); + + const stuckOrders = await Order.find({ + printStatus: 'printing', + printingBy: workerId, // Only check orders owned by this worker + printStartedAt: { $lt: thirtyMinutesAgo }, + }); + + if (stuckOrders.length > 0) { + console.log(`⚠️ Found ${stuckOrders.length} stuck order(s) owned by this worker`); + + for (const order of stuckOrders) { + console.log(`🔄 Resetting stuck order: ${order.orderId}`); + await markOrderAsFailed( + order._id.toString(), + 'Order stuck in printing state for more than 30 minutes. Auto-reset to pending.' + ); + } + } + } catch (error) { + console.error('❌ Error checking stuck orders:', error); + } +} + diff --git a/Printing-Server/src/services/orderSummaryGenerator.ts b/Printing-Server/src/services/orderSummaryGenerator.ts new file mode 100644 index 0000000..467bd50 --- /dev/null +++ b/Printing-Server/src/services/orderSummaryGenerator.ts @@ -0,0 +1,149 @@ +/** + * Order Summary PDF Generator + * Generates a single-page PDF with order and customer information + * This PDF will be printed FIRST (physically on top) of the printed stack + */ + +import PDFDocument from 'pdfkit'; +import { IOrder } from '../models/Order'; + +/** + * Generate order summary PDF + * Returns a Buffer containing the PDF + */ +export async function generateOrderSummaryPDF(order: IOrder): Promise { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: order.printingOptions?.pageSize === 'A3' ? 'A3' : 'A4', + margins: { top: 50, bottom: 50, left: 50, right: 50 }, + }); + + const buffers: Buffer[] = []; + doc.on('data', buffers.push.bind(buffers)); + doc.on('end', () => { + const pdfBuffer = Buffer.concat(buffers); + resolve(pdfBuffer); + }); + doc.on('error', reject); + + // Header + doc.fontSize(20).font('Helvetica-Bold').text('ORDER SUMMARY', { align: 'center' }); + doc.moveDown(1); + + // Order Information Section + doc.fontSize(14).font('Helvetica-Bold').text('ORDER INFORMATION', { underline: true }); + doc.moveDown(0.5); + doc.fontSize(11).font('Helvetica'); + + doc.text(`Order ID: ${order.orderId}`, { continued: false }); + doc.text(`Order Date: ${new Date(order.createdAt).toLocaleString('en-IN', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}`); + + if (order.printJobId) { + doc.text(`Print Job ID: ${order.printJobId.substring(0, 8)}...`); + } + + doc.text(`Payment Status: ${order.paymentStatus.toUpperCase()}`); + doc.moveDown(1); + + // Customer Information Section + doc.fontSize(14).font('Helvetica-Bold').text('CUSTOMER INFORMATION', { underline: true }); + doc.moveDown(0.5); + doc.fontSize(11).font('Helvetica'); + + const customerName = order.customerInfo?.name || 'N/A'; + const customerPhone = order.customerInfo?.phone || 'N/A'; + const customerEmail = order.customerInfo?.email || 'N/A'; + + doc.text(`Name: ${customerName}`); + doc.text(`Phone: ${customerPhone}`); + doc.text(`Email: ${customerEmail}`); + + // Delivery/Pickup Type (if available in order) + const deliveryType = (order as any).deliveryOption?.type || 'pickup'; + doc.text(`Delivery Type: ${deliveryType === 'delivery' ? 'Delivery' : 'Pickup'}`); + + if ((order as any).deliveryOption?.address) { + doc.text(`Address: ${(order as any).deliveryOption.address}`); + } + doc.moveDown(1); + + // Printing Breakdown Section + doc.fontSize(14).font('Helvetica-Bold').text('PRINTING BREAKDOWN', { underline: true }); + doc.moveDown(0.5); + doc.fontSize(11).font('Helvetica'); + + const pageCount = order.printingOptions?.pageCount || 1; + const copies = order.printingOptions?.copies || 1; + const totalPages = pageCount * copies; + + doc.text(`Total Pages: ${totalPages} (${pageCount} pages × ${copies} copies)`); + + // Color/BW breakdown + if (order.printingOptions?.color === 'mixed' && order.printingOptions?.pageColors) { + const pageColors = order.printingOptions.pageColors; + const colorPages = Array.isArray(pageColors) + ? pageColors.reduce((acc, pc) => acc + (pc.colorPages?.length || 0), 0) + : (pageColors.colorPages?.length || 0); + const bwPages = Array.isArray(pageColors) + ? pageColors.reduce((acc, pc) => acc + (pc.bwPages?.length || 0), 0) + : (pageColors.bwPages?.length || 0); + + doc.text(`Color Pages: ${colorPages}`); + doc.text(`Black & White Pages: ${bwPages}`); + } else { + doc.text(`Print Mode: ${order.printingOptions?.color === 'color' ? 'Color' : order.printingOptions?.color === 'bw' ? 'Black & White' : 'Mixed'}`); + } + + doc.text(`Paper Size: ${order.printingOptions?.pageSize || 'A4'}`); + doc.text(`Sided: ${order.printingOptions?.sided === 'double' ? 'Double-sided (Duplex)' : 'Single-sided'}`); + doc.moveDown(1); + + // Printing Instructions Section + doc.fontSize(14).font('Helvetica-Bold').text('PRINTING INSTRUCTIONS', { underline: true }); + doc.moveDown(0.5); + doc.fontSize(11).font('Helvetica'); + + // Special notes (if any) + if (order.printError) { + doc.font('Helvetica-Bold').fillColor('red').text(`⚠️ Error: ${order.printError}`); + doc.fillColor('black'); + } + + // Admin comments (if stored in order metadata) + const adminComments = (order as any).adminComments || (order as any).notes; + if (adminComments) { + doc.text(`Admin Notes: ${adminComments}`); + } + + // Print segments info (if available) + if (order.printSegments && order.printSegments.length > 0) { + doc.moveDown(0.5); + doc.font('Helvetica-Bold').text('Print Segments:'); + doc.font('Helvetica'); + order.printSegments.forEach((segment, index) => { + doc.text(` ${index + 1}. Segment ${segment.segmentId} - ${segment.status}`); + }); + } + + doc.moveDown(1); + + // Footer + doc.fontSize(9).font('Helvetica-Oblique').fillColor('gray'); + doc.text('This page was automatically generated by the Printing Server', { align: 'center' }); + doc.text(`Generated at: ${new Date().toLocaleString('en-IN')}`, { align: 'center' }); + doc.fillColor('black'); + + doc.end(); + } catch (error) { + reject(error); + } + }); +} + diff --git a/Printing-Server/src/services/printExecutor.ts b/Printing-Server/src/services/printExecutor.ts new file mode 100644 index 0000000..b735f85 --- /dev/null +++ b/Printing-Server/src/services/printExecutor.ts @@ -0,0 +1,340 @@ +/** + * Print Executor Service + * Handles actual printing of documents with order summary and segmented printing + */ + +import { print } from 'pdf-to-printer'; +import fetch from 'node-fetch'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { validateFile } from '../utils/fileValidator'; +import { generateOrderSummaryPDF } from './orderSummaryGenerator'; +import { mergeDocumentWithSummary, mergePDFsInReverseOrder } from '../utils/pdfMerger'; +import { PrintSegment, getExecutionOrder } from './segmentAnalyzer'; +import { PDFDocument } from 'pdf-lib'; +import { IOrder } from '../models/Order'; + +export interface PrintJobOptions { + fileURL: string; + fileName: string; + printingOptions: { + pageSize: 'A4' | 'A3'; + color: 'color' | 'bw' | 'mixed'; + sided: 'single' | 'double'; + copies: number; + pageCount?: number; + pageColors?: { + colorPages?: number[]; + bwPages?: number[]; + }; + }; + printerName: string; + orderId: string; + printJobId: string; // UUID for idempotency + order?: IOrder; // Full order object for order summary generation + segments?: PrintSegment[]; // Print segments for mixed printing +} + +/** + * Download file from URL + */ +async function downloadFile(url: string, outputPath: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download file: ${response.statusText}`); + } + + const buffer = await response.buffer(); + fs.writeFileSync(outputPath, buffer); + } catch (error) { + throw new Error(`Error downloading file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Convert file to PDF if needed + * For now, we assume files are already PDFs or can be printed directly + */ +async function convertToPrintFormat(filePath: string, options: PrintJobOptions): Promise { + // For now, we'll assume the file is already in a printable format + // In the future, you might want to add conversion logic here + // (e.g., DOCX to PDF, images to PDF, etc.) + + const ext = path.extname(filePath).toLowerCase(); + + if (ext === '.pdf') { + return filePath; // Already PDF + } + + // For non-PDF files, you would need to add conversion logic here + // For now, we'll throw an error for unsupported formats + throw new Error(`Unsupported file format: ${ext}. Only PDF files are supported.`); +} + +/** + * Extract pages from PDF + */ +async function extractPagesFromPDF(pdfBuffer: Buffer, pageRange: { start: number; end: number }): Promise { + try { + const sourcePdf = await PDFDocument.load(pdfBuffer); + const targetPdf = await PDFDocument.create(); + + // PDF pages are 0-indexed, but our pageRange is 1-indexed + const startIndex = pageRange.start - 1; + const endIndex = pageRange.end - 1; + + for (let i = startIndex; i <= endIndex && i < sourcePdf.getPageCount(); i++) { + const [page] = await targetPdf.copyPages(sourcePdf, [i]); + targetPdf.addPage(page); + } + + const pdfBytes = await targetPdf.save(); + return Buffer.from(pdfBytes); + } catch (error) { + throw new Error(`Failed to extract pages ${pageRange.start}-${pageRange.end}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Execute segmented print job with order summary + * Prints segments in reverse order (last segment first) so final stack is correct + * Order summary is printed LAST (so it appears FIRST physically on top) + */ +export async function executeSegmentedPrint( + order: IOrder, + segments: PrintSegment[], + documentBuffer: Buffer, + printerName: string +): Promise { + const tempDir = os.tmpdir(); + const orderSummaryPath = path.join(tempDir, `summary_${order.orderId}_${Date.now()}.pdf`); + const segmentPaths: string[] = []; + const { Order } = await import('../models/Order'); + const { randomUUID } = await import('crypto'); + + try { + console.log(`📋 Executing segmented print for order: ${order.orderId}`); + console.log(` Segments: ${segments.length}`); + + // Generate order summary PDF + console.log(`📄 Generating order summary PDF...`); + const orderSummaryBuffer = await generateOrderSummaryPDF(order); + fs.writeFileSync(orderSummaryPath, orderSummaryBuffer); + console.log(`✅ Order summary generated`); + + // Get segments in reverse execution order (last segment first) + const executionOrder = getExecutionOrder(segments); + console.log(`🔄 Printing segments in reverse order (last segment first)`); + + // Print each segment in reverse order + for (let i = 0; i < executionOrder.length; i++) { + const segment = executionOrder[i]; + + console.log(`📄 Printing segment ${i + 1}/${executionOrder.length}: Pages ${segment.pageRange.start}-${segment.pageRange.end} (${segment.printMode})`); + + // Update segment status to 'printing' + const segmentPrintJobId = randomUUID(); + await Order.findOneAndUpdate( + { _id: order._id, 'printSegments.segmentId': segment.segmentId }, + { + $set: { + 'printSegments.$.status': 'printing', + 'printSegments.$.printJobId': segmentPrintJobId, + 'printSegments.$.startedAt': new Date(), + }, + } + ); + + try { + // Extract pages for this segment + const segmentBuffer = await extractPagesFromPDF(documentBuffer, segment.pageRange); + const segmentPath = path.join(tempDir, `segment_${segment.segmentId}_${Date.now()}.pdf`); + fs.writeFileSync(segmentPath, segmentBuffer); + segmentPaths.push(segmentPath); + + // Prepare print options for segment + const printOptions: any = { + printer: printerName, + copies: segment.copies || 1, + }; + + // Set color mode based on segment + if (segment.printMode === 'color') { + // Note: pdf-to-printer may need additional options for color mode + // This depends on your printer driver + } else { + // B&W mode + // Note: pdf-to-printer may need additional options for B&W mode + } + + // Print segment + await print(segmentPath, printOptions); + + // Update segment status to 'completed' + await Order.findOneAndUpdate( + { _id: order._id, 'printSegments.segmentId': segment.segmentId }, + { + $set: { + 'printSegments.$.status': 'completed', + 'printSegments.$.completedAt': new Date(), + }, + $unset: { + 'printSegments.$.error': '', + }, + } + ); + + console.log(`✅ Segment ${segment.segmentId} printed`); + } catch (segmentError) { + // Update segment status to 'failed' + const errorMessage = segmentError instanceof Error ? segmentError.message : 'Unknown error'; + await Order.findOneAndUpdate( + { _id: order._id, 'printSegments.segmentId': segment.segmentId }, + { + $set: { + 'printSegments.$.status': 'failed', + 'printSegments.$.error': errorMessage, + }, + } + ); + console.error(`❌ Segment ${segment.segmentId} failed: ${errorMessage}`); + throw segmentError; // Re-throw to stop printing + } + } + + // Finally, print order summary LAST (so it appears FIRST physically on top) + console.log(`📄 Printing order summary (will appear on top of stack)...`); + await print(orderSummaryPath, { + printer: printerName, + copies: 1, + }); + console.log(`✅ Order summary printed`); + + console.log(`✅ Segmented print job completed: ${order.orderId}`); + } catch (error) { + console.error(`❌ Error executing segmented print job:`, error); + throw new Error(`Segmented print execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + // Cleanup temporary files + try { + if (fs.existsSync(orderSummaryPath)) { + fs.unlinkSync(orderSummaryPath); + } + segmentPaths.forEach((segmentPath) => { + if (fs.existsSync(segmentPath)) { + fs.unlinkSync(segmentPath); + } + }); + console.log(`🗑️ Cleaned up temporary files`); + } catch (cleanupError) { + console.error('Error cleaning up temporary files:', cleanupError); + } + } +} + +/** + * Execute print job with order summary + * Merges order summary with document in reverse order + */ +export async function executePrint(options: PrintJobOptions): Promise { + const tempDir = os.tmpdir(); + const tempFilePath = path.join(tempDir, `print_${Date.now()}_${options.fileName}`); + const mergedFilePath = path.join(tempDir, `merged_${Date.now()}_${options.fileName}`); + + try { + console.log(`📥 Downloading file: ${options.fileName}`); + + // Download file + await downloadFile(options.fileURL, tempFilePath); + + console.log(`✅ File downloaded: ${tempFilePath}`); + + // Validate file integrity + const validation = validateFile(tempFilePath); + if (!validation.valid) { + throw new Error(`File validation failed: ${validation.errors.join(', ')}`); + } + console.log(`✅ File validation passed`); + + // Convert to print format if needed + const printFilePath = await convertToPrintFormat(tempFilePath, options); + + // Generate order summary if order object is provided + let finalPrintPath = printFilePath; + if (options.order) { + console.log(`📄 Generating order summary PDF...`); + const orderSummaryBuffer = await generateOrderSummaryPDF(options.order); + const documentBuffer = fs.readFileSync(printFilePath); + + // Merge document with order summary (order summary goes LAST in PDF, prints FIRST physically) + console.log(`🔄 Merging document with order summary (reverse order)...`); + const mergedBuffer = await mergeDocumentWithSummary(documentBuffer, orderSummaryBuffer); + fs.writeFileSync(mergedFilePath, mergedBuffer); + finalPrintPath = mergedFilePath; + console.log(`✅ Document merged with order summary`); + } + + // Prepare print options + const printOptions: any = { + printer: options.printerName, + copies: options.printingOptions.copies || 1, + }; + + // Handle segmented printing if segments are provided + if (options.segments && options.segments.length > 0) { + const documentBuffer = fs.readFileSync(printFilePath); + await executeSegmentedPrint(options.order!, options.segments, documentBuffer, options.printerName); + return; // Segmented print handles its own cleanup + } + + // Handle page range for color/BW printing (legacy mixed printing) + if (options.printingOptions.color === 'mixed' && options.printingOptions.pageColors) { + const colorPages = options.printingOptions.pageColors.colorPages || []; + const bwPages = options.printingOptions.pageColors.bwPages || []; + + if (colorPages.length > 0) { + printOptions.pages = colorPages.join(','); + console.log(`🖨️ Printing color pages: ${colorPages.join(',')}`); + await print(finalPrintPath, printOptions); + } + + if (bwPages.length > 0) { + printOptions.pages = bwPages.join(','); + console.log(`🖨️ Printing BW pages: ${bwPages.join(',')}`); + await print(finalPrintPath, printOptions); + } + } else { + // Standard printing + console.log(`🖨️ Printing to ${options.printerName}...`); + console.log(` Options:`, { + copies: printOptions.copies, + pageSize: options.printingOptions.pageSize, + color: options.printingOptions.color, + sided: options.printingOptions.sided, + }); + + await print(finalPrintPath, printOptions); + } + + console.log(`✅ Print job completed: ${options.orderId}, printJobId: ${options.printJobId}`); + } catch (error) { + console.error(`❌ Error executing print job:`, error); + throw new Error(`Print execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + // Cleanup temporary files + try { + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + if (fs.existsSync(mergedFilePath) && mergedFilePath !== tempFilePath) { + fs.unlinkSync(mergedFilePath); + } + console.log(`🗑️ Cleaned up temporary files`); + } catch (cleanupError) { + console.error('Error cleaning up temporary files:', cleanupError); + } + } +} + diff --git a/Printing-Server/src/services/printerHealth.ts b/Printing-Server/src/services/printerHealth.ts new file mode 100644 index 0000000..2591315 --- /dev/null +++ b/Printing-Server/src/services/printerHealth.ts @@ -0,0 +1,273 @@ +/** + * Printer Health Monitoring Service + * Monitors printer health and updates MongoDB + */ + +import { Printer } from '../models/Printer'; +import { Order } from '../models/Order'; +import { + checkWindowsPrinterStatus, + getWindowsPrinters, + getPrinterDriverName, + PrinterStatus, +} from '../utils/printerDriver'; +import { recordPrinterOffline, recordPrinterOnline } from './metricsCollector'; + +const HEALTH_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); +const PRINTER_ID = process.env.PRINTER_ID || 'printer_001'; +const PRINTER_NAME = process.env.PRINTER_NAME || 'Default Printer'; +const SYSTEM_NAME = (process.env.SYSTEM_NAME || 'Windows') as 'Windows' | 'Linux'; + +/** + * Check health of a specific printer + */ +export async function checkPrinterHealth(printer: any): Promise { + try { + const status = await checkWindowsPrinterStatus(printer.name); + + // Get driver name if not set + let driverName = printer.driver_name; + if (!driverName) { + driverName = await getPrinterDriverName(printer.name) || undefined; + } + + // Update printer status in MongoDB + await updatePrinterStatus(printer._id.toString(), status, driverName); + + // Record metrics + if (status.status === 'offline' || status.status === 'error') { + recordPrinterOffline(); + } else if (status.status === 'online' || status.status === 'busy') { + recordPrinterOnline(); + } + + return status; + } catch (error) { + console.error(`Error checking printer health for ${printer.name}:`, error); + + // Update to error status + await updatePrinterStatus(printer._id.toString(), { + available: false, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + queueLength: 0, + }); + + return { + available: false, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + queueLength: 0, + }; + } +} + +/** + * Update printer status in MongoDB + * Auto-pauses printing on hard errors, auto-resumes on recovery + */ +export async function updatePrinterStatus( + printerId: string, + status: PrinterStatus, + driverName?: string +): Promise { + try { + // Get current printer state + const currentPrinter = await Printer.findById(printerId); + const wasInError = currentPrinter?.status === 'error' || currentPrinter?.status === 'offline'; + const isNowInError = status.status === 'error' || status.status === 'offline'; + const isNowRecovered = (status.status === 'online' || status.status === 'busy') && wasInError; + + const updateData: any = { + status: status.status, + last_seen_at: new Date(), + queue_length: status.queueLength, + error_message: status.errorMessage || undefined, + system_name: SYSTEM_NAME, + }; + + if (driverName) { + updateData.driver_name = driverName; + } + + // Auto-pause printing on hard errors + if (isNowInError && !wasInError) { + updateData.autoPrintEnabled = false; + console.log(`⏸️ Auto-pausing printing due to printer error: ${status.errorMessage || status.status}`); + } + + // Auto-resume printing when printer recovers + if (isNowRecovered) { + updateData.autoPrintEnabled = true; + updateData.$unset = { error_message: '' }; + console.log(`▶️ Auto-resuming printing: printer recovered to ${status.status}`); + } else if (status.status === 'online' || status.status === 'busy') { + // Clear error message if printer is online (but don't auto-resume if it wasn't in error) + updateData.$unset = { error_message: '' }; + } + + await Printer.findByIdAndUpdate(printerId, { + $set: updateData, + }); + + console.log(`✅ Updated printer status: ${status.status} (queue: ${status.queueLength}, autoPrint: ${updateData.autoPrintEnabled ?? currentPrinter?.autoPrintEnabled ?? true})`); + } catch (error) { + console.error('Error updating printer status:', error); + } +} + +/** + * Get available printer (online and not busy) + */ +export async function getAvailablePrinter(): Promise { + try { + // Find active printers that are online or busy + const printers = await Printer.find({ + isActive: true, + autoPrintEnabled: true, + status: { $in: ['online', 'busy'] }, + }).sort({ queue_length: 1 }); // Prefer printer with lowest queue + + if (printers.length === 0) { + return null; + } + + // Return the first available printer + return printers[0]; + } catch (error) { + console.error('Error getting available printer:', error); + return null; + } +} + +/** + * Check all printers health + */ +export async function checkAllPrintersHealth(): Promise { + try { + console.log('🔍 Checking printer health...'); + + // Get all active printers + const printers = await Printer.find({ isActive: true }); + + if (printers.length === 0) { + console.log('⚠️ No active printers found'); + return; + } + + // Check each printer + for (const printer of printers) { + await checkPrinterHealth(printer); + } + + // Update queue lengths based on actual pending orders + await updateQueueLengths(); + } catch (error) { + console.error('Error checking all printers health:', error); + } +} + +/** + * Update queue lengths based on actual pending orders + */ +async function updateQueueLengths(): Promise { + try { + const printers = await Printer.find({ isActive: true }); + + for (const printer of printers) { + // Count orders assigned to this printer that are pending or printing + const queueLength = await Order.countDocuments({ + printerId: printer._id.toString(), + printStatus: { $in: ['pending', 'printing'] }, + paymentStatus: 'completed', + }); + + // Update queue length + await Printer.findByIdAndUpdate(printer._id, { + $set: { queue_length: queueLength }, + }); + } + } catch (error) { + console.error('Error updating queue lengths:', error); + } +} + +/** + * Initialize or update printer record + */ +export async function initializePrinter(): Promise { + try { + console.log('🔧 Initializing printer...'); + + // Get Windows printers + const windowsPrinters = await getWindowsPrinters(); + console.log(`📋 Found ${windowsPrinters.length} Windows printer(s):`, windowsPrinters); + + if (windowsPrinters.length === 0) { + console.log('⚠️ No printers found on system'); + return; + } + + // Use the first available printer or the one specified in env + const printerName = PRINTER_NAME !== 'Default Printer' && windowsPrinters.includes(PRINTER_NAME) + ? PRINTER_NAME + : windowsPrinters[0]; + + // Check if printer record exists + let printer = await Printer.findOne({ + $or: [ + { printer_id: PRINTER_ID }, + { name: printerName }, + ], + }); + + if (!printer) { + // Create new printer record + console.log(`➕ Creating printer record: ${printerName}`); + printer = new Printer({ + name: printerName, + printer_id: PRINTER_ID, + printer_name: printerName, + status: 'offline', + connectionType: 'usb', + connectionString: 'USB001', + system_name: SYSTEM_NAME, + queue_length: 0, + isActive: true, + autoPrintEnabled: true, + }); + await printer.save(); + } else { + // Update existing printer + console.log(`🔄 Updating printer record: ${printerName}`); + await Printer.findByIdAndUpdate(printer._id, { + $set: { + name: printerName, + printer_id: PRINTER_ID, + printer_name: printerName, + system_name: SYSTEM_NAME, + }, + }); + } + + // Check health immediately + const status = await checkPrinterHealth(printer); + console.log(`✅ Printer initialized: ${printerName} (${status.status})`); + } catch (error) { + console.error('Error initializing printer:', error); + } +} + +/** + * Start health monitoring loop + */ +export function startHealthMonitoring(): void { + console.log(`🔄 Starting health monitoring (interval: ${HEALTH_CHECK_INTERVAL}ms)`); + + // Check immediately + checkAllPrintersHealth(); + + // Then check at intervals + setInterval(checkAllPrintersHealth, HEALTH_CHECK_INTERVAL); +} + diff --git a/Printing-Server/src/services/segmentAnalyzer.ts b/Printing-Server/src/services/segmentAnalyzer.ts new file mode 100644 index 0000000..3726452 --- /dev/null +++ b/Printing-Server/src/services/segmentAnalyzer.ts @@ -0,0 +1,187 @@ +/** + * Segment Analyzer Service + * Analyzes documents to identify print segments based on color/BW pages + */ + +import { IOrder } from '../models/Order'; +import { randomUUID } from 'crypto'; + +export interface PrintSegment { + segmentId: string; + pageRange: { + start: number; + end: number; + }; + printMode: 'color' | 'bw'; + copies: number; + paperSize: 'A4' | 'A3'; + duplex: boolean; + status: 'pending' | 'printing' | 'completed' | 'failed'; + printJobId?: string; + startedAt?: Date; + completedAt?: Date; + error?: string; +} + +/** + * Analyze document to identify print segments + * Returns segments sorted in execution order (last segment first for reverse printing) + */ +export async function analyzeDocumentSegments(order: IOrder): Promise { + const segments: PrintSegment[] = []; + const pageCount = order.printingOptions?.pageCount || 1; + const copies = order.printingOptions?.copies || 1; + const paperSize = order.printingOptions?.pageSize || 'A4'; + const duplex = order.printingOptions?.sided === 'double'; + const colorMode = order.printingOptions?.color || 'bw'; + + // If not mixed printing, create a single segment + if (colorMode !== 'mixed') { + const segment: PrintSegment = { + segmentId: randomUUID(), + pageRange: { + start: 1, + end: pageCount, + }, + printMode: colorMode === 'color' ? 'color' : 'bw', + copies, + paperSize, + duplex, + status: 'pending', + }; + segments.push(segment); + return segments; + } + + // Mixed printing: analyze page colors + const pageColors = order.printingOptions?.pageColors; + + if (!pageColors) { + // No page color info, create single segment with mixed mode + const segment: PrintSegment = { + segmentId: randomUUID(), + pageRange: { + start: 1, + end: pageCount, + }, + printMode: 'color', // Default to color if mixed but no info + copies, + paperSize, + duplex, + status: 'pending', + }; + segments.push(segment); + return segments; + } + + // Handle both single object and array format for pageColors + let colorPages: number[] = []; + let bwPages: number[] = []; + + if (Array.isArray(pageColors)) { + // Per-file page colors + colorPages = pageColors.reduce((acc, pc) => { + return acc.concat(pc.colorPages || []); + }, [] as number[]); + bwPages = pageColors.reduce((acc, pc) => { + return acc.concat(pc.bwPages || []); + }, [] as number[]); + } else { + // Single object format + colorPages = pageColors.colorPages || []; + bwPages = pageColors.bwPages || []; + } + + // Sort page numbers + colorPages.sort((a, b) => a - b); + bwPages.sort((a, b) => a - b); + + // Group consecutive pages into segments + const colorSegments = groupConsecutivePages(colorPages); + const bwSegments = groupConsecutivePages(bwPages); + + // Create segments for color pages + for (const range of colorSegments) { + segments.push({ + segmentId: randomUUID(), + pageRange: range, + printMode: 'color', + copies, + paperSize, + duplex, + status: 'pending', + }); + } + + // Create segments for BW pages + for (const range of bwSegments) { + segments.push({ + segmentId: randomUUID(), + pageRange: range, + printMode: 'bw', + copies, + paperSize, + duplex, + status: 'pending', + }); + } + + // Sort segments by page range (ascending) - will be reversed for printing + segments.sort((a, b) => a.pageRange.start - b.pageRange.start); + + return segments; +} + +/** + * Group consecutive page numbers into ranges + */ +function groupConsecutivePages(pages: number[]): Array<{ start: number; end: number }> { + if (pages.length === 0) { + return []; + } + + const ranges: Array<{ start: number; end: number }> = []; + let start = pages[0]; + let end = pages[0]; + + for (let i = 1; i < pages.length; i++) { + if (pages[i] === end + 1) { + // Consecutive page + end = pages[i]; + } else { + // Break in sequence + ranges.push({ start, end }); + start = pages[i]; + end = pages[i]; + } + } + + // Add the last range + ranges.push({ start, end }); + + return ranges; +} + +/** + * Get segments in reverse execution order + * Last segment first, so it prints first physically + */ +export function getSegmentsInReverseOrder(segments: PrintSegment[]): PrintSegment[] { + // Sort by page range descending (last segment first) + return [...segments].sort((a, b) => { + // Sort by end page descending, then by start page descending + if (b.pageRange.end !== a.pageRange.end) { + return b.pageRange.end - a.pageRange.end; + } + return b.pageRange.start - a.pageRange.start; + }); +} + +/** + * Get execution order for segments + * Returns segments sorted for printing (last segment first) + */ +export function getExecutionOrder(segments: PrintSegment[]): PrintSegment[] { + return getSegmentsInReverseOrder(segments); +} + diff --git a/Printing-Server/src/services/shutdownHandler.ts b/Printing-Server/src/services/shutdownHandler.ts new file mode 100644 index 0000000..1d14c30 --- /dev/null +++ b/Printing-Server/src/services/shutdownHandler.ts @@ -0,0 +1,88 @@ +/** + * Shutdown Handler Service + * Handles graceful shutdown by resetting orders owned by this worker + */ + +import { Order } from '../models/Order'; +import { PrintLog } from '../models/PrintLog'; +import { getWorkerId } from '../utils/workerId'; +import { connectMongoDB } from '../config/mongodb'; + +/** + * Reset all orders owned by this worker on shutdown + */ +export async function handleShutdown(): Promise { + try { + const workerId = getWorkerId(); + console.log(`🛑 Shutting down worker: ${workerId}`); + console.log(`🔄 Resetting orders owned by this worker...`); + + // Ensure MongoDB is connected + await connectMongoDB(); + + // Find all orders with printStatus = 'printing' AND printingBy = workerId + const ownedOrders = await Order.find({ + printStatus: 'printing', + printingBy: workerId, + }); + + if (ownedOrders.length === 0) { + console.log(`✅ No orders to reset on shutdown`); + return 0; + } + + console.log(`📋 Found ${ownedOrders.length} order(s) owned by this worker`); + + let resetCount = 0; + + // Reset each order to pending + for (const order of ownedOrders) { + try { + await Order.findByIdAndUpdate( + order._id, + { + $set: { + printStatus: 'pending', + printError: 'Server shutdown - order reset to pending', + }, + $unset: { + printStartedAt: '', + printerId: '', + printerName: '', + printingBy: '', + printJobId: '', + }, + } + ); + + // Log shutdown action + try { + await PrintLog.create({ + action: 'server_shutdown', + orderId: order.orderId, + printJobId: order.printJobId, + previousStatus: 'printing', + newStatus: 'pending', + reason: 'Server shutdown - order reset to pending', + timestamp: new Date(), + metadata: { workerId }, + }); + } catch (logError) { + console.error('Error logging shutdown action:', logError); + } + + resetCount++; + console.log(`✅ Reset order ${order.orderId} to pending`); + } catch (error) { + console.error(`❌ Error resetting order ${order.orderId}:`, error); + } + } + + console.log(`✅ Reset ${resetCount} order(s) on shutdown`); + return resetCount; + } catch (error) { + console.error('❌ Error in shutdown handler:', error); + return 0; + } +} + diff --git a/Printing-Server/src/services/staleJobDetector.ts b/Printing-Server/src/services/staleJobDetector.ts new file mode 100644 index 0000000..64bd8c7 --- /dev/null +++ b/Printing-Server/src/services/staleJobDetector.ts @@ -0,0 +1,162 @@ +/** + * Stale Job Detector + * Detects and recovers orders with stale heartbeats (crashed servers) + */ + +import { Order } from '../models/Order'; +import { PrintLog } from '../models/PrintLog'; +import { markOrderAsFailed } from '../utils/atomicUpdate'; + +const STALE_THRESHOLD_MINUTES = parseInt(process.env.STALE_THRESHOLD_MINUTES || '5', 10); // Default: 5 minutes + +/** + * Detect and recover stale printing jobs + * A job is considered stale if: + * - printStatus = 'printing' + * - printingHeartbeatAt is older than threshold OR doesn't exist + */ +export async function detectAndRecoverStaleJobs(): Promise { + try { + const threshold = new Date(Date.now() - STALE_THRESHOLD_MINUTES * 60 * 1000); + + // Find orders with stale heartbeats + const staleOrders = await Order.find({ + printStatus: 'printing', + $or: [ + { printingHeartbeatAt: { $lt: threshold } }, // Heartbeat too old + { printingHeartbeatAt: { $exists: false } }, // No heartbeat (old jobs) + ], + }); + + if (staleOrders.length === 0) { + return 0; + } + + console.log(`⚠️ Found ${staleOrders.length} stale printing job(s)`); + + let recoveredCount = 0; + + for (const order of staleOrders) { + try { + // Check if heartbeat is actually stale + const heartbeatAge = order.printingHeartbeatAt + ? Date.now() - order.printingHeartbeatAt.getTime() + : Infinity; + const heartbeatAgeMinutes = Math.floor(heartbeatAge / (60 * 1000)); + + if (heartbeatAgeMinutes >= STALE_THRESHOLD_MINUTES) { + console.log(`🔄 Recovering stale order: ${order.orderId} (heartbeat age: ${heartbeatAgeMinutes} minutes)`); + + // Reset order to pending using atomic update + // Note: This will only work if the order is owned by the current worker + // For orphaned orders (no printingBy), we need to reset directly + if (order.printingBy) { + // Try using atomic update (will only work if owned by this worker) + const reset = await markOrderAsFailed( + order._id.toString(), + `Stale print job recovered: heartbeat not updated for ${heartbeatAgeMinutes} minutes. Server may have crashed.` + ); + + if (!reset) { + // Order owned by different worker, reset directly + await Order.findByIdAndUpdate( + order._id, + { + $set: { + printStatus: 'pending', + printError: `Stale print job recovered: heartbeat not updated for ${heartbeatAgeMinutes} minutes. Server may have crashed.`, + printAttempt: (order.printAttempt || 0) + 1, + }, + $unset: { + printStartedAt: '', + printerId: '', + printerName: '', + printingBy: '', + printJobId: '', + printingHeartbeatAt: '', + }, + } + ); + } + } else { + // Orphaned order (no printingBy), reset directly + await Order.findByIdAndUpdate( + order._id, + { + $set: { + printStatus: 'pending', + printError: `Stale print job recovered: orphaned order (no worker ownership).`, + printAttempt: (order.printAttempt || 0) + 1, + }, + $unset: { + printStartedAt: '', + printerId: '', + printerName: '', + printingBy: '', + printJobId: '', + printingHeartbeatAt: '', + }, + } + ); + } + + // Log recovery action + try { + await PrintLog.create({ + action: 'stale_print_recovered', + orderId: order.orderId, + printJobId: order.printJobId, + previousStatus: 'printing', + newStatus: 'pending', + reason: `Stale print job recovered: heartbeat age ${heartbeatAgeMinutes} minutes`, + timestamp: new Date(), + metadata: { + heartbeatAgeMinutes, + printingBy: order.printingBy || 'orphaned', + thresholdMinutes: STALE_THRESHOLD_MINUTES, + }, + }); + } catch (logError) { + console.error('Error logging stale job recovery:', logError); + } + + recoveredCount++; + } + } catch (error) { + console.error(`❌ Error recovering stale order ${order.orderId}:`, error); + } + } + + if (recoveredCount > 0) { + console.log(`✅ Recovered ${recoveredCount} stale printing job(s)`); + } + + return recoveredCount; + } catch (error) { + console.error('❌ Error detecting stale jobs:', error); + return 0; + } +} + +/** + * Get count of stale jobs without recovering them + */ +export async function getStaleJobCount(): Promise { + try { + const threshold = new Date(Date.now() - STALE_THRESHOLD_MINUTES * 60 * 1000); + + const count = await Order.countDocuments({ + printStatus: 'printing', + $or: [ + { printingHeartbeatAt: { $lt: threshold } }, + { printingHeartbeatAt: { $exists: false } }, + ], + }); + + return count; + } catch (error) { + console.error('❌ Error counting stale jobs:', error); + return 0; + } +} + diff --git a/Printing-Server/src/services/startupRecovery.ts b/Printing-Server/src/services/startupRecovery.ts new file mode 100644 index 0000000..2410587 --- /dev/null +++ b/Printing-Server/src/services/startupRecovery.ts @@ -0,0 +1,107 @@ +/** + * Startup Recovery Service + * Recovers crashed jobs on server startup + */ + +import { Order } from '../models/Order'; +import { PrintLog } from '../models/PrintLog'; +import { getWorkerId } from '../utils/workerId'; + +/** + * Recover crashed jobs on startup + * Finds orders with printStatus = 'printing' that are: + * - Owned by this worker (printingBy = workerId) + * - Or orphaned (printingBy = null/undefined) + * Resets them to 'pending' and increments printAttempt + */ +export async function recoverCrashedJobs(): Promise { + try { + const workerId = getWorkerId(); + console.log(`🔄 Starting crash recovery for worker: ${workerId}`); + + // Find orders owned by this worker that are in 'printing' state + const ownedOrders = await Order.find({ + printStatus: 'printing', + printingBy: workerId, + }); + + // Find orphaned orders (no worker ownership) + const orphanedOrders = await Order.find({ + printStatus: 'printing', + $or: [ + { printingBy: { $exists: false } }, + { printingBy: null }, + ], + }); + + const allCrashedOrders = [...ownedOrders, ...orphanedOrders]; + + if (allCrashedOrders.length === 0) { + console.log('✅ No crashed jobs to recover'); + return 0; + } + + console.log(`⚠️ Found ${allCrashedOrders.length} crashed job(s) to recover (${ownedOrders.length} owned, ${orphanedOrders.length} orphaned)`); + + let recoveredCount = 0; + + for (const order of allCrashedOrders) { + try { + const isOwned = order.printingBy === workerId; + const newAttempt = (order.printingAttempt || 0) + 1; + + // Reset order to pending + await Order.findByIdAndUpdate( + order._id, + { + $set: { + printStatus: 'pending', + printError: `Server crash recovery: order was in 'printing' state on startup. Reset to pending.`, + printAttempt: newAttempt, + }, + $unset: { + printStartedAt: '', + printerId: '', + printerName: '', + printingBy: '', + printJobId: '', + printingHeartbeatAt: '', + }, + } + ); + + // Log recovery action + try { + await PrintLog.create({ + action: 'crash_recovery', + orderId: order.orderId, + printJobId: order.printJobId, + previousStatus: 'printing', + newStatus: 'pending', + reason: `Server crash recovery: order was in 'printing' state on startup`, + timestamp: new Date(), + metadata: { + workerId, + wasOwned: isOwned, + printAttempt: newAttempt, + }, + }); + } catch (logError) { + console.error('Error logging crash recovery:', logError); + } + + recoveredCount++; + console.log(`✅ Recovered crashed order: ${order.orderId} (attempt: ${newAttempt})`); + } catch (error) { + console.error(`❌ Error recovering crashed order ${order.orderId}:`, error); + } + } + + console.log(`✅ Crash recovery completed: ${recoveredCount} order(s) recovered`); + return recoveredCount; + } catch (error) { + console.error('❌ Error in crash recovery:', error); + return 0; + } +} + diff --git a/Printing-Server/src/types/node-fetch.d.ts b/Printing-Server/src/types/node-fetch.d.ts new file mode 100644 index 0000000..36e5956 --- /dev/null +++ b/Printing-Server/src/types/node-fetch.d.ts @@ -0,0 +1,22 @@ +declare module 'node-fetch' { + export default function fetch( + url: string | URL, + init?: RequestInit + ): Promise; + + export class Response { + ok: boolean; + status: number; + statusText: string; + buffer(): Promise; + json(): Promise; + text(): Promise; + } + + export interface RequestInit { + method?: string; + headers?: Record; + body?: any; + } +} + diff --git a/Printing-Server/src/utils/atomicUpdate.ts b/Printing-Server/src/utils/atomicUpdate.ts new file mode 100644 index 0000000..ff2f7af --- /dev/null +++ b/Printing-Server/src/utils/atomicUpdate.ts @@ -0,0 +1,354 @@ +/** + * Atomic update utilities for order state transitions + */ + +import { Order } from '../models/Order'; +import { PrintLog } from '../models/PrintLog'; +import { randomUUID } from 'crypto'; +import { getWorkerId } from './workerId'; +import { validateTransition } from './stateMachine'; + +export interface ClaimOrderResult { + success: boolean; + order?: any; + error?: string; + printJobId?: string; +} + +/** + * Atomically claim an order for printing + * Only updates if order is still in 'pending' state + * Generates printJobId for idempotency and sets worker ownership + * Validates state transition and enforces retry limits + */ +export async function claimOrderForPrinting( + orderId: string, + printerId: string, + printerName: string +): Promise { + try { + const workerId = getWorkerId(); + + // Get current order to check state and retry limits + const currentOrder = await Order.findById(orderId); + if (!currentOrder) { + return { + success: false, + error: 'Order not found', + }; + } + + // Validate state transition: pending → printing + const transitionValidation = validateTransition( + currentOrder.printStatus, + 'printing', + false + ); + if (!transitionValidation.allowed) { + return { + success: false, + error: `Invalid state transition: ${transitionValidation.reason}`, + }; + } + + // Check retry limits + const maxAttempts = currentOrder.maxPrintAttempts || 3; + const currentAttempt = currentOrder.printAttempt || 0; + if (currentAttempt >= maxAttempts) { + // Mark as failed with max attempts reached + await Order.findByIdAndUpdate(orderId, { + $set: { + printError: 'Max print attempts reached. Requires admin action.', + }, + }); + + // Log max attempts reached + try { + await PrintLog.create({ + action: 'max_attempts_reached', + orderId: currentOrder.orderId, + printJobId: currentOrder.printJobId, + previousStatus: currentOrder.printStatus, + newStatus: 'pending', + reason: `Max print attempts (${maxAttempts}) reached. Requires admin action.`, + timestamp: new Date(), + metadata: { printAttempt: currentAttempt, maxPrintAttempts: maxAttempts }, + }); + } catch (logError) { + console.error('Error logging max attempts:', logError); + } + + return { + success: false, + error: `Max print attempts (${maxAttempts}) reached. Requires admin action.`, + }; + } + + const printJobId = randomUUID(); + const newAttempt = currentAttempt + 1; + + const order = await Order.findOneAndUpdate( + { + _id: orderId, + printStatus: 'pending', + paymentStatus: 'completed', + // Ensure we haven't exceeded max attempts + $or: [ + { printAttempt: { $lt: maxAttempts } }, + { printAttempt: { $exists: false } }, + ], + }, + { + $set: { + printStatus: 'printing', + printStartedAt: new Date(), + printerId: printerId, + printerName: printerName, + printJobId: printJobId, + printingBy: workerId, + printAttempt: newAttempt, + printingHeartbeatAt: new Date(), // Initialize heartbeat + }, + }, + { + new: true, + runValidators: true, + } + ); + + if (!order) { + return { + success: false, + error: 'Order not found, already claimed, or max attempts reached', + }; + } + + // Log state transition + try { + await PrintLog.create({ + action: 'state_transition', + orderId: order.orderId, + printJobId: printJobId, + previousStatus: 'pending', + newStatus: 'printing', + reason: `Claimed for printing (attempt ${newAttempt}/${maxAttempts})`, + timestamp: new Date(), + metadata: { workerId, printerId, printerName, printAttempt: newAttempt }, + }); + } catch (logError) { + console.error('Error logging state transition:', logError); + } + + console.log(`✅ Claimed order ${orderId} with printJobId: ${printJobId}, workerId: ${workerId}, attempt: ${newAttempt}/${maxAttempts}`); + + return { + success: true, + order, + printJobId, + }; + } catch (error) { + console.error('Error claiming order:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Mark order as printed + * Only allows the owning worker to complete the job + * Validates state transition: printing → printed + */ +export async function markOrderAsPrinted(orderId: string): Promise { + try { + const workerId = getWorkerId(); + + // Get current order to validate transition + const currentOrder = await Order.findById(orderId); + if (!currentOrder) { + console.warn(`⚠️ Order ${orderId} not found`); + return false; + } + + // Validate state transition: printing → printed + const transitionValidation = validateTransition( + currentOrder.printStatus, + 'printed', + false + ); + if (!transitionValidation.allowed) { + console.warn(`⚠️ Invalid state transition for order ${orderId}: ${transitionValidation.reason}`); + return false; + } + + const result = await Order.findOneAndUpdate( + { + _id: orderId, + printingBy: workerId, // Only allow owning worker + printStatus: 'printing', + }, + { + $set: { + printStatus: 'printed', + printCompletedAt: new Date(), + }, + $unset: { + printError: '', + printingBy: '', + printingHeartbeatAt: '', // Clear heartbeat + }, + }, + { new: true } + ); + + if (!result) { + console.warn(`⚠️ Cannot mark order ${orderId} as printed: not owned by worker ${workerId}`); + return false; + } + + // Log state transition + try { + await PrintLog.create({ + action: 'state_transition', + orderId: result.orderId, + printJobId: result.printJobId, + previousStatus: 'printing', + newStatus: 'printed', + reason: 'Print job completed successfully', + timestamp: new Date(), + metadata: { workerId }, + }); + } catch (logError) { + console.error('Error logging state transition:', logError); + } + + return true; + } catch (error) { + console.error('Error marking order as printed:', error); + return false; + } +} + +/** + * Mark order as failed and reset to pending + * Only allows the owning worker to reset the job + * Validates state transition: printing → pending + */ +export async function markOrderAsFailed( + orderId: string, + errorMessage: string +): Promise { + try { + const workerId = getWorkerId(); + + // Get current order to validate transition and increment printAttempt + const currentOrder = await Order.findById(orderId); + if (!currentOrder) { + console.warn(`⚠️ Order ${orderId} not found`); + return false; + } + + // Validate state transition: printing → pending + const transitionValidation = validateTransition( + currentOrder.printStatus, + 'pending', + false + ); + if (!transitionValidation.allowed) { + console.warn(`⚠️ Invalid state transition for order ${orderId}: ${transitionValidation.reason}`); + return false; + } + + const newAttempt = (currentOrder.printAttempt || 0) + 1; + const previousPrintJobId = currentOrder.printJobId; + + const result = await Order.findOneAndUpdate( + { + _id: orderId, + printingBy: workerId, // Only allow owning worker + printStatus: 'printing', + }, + { + $set: { + printStatus: 'pending', + printError: errorMessage, + printAttempt: newAttempt, + }, + $unset: { + printStartedAt: '', + printerId: '', + printerName: '', + printingBy: '', + printJobId: '', // Clear printJobId to allow new attempt + printingHeartbeatAt: '', // Clear heartbeat + }, + }, + { new: true } + ); + + if (!result) { + console.warn(`⚠️ Cannot reset order ${orderId}: not owned by worker ${workerId}`); + return false; + } + + // Log state transition + try { + await PrintLog.create({ + action: 'state_transition', + orderId: result.orderId, + printJobId: previousPrintJobId, // Preserve old printJobId in log + previousStatus: 'printing', + newStatus: 'pending', + reason: `Print job failed: ${errorMessage}`, + timestamp: new Date(), + metadata: { workerId, printAttempt: newAttempt, error: errorMessage }, + }); + } catch (logError) { + console.error('Error logging state transition:', logError); + } + + return true; + } catch (error) { + console.error('Error marking order as failed:', error); + return false; + } +} + +/** + * Check if a printJobId has already been printed (idempotency check) + * Checks both orders collection and print_logs to ensure absolute idempotency + */ +export async function isPrintJobIdAlreadyPrinted(printJobId: string): Promise { + try { + // Check orders collection + const order = await Order.findOne({ + printJobId: printJobId, + printStatus: 'printed', + }); + + if (order) { + console.log(`⏭️ Print job ${printJobId} already printed (found in orders)`); + return true; + } + + // Check print_logs for any successful print with this printJobId + const logEntry = await PrintLog.findOne({ + printJobId: printJobId, + action: { $in: ['state_transition', 'print_completed'] }, + newStatus: 'printed', + }); + + if (logEntry) { + console.log(`⏭️ Print job ${printJobId} already printed (found in logs)`); + return true; + } + + return false; + } catch (error) { + console.error('Error checking printJobId:', error); + // Fail-safe: if we can't check, assume it's not printed (safer than assuming it is) + return false; + } +} + diff --git a/Printing-Server/src/utils/capabilityValidator.ts b/Printing-Server/src/utils/capabilityValidator.ts new file mode 100644 index 0000000..1185d5a --- /dev/null +++ b/Printing-Server/src/utils/capabilityValidator.ts @@ -0,0 +1,76 @@ +/** + * Capability Validator Utility + * Validates print jobs against printer capabilities before printing + */ + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Validate order against printer capabilities + */ +export function validateCapabilities( + order: any, + printer: any +): ValidationResult { + const errors: string[] = []; + + if (!printer.capabilities) { + // If no capabilities defined, skip validation (backward compatibility) + return { valid: true, errors: [] }; + } + + const capabilities = printer.capabilities; + const printingOptions = order.printingOptions; + + // Check page size + if (capabilities.maxPaperSize) { + const orderPageSize = printingOptions?.pageSize; + const maxPaperSize = capabilities.maxPaperSize; + + // Paper size hierarchy: A3 > A4 > Letter + const sizeOrder: Record = { + 'A3': 3, + 'A4': 2, + 'Letter': 1, + }; + + const orderSizeValue = sizeOrder[orderPageSize] || 0; + const maxSizeValue = sizeOrder[maxPaperSize] || 0; + + if (orderSizeValue > maxSizeValue) { + errors.push(`Page size ${orderPageSize} exceeds printer maximum ${maxPaperSize}`); + } + } + + // Check color support + if (printingOptions?.color === 'color' && capabilities.supportsColor === false) { + errors.push('Order requires color printing but printer does not support color'); + } + + // Check duplex support + if (printingOptions?.sided === 'double' && capabilities.supportsDuplex === false) { + errors.push('Order requires duplex printing but printer does not support duplex'); + } + + // Check max copies + if (capabilities.maxCopies && printingOptions?.copies > capabilities.maxCopies) { + errors.push(`Order requires ${printingOptions.copies} copies but printer maximum is ${capabilities.maxCopies}`); + } + + // Check supported page sizes + if (capabilities.supportedPageSizes && capabilities.supportedPageSizes.length > 0) { + const orderPageSize = printingOptions?.pageSize; + if (!capabilities.supportedPageSizes.includes(orderPageSize)) { + errors.push(`Page size ${orderPageSize} not supported. Supported sizes: ${capabilities.supportedPageSizes.join(', ')}`); + } + } + + return { + valid: errors.length === 0, + errors, + }; +} + diff --git a/Printing-Server/src/utils/fileValidator.ts b/Printing-Server/src/utils/fileValidator.ts new file mode 100644 index 0000000..0a2e559 --- /dev/null +++ b/Printing-Server/src/utils/fileValidator.ts @@ -0,0 +1,118 @@ +/** + * File Validator Utility + * Validates files before printing to prevent errors + */ + +import * as fs from 'fs'; + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +const MIN_FILE_SIZE = 500; // Minimum file size in bytes (500 bytes - increased for fail-safe) +const MAX_FILE_SIZE = 100 * 1024 * 1024; // Maximum file size (100 MB) +const PDF_MAGIC_BYTES = Buffer.from('%PDF'); + +/** + * Validate that file exists + */ +export function validateFileExists(filePath: string): ValidationResult { + if (!fs.existsSync(filePath)) { + return { + valid: false, + errors: [`File does not exist: ${filePath}`], + }; + } + + return { valid: true, errors: [] }; +} + +/** + * Validate file size (minimum and maximum size check) + */ +export function validateFileSize(filePath: string, minSize: number = MIN_FILE_SIZE, maxSize: number = MAX_FILE_SIZE): ValidationResult { + try { + const stats = fs.statSync(filePath); + + if (stats.size < minSize) { + return { + valid: false, + errors: [`File size too small: ${stats.size} bytes (minimum: ${minSize} bytes)`], + }; + } + + if (stats.size > maxSize) { + return { + valid: false, + errors: [`File size too large: ${stats.size} bytes (maximum: ${maxSize} bytes)`], + }; + } + + return { valid: true, errors: [] }; + } catch (error) { + return { + valid: false, + errors: [`Error checking file size: ${error instanceof Error ? error.message : 'Unknown error'}`], + }; + } +} + +/** + * Validate PDF header (magic bytes) + */ +export function validatePDFHeader(filePath: string): ValidationResult { + try { + const fd = fs.openSync(filePath, 'r'); + const buffer = Buffer.alloc(4); + fs.readSync(fd, buffer, 0, 4, 0); + fs.closeSync(fd); + + if (!buffer.equals(PDF_MAGIC_BYTES)) { + return { + valid: false, + errors: [`Invalid PDF header. Expected '%PDF', got: ${buffer.toString('utf8', 0, 4)}`], + }; + } + + return { valid: true, errors: [] }; + } catch (error) { + return { + valid: false, + errors: [`Error reading PDF header: ${error instanceof Error ? error.message : 'Unknown error'}`], + }; + } +} + +/** + * Combined file validation + * Validates file existence, size, and PDF header + */ +export function validateFile(filePath: string, minSize: number = MIN_FILE_SIZE): ValidationResult { + const errors: string[] = []; + + // Check file exists + const existsResult = validateFileExists(filePath); + if (!existsResult.valid) { + errors.push(...existsResult.errors); + return { valid: false, errors }; + } + + // Check file size + const sizeResult = validateFileSize(filePath, minSize); + if (!sizeResult.valid) { + errors.push(...sizeResult.errors); + } + + // Check PDF header + const headerResult = validatePDFHeader(filePath); + if (!headerResult.valid) { + errors.push(...headerResult.errors); + } + + return { + valid: errors.length === 0, + errors, + }; +} + diff --git a/Printing-Server/src/utils/pdfMerger.ts b/Printing-Server/src/utils/pdfMerger.ts new file mode 100644 index 0000000..2be3e0e --- /dev/null +++ b/Printing-Server/src/utils/pdfMerger.ts @@ -0,0 +1,94 @@ +/** + * PDF Merger Utility + * Merges PDFs in reverse order to ensure correct physical print stack + * + * IMPORTANT: Most printers print in REVERSE ORDER (last page first) + * Therefore, to get Order Summary on TOP physically: + * - Order Summary must be LAST in the PDF file + * - Document pages come FIRST in the PDF file + * + * Physical output (top to bottom): + * Order Summary (top) + * Document Page 1 + * Document Page 2 + * ... + * Document Page N + */ + +import { PDFDocument } from 'pdf-lib'; +import * as fs from 'fs'; + +/** + * Merge PDFs in reverse order + * @param documentBuffers - Array of PDF buffers for document pages (will be first in PDF) + * @param orderSummaryBuffer - Order summary PDF buffer (will be last in PDF, prints first) + * @returns Merged PDF buffer + */ +export async function mergePDFsInReverseOrder( + documentBuffers: Buffer[], + orderSummaryBuffer: Buffer +): Promise { + try { + // Create a new PDF document + const mergedPdf = await PDFDocument.create(); + + // First, add all document pages (these will be in the middle/end of physical stack) + for (const docBuffer of documentBuffers) { + try { + const sourcePdf = await PDFDocument.load(docBuffer); + const pages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices()); + pages.forEach((page) => mergedPdf.addPage(page)); + } catch (error) { + console.error('Error merging document PDF:', error); + throw new Error(`Failed to merge document PDF: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Finally, add order summary page (this will be LAST in PDF, so it prints FIRST physically) + try { + const summaryPdf = await PDFDocument.load(orderSummaryBuffer); + const summaryPages = await mergedPdf.copyPages(summaryPdf, summaryPdf.getPageIndices()); + summaryPages.forEach((page) => mergedPdf.addPage(page)); + } catch (error) { + console.error('Error merging order summary PDF:', error); + throw new Error(`Failed to merge order summary PDF: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + // Save the merged PDF + const pdfBytes = await mergedPdf.save(); + return Buffer.from(pdfBytes); + } catch (error) { + console.error('Error merging PDFs:', error); + throw new Error(`PDF merge failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Merge a single document PDF with order summary + * @param documentBuffer - Single document PDF buffer + * @param orderSummaryBuffer - Order summary PDF buffer + * @returns Merged PDF buffer + */ +export async function mergeDocumentWithSummary( + documentBuffer: Buffer, + orderSummaryBuffer: Buffer +): Promise { + return mergePDFsInReverseOrder([documentBuffer], orderSummaryBuffer); +} + +/** + * Save merged PDF to file (for debugging/testing) + */ +export async function saveMergedPDFToFile( + mergedBuffer: Buffer, + outputPath: string +): Promise { + try { + fs.writeFileSync(outputPath, mergedBuffer); + console.log(`✅ Saved merged PDF to: ${outputPath}`); + } catch (error) { + console.error('Error saving merged PDF:', error); + throw new Error(`Failed to save merged PDF: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + diff --git a/Printing-Server/src/utils/printerDriver.ts b/Printing-Server/src/utils/printerDriver.ts new file mode 100644 index 0000000..6765e60 --- /dev/null +++ b/Printing-Server/src/utils/printerDriver.ts @@ -0,0 +1,164 @@ +/** + * Windows Printer Driver Utilities + * Provides OS-specific printer access and status checking + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +export interface PrinterStatus { + available: boolean; + status: 'online' | 'offline' | 'busy' | 'error'; + errorMessage?: string; + queueLength: number; +} + +/** + * Get list of available printers on Windows + */ +export async function getWindowsPrinters(): Promise { + try { + const { stdout } = await execAsync('wmic printer get name /value'); + const lines = stdout.split('\n'); + const printers: string[] = []; + + for (const line of lines) { + const match = line.match(/^Name=(.+)$/); + if (match && match[1].trim()) { + printers.push(match[1].trim()); + } + } + + return printers; + } catch (error) { + console.error('Error getting Windows printers:', error); + return []; + } +} + +/** + * Check if a printer is available on Windows + */ +export async function checkWindowsPrinterStatus(printerName: string): Promise { + try { + // Check if printer exists + const { stdout } = await execAsync(`wmic printer where name="${printerName}" get PrinterStatus,WorkOffline /value`); + + if (!stdout || stdout.includes('No Instance(s) Available')) { + return { + available: false, + status: 'offline', + errorMessage: 'Printer not found', + queueLength: 0, + }; + } + + // Parse printer status + const statusMatch = stdout.match(/PrinterStatus=(\d+)/); + const offlineMatch = stdout.match(/WorkOffline=(.+)/); + + const printerStatus = statusMatch ? parseInt(statusMatch[1], 10) : null; + const isOffline = offlineMatch && offlineMatch[1].trim().toLowerCase() === 'true'; + + if (isOffline) { + return { + available: false, + status: 'offline', + errorMessage: 'Printer is offline', + queueLength: 0, + }; + } + + // Printer status codes (Windows): + // 0 = Other + // 1 = Unknown + // 2 = Idle + // 3 = Printing + // 4 = Warming Up + // 5 = Stopped Printing + // 6 = Offline + // 7 = Paused + // 8 = Error + // 9 = Busy + // 10 = Not Available + // 11 = Waiting + // 12 = Processing + // 13 = Initialization + // 14 = Power Save + // 15 = Pending Deletion + + let status: 'online' | 'offline' | 'busy' | 'error' = 'online'; + let errorMessage: string | undefined; + + if (printerStatus === null) { + status = 'offline'; + errorMessage = 'Unable to determine printer status'; + } else if (printerStatus === 3 || printerStatus === 9 || printerStatus === 12) { + status = 'busy'; + } else if (printerStatus === 6 || printerStatus === 10) { + status = 'offline'; + errorMessage = 'Printer is offline'; + } else if (printerStatus === 8) { + status = 'error'; + errorMessage = 'Printer error detected'; + } else if (printerStatus === 5 || printerStatus === 7) { + status = 'error'; + errorMessage = 'Printer stopped or paused'; + } + + // Get queue length + const queueLength = await getPrinterQueueLength(printerName); + + return { + available: status === 'online' || status === 'busy', + status, + errorMessage, + queueLength, + }; + } catch (error) { + console.error(`Error checking printer status for ${printerName}:`, error); + return { + available: false, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + queueLength: 0, + }; + } +} + +/** + * Get the number of jobs in the printer queue + */ +async function getPrinterQueueLength(printerName: string): Promise { + try { + const { stdout } = await execAsync(`wmic printjob where "name like '%${printerName}%'" get JobStatus /value`); + + if (!stdout || stdout.includes('No Instance(s) Available')) { + return 0; + } + + // Count lines with JobStatus + const matches = stdout.match(/JobStatus=/g); + return matches ? matches.length : 0; + } catch (error) { + console.error(`Error getting queue length for ${printerName}:`, error); + return 0; + } +} + +/** + * Get printer driver name + */ +export async function getPrinterDriverName(printerName: string): Promise { + try { + const { stdout } = await execAsync(`wmic printer where name="${printerName}" get DriverName /value`); + const match = stdout.match(/DriverName=(.+)/); + return match ? match[1].trim() : null; + } catch (error) { + console.error(`Error getting driver name for ${printerName}:`, error); + return null; + } +} + diff --git a/Printing-Server/src/utils/stateMachine.ts b/Printing-Server/src/utils/stateMachine.ts new file mode 100644 index 0000000..13d2ad7 --- /dev/null +++ b/Printing-Server/src/utils/stateMachine.ts @@ -0,0 +1,127 @@ +/** + * State Machine Validator for Print Status Transitions + * Enforces strict state transition rules to prevent invalid operations + */ + +export type PrintStatus = 'pending' | 'printing' | 'printed'; + +/** + * Allowed state transitions + * Key: from state, Value: array of allowed to states + */ +const ALLOWED_TRANSITIONS: Record = { + pending: ['printing'], // pending → printing (allowed) + printing: ['printed', 'pending'], // printing → printed (success) or printing → pending (failure/reset) + printed: ['pending'], // printed → pending (admin override only) +}; + +/** + * Validate if a state transition is allowed + * @param from - Current state + * @param to - Target state + * @param isAdmin - Whether this is an admin override + * @returns Object with validation result + */ +export interface TransitionValidation { + allowed: boolean; + reason?: string; +} + +export function validateTransition( + from: PrintStatus | string | undefined, + to: PrintStatus | string, + isAdmin: boolean = false +): TransitionValidation { + // Handle undefined/null from state (new orders) + if (!from || from === 'undefined' || from === 'null') { + // Only allow starting from 'pending' for new orders + if (to === 'pending') { + return { allowed: true }; + } + return { + allowed: false, + reason: `Cannot transition from undefined to ${to}. New orders must start as 'pending'.`, + }; + } + + // Validate from state is a valid PrintStatus + if (!['pending', 'printing', 'printed'].includes(from)) { + return { + allowed: false, + reason: `Invalid from state: ${from}. Must be one of: pending, printing, printed.`, + }; + } + + // Validate to state is a valid PrintStatus + if (!['pending', 'printing', 'printed'].includes(to)) { + return { + allowed: false, + reason: `Invalid to state: ${to}. Must be one of: pending, printing, printed.`, + }; + } + + const fromState = from as PrintStatus; + const toState = to as PrintStatus; + + // Check if transition is in allowed list + const allowedTransitions = ALLOWED_TRANSITIONS[fromState] || []; + const isAllowed = allowedTransitions.includes(toState); + + // Special handling for admin overrides + if (!isAllowed && isAdmin) { + // Admin can override printed → pending (for reprints) + if (fromState === 'printed' && toState === 'pending') { + return { + allowed: true, + reason: 'Admin override: printed → pending (reprint)', + }; + } + // Admin can override printing → printed (force complete) + if (fromState === 'printing' && toState === 'printed') { + return { + allowed: true, + reason: 'Admin override: printing → printed (force complete)', + }; + } + } + + if (!isAllowed) { + return { + allowed: false, + reason: `Transition from '${fromState}' to '${toState}' is not allowed. Allowed transitions from '${fromState}': ${allowedTransitions.join(', ')}.`, + }; + } + + return { allowed: true }; +} + +/** + * Get all allowed transitions from a given state + * @param from - Current state + * @returns Array of allowed target states + */ +export function getAllowedTransitions(from: PrintStatus | string | undefined): PrintStatus[] { + if (!from || !['pending', 'printing', 'printed'].includes(from)) { + return ['pending']; // Default: can always set to pending for new orders + } + + return ALLOWED_TRANSITIONS[from as PrintStatus] || []; +} + +/** + * Check if a transition requires admin override + * @param from - Current state + * @param to - Target state + * @returns True if admin override is required + */ +export function requiresAdminOverride(from: PrintStatus | string, to: PrintStatus | string): boolean { + const validation = validateTransition(from, to, false); + if (validation.allowed) { + return false; // Transition is allowed without admin + } + + // Check if it would be allowed with admin override + const adminValidation = validateTransition(from, to, true); + return adminValidation.allowed && !validation.allowed; +} + diff --git a/Printing-Server/src/utils/workerId.ts b/Printing-Server/src/utils/workerId.ts new file mode 100644 index 0000000..0db223e --- /dev/null +++ b/Printing-Server/src/utils/workerId.ts @@ -0,0 +1,49 @@ +/** + * Worker ID Generator + * Generates a unique worker ID on server startup for ownership tracking + */ + +import { randomUUID } from 'crypto'; +import * as os from 'os'; + +let workerId: string | null = null; + +/** + * Generate or retrieve worker ID + * Worker ID format: {uuid}-{hostname}-{timestamp} + */ +export function getWorkerId(): string { + if (workerId) { + return workerId; + } + + // Try to get from environment variable (for persistence across restarts) + const envWorkerId = process.env.WORKER_ID; + if (envWorkerId) { + workerId = envWorkerId; + return workerId; + } + + // Generate new worker ID + const hostname = os.hostname(); + const timestamp = Date.now(); + const uuid = randomUUID(); + + workerId = `${uuid}-${hostname}-${timestamp}`; + + // Store in environment variable for persistence + process.env.WORKER_ID = workerId; + + console.log(`🆔 Generated Worker ID: ${workerId}`); + + return workerId; +} + +/** + * Reset worker ID (for testing) + */ +export function resetWorkerId(): void { + workerId = null; + delete process.env.WORKER_ID; +} + diff --git a/Printing-Server/tsconfig.json b/Printing-Server/tsconfig.json new file mode 100644 index 0000000..bbce4ff --- /dev/null +++ b/Printing-Server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/package-lock.json b/package-lock.json index 67fd0f7..0b60029 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4319,9 +4319,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", diff --git a/package.json b/package.json index 605a244..82c9c44 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test-libreoffice": "node scripts/test-libreoffice.js", "test-api-endpoint": "node scripts/test-api-endpoint.js", "test-integration": "node scripts/test-integration-complete.js", - "fix-filetypes": "ts-node scripts/fix-filetype-in-orders.ts" + "fix-filetypes": "ts-node scripts/fix-filetype-in-orders.ts", + "migrate-print-status": "ts-node scripts/migrate-print-status.ts" }, "dependencies": { "@thasmorato/docx-parser": "^1.4.0", diff --git a/scripts/migrate-print-status.ts b/scripts/migrate-print-status.ts new file mode 100644 index 0000000..a629a3f --- /dev/null +++ b/scripts/migrate-print-status.ts @@ -0,0 +1,165 @@ +/** + * Migration Script: Add printStatus to existing orders + * + * This script: + * 1. Adds printStatus field to all existing orders + * 2. Sets printStatus: 'pending' for orders with paymentStatus: 'completed' and no print job + * 3. Sets printStatus: 'printed' for orders with orderStatus: 'delivered' + * 4. Creates required indexes + * 5. Initializes printer records if none exist + */ + +import mongoose from 'mongoose'; +import dotenv from 'dotenv'; +import path from 'path'; + +// Load environment variables +dotenv.config({ path: path.join(process.cwd(), '.env.local') }); + +// Import models +import Order from '../src/models/Order'; +import Printer from '../src/models/Printer'; +import PrintLog from '../src/models/PrintLog'; + +const MONGODB_URI = process.env.MONGODB_URI; + +if (!MONGODB_URI) { + console.error('❌ MONGODB_URI environment variable is not set'); + process.exit(1); +} + +async function migrate() { + try { + console.log('🔄 Starting migration...'); + + // Connect to MongoDB + await mongoose.connect(MONGODB_URI); + console.log('✅ Connected to MongoDB'); + + // Step 1: Add printStatus to orders with completed payment + console.log('\n📋 Step 1: Updating orders with completed payment...'); + const pendingResult = await Order.updateMany( + { + paymentStatus: 'completed', + $or: [ + { printStatus: { $exists: false } }, + { printStatus: null } + ] + }, + { + $set: { printStatus: 'pending' } + } + ); + console.log(` ✅ Updated ${pendingResult.modifiedCount} orders to printStatus: 'pending'`); + + // Step 2: Set printStatus: 'printed' for delivered orders + console.log('\n📋 Step 2: Updating delivered orders...'); + const printedResult = await Order.updateMany( + { + orderStatus: 'delivered', + $or: [ + { printStatus: { $exists: false } }, + { printStatus: null } + ] + }, + { + $set: { printStatus: 'printed' } + } + ); + console.log(` ✅ Updated ${printedResult.modifiedCount} orders to printStatus: 'printed'`); + + // Step 3: Create indexes + console.log('\n📋 Step 3: Creating indexes...'); + + // Orders collection indexes + await Order.collection.createIndex({ printStatus: 1, paymentStatus: 1, createdAt: 1 }); + console.log(' ✅ Created index: orders.printStatus + paymentStatus + createdAt'); + + await Order.collection.createIndex({ printStatus: 1, createdAt: -1 }); + console.log(' ✅ Created index: orders.printStatus + createdAt'); + + await Order.collection.createIndex({ printerId: 1, printStatus: 1 }); + console.log(' ✅ Created index: orders.printerId + printStatus'); + + // Printers collection indexes + await Printer.collection.createIndex({ printer_id: 1 }, { unique: true, sparse: true }); + console.log(' ✅ Created index: printers.printer_id (unique)'); + + await Printer.collection.createIndex({ last_seen_at: -1 }); + console.log(' ✅ Created index: printers.last_seen_at'); + + // PrintLogs collection indexes + await PrintLog.collection.createIndex({ orderId: 1, timestamp: -1 }); + console.log(' ✅ Created index: print_logs.orderId + timestamp'); + + await PrintLog.collection.createIndex({ timestamp: -1 }); + console.log(' ✅ Created index: print_logs.timestamp'); + + await PrintLog.collection.createIndex({ action: 1, timestamp: -1 }); + console.log(' ✅ Created index: print_logs.action + timestamp'); + + // Step 4: Initialize printer records if none exist + console.log('\n📋 Step 4: Checking for printer records...'); + const printerCount = await Printer.countDocuments(); + + if (printerCount === 0) { + console.log(' ⚠️ No printers found. Creating sample printer...'); + + const samplePrinter = new Printer({ + name: 'Default Printer', + printerModel: 'Unknown', + manufacturer: 'Unknown', + connectionType: 'usb', + connectionString: 'USB001', + status: 'offline', + printer_id: 'printer_001', + printer_name: 'Default Printer', + system_name: 'Windows', + queue_length: 0, + capabilities: { + supportedPageSizes: ['A4', 'A3'], + supportsColor: true, + supportsDuplex: true, + maxCopies: 99, + supportedFileTypes: ['application/pdf', 'application/msword'] + }, + queueLength: 0, + totalPagesPrinted: 0, + isActive: true, + autoPrintEnabled: true + }); + + await samplePrinter.save(); + console.log(' ✅ Created sample printer: Default Printer'); + } else { + console.log(` ✅ Found ${printerCount} existing printer(s)`); + } + + // Summary + console.log('\n✅ Migration completed successfully!'); + console.log('\n📊 Summary:'); + console.log(` - Orders set to 'pending': ${pendingResult.modifiedCount}`); + console.log(` - Orders set to 'printed': ${printedResult.modifiedCount}`); + console.log(` - Indexes created: 8`); + console.log(` - Printers: ${printerCount === 0 ? '1 (created)' : printerCount}`); + + } catch (error) { + console.error('❌ Migration failed:', error); + throw error; + } finally { + await mongoose.disconnect(); + console.log('\n🔌 Disconnected from MongoDB'); + } +} + +// Run migration +migrate() + .then(() => { + console.log('\n✨ Migration script completed'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 Migration script failed:', error); + process.exit(1); + }); + diff --git a/src/app/admin/orders/[id]/page.tsx b/src/app/admin/orders/[id]/page.tsx index 8732a9b..3f937f2 100644 --- a/src/app/admin/orders/[id]/page.tsx +++ b/src/app/admin/orders/[id]/page.tsx @@ -99,6 +99,25 @@ interface Order { orderStatus: 'pending' | 'processing' | 'printing' | 'dispatched' | 'delivered' | 'cancelled'; amount: number; expectedDate?: string | Date; + printStatus?: 'pending' | 'printing' | 'printed'; + printError?: string; + printSegments?: Array<{ + segmentId: string; + pageRange?: { + start: number; + end: number; + }; + printMode?: 'color' | 'bw'; + copies?: number; + paperSize?: 'A4' | 'A3'; + duplex?: boolean; + status: 'pending' | 'printing' | 'completed' | 'failed'; + printJobId?: string; + startedAt?: string | Date; + completedAt?: string | Date; + error?: string; + executionOrder?: number; + }>; createdAt: string; } @@ -192,10 +211,10 @@ function OrderDetailPageContent() { : []; // Convert legacy single fields, if present - if ((orderData.fileURLs?.length ?? 0) === 0 && orderData.fileURL) { + if ((!orderData.fileURLs || !Array.isArray(orderData.fileURLs) || orderData.fileURLs.length === 0) && orderData.fileURL) { orderData.fileURLs = [orderData.fileURL]; } - if ((orderData.originalFileNames?.length ?? 0) === 0 && orderData.originalFileName) { + if ((!orderData.originalFileNames || !Array.isArray(orderData.originalFileNames) || orderData.originalFileNames.length === 0) && orderData.originalFileName) { orderData.originalFileNames = [orderData.originalFileName]; } @@ -755,6 +774,104 @@ function OrderDetailPageContent() { + {/* Print Segments Section */} + {order.printSegments && Array.isArray(order.printSegments) && order.printSegments.length > 0 && ( +
+

+ + Print Segments +

+
+

+ This order will be printed in {order.printSegments.length} segment(s) with reverse order execution. + Order Summary page will appear on top of the physical stack. +

+
+ {order.printSegments.map((segment: any, index: number) => { + const executionOrder = segment?.executionOrder || (order.printSegments!.length - index); + return ( +
+
+
+ + Segment {index + 1} + + + {(segment?.status || 'pending').toUpperCase()} + + + {segment?.printMode === 'color' ? 'Color' : 'B&W'} + +
+ + Execution Order: #{executionOrder} + +
+ {segment?.pageRange && ( +
+ Pages: {segment.pageRange.start} - {segment.pageRange.end} +
+ )} +
+
+ Copies: {segment?.copies || 1} +
+
+ Paper: {segment?.paperSize || 'A4'} +
+
+ Duplex: {segment?.duplex ? 'Yes' : 'No'} +
+ {segment?.printJobId && ( +
+ Job ID: {segment.printJobId.substring(0, 8)}... +
+ )} +
+ {segment?.startedAt && ( +
+ Started: {formatDate(segment.startedAt.toString())} +
+ )} + {segment?.completedAt && ( +
+ Completed: {formatDate(segment.completedAt.toString())} +
+ )} + {segment?.error && ( +
+ Error: {segment.error} +
+ )} +
+ ); + })} +
+
+

+ Note: Segments are printed in reverse order (last segment first) so the final physical stack has pages in correct order. + Order Summary page is printed last (appears first on top of stack). +

+
+
+
+ )} + {/* Student Information */}

diff --git a/src/app/admin/printer-monitor/page.tsx b/src/app/admin/printer-monitor/page.tsx index f524b7c..56cd6f2 100644 --- a/src/app/admin/printer-monitor/page.tsx +++ b/src/app/admin/printer-monitor/page.tsx @@ -5,133 +5,131 @@ import AdminGoogleAuth from '@/components/admin/AdminGoogleAuth'; import AdminNavigation from '@/components/admin/AdminNavigation'; import LoadingSpinner from '@/components/admin/LoadingSpinner'; import NotificationProvider from '@/components/admin/NotificationProvider'; -import { showError } from '@/lib/adminNotifications'; -import { PrinterIcon, TrashIcon, RefreshIcon, CheckIcon, ErrorIcon, DocumentIcon, ClockIcon, PauseIcon, PlayIcon } from '@/components/SocialIcons'; - -interface PrinterStatus { - success: boolean; - printerIndex: number; - printerUrl: string; - printerApi: { - health: { - status: string; - printer?: { - available: boolean; - message: string; - }; - queue?: { - total: number; - pending: number; - }; - timestamp?: string; - error?: string; - }; - queue: { - total: number; - pending: number; - isPaused?: boolean; - jobs: Array<{ - id: string; - job: { - fileName: string; - deliveryNumber: string; - orderId?: string; - customerInfo?: { - name: string; - email: string; - phone: string; - }; - }; - attempts: number; - createdAt: string; - lastAttemptAt?: string; - }>; - }; +import { showError, showSuccess } from '@/lib/adminNotifications'; +import { PrinterIcon, RefreshIcon, CheckIcon, ErrorIcon, DocumentIcon, ClockIcon, XIcon, RotateCcwIcon } from '@/components/SocialIcons'; + +interface PrintSegment { + segmentId: string; + pageRange?: { + start: number; + end: number; }; - funPrinting: { - printerHealth: { - available: boolean; - message: string; - }; - retryQueue: { - total: number; - jobs: Array<{ timestamp: Date }>; - }; + printMode?: 'color' | 'bw'; + copies?: number; + paperSize?: 'A4' | 'A3'; + duplex?: boolean; + status: 'pending' | 'printing' | 'completed' | 'failed'; + printJobId?: string; + startedAt?: string; + completedAt?: string; + error?: string; + executionOrder?: number; +} + +interface Order { + _id: string; + orderId: string; + fileName: string; + fileNames: string[]; + printStatus: 'pending' | 'printing' | 'printed'; + printerName: string; + createdAt: string; + printStartedAt?: string; + printCompletedAt?: string; + errorMessage?: string; + printSegments?: PrintSegment[]; +} + +interface Printer { + _id: string; + name: string; + printer_id?: string; + printer_name?: string; + status: 'online' | 'offline' | 'busy' | 'error' | 'maintenance'; + connectionType: string; + queue_length: number; + last_seen_at?: string; + last_successful_print_at?: string; + error_message?: string; + driver_name?: string; + system_name?: string; +} + +interface MonitorData { + orders: { + pending: Order[]; + printing: Order[]; + printed: Order[]; }; + printers: Printer[]; + recentLogs: Array<{ + action: string; + orderId: string; + printJobId?: string; + adminEmail?: string; + previousStatus?: string; + newStatus?: string; + reason?: string; + timestamp: string; + }>; + metrics?: { + prints_per_hour: number; + failures_per_hour: number; + average_print_start_delay: number; + printer_offline_duration: number; + timestamp: string; + } | null; timestamp: string; - error?: string; } function PrinterMonitorContent() { - const [status, setStatus] = useState(null); + const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [autoRefresh, setAutoRefresh] = useState(true); - const [refreshInterval, setRefreshInterval] = useState(5); // seconds + const [refreshInterval, setRefreshInterval] = useState(3); // seconds + const [adminEmail, setAdminEmail] = useState(''); - const fetchStatus = async () => { + useEffect(() => { + // Get admin email from localStorage or session + const email = localStorage.getItem('adminEmail') || 'admin@example.com'; + setAdminEmail(email); + }, []); + + const fetchData = async () => { try { setIsLoading(true); - const response = await fetch(`/api/admin/printer-status?t=${Date.now()}`); - const data = await response.json(); + const response = await fetch(`/api/admin/printer-monitor-data?t=${Date.now()}`); + const result = await response.json(); - // Ensure data has the expected structure - if (data && data.success !== undefined) { - setStatus(data); + if (result.success && result.data) { + setData(result.data); } else { - // Handle unexpected response structure - setStatus({ - success: false, - printerIndex: 1, - printerUrl: data?.printerUrl || '', - printerApi: { - health: data?.printerApi?.health || { status: 'unknown' }, - queue: data?.printerApi?.queue || { total: 0, pending: 0, jobs: [] } - }, - funPrinting: { - printerHealth: data?.funPrinting?.printerHealth || { available: false, message: 'Unknown status' }, - retryQueue: data?.funPrinting?.retryQueue || { total: 0, jobs: [] } - }, - timestamp: new Date().toISOString(), - error: 'Unexpected response format' - }); + showError('Failed to fetch monitor data'); } } catch (error) { - console.error('Error fetching printer status:', error); - setStatus({ - success: false, - printerIndex: 1, - printerUrl: '', - printerApi: { - health: { status: 'error' }, - queue: { total: 0, pending: 0, jobs: [] } - }, - funPrinting: { - printerHealth: { available: false, message: 'Error fetching status' }, - retryQueue: { total: 0, jobs: [] } - }, - timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : 'Unknown error' - }); + console.error('Error fetching monitor data:', error); + showError('Error fetching monitor data'); } finally { setIsLoading(false); } }; useEffect(() => { - fetchStatus(); + fetchData(); }, []); useEffect(() => { if (!autoRefresh) return; const interval = setInterval(() => { - fetchStatus(); + fetchData(); }, refreshInterval * 1000); return () => clearInterval(interval); }, [autoRefresh, refreshInterval]); - const formatDate = (dateString: string) => { + const formatDate = (dateString: string | undefined) => { + if (!dateString) return 'N/A'; return new Date(dateString).toLocaleString('en-IN', { year: 'numeric', month: 'short', @@ -142,8 +140,152 @@ function PrinterMonitorContent() { }); }; - if (isLoading && !status) { - return ; + const handleReprint = async (orderId: string) => { + if (!confirm(`Reprint order ${orderId}?`)) return; + + try { + const response = await fetch('/api/admin/print-actions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-admin-email': adminEmail, + }, + body: JSON.stringify({ orderId, reason: 'Admin requested reprint' }), + }); + + const result = await response.json(); + if (result.success) { + showSuccess('Order reset to pending for reprinting'); + fetchData(); + } else { + showError(result.error || 'Failed to reprint order'); + } + } catch (error) { + showError('Error reprinting order'); + } + }; + + const handleCancel = async (orderId: string) => { + if (!confirm(`Cancel order ${orderId}?`)) return; + + try { + const response = await fetch('/api/admin/print-actions', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-admin-email': adminEmail, + }, + body: JSON.stringify({ orderId, reason: 'Admin cancelled order' }), + }); + + const result = await response.json(); + if (result.success) { + showSuccess('Order cancelled'); + fetchData(); + } else { + showError(result.error || 'Failed to cancel order'); + } + } catch (error) { + showError('Error cancelling order'); + } + }; + + const handleReset = async (orderId: string) => { + if (!confirm(`Reset order ${orderId} from printing to pending?`)) return; + + try { + const response = await fetch('/api/admin/print-actions', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'x-admin-email': adminEmail, + }, + body: JSON.stringify({ orderId, reason: 'Admin reset stuck order' }), + }); + + const result = await response.json(); + if (result.success) { + showSuccess('Order reset to pending'); + fetchData(); + } else { + showError(result.error || 'Failed to reset order'); + } + } catch (error) { + showError('Error resetting order'); + } + }; + + const handleForcePrinted = async (orderId: string) => { + if (!confirm(`Force mark order ${orderId} as printed? This should only be used if the order was already printed manually.`)) return; + + try { + const response = await fetch('/api/admin/print-actions', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'x-admin-email': adminEmail, + }, + body: JSON.stringify({ orderId, reason: 'Admin force marked as printed' }), + }); + + const result = await response.json(); + if (result.success) { + showSuccess('Order marked as printed'); + fetchData(); + } else { + showError(result.error || 'Failed to mark order as printed'); + } + } catch (error) { + showError('Error marking order as printed'); + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'online': + return 'bg-green-100 text-green-800'; + case 'busy': + return 'bg-yellow-100 text-yellow-800'; + case 'offline': + case 'error': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const getPrintStatusColor = (status: string) => { + switch (status) { + case 'pending': + return 'bg-yellow-100 text-yellow-800'; + case 'printing': + return 'bg-blue-100 text-blue-800'; + case 'printed': + return 'bg-green-100 text-green-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + if (isLoading && !data) { + return ; + } + + if (!data) { + return ( +
+
+ +

Failed to load monitor data

+ +
+
+ ); } return ( @@ -151,7 +293,7 @@ function PrinterMonitorContent() {
@@ -171,394 +313,309 @@ function PrinterMonitorContent() { onChange={(e) => setRefreshInterval(Number(e.target.value))} className="px-2 py-1 border border-gray-300 rounded text-sm" > + + - -
)}
} /> - {status && ( -
- {/* Status Summary Cards */} -
-
-
-
-

Printer API Status

-

- {status.printerApi?.health?.status === 'healthy' ? ( - <> - - Online - - ) : ( - <> - - Offline - - )} -

-
-
- -
-
+ {/* Printer Health Panel */} +
+ {data.printers.map((printer) => ( +
+
+

{printer.printer_name || printer.name}

+ + {printer.status} +
- -
-
-
-

Queue Total

-

- {status.printerApi?.queue?.total || 0} -

-
-
- -
+
+
+ Connection: + {printer.connectionType}
-
- -
-
-
-

Pending Jobs

-

- {status.printerApi?.queue?.pending || 0} -

-
-
- -
+
+ Queue Length: + {printer.queue_length || 0}
-
- -
-
-
-

Retry Queue

-

- {status.funPrinting?.retryQueue?.total || 0} -

+
+ Last Seen: + {formatDate(printer.last_seen_at)} +
+ {printer.last_successful_print_at && ( +
+ Last Print: + {formatDate(printer.last_successful_print_at)}
-
- + )} + {printer.error_message && ( +
+ {printer.error_message}
-
+ )}
+ ))} +
- {/* Printer API Details */} -
-
-

Printer API Details

-
-
-
-
-

Connection

-
-
- URL: - {status.printerUrl || 'Not configured'} + {/* Orders by Status */} +
+ {/* Pending Orders */} +
+
+

+ Pending ({data.orders.pending.length}) +

+
+
+ {data.orders.pending.length === 0 ? ( +

No pending orders

+ ) : ( +
+ {data.orders.pending.map((order) => ( +
+
+ + {order.orderId} + + + {order.printStatus} +
-
- Printer Index: - {status.printerIndex || 1} +

{order.fileName}

+

Created: {formatDate(order.createdAt)}

+ {order.errorMessage && ( +

{order.errorMessage}

+ )} + {order.printSegments && order.printSegments.length > 0 && ( +
+

Print Segments ({order.printSegments.length}):

+
+ {order.printSegments.map((seg) => ( +
+ + {seg.status} + + {seg.pageRange && ( + + Pages {seg.pageRange.start}-{seg.pageRange.end} + + )} + + {seg.printMode === 'color' ? 'Color' : 'B&W'} + + {seg.executionOrder && ( + Order: {seg.executionOrder} + )} +
+ ))} +
+
+ )} +
+ +
-
- Status: - - {status.printerApi?.health?.status === 'healthy' ? 'Healthy' : 'Unhealthy'} +
+ ))} +
+ )} +
+
+ + {/* Printing Orders */} +
+
+

+ Printing ({data.orders.printing.length}) +

+
+
+ {data.orders.printing.length === 0 ? ( +

No orders printing

+ ) : ( +
+ {data.orders.printing.map((order) => ( +
+
+ + {order.orderId} + + + {order.printStatus}
-
-
-
-

Printer Status

-
- {status.printerApi?.health?.printer && ( - <> -
- Available: - - {status.printerApi.health.printer.available ? 'Yes' : 'No'} - -
-
- Message: - {status.printerApi.health.printer.message} +

{order.fileName}

+

Printer: {order.printerName || 'N/A'}

+

Started: {formatDate(order.printStartedAt)}

+ {order.printSegments && order.printSegments.length > 0 && ( +
+

Print Segments ({order.printSegments.length}):

+
+ {order.printSegments.map((seg) => ( +
+ + {seg.status} + + {seg.pageRange && ( + + Pages {seg.pageRange.start}-{seg.pageRange.end} + + )} + + {seg.printMode === 'color' ? 'Color' : 'B&W'} + + {seg.executionOrder && ( + Exec: #{seg.executionOrder} + )} + {seg.error && ( + Error: {seg.error} + )} +
+ ))}
- - )} - {status.printerApi?.health?.error && ( -
- Error: - {status.printerApi.health.error}
)} +
+ + +
-
+ ))}
-
+ )}
+
- {/* Print Queue */} -
-
-
-

- Print Queue ({status.printerApi?.queue?.pending || 0} pending) -

- {status.printerApi?.queue?.isPaused && ( -

- - Queue is paused -

- )} -
-
- {status.printerApi?.queue?.isPaused ? ( - - ) : ( - - )} + {/* Printed Orders */} +
+
+

+ Printed ({data.orders.printed.length}) +

+
+
+ {data.orders.printed.length === 0 ? ( +

No printed orders (last 24h)

+ ) : ( +
+ {data.orders.printed.map((order) => ( +
+
+ + {order.orderId} + + + {order.printStatus} + +
+

{order.fileName}

+

Printer: {order.printerName || 'N/A'}

+

Completed: {formatDate(order.printCompletedAt)}

+
+ ))}
-
-
- {status.printerApi?.queue?.jobs && status.printerApi.queue.jobs.length > 0 ? ( - - - - - - - - - - - - - - - - {status.printerApi?.queue?.jobs?.map((job: any) => ( - - - - - - - - - - - - ))} - -
- Job ID - - Order ID - - Customer - - File Name - - Delivery Number - - Attempts - - Created At - - Last Attempt - - Actions -
- {job.id.substring(0, 20)}... - - {job.job.orderId ? ( - - {job.job.orderId} - - ) : ( - N/A - )} - - {job.job.customerInfo ? ( -
-
{job.job.customerInfo.name}
-
{job.job.customerInfo.email}
-
- ) : ( - N/A - )} -
- {job.job.fileName} - - {job.job.deliveryNumber} - - 3 ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800' - }`}> - {job.attempts} - - - {formatDate(job.createdAt)} - - {job.lastAttemptAt ? formatDate(job.lastAttemptAt) : 'Never'} - - -
- ) : ( -
- No jobs in queue -
- )} -
+ )}
+
+
- {/* Retry Queue (funPrinting) */} - {status.funPrinting?.retryQueue && status.funPrinting.retryQueue.total > 0 && ( -
-
-

- Retry Queue ({status.funPrinting.retryQueue.total} jobs) -

-

- Jobs waiting to be sent to printer API + {/* Metrics Dashboard */} + {data.metrics && ( +

+
+

System Metrics

+

Last updated: {formatDate(data.metrics.timestamp)}

+
+
+
+
+

Prints per Hour

+

{data.metrics.prints_per_hour}

+
+
+

Failures per Hour

+

{data.metrics.failures_per_hour}

+
+
+

Avg. Print Delay

+

+ {data.metrics.average_print_start_delay.toFixed(1)}s

-
-
- {status.funPrinting.retryQueue.jobs?.map((job: any, index: number) => ( -
- - Queued at: {formatDate(job.timestamp.toString())} - -
- ))} -
+
+

Printer Offline

+

+ {Math.round(data.metrics.printer_offline_duration)}s +

- )} - - {/* Last Updated */} -
- Last updated: {formatDate(status.timestamp)}
)} - {status && !status.success && ( -
-

Error

-

{status.error || 'Failed to fetch printer status'}

-
- )} + {/* Last Updated */} +
+ Last updated: {formatDate(data.timestamp)} +
); @@ -568,7 +625,7 @@ export default function PrinterMonitorPage() { return ( @@ -576,4 +633,3 @@ export default function PrinterMonitorPage() { ); } - diff --git a/src/app/api/admin/print-actions/route.ts b/src/app/api/admin/print-actions/route.ts new file mode 100644 index 0000000..584c80a --- /dev/null +++ b/src/app/api/admin/print-actions/route.ts @@ -0,0 +1,417 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectDB from '@/lib/mongodb'; +import Order from '@/models/Order'; +import PrintLog from '@/models/PrintLog'; +import { validatePrintTransition } from '@/utils/printStateMachine'; + +/** + * Verify admin authentication + * For now, we'll accept admin email from request header or body + * In production, you should use proper session/JWT authentication + */ +async function verifyAdmin(request: NextRequest): Promise<{ isAdmin: boolean; adminEmail?: string }> { + try { + // Get admin email from header or body + const adminEmail = request.headers.get('x-admin-email') || + (await request.json().catch(() => ({}))).adminEmail; + + if (!adminEmail) { + return { isAdmin: false }; + } + + // For now, check against environment variable or hardcoded admin email + const allowedAdminEmail = process.env.ADMIN_EMAIL || 'adityapandey.dev.in@gmail.com'; + + if (adminEmail.toLowerCase() === allowedAdminEmail.toLowerCase()) { + return { isAdmin: true, adminEmail: adminEmail.toLowerCase() }; + } + + return { isAdmin: false }; + } catch (error) { + console.error('Error verifying admin:', error); + return { isAdmin: false }; + } +} + +/** + * Log admin action + */ +async function logAction( + action: string, + orderId: string, + adminEmail: string, + previousStatus?: string, + newStatus?: string, + reason?: string, + printJobId?: string +): Promise { + try { + await connectDB(); + + // Get printJobId from order if not provided + let jobId = printJobId; + if (!jobId) { + const order = await Order.findOne({ orderId }); + jobId = order?.printJobId; + } + + const log = new PrintLog({ + action, + orderId, + printJobId: jobId, + adminEmail, + previousStatus, + newStatus, + reason, + timestamp: new Date(), + }); + await log.save(); + } catch (error) { + console.error('Error logging action:', error); + } +} + +/** + * POST /api/admin/print-actions/reprint + * Reset order to pending for reprinting + */ +export async function POST(request: NextRequest) { + try { + const auth = await verifyAdmin(request); + if (!auth.isAdmin) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { orderId, reason } = body; + + if (!orderId) { + return NextResponse.json( + { success: false, error: 'Order ID is required' }, + { status: 400 } + ); + } + + await connectDB(); + + // Find order + const order = await Order.findOne({ orderId }); + if (!order) { + return NextResponse.json( + { success: false, error: 'Order not found' }, + { status: 404 } + ); + } + + const previousStatus = order.printStatus; + + // Safety guard: Cannot reprint if currently printing + if (order.printStatus === 'printing') { + return NextResponse.json( + { success: false, error: 'Cannot reprint an order that is currently printing. Reset it first.' }, + { status: 400 } + ); + } + + // Validate state transition + const transitionValidation = validatePrintTransition(previousStatus, 'pending', true); + if (!transitionValidation.allowed) { + return NextResponse.json( + { success: false, error: `Invalid state transition: ${transitionValidation.reason}` }, + { status: 400 } + ); + } + + // Reset to pending + await Order.findByIdAndUpdate(order._id, { + $set: { + printStatus: 'pending', + printError: undefined, + }, + $unset: { + printStartedAt: '', + printCompletedAt: '', + printerId: '', + printerName: '', + printingBy: '', + printJobId: '', + printingHeartbeatAt: '', + }, + }); + + // Log action + await logAction('reprint', orderId, auth.adminEmail!, previousStatus, 'pending', reason, order.printJobId); + + return NextResponse.json({ + success: true, + message: 'Order reset to pending for reprinting', + }); + } catch (error) { + console.error('Error in reprint action:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} + +/** + * PUT /api/admin/print-actions/cancel + * Cancel a pending order + */ +export async function PUT(request: NextRequest) { + try { + const auth = await verifyAdmin(request); + if (!auth.isAdmin) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { orderId, reason } = body; + + if (!orderId) { + return NextResponse.json( + { success: false, error: 'Order ID is required' }, + { status: 400 } + ); + } + + await connectDB(); + + // Find order + const order = await Order.findOne({ orderId }); + if (!order) { + return NextResponse.json( + { success: false, error: 'Order not found' }, + { status: 404 } + ); + } + + if (order.printStatus !== 'pending') { + return NextResponse.json( + { success: false, error: 'Only pending orders can be cancelled' }, + { status: 400 } + ); + } + + const previousStatus = order.printStatus; + + // Cancel order + await Order.findByIdAndUpdate(order._id, { + $set: { + printStatus: undefined, // Remove print status + orderStatus: 'cancelled', + status: 'cancelled', + }, + }); + + // Log action + await logAction('cancel', orderId, auth.adminEmail!, previousStatus, 'cancelled', reason, order.printJobId); + + return NextResponse.json({ + success: true, + message: 'Order cancelled', + }); + } catch (error) { + console.error('Error in cancel action:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/admin/print-actions/reset + * Reset order from 'printing' to 'pending' (manual override) + */ +export async function PATCH(request: NextRequest) { + try { + const auth = await verifyAdmin(request); + if (!auth.isAdmin) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { orderId, reason } = body; + + if (!orderId) { + return NextResponse.json( + { success: false, error: 'Order ID is required' }, + { status: 400 } + ); + } + + await connectDB(); + + // Find order + const order = await Order.findOne({ orderId }); + if (!order) { + return NextResponse.json( + { success: false, error: 'Order not found' }, + { status: 404 } + ); + } + + if (order.printStatus !== 'printing') { + return NextResponse.json( + { success: false, error: 'Only printing orders can be reset' }, + { status: 400 } + ); + } + + const previousStatus = order.printStatus; + + // Validate state transition + const transitionValidation = validatePrintTransition(previousStatus, 'pending', true); + if (!transitionValidation.allowed) { + return NextResponse.json( + { success: false, error: `Invalid state transition: ${transitionValidation.reason}` }, + { status: 400 } + ); + } + + // Reset to pending + await Order.findByIdAndUpdate(order._id, { + $set: { + printStatus: 'pending', + printError: reason || 'Manually reset by admin', + }, + $unset: { + printStartedAt: '', + printerId: '', + printerName: '', + printingBy: '', + printJobId: '', + printingHeartbeatAt: '', + }, + }); + + // Log action + await logAction('reset_state', orderId, auth.adminEmail!, previousStatus, 'pending', reason, order.printJobId); + + return NextResponse.json({ + success: true, + message: 'Order reset to pending', + }); + } catch (error) { + console.error('Error in reset action:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/admin/print-actions/force-printed + * Force mark order as printed (admin only) + */ +export async function DELETE(request: NextRequest) { + try { + const auth = await verifyAdmin(request); + if (!auth.isAdmin) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { orderId, reason, confirmed } = body; + + if (!orderId) { + return NextResponse.json( + { success: false, error: 'Order ID is required' }, + { status: 400 } + ); + } + + // Safety guard: Require confirmation for force printed + if (!confirmed) { + return NextResponse.json( + { success: false, error: 'Confirmation required for force-printed action. Set confirmed: true.' }, + { status: 400 } + ); + } + + // Safety guard: Reason is mandatory for force printed + if (!reason || reason.trim().length === 0) { + return NextResponse.json( + { success: false, error: 'Reason is required for force-printed action' }, + { status: 400 } + ); + } + + await connectDB(); + + // Find order + const order = await Order.findOne({ orderId }); + if (!order) { + return NextResponse.json( + { success: false, error: 'Order not found' }, + { status: 404 } + ); + } + + const previousStatus = order.printStatus; + + // Validate state transition + const transitionValidation = validatePrintTransition(previousStatus, 'printed', true); + if (!transitionValidation.allowed) { + return NextResponse.json( + { success: false, error: `Invalid state transition: ${transitionValidation.reason}` }, + { status: 400 } + ); + } + + // Force mark as printed + await Order.findByIdAndUpdate(order._id, { + $set: { + printStatus: 'printed', + printCompletedAt: new Date(), + printError: undefined, + }, + $unset: { + printingBy: '', + printingHeartbeatAt: '', + }, + }); + + // Log action + await logAction('force_printed', orderId, auth.adminEmail!, previousStatus, 'printed', reason, order.printJobId); + + return NextResponse.json({ + success: true, + message: 'Order marked as printed', + }); + } catch (error) { + console.error('Error in force-printed action:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/admin/printer-monitor-data/route.ts b/src/app/api/admin/printer-monitor-data/route.ts new file mode 100644 index 0000000..ae44ee1 --- /dev/null +++ b/src/app/api/admin/printer-monitor-data/route.ts @@ -0,0 +1,216 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectDB from '@/lib/mongodb'; +import Order from '@/models/Order'; +import Printer from '@/models/Printer'; +import PrintLog from '@/models/PrintLog'; +import mongoose from 'mongoose'; + +/** + * GET /api/admin/printer-monitor-data + * Get real-time data for printer monitor page + */ +export async function GET(request: NextRequest) { + try { + await connectDB(); + + // Get orders grouped by printStatus (include max attempts and segments info) + const pendingOrders = await Order.find({ + printStatus: 'pending', + paymentStatus: 'completed', + }) + .select('_id orderId originalFileName originalFileNames fileURL fileURLs printStatus printError printerName printAttempt maxPrintAttempts printSegments createdAt') + .sort({ createdAt: -1 }) + .limit(100) + .lean(); + + // Get orders that exceeded max attempts (require admin action) + const maxAttemptsReached = await Order.find({ + printStatus: 'pending', + paymentStatus: 'completed', + $expr: { + $gte: [ + { $ifNull: ['$printAttempt', 0] }, + { $ifNull: ['$maxPrintAttempts', 3] } + ] + }, + }) + .select('_id orderId originalFileName originalFileNames printStatus printError printAttempt maxPrintAttempts printSegments createdAt') + .sort({ createdAt: -1 }) + .limit(50) + .lean(); + + const printingOrders = await Order.find({ + printStatus: 'printing', + }) + .select('_id orderId originalFileName originalFileNames fileURL fileURLs printStatus printError printerName printStartedAt printAttempt maxPrintAttempts printingBy printingHeartbeatAt printSegments createdAt') + .sort({ printStartedAt: -1 }) + .limit(50) + .lean(); + + // Get printed orders from last 24 hours + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const printedOrders = await Order.find({ + printStatus: 'printed', + printCompletedAt: { $gte: twentyFourHoursAgo }, + }) + .select('_id orderId originalFileName originalFileNames fileURL fileURLs printStatus printerName printCompletedAt printSegments createdAt') + .sort({ printCompletedAt: -1 }) + .limit(100) + .lean(); + + // Get all active printers with health status + const printers = await Printer.find({ isActive: true }) + .select('_id name printer_id printer_name status last_seen_at last_successful_print_at queue_length error_message driver_name system_name connectionType connectionString') + .sort({ name: 1 }); + + // Get recent print logs (last 50) + const recentLogs = await PrintLog.find() + .select('action orderId printJobId adminEmail previousStatus newStatus reason timestamp metadata') + .sort({ timestamp: -1 }) + .limit(50); + + // Get recent alerts from logs + const recentAlerts = await PrintLog.find({ + action: 'alert', + timestamp: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }, // Last 24 hours + }) + .select('action orderId reason timestamp metadata') + .sort({ timestamp: -1 }) + .limit(20); + + // Get latest metrics + const Metrics = mongoose.models.Metrics || mongoose.model('Metrics', new mongoose.Schema({ + timestamp: Date, + prints_per_hour: Number, + failures_per_hour: Number, + average_print_start_delay: Number, + printer_offline_duration: Number, + workerId: String, + }, { timestamps: true })); + + const latestMetrics = await Metrics.findOne() + .sort({ timestamp: -1 }) + .limit(1); + + // Format orders for display + const formatOrder = (order: any) => { + const fileNames = (order.originalFileNames && Array.isArray(order.originalFileNames) && order.originalFileNames.length > 0) + ? order.originalFileNames + : order.originalFileName + ? [order.originalFileName] + : ['Unknown file']; + + // Calculate execution order for segments (reverse order for printing) + let segmentsWithOrder: any[] = []; + if (order.printSegments && Array.isArray(order.printSegments) && order.printSegments.length > 0) { + // Sort segments by page range descending (last segment first for printing) + const sortedSegments = [...order.printSegments].sort((a: any, b: any) => { + const aEnd = a?.pageRange?.end || 0; + const bEnd = b?.pageRange?.end || 0; + if (bEnd !== aEnd) { + return bEnd - aEnd; + } + const aStart = a?.pageRange?.start || 0; + const bStart = b?.pageRange?.start || 0; + return bStart - aStart; + }); + + segmentsWithOrder = sortedSegments.map((seg: any, index: number) => ({ + segmentId: seg?.segmentId || '', + pageRange: seg?.pageRange || { start: 1, end: 1 }, + printMode: seg?.printMode || 'bw', + copies: seg?.copies || 1, + paperSize: seg?.paperSize || 'A4', + duplex: seg?.duplex || false, + status: seg?.status || 'pending', + printJobId: seg?.printJobId, + startedAt: seg?.startedAt, + completedAt: seg?.completedAt, + error: seg?.error, + executionOrder: index + 1, // Execution order (1 = prints first physically) + })); + } + + return { + _id: order._id.toString(), + orderId: order.orderId, + fileName: fileNames[0], + fileNames: fileNames, + printStatus: order.printStatus, + printerName: order.printerName || 'N/A', + createdAt: order.createdAt, + printStartedAt: order.printStartedAt, + printCompletedAt: order.printCompletedAt, + errorMessage: order.printError, + printAttempt: order.printAttempt || 0, + maxPrintAttempts: order.maxPrintAttempts || 3, + printingBy: order.printingBy, + printingHeartbeatAt: order.printingHeartbeatAt, + requiresAdminAction: (order.printAttempt || 0) >= (order.maxPrintAttempts || 3), + printSegments: segmentsWithOrder, + }; + }; + + return NextResponse.json({ + success: true, + data: { + orders: { + pending: pendingOrders.map(formatOrder), + printing: printingOrders.map(formatOrder), + printed: printedOrders.map(formatOrder), + maxAttemptsReached: maxAttemptsReached.map(formatOrder), + }, + printers: printers.map((printer: any) => ({ + _id: printer._id.toString(), + name: printer.name, + printer_id: printer.printer_id, + printer_name: printer.printer_name || printer.name, + status: printer.status, + connectionType: printer.connectionType, + queue_length: printer.queue_length || 0, + last_seen_at: printer.last_seen_at, + last_successful_print_at: printer.last_successful_print_at, + error_message: printer.error_message, + driver_name: printer.driver_name, + system_name: printer.system_name, + })), + recentLogs: recentLogs.map((log: any) => ({ + action: log.action, + orderId: log.orderId, + printJobId: log.printJobId, + adminEmail: log.adminEmail, + previousStatus: log.previousStatus, + newStatus: log.newStatus, + reason: log.reason, + timestamp: log.timestamp, + metadata: log.metadata, + })), + alerts: recentAlerts.map((alert: any) => ({ + type: alert.metadata?.alertType || 'unknown', + severity: alert.metadata?.severity || 'warning', + message: alert.reason, + timestamp: alert.timestamp, + metadata: alert.metadata, + })), + metrics: latestMetrics ? { + prints_per_hour: latestMetrics.prints_per_hour || 0, + failures_per_hour: latestMetrics.failures_per_hour || 0, + average_print_start_delay: latestMetrics.average_print_start_delay || 0, + printer_offline_duration: latestMetrics.printer_offline_duration || 0, + timestamp: latestMetrics.timestamp, + } : null, + timestamp: new Date().toISOString(), + }, + }); + } catch (error) { + console.error('Error fetching printer monitor data:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} + diff --git a/src/lib/mongodbChangeStreams.ts b/src/lib/mongodbChangeStreams.ts new file mode 100644 index 0000000..2656b22 --- /dev/null +++ b/src/lib/mongodbChangeStreams.ts @@ -0,0 +1,179 @@ +/** + * MongoDB Change Streams Utility + * Provides real-time updates via Change Streams with polling fallback + */ + +import mongoose from 'mongoose'; +import connectDB from './mongodb'; + +let changeStream: any = null; +let isReplicaSet = false; + +/** + * Check if MongoDB is configured as a replica set + */ +async function checkReplicaSet(): Promise { + try { + await connectDB(); + const admin = mongoose.connection.db.admin(); + const status = await admin.command({ isMaster: 1 }); + return !!(status.setName || status.ismaster); + } catch (error) { + console.warn('⚠️ Could not check replica set status:', error); + return false; + } +} + +/** + * Initialize Change Streams (if replica set available) or return polling function + */ +export async function initializeChangeStreams( + onOrderChange: (change: any) => void, + onPrinterChange: (change: any) => void +): Promise<() => void> { + try { + await connectDB(); + isReplicaSet = await checkReplicaSet(); + + if (isReplicaSet) { + console.log('✅ MongoDB replica set detected. Using Change Streams for real-time updates.'); + return setupChangeStreams(onOrderChange, onPrinterChange); + } else { + console.log('⚠️ MongoDB replica set not detected. Using polling fallback.'); + return setupPolling(onOrderChange, onPrinterChange); + } + } catch (error) { + console.error('❌ Error initializing change streams:', error); + console.log('⚠️ Falling back to polling.'); + return setupPolling(onOrderChange, onPrinterChange); + } +} + +/** + * Setup Change Streams (requires replica set) + */ +function setupChangeStreams( + onOrderChange: (change: any) => void, + onPrinterChange: (change: any) => void +): () => void { + const Order = mongoose.models.Order; + const Printer = mongoose.models.Printer; + + // Watch orders collection + const orderStream = Order.watch([ + { $match: { 'updateDescription.updatedFields.printStatus': { $exists: true } } }, + ]); + + orderStream.on('change', (change: any) => { + console.log('📊 Order change detected:', change.operationType); + onOrderChange(change); + }); + + orderStream.on('error', (error: any) => { + console.error('❌ Order change stream error:', error); + }); + + // Watch printers collection + const printerStream = Printer.watch([ + { $match: { 'updateDescription.updatedFields.status': { $exists: true } } }, + ]); + + printerStream.on('change', (change: any) => { + console.log('🖨️ Printer change detected:', change.operationType); + onPrinterChange(change); + }); + + printerStream.on('error', (error: any) => { + console.error('❌ Printer change stream error:', error); + }); + + changeStream = { orderStream, printerStream }; + + // Return cleanup function + return () => { + if (orderStream) { + orderStream.close(); + } + if (printerStream) { + printerStream.close(); + } + }; +} + +/** + * Setup polling fallback (works with any MongoDB setup) + */ +function setupPolling( + onOrderChange: (change: any) => void, + onPrinterChange: (change: any) => void +): () => void { + const POLL_INTERVAL = 2000; // Poll every 2 seconds + let lastOrderCheck: Date | null = null; + let lastPrinterCheck: Date | null = null; + + const Order = mongoose.models.Order; + const Printer = mongoose.models.Printer; + + const interval = setInterval(async () => { + try { + // Check for order changes + const orderQuery: any = {}; + if (lastOrderCheck) { + orderQuery.updatedAt = { $gt: lastOrderCheck }; + } + + const changedOrders = await Order.find(orderQuery) + .select('_id orderId printStatus updatedAt') + .limit(50) + .sort({ updatedAt: -1 }); + + if (changedOrders.length > 0) { + changedOrders.forEach((order) => { + onOrderChange({ + operationType: 'update', + documentKey: { _id: order._id }, + fullDocument: order.toObject(), + }); + }); + lastOrderCheck = new Date(); + } + + // Check for printer changes + const printerQuery: any = {}; + if (lastPrinterCheck) { + printerQuery.updatedAt = { $gt: lastPrinterCheck }; + } + + const changedPrinters = await Printer.find(printerQuery) + .select('_id name status updatedAt') + .limit(10) + .sort({ updatedAt: -1 }); + + if (changedPrinters.length > 0) { + changedPrinters.forEach((printer) => { + onPrinterChange({ + operationType: 'update', + documentKey: { _id: printer._id }, + fullDocument: printer.toObject(), + }); + }); + lastPrinterCheck = new Date(); + } + } catch (error) { + console.error('❌ Polling error:', error); + } + }, POLL_INTERVAL); + + // Return cleanup function + return () => { + clearInterval(interval); + }; +} + +/** + * Check if Change Streams are available + */ +export function isChangeStreamsAvailable(): boolean { + return isReplicaSet; +} + diff --git a/src/models/Order.ts b/src/models/Order.ts index 2573fff..db1518e 100644 --- a/src/models/Order.ts +++ b/src/models/Order.ts @@ -92,6 +92,35 @@ export interface IOrder { templateCreatorUserId?: string; // Reference to template creator user ID (string for simplicity) expectedDate?: Date; deliveryNumber?: string; // Format: {LETTER}{YYYYMMDD}{PRINTER_INDEX} + // Print status fields (MongoDB-based printing system) + printStatus?: 'pending' | 'printing' | 'printed'; // Required for printing system + printError?: string; // Error message if printing fails + printerId?: string; // Reference to printer that processed the order + printerName?: string; // Printer name for display + printStartedAt?: Date; // When printing started + printCompletedAt?: Date; // When printing completed + // Production-critical: Idempotency and ownership + printJobId?: string; // UUID for print job idempotency + printAttempt?: number; // Number of print attempts (default: 0) + maxPrintAttempts?: number; // Maximum print attempts before requiring admin action (default: 3) + printingBy?: string; // Worker ID that owns this print job + printingHeartbeatAt?: Date; // Last heartbeat update while printing (for stale job detection) + printSegments?: Array<{ // For mixed printing (color/BW segments) + segmentId: string; + pageRange?: { + start: number; + end: number; + }; + printMode?: 'color' | 'bw'; + copies?: number; + paperSize?: 'A4' | 'A3'; + duplex?: boolean; + status: 'pending' | 'printing' | 'completed' | 'failed'; + printJobId?: string; + startedAt?: Date; + completedAt?: Date; + error?: string; + }>; createdAt: Date; updatedAt: Date; } @@ -264,12 +293,52 @@ const orderSchema = new mongoose.Schema({ } }, deliveryNumber: String, // Format: {LETTER}{YYYYMMDD}{PRINTER_INDEX} + // Print status fields (MongoDB-based printing system) + printStatus: { + type: String, + enum: ['pending', 'printing', 'printed'], + default: undefined, // Will be set by migration or when payment completes + index: true, + }, + printError: String, // Error message if printing fails + printerId: String, // Reference to printer that processed the order + printerName: String, // Printer name for display + printStartedAt: Date, // When printing started + printCompletedAt: Date, // When printing completed + // Production-critical: Idempotency and ownership + printJobId: { type: String, index: true, sparse: true, unique: true }, // UUID for print job idempotency + printAttempt: { type: Number, default: 0 }, // Number of print attempts + maxPrintAttempts: { type: Number, default: 3 }, // Maximum print attempts before requiring admin action + printingBy: { type: String, index: true }, // Worker ID that owns this print job + printingHeartbeatAt: { type: Date, index: true }, // Last heartbeat update while printing (for stale job detection) + printSegments: [{ // For mixed printing (color/BW segments) + segmentId: String, + pageRange: { + start: Number, + end: Number, + }, + printMode: { type: String, enum: ['color', 'bw'] }, + copies: Number, + paperSize: { type: String, enum: ['A4', 'A3'] }, + duplex: Boolean, + status: { type: String, enum: ['pending', 'printing', 'completed', 'failed'] }, + printJobId: String, + startedAt: Date, + completedAt: Date, + error: String, + }], }, { timestamps: true, }); -// Generate order ID before saving +// Pre-save hook: Set printStatus and generate order ID orderSchema.pre('save', async function(next) { + // Set printStatus to 'pending' when payment is completed + if (this.paymentStatus === 'completed' && !this.printStatus) { + this.printStatus = 'pending'; + } + + // Generate order ID before saving if (this.isNew && !this.orderId) { let attempts = 0; const maxAttempts = 10; diff --git a/src/models/PrintLog.ts b/src/models/PrintLog.ts new file mode 100644 index 0000000..22cb626 --- /dev/null +++ b/src/models/PrintLog.ts @@ -0,0 +1,52 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export interface IPrintLog extends Document { + _id: string; + action: string; // e.g., "reprint", "cancel", "reset_state", "force_printed", "server_shutdown" + orderId: string; // Order ID (indexed) + printJobId?: string; // Print job ID for idempotency tracking + adminId?: string; // Who performed the action + adminEmail?: string; // Admin email for audit + previousStatus?: string; // Previous printStatus + newStatus?: string; // New printStatus + reason?: string; // Reason for the action + timestamp: Date; // When the action occurred + metadata?: Record; // Additional context + createdAt: Date; + updatedAt: Date; +} + +const printLogSchema: Schema = new Schema({ + action: { + type: String, + required: true, + index: true + }, + orderId: { + type: String, + required: true, + index: true + }, + printJobId: String, // Print job ID for idempotency tracking + adminId: String, // Who performed the action + adminEmail: String, // Admin email for audit + previousStatus: String, // Previous printStatus + newStatus: String, // New printStatus + reason: String, // Reason for the action + timestamp: { + type: Date, + default: Date.now, + index: true + }, + metadata: mongoose.Schema.Types.Mixed, // Additional context +}, { + timestamps: true, +}); + +// Indexes for efficient queries +printLogSchema.index({ orderId: 1, timestamp: -1 }); +printLogSchema.index({ timestamp: -1 }); +printLogSchema.index({ action: 1, timestamp: -1 }); + +export default mongoose.models.PrintLog || mongoose.model('PrintLog', printLogSchema); + diff --git a/src/models/Printer.ts b/src/models/Printer.ts index 30d288e..0c30e6f 100644 --- a/src/models/Printer.ts +++ b/src/models/Printer.ts @@ -7,13 +7,27 @@ export interface IPrinter extends Document { manufacturer: string; connectionType: 'usb' | 'network' | 'wireless'; connectionString: string; // USB path, IP address, or network path - status: 'online' | 'offline' | 'error' | 'maintenance'; + status: 'online' | 'offline' | 'error' | 'maintenance' | 'busy'; + // Health monitoring fields + printer_id?: string; // Unique identifier (alternative to _id) + printer_name?: string; // Display name (alternative to name) + last_seen_at?: Date; // Last health check timestamp + last_successful_print_at?: Date; // Last successful print timestamp + queue_length?: number; // Current queue length (alternative to queueLength) + error_message?: string; // Current error message if status is 'error' + driver_name?: string; // Printer driver name + system_name?: 'Windows' | 'Linux'; // Operating system capabilities: { supportedPageSizes: string[]; supportsColor: boolean; supportsDuplex: boolean; maxCopies: number; supportedFileTypes: string[]; + // Enhanced capabilities + maxPaperSize?: string; // e.g., "A3", "A4", "Letter" + recommendedDPI?: number; // e.g., 300, 600 + supportsPostScript?: boolean; + supportsPCL?: boolean; }; currentJob?: string; // PrintJob ID queueLength: number; @@ -38,16 +52,30 @@ const printerSchema: Schema = new Schema({ connectionString: { type: String, required: true }, status: { type: String, - enum: ['online', 'offline', 'error', 'maintenance'], + enum: ['online', 'offline', 'error', 'maintenance', 'busy'], default: 'offline', index: true }, + // Health monitoring fields + printer_id: { type: String, unique: true, sparse: true }, // Unique identifier + printer_name: String, // Display name + last_seen_at: Date, // Last health check timestamp + last_successful_print_at: Date, // Last successful print timestamp + queue_length: { type: Number, default: 0 }, // Current queue length + error_message: String, // Current error message if status is 'error' + driver_name: String, // Printer driver name + system_name: { type: String, enum: ['Windows', 'Linux'] }, // Operating system capabilities: { supportedPageSizes: [{ type: String }], supportsColor: { type: Boolean, default: false }, supportsDuplex: { type: Boolean, default: false }, maxCopies: { type: Number, default: 1 }, - supportedFileTypes: [{ type: String }] + supportedFileTypes: [{ type: String }], + // Enhanced capabilities + maxPaperSize: String, // e.g., "A3", "A4", "Letter" + recommendedDPI: Number, // e.g., 300, 600 + supportsPostScript: { type: Boolean, default: false }, + supportsPCL: { type: Boolean, default: false }, }, currentJob: { type: String, ref: 'PrintJob' }, queueLength: { type: Number, default: 0 }, @@ -63,5 +91,7 @@ const printerSchema: Schema = new Schema({ // Indexes for efficient queries printerSchema.index({ status: 1, isActive: 1 }); printerSchema.index({ autoPrintEnabled: 1, status: 1 }); +printerSchema.index({ printer_id: 1 }, { unique: true, sparse: true }); +printerSchema.index({ last_seen_at: -1 }); export default mongoose.models.Printer || mongoose.model('Printer', printerSchema); diff --git a/src/utils/printStateMachine.ts b/src/utils/printStateMachine.ts new file mode 100644 index 0000000..b1730e1 --- /dev/null +++ b/src/utils/printStateMachine.ts @@ -0,0 +1,92 @@ +/** + * State Machine Validator for Print Status Transitions (Website) + * Enforces strict state transition rules to prevent invalid operations + */ + +export type PrintStatus = 'pending' | 'printing' | 'printed'; + +/** + * Allowed state transitions + * Key: from state, Value: array of allowed to states + */ +const ALLOWED_TRANSITIONS: Record = { + pending: ['printing'], // pending → printing (allowed) + printing: ['printed', 'pending'], // printing → printed (success) or printing → pending (failure/reset) + printed: ['pending'], // printed → pending (admin override only) +}; + +/** + * Validate if a state transition is allowed + */ +export interface TransitionValidation { + allowed: boolean; + reason?: string; +} + +export function validatePrintTransition( + from: PrintStatus | string | undefined, + to: PrintStatus | string, + isAdmin: boolean = false +): TransitionValidation { + // Handle undefined/null from state (new orders) + if (!from || from === 'undefined' || from === 'null') { + if (to === 'pending') { + return { allowed: true }; + } + return { + allowed: false, + reason: `Cannot transition from undefined to ${to}. New orders must start as 'pending'.`, + }; + } + + // Validate from state is a valid PrintStatus + if (!['pending', 'printing', 'printed'].includes(from)) { + return { + allowed: false, + reason: `Invalid from state: ${from}. Must be one of: pending, printing, printed.`, + }; + } + + // Validate to state is a valid PrintStatus + if (!['pending', 'printing', 'printed'].includes(to)) { + return { + allowed: false, + reason: `Invalid to state: ${to}. Must be one of: pending, printing, printed.`, + }; + } + + const fromState = from as PrintStatus; + const toState = to as PrintStatus; + + // Check if transition is in allowed list + const allowedTransitions = ALLOWED_TRANSITIONS[fromState] || []; + const isAllowed = allowedTransitions.includes(toState); + + // Special handling for admin overrides + if (!isAllowed && isAdmin) { + // Admin can override printed → pending (for reprints) + if (fromState === 'printed' && toState === 'pending') { + return { + allowed: true, + reason: 'Admin override: printed → pending (reprint)', + }; + } + // Admin can override printing → printed (force complete) + if (fromState === 'printing' && toState === 'printed') { + return { + allowed: true, + reason: 'Admin override: printing → printed (force complete)', + }; + } + } + + if (!isAllowed) { + return { + allowed: false, + reason: `Transition from '${fromState}' to '${toState}' is not allowed. Allowed transitions from '${fromState}': ${allowedTransitions.join(', ')}.`, + }; + } + + return { allowed: true }; +} +