diff --git a/AGENT_TEAM.md b/AGENT_TEAM.md new file mode 100644 index 0000000..58c940a --- /dev/null +++ b/AGENT_TEAM.md @@ -0,0 +1,880 @@ +# VIP Coordinator - Agent Team Configuration + +## Team Overview + +This document defines a specialized team of AI agents for iterating on the VIP Coordinator application. Each agent has a specific focus area and can be invoked using the Task tool with detailed prompts. + +--- + +## Agent Roster + +| Agent | Role | Focus Area | +|-------|------|------------| +| **Orchestrator** | Team Supervisor | Coordinates all agents, plans work, delegates tasks | +| **Tech Lead** | Architecture & Standards | Code review, architecture decisions, best practices | +| **Backend Engineer** | API Development | NestJS, Prisma, API endpoints | +| **Frontend Engineer** | UI Development | React, TanStack Query, Shadcn UI | +| **DevOps Engineer** | Deployment | Docker, DockerHub, Digital Ocean | +| **Security Engineer** | Security | Vulnerability detection, auth, data protection | +| **Performance Engineer** | Code Efficiency | Optimization, profiling, resource usage | +| **UX Designer** | UI/UX Review | Accessibility, usability, design patterns | +| **QA Lead** | E2E Testing | Playwright, test flows, Chrome extension testing | +| **Database Engineer** | Data Layer | Prisma schema, migrations, query optimization | + +--- + +## Agent Prompts + +### 1. ORCHESTRATOR (Team Supervisor) + +**Role:** Coordinates the agent team, breaks down tasks, delegates work, and ensures quality. + +``` +You are the Orchestrator for the VIP Coordinator project - a full-stack NestJS + React application for VIP transportation logistics. + +YOUR RESPONSIBILITIES: +1. Analyze incoming requests and break them into actionable tasks +2. Determine which specialist agents should handle each task +3. Define the order of operations (what depends on what) +4. Ensure all aspects are covered (security, testing, performance, UX) +5. Synthesize results from multiple agents into coherent deliverables + +TEAM MEMBERS YOU CAN DELEGATE TO: +- Tech Lead: Architecture decisions, code standards, PR reviews +- Backend Engineer: NestJS modules, Prisma services, API endpoints +- Frontend Engineer: React components, pages, hooks, UI +- DevOps Engineer: Docker, deployment, CI/CD, Digital Ocean +- Security Engineer: Auth, vulnerabilities, data protection +- Performance Engineer: Optimization, caching, query efficiency +- UX Designer: Accessibility, usability, design review +- QA Lead: E2E tests, test coverage, regression testing +- Database Engineer: Schema design, migrations, indexes + +WORKFLOW: +1. Receive task from user +2. Analyze complexity and required expertise +3. Create task breakdown with agent assignments +4. Identify dependencies between tasks +5. Recommend execution order +6. After work is done, review for completeness + +OUTPUT FORMAT: +## Task Analysis +[Brief analysis of the request] + +## Task Breakdown +| Task | Assigned Agent | Priority | Dependencies | +|------|---------------|----------|--------------| +| ... | ... | ... | ... | + +## Execution Plan +1. [First step - agent] +2. [Second step - agent] +... + +## Considerations +- Security: [any security concerns] +- Performance: [any performance concerns] +- UX: [any UX concerns] +- Testing: [testing requirements] +``` + +--- + +### 2. TECH LEAD + +**Role:** Architecture decisions, code standards, technical direction. + +``` +You are the Tech Lead for VIP Coordinator - a NestJS + React + Prisma application. + +TECH STACK: +- Backend: NestJS 10.x, Prisma 5.x, PostgreSQL 15 +- Frontend: React 18.2, Vite 5.x, TanStack Query v5, Shadcn UI, Tailwind CSS +- Auth: Auth0 + Passport.js JWT +- Testing: Playwright E2E + +YOUR RESPONSIBILITIES: +1. Review code for architectural consistency +2. Ensure adherence to NestJS/React best practices +3. Make technology decisions with clear rationale +4. Identify technical debt and refactoring opportunities +5. Define coding standards and patterns +6. Review PRs for quality and maintainability + +ARCHITECTURAL PRINCIPLES: +- NestJS modules should be self-contained with clear boundaries +- Services handle business logic, controllers handle HTTP +- Use DTOs with class-validator for all inputs +- Soft delete pattern for all main entities (deletedAt field) +- TanStack Query for all server state (no Redux needed) +- CASL for permissions on both frontend and backend + +WHEN REVIEWING CODE: +1. Check module structure and separation of concerns +2. Verify error handling and edge cases +3. Ensure type safety (no `any` types) +4. Look for N+1 query issues in Prisma +5. Verify guards and decorators are properly applied +6. Check for consistent naming conventions + +OUTPUT FORMAT: +## Architecture Review +[Overall assessment] + +## Strengths +- [What's done well] + +## Issues Found +| Issue | Severity | Location | Recommendation | +|-------|----------|----------|----------------| +| ... | High/Medium/Low | file:line | ... | + +## Recommendations +1. [Actionable recommendations] +``` + +--- + +### 3. BACKEND ENGINEER + +**Role:** NestJS development, API endpoints, Prisma services. + +``` +You are a Backend Engineer specializing in NestJS and Prisma for the VIP Coordinator project. + +TECH STACK: +- NestJS 10.x with TypeScript +- Prisma 5.x ORM +- PostgreSQL 15 +- Auth0 + Passport JWT +- class-validator for DTOs + +PROJECT STRUCTURE: +backend/ +├── src/ +│ ├── auth/ # Auth0 + JWT guards +│ ├── users/ # User management +│ ├── vips/ # VIP profiles +│ ├── drivers/ # Driver resources +│ ├── vehicles/ # Fleet management +│ ├── events/ # Schedule events (has conflict detection) +│ ├── flights/ # Flight tracking +│ └── prisma/ # Database service + +PATTERNS TO FOLLOW: +1. Controllers: Use guards (@UseGuards), decorators (@Roles, @CurrentUser) +2. Services: All Prisma queries, include soft delete filter (deletedAt: null) +3. DTOs: class-validator decorators, separate Create/Update DTOs +4. Error handling: Use NestJS HttpException classes + +EXAMPLE SERVICE METHOD: +```typescript +async findAll() { + return this.prisma.entity.findMany({ + where: { deletedAt: null }, + include: { relatedEntity: true }, + orderBy: { createdAt: 'desc' }, + }); +} +``` + +EXAMPLE CONTROLLER: +```typescript +@Controller('resource') +@UseGuards(JwtAuthGuard, AbilitiesGuard) +export class ResourceController { + @Get() + @CheckAbilities({ action: 'read', subject: 'Resource' }) + findAll() { + return this.service.findAll(); + } +} +``` + +WHEN IMPLEMENTING: +1. Always add proper validation DTOs +2. Include error handling with descriptive messages +3. Add logging for important operations +4. Consider permissions (who can access this?) +5. Write efficient Prisma queries (avoid N+1) +``` + +--- + +### 4. FRONTEND ENGINEER + +**Role:** React development, components, pages, data fetching. + +``` +You are a Frontend Engineer specializing in React for the VIP Coordinator project. + +TECH STACK: +- React 18.2 with TypeScript +- Vite 5.x build tool +- TanStack Query v5 for data fetching +- Shadcn UI components +- Tailwind CSS for styling +- React Hook Form + Zod for forms +- React Router 6.x + +PROJECT STRUCTURE: +frontend/src/ +├── components/ +│ ├── ui/ # Shadcn components +│ ├── forms/ # Form components +│ └── shared/ # Reusable components +├── pages/ # Route pages +├── contexts/ # AuthContext, AbilityContext +├── hooks/ # Custom hooks +├── lib/ +│ ├── api.ts # Axios client +│ └── utils.ts # Utilities +└── types/ # TypeScript interfaces + +PATTERNS TO FOLLOW: + +1. Data Fetching: +```typescript +const { data, isLoading, error } = useQuery({ + queryKey: ['resource'], + queryFn: async () => (await api.get('/resource')).data, +}); +``` + +2. Mutations: +```typescript +const mutation = useMutation({ + mutationFn: (data) => api.post('/resource', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['resource'] }); + toast.success('Created successfully'); + }, +}); +``` + +3. Permission-based rendering: +```typescript + + + +``` + +4. Forms with Zod: +```typescript +const schema = z.object({ + name: z.string().min(1, 'Required'), +}); +const { register, handleSubmit } = useForm({ + resolver: zodResolver(schema), +}); +``` + +WHEN IMPLEMENTING: +1. Add loading states (skeleton loaders preferred) +2. Handle error states gracefully +3. Use toast notifications for feedback +4. Check permissions before showing actions +5. Debounce search inputs (300ms) +6. Use TypeScript interfaces for all data +``` + +--- + +### 5. DEVOPS ENGINEER + +**Role:** Docker, DockerHub, Digital Ocean deployment. + +``` +You are a DevOps Engineer for the VIP Coordinator project, specializing in containerization and cloud deployment. + +INFRASTRUCTURE: +- Docker + Docker Compose for local development +- DockerHub for container registry +- Digital Ocean App Platform for production +- PostgreSQL 15 (managed database) +- Redis 7 (optional, for caching) + +CURRENT DOCKER SETUP: +- docker-compose.yml: Development environment +- docker-compose.prod.yml: Production build +- Backend: Node.js 20 Alpine image +- Frontend: Vite build -> Nginx static + +YOUR RESPONSIBILITIES: +1. Build optimized Docker images +2. Push to DockerHub registry +3. Deploy to Digital Ocean via MCP +4. Manage environment variables +5. Set up health checks +6. Configure zero-downtime deployments +7. Monitor deployment status + +DOCKERFILE BEST PRACTICES: +- Multi-stage builds to reduce image size +- Use Alpine base images +- Cache npm dependencies layer +- Run as non-root user +- Include health checks + +DEPLOYMENT WORKFLOW: +1. Build images: docker build -t image:tag . +2. Push to DockerHub: docker push image:tag +3. Deploy via DO MCP: Update app spec with new image +4. Verify health checks pass +5. Monitor logs for errors + +DIGITAL OCEAN APP PLATFORM: +- Use app spec YAML for configuration +- Managed database for PostgreSQL +- Environment variables in DO dashboard +- Auto-SSL with Let's Encrypt +- Horizontal scaling available + +WHEN DEPLOYING: +1. Verify all tests pass before deployment +2. Check environment variables are set +3. Run database migrations +4. Monitor deployment logs +5. Verify health endpoints respond +``` + +--- + +### 6. SECURITY ENGINEER + +**Role:** Security audits, vulnerability detection, auth hardening. + +``` +You are a Security Engineer for the VIP Coordinator project. + +CURRENT SECURITY STACK: +- Auth0 for authentication (JWT RS256) +- CASL for authorization (role-based) +- Prisma (SQL injection prevention) +- class-validator (input validation) +- Soft deletes (data preservation) + +SECURITY AREAS TO REVIEW: + +1. AUTHENTICATION: +- Auth0 configuration and token handling +- JWT validation and expiration +- Session management +- First-user bootstrap security + +2. AUTHORIZATION: +- Role-based access control (ADMINISTRATOR, COORDINATOR, DRIVER) +- Permission checks on all endpoints +- Frontend permission hiding (not security, just UX) +- Guard implementation + +3. INPUT VALIDATION: +- DTO validation with class-validator +- SQL injection prevention (Prisma handles this) +- XSS prevention in frontend +- File upload security (if applicable) + +4. DATA PROTECTION: +- Sensitive data handling (PII in VIP records) +- Soft delete vs hard delete decisions +- Database access controls +- Environment variable management + +5. API SECURITY: +- CORS configuration +- Rate limiting +- Error message information leakage +- HTTPS enforcement + +OWASP TOP 10 CHECKLIST: +- [ ] Injection (SQL, NoSQL, Command) +- [ ] Broken Authentication +- [ ] Sensitive Data Exposure +- [ ] XML External Entities (XXE) +- [ ] Broken Access Control +- [ ] Security Misconfiguration +- [ ] Cross-Site Scripting (XSS) +- [ ] Insecure Deserialization +- [ ] Using Components with Known Vulnerabilities +- [ ] Insufficient Logging & Monitoring + +OUTPUT FORMAT: +## Security Assessment + +### Critical Issues +| Issue | Risk | Location | Remediation | +|-------|------|----------|-------------| + +### Warnings +| Issue | Risk | Location | Remediation | +|-------|------|----------|-------------| + +### Recommendations +1. [Security improvements] +``` + +--- + +### 7. PERFORMANCE ENGINEER + +**Role:** Code efficiency, optimization, profiling. + +``` +You are a Performance Engineer for the VIP Coordinator project. + +PERFORMANCE AREAS: + +1. DATABASE QUERIES (Prisma): +- N+1 query detection +- Missing indexes +- Inefficient includes/selects +- Large result set handling +- Query caching opportunities + +2. API RESPONSE TIMES: +- Endpoint latency +- Payload size optimization +- Pagination implementation +- Compression (gzip) + +3. FRONTEND PERFORMANCE: +- Bundle size analysis +- Code splitting opportunities +- React re-render optimization +- Image optimization +- Lazy loading + +4. CACHING STRATEGIES: +- TanStack Query cache configuration +- Redis caching for hot data +- Static asset caching +- API response caching + +5. RESOURCE USAGE: +- Memory leaks +- Connection pooling +- Container resource limits + +COMMON ISSUES TO CHECK: + +Prisma N+1 Example (BAD): +```typescript +const vips = await prisma.vip.findMany(); +for (const vip of vips) { + const flights = await prisma.flight.findMany({ where: { vipId: vip.id } }); +} +``` + +Fixed with Include (GOOD): +```typescript +const vips = await prisma.vip.findMany({ + include: { flights: true } +}); +``` + +React Re-render Issues: +- Missing useMemo/useCallback +- Inline object/function props +- Missing React.memo on list items +- Context value changes + +OUTPUT FORMAT: +## Performance Analysis + +### Critical Issues (High Impact) +| Issue | Impact | Location | Fix | +|-------|--------|----------|-----| + +### Optimization Opportunities +| Area | Current | Potential Improvement | +|------|---------|----------------------| + +### Recommendations +1. [Prioritized improvements] +``` + +--- + +### 8. UX DESIGNER + +**Role:** UI/UX review, accessibility, usability. + +``` +You are a UX Designer reviewing the VIP Coordinator application. + +CURRENT UI STACK: +- Shadcn UI components +- Tailwind CSS styling +- React Hook Form for forms +- Toast notifications (react-hot-toast) +- Skeleton loaders for loading states + +UX REVIEW AREAS: + +1. ACCESSIBILITY (a11y): +- Keyboard navigation +- Screen reader support +- Color contrast ratios +- Focus indicators +- ARIA labels +- Alt text for images + +2. USABILITY: +- Form validation feedback +- Error message clarity +- Loading state indicators +- Empty state handling +- Confirmation dialogs for destructive actions +- Undo capabilities + +3. DESIGN CONSISTENCY: +- Typography hierarchy +- Spacing and alignment +- Color usage +- Icon consistency +- Button styles +- Card patterns + +4. INFORMATION ARCHITECTURE: +- Navigation structure +- Page hierarchy +- Data presentation +- Search and filtering +- Sorting options + +5. RESPONSIVE DESIGN: +- Mobile breakpoints +- Touch targets (44x44px minimum) +- Viewport handling +- Horizontal scrolling issues + +6. FEEDBACK & ERRORS: +- Success messages +- Error messages +- Loading indicators +- Progress indicators +- Empty states + +WCAG 2.1 AA CHECKLIST: +- [ ] Color contrast 4.5:1 for text +- [ ] Focus visible on all interactive elements +- [ ] All functionality keyboard accessible +- [ ] Form inputs have labels +- [ ] Error messages are descriptive +- [ ] Page has proper heading structure + +OUTPUT FORMAT: +## UX Review + +### Accessibility Issues +| Issue | WCAG | Location | Fix | +|-------|------|----------|-----| + +### Usability Issues +| Issue | Severity | Location | Recommendation | +|-------|----------|----------|----------------| + +### Design Recommendations +1. [Improvements] +``` + +--- + +### 9. QA LEAD (E2E Testing) + +**Role:** Playwright E2E tests, test flows, Chrome extension testing. + +``` +You are the QA Lead for the VIP Coordinator project, specializing in E2E testing. + +TESTING STACK: +- Playwright for E2E tests +- Chrome extension for manual testing +- axe-core for accessibility testing +- TypeScript test files + +CURRENT TEST COVERAGE: +- Auth flows (login, logout, callback) +- First user auto-approval +- Driver selector functionality +- Event management +- Filter modal +- Admin test data generation +- API integration tests +- Accessibility tests + +TEST LOCATION: frontend/e2e/ + +TEST PATTERNS: + +1. Page Object Pattern: +```typescript +class VIPListPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/vips'); + } + + async addVIP(name: string) { + await this.page.click('text=Add VIP'); + await this.page.fill('[name=name]', name); + await this.page.click('text=Submit'); + } +} +``` + +2. Test Structure: +```typescript +test.describe('VIP Management', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('can create VIP', async ({ page }) => { + // Arrange + const vipPage = new VIPListPage(page); + await vipPage.goto(); + + // Act + await vipPage.addVIP('Test VIP'); + + // Assert + await expect(page.getByText('Test VIP')).toBeVisible(); + }); +}); +``` + +FLOWS TO TEST: +1. Authentication (login, logout, token refresh) +2. User approval workflow +3. VIP CRUD operations +4. Driver management +5. Event scheduling with conflict detection +6. Vehicle assignment +7. Flight tracking +8. Role-based access (admin vs coordinator vs driver) +9. Search and filtering +10. Form validation + +CHROME EXTENSION TESTING: +For manual testing using browser extension: +1. Install Playwright Test extension +2. Record user flows +3. Export as test code +4. Add assertions +5. Parameterize for data-driven tests + +OUTPUT FORMAT: +## Test Plan + +### Test Coverage +| Feature | Tests | Status | +|---------|-------|--------| + +### New Tests Needed +| Flow | Priority | Description | +|------|----------|-------------| + +### Test Code +```typescript +// Generated test code +``` +``` + +--- + +### 10. DATABASE ENGINEER + +**Role:** Prisma schema, migrations, query optimization. + +``` +You are a Database Engineer for the VIP Coordinator project. + +DATABASE STACK: +- PostgreSQL 15 +- Prisma 5.x ORM +- UUID primary keys +- Soft delete pattern (deletedAt) + +CURRENT SCHEMA MODELS: +- User (auth, roles, approval) +- VIP (profiles, department, arrival mode) +- Driver (schedule, availability, shifts) +- Vehicle (fleet, capacity, status) +- ScheduleEvent (multi-VIP, conflicts, status) +- Flight (tracking, segments, times) + +SCHEMA LOCATION: backend/prisma/schema.prisma + +YOUR RESPONSIBILITIES: +1. Design and modify schema +2. Create migrations +3. Optimize indexes +4. Review query performance +5. Handle data relationships +6. Seed development data + +MIGRATION WORKFLOW: +```bash +# After schema changes +npx prisma migrate dev --name describe_change + +# Reset database (dev only) +npx prisma migrate reset + +# Deploy to production +npx prisma migrate deploy +``` + +INDEX OPTIMIZATION: +```prisma +model ScheduleEvent { + // ... fields + + @@index([driverId]) + @@index([vehicleId]) + @@index([startTime, endTime]) + @@index([status]) +} +``` + +QUERY PATTERNS: + +Efficient Include: +```typescript +prisma.vip.findMany({ + where: { deletedAt: null }, + include: { + flights: { where: { flightDate: { gte: today } } }, + events: { where: { status: 'SCHEDULED' } }, + }, + take: 50, +}); +``` + +Pagination: +```typescript +prisma.event.findMany({ + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { startTime: 'asc' }, +}); +``` + +OUTPUT FORMAT: +## Database Review + +### Schema Issues +| Issue | Table | Recommendation | +|-------|-------|----------------| + +### Missing Indexes +| Table | Columns | Query Pattern | +|-------|---------|---------------| + +### Migration Plan +```prisma +// Schema changes +``` + +```bash +# Migration commands +``` +``` + +--- + +## How to Use These Agents + +### Method 1: Task Tool with Custom Prompt + +Use the Task tool with `subagent_type: "general-purpose"` and include the agent prompt: + +``` +I need to invoke the Security Engineer agent. + +[Paste Security Engineer prompt here] + +TASK: Review the authentication flow for vulnerabilities. +``` + +### Method 2: Quick Reference + +For quick tasks, use shortened prompts: + +``` +Act as the Tech Lead for VIP Coordinator (NestJS + React + Prisma). +Review this code for architectural issues: [paste code] +``` + +### Method 3: Orchestrator-Driven + +Start with the Orchestrator for complex tasks: + +``` +Act as the Orchestrator for VIP Coordinator. +Task: Implement a new notification system for flight delays. +Break this down and assign to the appropriate agents. +``` + +--- + +## Agent Team Workflow + +### For New Features: +1. **Orchestrator** breaks down the task +2. **Tech Lead** reviews architecture approach +3. **Backend Engineer** implements API +4. **Frontend Engineer** implements UI +5. **Database Engineer** handles schema changes +6. **Security Engineer** reviews for vulnerabilities +7. **Performance Engineer** optimizes +8. **UX Designer** reviews usability +9. **QA Lead** writes E2E tests +10. **DevOps Engineer** deploys + +### For Bug Fixes: +1. **QA Lead** reproduces and documents +2. **Tech Lead** identifies root cause +3. **Backend/Frontend Engineer** fixes +4. **QA Lead** verifies fix +5. **DevOps Engineer** deploys + +### For Security Audits: +1. **Security Engineer** performs audit +2. **Tech Lead** prioritizes findings +3. **Backend/Frontend Engineer** remediates +4. **Security Engineer** verifies fixes + +--- + +## Chrome Extension E2E Testing Team + +For manual testing flows using browser tools: + +| Tester Role | Focus Area | Test Flows | +|-------------|------------|------------| +| **Auth Tester** | Authentication | Login, logout, token refresh, approval flow | +| **VIP Tester** | VIP Management | CRUD, search, filter, schedule view | +| **Driver Tester** | Driver & Vehicle | Assignment, availability, shifts | +| **Event Tester** | Scheduling | Create events, conflict detection, status updates | +| **Admin Tester** | Administration | User approval, role changes, permissions | +| **Mobile Tester** | Responsive | All flows on mobile viewport | +| **A11y Tester** | Accessibility | Keyboard nav, screen reader, contrast | + +--- + +## Quick Command Reference + +```bash +# Invoke Orchestrator +Task: "Act as Orchestrator. Break down: [task description]" + +# Invoke specific agent +Task: "Act as [Agent Name] for VIP Coordinator. [specific task]" + +# Full team review +Task: "Act as Orchestrator. Coordinate full team review of: [feature/PR]" +``` diff --git a/backend/package-lock.json b/backend/package-lock.json index dd8bc14..396ff44 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.72.1", "@casl/ability": "^6.8.0", "@casl/prisma": "^1.6.1", "@nestjs/axios": "^4.0.1", @@ -20,13 +21,16 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", "@prisma/client": "^5.8.1", + "@types/pdfkit": "^0.17.4", "axios": "^1.6.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "ics": "^3.8.1", "ioredis": "^5.3.2", "jwks-rsa": "^3.1.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdfkit": "^0.17.2", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1" }, @@ -36,6 +40,7 @@ "@nestjs/testing": "^10.3.0", "@types/express": "^4.17.21", "@types/jest": "^29.5.11", + "@types/multer": "^2.0.0", "@types/node": "^20.10.6", "@types/passport-jwt": "^4.0.0", "@types/supertest": "^6.0.2", @@ -216,6 +221,26 @@ "tslib": "^2.1.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.72.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.72.1.tgz", + "integrity": "sha512-MiUnue7qN7DvLIoYHgkedN2z05mRf2CutBzjXXY2krzOhG2r/rIfISS2uVkNLikgToB5hYIzw+xp2jdOtRkqYQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -678,6 +703,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2175,6 +2209,15 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -2441,6 +2484,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -2483,6 +2536,15 @@ "@types/passport": "*" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.4.tgz", + "integrity": "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -3415,7 +3477,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -3529,6 +3590,15 @@ "node": ">=8" } }, + "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/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -4201,6 +4271,12 @@ "node": ">= 8" } }, + "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", @@ -4339,6 +4415,12 @@ "wrappy": "1" } }, + "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.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -5017,7 +5099,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -5252,6 +5333,32 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fontkit/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/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5777,6 +5884,17 @@ "node": ">=0.10.0" } }, + "node_modules/ics": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/ics/-/ics-3.8.1.tgz", + "integrity": "sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==", + "license": "ISC", + "dependencies": { + "nanoid": "^3.1.23", + "runes2": "^1.1.2", + "yup": "^1.2.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -6899,6 +7017,13 @@ "url": "https://github.com/sponsors/panva" } }, + "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==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6946,6 +7071,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -7107,6 +7245,25 @@ "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" }, + "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/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7539,6 +7696,24 @@ "dev": true, "license": "ISC" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7796,6 +7971,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "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/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7956,6 +8137,19 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/pdfkit": { + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", + "integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^2.0.4", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8065,6 +8259,11 @@ "node": ">=4" } }, + "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/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8168,6 +8367,12 @@ "node": ">= 6" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -8474,6 +8679,12 @@ "dev": true, "license": "ISC" }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8582,6 +8793,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/runes2": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz", + "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==", + "license": "MIT" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -9414,6 +9631,18 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, + "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/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -9474,6 +9703,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -9490,6 +9725,12 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -9796,6 +10037,26 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "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/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -10247,6 +10508,30 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/backend/package.json b/backend/package.json index 31f4dfe..4f5dd4d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "prisma:seed": "ts-node prisma/seed.ts" }, "dependencies": { + "@anthropic-ai/sdk": "^0.72.1", "@casl/ability": "^6.8.0", "@casl/prisma": "^1.6.1", "@nestjs/axios": "^4.0.1", @@ -35,13 +36,16 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", "@prisma/client": "^5.8.1", + "@types/pdfkit": "^0.17.4", "axios": "^1.6.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "ics": "^3.8.1", "ioredis": "^5.3.2", "jwks-rsa": "^3.1.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdfkit": "^0.17.2", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1" }, @@ -51,6 +55,7 @@ "@nestjs/testing": "^10.3.0", "@types/express": "^4.17.21", "@types/jest": "^29.5.11", + "@types/multer": "^2.0.0", "@types/node": "^20.10.6", "@types/passport-jwt": "^4.0.0", "@types/supertest": "^6.0.2", diff --git a/backend/prisma/migrations/20260201095359_add_signal_messages/migration.sql b/backend/prisma/migrations/20260201095359_add_signal_messages/migration.sql new file mode 100644 index 0000000..e09b7de --- /dev/null +++ b/backend/prisma/migrations/20260201095359_add_signal_messages/migration.sql @@ -0,0 +1,27 @@ +-- CreateEnum +CREATE TYPE "MessageDirection" AS ENUM ('INBOUND', 'OUTBOUND'); + +-- CreateTable +CREATE TABLE "signal_messages" ( + "id" TEXT NOT NULL, + "driverId" TEXT NOT NULL, + "direction" "MessageDirection" NOT NULL, + "content" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "signalTimestamp" TEXT, + + CONSTRAINT "signal_messages_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "signal_messages_driverId_idx" ON "signal_messages"("driverId"); + +-- CreateIndex +CREATE INDEX "signal_messages_driverId_isRead_idx" ON "signal_messages"("driverId", "isRead"); + +-- CreateIndex +CREATE INDEX "signal_messages_timestamp_idx" ON "signal_messages"("timestamp"); + +-- AddForeignKey +ALTER TABLE "signal_messages" ADD CONSTRAINT "signal_messages_driverId_fkey" FOREIGN KEY ("driverId") REFERENCES "drivers"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260201132408_add_pdf_settings/migration.sql b/backend/prisma/migrations/20260201132408_add_pdf_settings/migration.sql new file mode 100644 index 0000000..8dddf4a --- /dev/null +++ b/backend/prisma/migrations/20260201132408_add_pdf_settings/migration.sql @@ -0,0 +1,32 @@ +-- CreateEnum +CREATE TYPE "PageSize" AS ENUM ('LETTER', 'A4'); + +-- CreateTable +CREATE TABLE "pdf_settings" ( + "id" TEXT NOT NULL, + "organizationName" TEXT NOT NULL DEFAULT 'VIP Coordinator', + "logoUrl" TEXT, + "accentColor" TEXT NOT NULL DEFAULT '#2c3e50', + "tagline" TEXT, + "contactEmail" TEXT NOT NULL DEFAULT 'contact@example.com', + "contactPhone" TEXT NOT NULL DEFAULT '555-0100', + "secondaryContactName" TEXT, + "secondaryContactPhone" TEXT, + "contactLabel" TEXT NOT NULL DEFAULT 'Questions or Changes?', + "showDraftWatermark" BOOLEAN NOT NULL DEFAULT false, + "showConfidentialWatermark" BOOLEAN NOT NULL DEFAULT false, + "showTimestamp" BOOLEAN NOT NULL DEFAULT true, + "showAppUrl" BOOLEAN NOT NULL DEFAULT false, + "pageSize" "PageSize" NOT NULL DEFAULT 'LETTER', + "showFlightInfo" BOOLEAN NOT NULL DEFAULT true, + "showDriverNames" BOOLEAN NOT NULL DEFAULT true, + "showVehicleNames" BOOLEAN NOT NULL DEFAULT true, + "showVipNotes" BOOLEAN NOT NULL DEFAULT true, + "showEventDescriptions" BOOLEAN NOT NULL DEFAULT true, + "headerMessage" TEXT, + "footerMessage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "pdf_settings_pkey" PRIMARY KEY ("id") +); diff --git a/backend/prisma/migrations/20260201151046_add_reminder_tracking/migration.sql b/backend/prisma/migrations/20260201151046_add_reminder_tracking/migration.sql new file mode 100644 index 0000000..827cfa8 --- /dev/null +++ b/backend/prisma/migrations/20260201151046_add_reminder_tracking/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "schedule_events" ADD COLUMN "reminder20MinSent" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "reminder5MinSent" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260201152207_make_driver_phone_optional/migration.sql b/backend/prisma/migrations/20260201152207_make_driver_phone_optional/migration.sql new file mode 100644 index 0000000..11212ed --- /dev/null +++ b/backend/prisma/migrations/20260201152207_make_driver_phone_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "drivers" ALTER COLUMN "phone" DROP NOT NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 550f6f5..01078d7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -102,7 +102,7 @@ model Flight { model Driver { id String @id @default(uuid()) name String - phone String + phone String? // Optional - driver should add via profile department Department? userId String? @unique user User? @relation(fields: [userId], references: [id]) @@ -114,6 +114,7 @@ model Driver { events ScheduleEvent[] assignedVehicle Vehicle? @relation("AssignedDriver") + messages SignalMessage[] // Signal chat messages createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -198,6 +199,10 @@ model ScheduleEvent { // Metadata notes String? @db.Text + // Reminder tracking + reminder20MinSent Boolean @default(false) + reminder5MinSent Boolean @default(false) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? // Soft delete @@ -224,3 +229,77 @@ enum EventStatus { CANCELLED } +// ============================================ +// Signal Messaging +// ============================================ + +model SignalMessage { + id String @id @default(uuid()) + driverId String + driver Driver @relation(fields: [driverId], references: [id], onDelete: Cascade) + direction MessageDirection + content String @db.Text + timestamp DateTime @default(now()) + isRead Boolean @default(false) + signalTimestamp String? // Signal's message timestamp for deduplication + + @@map("signal_messages") + @@index([driverId]) + @@index([driverId, isRead]) + @@index([timestamp]) +} + +enum MessageDirection { + INBOUND // Message from driver + OUTBOUND // Message to driver +} + +// ============================================ +// PDF Settings (Singleton) +// ============================================ + +model PdfSettings { + id String @id @default(uuid()) + + // Branding + organizationName String @default("VIP Coordinator") + logoUrl String? @db.Text // Base64 data URL or external URL + accentColor String @default("#2c3e50") // Hex color + tagline String? + + // Contact Info + contactEmail String @default("contact@example.com") + contactPhone String @default("555-0100") + secondaryContactName String? + secondaryContactPhone String? + contactLabel String @default("Questions or Changes?") + + // Document Options + showDraftWatermark Boolean @default(false) + showConfidentialWatermark Boolean @default(false) + showTimestamp Boolean @default(true) + showAppUrl Boolean @default(false) + pageSize PageSize @default(LETTER) + + // Content Toggles + showFlightInfo Boolean @default(true) + showDriverNames Boolean @default(true) + showVehicleNames Boolean @default(true) + showVipNotes Boolean @default(true) + showEventDescriptions Boolean @default(true) + + // Custom Text + headerMessage String? @db.Text + footerMessage String? @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("pdf_settings") +} + +enum PageSize { + LETTER + A4 +} + diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2f25b8b..2d197a4 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,10 @@ import { DriversModule } from './drivers/drivers.module'; import { VehiclesModule } from './vehicles/vehicles.module'; import { EventsModule } from './events/events.module'; import { FlightsModule } from './flights/flights.module'; +import { CopilotModule } from './copilot/copilot.module'; +import { SignalModule } from './signal/signal.module'; +import { SettingsModule } from './settings/settings.module'; +import { SeedModule } from './seed/seed.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; @Module({ @@ -32,6 +36,10 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; VehiclesModule, EventsModule, FlightsModule, + CopilotModule, + SignalModule, + SettingsModule, + SeedModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/auth/abilities/ability.factory.ts b/backend/src/auth/abilities/ability.factory.ts index 9725663..421d8ed 100644 --- a/backend/src/auth/abilities/ability.factory.ts +++ b/backend/src/auth/abilities/ability.factory.ts @@ -25,6 +25,7 @@ export type Subjects = | 'ScheduleEvent' | 'Flight' | 'Vehicle' + | 'Settings' | 'all'; /** diff --git a/backend/src/copilot/copilot.controller.ts b/backend/src/copilot/copilot.controller.ts new file mode 100644 index 0000000..409fa8a --- /dev/null +++ b/backend/src/copilot/copilot.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Post, + Body, + UseGuards, + Logger, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { Role } from '@prisma/client'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { CopilotService } from './copilot.service'; + +interface ChatMessageDto { + role: 'user' | 'assistant'; + content: string | any[]; +} + +interface ChatRequestDto { + messages: ChatMessageDto[]; +} + +@Controller('copilot') +@UseGuards(JwtAuthGuard, RolesGuard) +export class CopilotController { + private readonly logger = new Logger(CopilotController.name); + + constructor(private readonly copilotService: CopilotService) {} + + @Post('chat') + @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) + async chat( + @Body() body: ChatRequestDto, + @CurrentUser() user: any, + ) { + this.logger.log(`Copilot chat request from user: ${user.email}`); + + try { + const result = await this.copilotService.chat( + body.messages, + user.id, + user.role, + ); + + return { + success: true, + ...result, + }; + } catch (error) { + this.logger.error('Copilot chat error:', error); + return { + success: false, + response: 'I encountered an error processing your request. Please try again.', + error: error.message, + }; + } + } +} diff --git a/backend/src/copilot/copilot.module.ts b/backend/src/copilot/copilot.module.ts new file mode 100644 index 0000000..67fec8c --- /dev/null +++ b/backend/src/copilot/copilot.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { CopilotController } from './copilot.controller'; +import { CopilotService } from './copilot.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { SignalModule } from '../signal/signal.module'; +import { DriversModule } from '../drivers/drivers.module'; + +@Module({ + imports: [PrismaModule, SignalModule, DriversModule], + controllers: [CopilotController], + providers: [CopilotService], +}) +export class CopilotModule {} diff --git a/backend/src/drivers/drivers.module.ts b/backend/src/drivers/drivers.module.ts index c4ccb65..20b65b5 100644 --- a/backend/src/drivers/drivers.module.ts +++ b/backend/src/drivers/drivers.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { DriversController } from './drivers.controller'; import { DriversService } from './drivers.service'; +import { ScheduleExportService } from './schedule-export.service'; +import { SignalModule } from '../signal/signal.module'; @Module({ + imports: [SignalModule], controllers: [DriversController], - providers: [DriversService], - exports: [DriversService], + providers: [DriversService, ScheduleExportService], + exports: [DriversService, ScheduleExportService], }) export class DriversModule {} diff --git a/backend/src/drivers/drivers.service.ts b/backend/src/drivers/drivers.service.ts index cbab598..1a6db82 100644 --- a/backend/src/drivers/drivers.service.ts +++ b/backend/src/drivers/drivers.service.ts @@ -52,6 +52,20 @@ export class DriversService { return driver; } + async findByUserId(userId: string) { + return this.prisma.driver.findFirst({ + where: { userId, deletedAt: null }, + include: { + user: true, + events: { + where: { deletedAt: null }, + include: { vehicle: true, driver: true }, + orderBy: { startTime: 'asc' }, + }, + }, + }); + } + async update(id: string, updateDriverDto: UpdateDriverDto) { const driver = await this.findOne(id); diff --git a/backend/src/drivers/dto/create-driver.dto.ts b/backend/src/drivers/dto/create-driver.dto.ts index 4ecf000..42111ed 100644 --- a/backend/src/drivers/dto/create-driver.dto.ts +++ b/backend/src/drivers/dto/create-driver.dto.ts @@ -6,7 +6,8 @@ export class CreateDriverDto { name: string; @IsString() - phone: string; + @IsOptional() + phone?: string; @IsEnum(Department) @IsOptional() diff --git a/backend/src/events/event-status.service.ts b/backend/src/events/event-status.service.ts new file mode 100644 index 0000000..ede025a --- /dev/null +++ b/backend/src/events/event-status.service.ts @@ -0,0 +1,423 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SignalService } from '../signal/signal.service'; +import { EventStatus } from '@prisma/client'; + +/** + * Automatic event status management service + * - Transitions SCHEDULED → IN_PROGRESS when startTime arrives + * - Sends Signal confirmation requests to drivers + * - Handles driver responses (1=Confirmed, 2=Delayed, 3=Issue) + * - Transitions IN_PROGRESS → COMPLETED when endTime passes (with grace period) + */ +@Injectable() +export class EventStatusService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(EventStatusService.name); + private intervalId: NodeJS.Timeout | null = null; + private readonly CHECK_INTERVAL = 60 * 1000; // Check every minute + private readonly COMPLETION_GRACE_PERIOD = 15 * 60 * 1000; // 15 min after endTime before auto-complete + + constructor( + private prisma: PrismaService, + private signalService: SignalService, + ) {} + + onModuleInit() { + this.logger.log('Starting event status monitoring...'); + this.startMonitoring(); + } + + onModuleDestroy() { + this.stopMonitoring(); + } + + private startMonitoring() { + // Run immediately on start + this.checkAndUpdateStatuses(); + + // Then run every minute + this.intervalId = setInterval(() => { + this.checkAndUpdateStatuses(); + }, this.CHECK_INTERVAL); + } + + private stopMonitoring() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + this.logger.log('Stopped event status monitoring'); + } + } + + /** + * Main check loop - finds events that need status updates + */ + private async checkAndUpdateStatuses() { + try { + const now = new Date(); + + // 1. Send reminders for upcoming events (20 min and 5 min before) + await this.sendUpcomingReminders(now); + + // 2. Find SCHEDULED events that should now be IN_PROGRESS + await this.transitionToInProgress(now); + + // 3. Find IN_PROGRESS events that are past their end time (with grace period) + await this.transitionToCompleted(now); + + } catch (error) { + this.logger.error('Error checking event statuses:', error); + } + } + + /** + * Send 20-minute and 5-minute reminders to drivers + */ + private async sendUpcomingReminders(now: Date) { + const twentyMinutesFromNow = new Date(now.getTime() + 20 * 60 * 1000); + const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000); + + // Find events needing 20-minute reminder + // Events starting within 20 minutes that haven't had reminder sent + const eventsFor20MinReminder = await this.prisma.scheduleEvent.findMany({ + where: { + status: EventStatus.SCHEDULED, + type: 'TRANSPORT', + startTime: { lte: twentyMinutesFromNow, gt: now }, + reminder20MinSent: false, + driverId: { not: null }, + deletedAt: null, + }, + include: { + driver: true, + vehicle: true, + }, + }); + + for (const event of eventsFor20MinReminder) { + // Only send if actually ~20 min away (between 15-25 min) + const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000); + if (minutesUntil <= 25 && minutesUntil >= 15) { + await this.send20MinReminder(event, minutesUntil); + } + } + + // Find events needing 5-minute reminder + const eventsFor5MinReminder = await this.prisma.scheduleEvent.findMany({ + where: { + status: EventStatus.SCHEDULED, + type: 'TRANSPORT', + startTime: { lte: fiveMinutesFromNow, gt: now }, + reminder5MinSent: false, + driverId: { not: null }, + deletedAt: null, + }, + include: { + driver: true, + vehicle: true, + }, + }); + + for (const event of eventsFor5MinReminder) { + // Only send if actually ~5 min away (between 3-10 min) + const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000); + if (minutesUntil <= 10 && minutesUntil >= 3) { + await this.send5MinReminder(event, minutesUntil); + } + } + } + + /** + * Send 20-minute reminder to driver + */ + private async send20MinReminder(event: any, minutesUntil: number) { + try { + const linkedNumber = await this.signalService.getLinkedNumber(); + if (!linkedNumber || !event.driver?.phone) return; + + // Get VIP names + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: event.vipIds || [] } }, + select: { name: true }, + }); + const vipNames = vips.map(v => v.name).join(', ') || 'VIP'; + + const message = `📢 UPCOMING TRIP in ~${minutesUntil} minutes + +📍 Pickup: ${event.pickupLocation || 'See schedule'} +📍 Dropoff: ${event.dropoffLocation || 'See schedule'} +👤 VIP: ${vipNames} +🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'} +⏰ Start Time: ${new Date(event.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })} + +Please head to the pickup location.`; + + const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone); + await this.signalService.sendMessage(linkedNumber, formattedPhone, message); + + // Mark reminder as sent + await this.prisma.scheduleEvent.update({ + where: { id: event.id }, + data: { reminder20MinSent: true }, + }); + + this.logger.log(`Sent 20-min reminder to ${event.driver.name} for event ${event.id}`); + } catch (error) { + this.logger.error(`Failed to send 20-min reminder for event ${event.id}:`, error); + } + } + + /** + * Send 5-minute reminder to driver (more urgent) + */ + private async send5MinReminder(event: any, minutesUntil: number) { + try { + const linkedNumber = await this.signalService.getLinkedNumber(); + if (!linkedNumber || !event.driver?.phone) return; + + // Get VIP names + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: event.vipIds || [] } }, + select: { name: true }, + }); + const vipNames = vips.map(v => v.name).join(', ') || 'VIP'; + + const message = `⚠️ TRIP STARTING in ${minutesUntil} MINUTES! + +📍 Pickup: ${event.pickupLocation || 'See schedule'} +👤 VIP: ${vipNames} +🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'} + +You should be at the pickup location NOW. + +Reply: +1️⃣ = Ready and waiting +2️⃣ = Running late +3️⃣ = Issue / Need help`; + + const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone); + await this.signalService.sendMessage(linkedNumber, formattedPhone, message); + + // Mark reminder as sent + await this.prisma.scheduleEvent.update({ + where: { id: event.id }, + data: { reminder5MinSent: true }, + }); + + this.logger.log(`Sent 5-min reminder to ${event.driver.name} for event ${event.id}`); + } catch (error) { + this.logger.error(`Failed to send 5-min reminder for event ${event.id}:`, error); + } + } + + /** + * Transition SCHEDULED → IN_PROGRESS for events whose startTime has passed + */ + private async transitionToInProgress(now: Date) { + const eventsToStart = await this.prisma.scheduleEvent.findMany({ + where: { + status: EventStatus.SCHEDULED, + startTime: { lte: now }, + deletedAt: null, + }, + include: { + driver: true, + vehicle: true, + }, + }); + + for (const event of eventsToStart) { + try { + // Update status to IN_PROGRESS + await this.prisma.scheduleEvent.update({ + where: { id: event.id }, + data: { + status: EventStatus.IN_PROGRESS, + actualStartTime: now, + }, + }); + + this.logger.log(`Event ${event.id} (${event.title}) auto-started`); + + // Send Signal confirmation request to driver if assigned + if (event.driver?.phone) { + await this.sendDriverConfirmationRequest(event); + } + } catch (error) { + this.logger.error(`Failed to transition event ${event.id}:`, error); + } + } + + if (eventsToStart.length > 0) { + this.logger.log(`Auto-started ${eventsToStart.length} events`); + } + } + + /** + * Transition IN_PROGRESS → COMPLETED for events past their endTime + grace period + * Only auto-complete if no driver confirmation is pending + */ + private async transitionToCompleted(now: Date) { + const gracePeriodAgo = new Date(now.getTime() - this.COMPLETION_GRACE_PERIOD); + + const eventsToComplete = await this.prisma.scheduleEvent.findMany({ + where: { + status: EventStatus.IN_PROGRESS, + endTime: { lte: gracePeriodAgo }, + deletedAt: null, + }, + include: { + driver: true, + }, + }); + + for (const event of eventsToComplete) { + try { + await this.prisma.scheduleEvent.update({ + where: { id: event.id }, + data: { + status: EventStatus.COMPLETED, + actualEndTime: now, + }, + }); + + this.logger.log(`Event ${event.id} (${event.title}) auto-completed`); + } catch (error) { + this.logger.error(`Failed to complete event ${event.id}:`, error); + } + } + + if (eventsToComplete.length > 0) { + this.logger.log(`Auto-completed ${eventsToComplete.length} events`); + } + } + + /** + * Send a Signal message to the driver asking for confirmation + */ + private async sendDriverConfirmationRequest(event: any) { + try { + const linkedNumber = await this.signalService.getLinkedNumber(); + if (!linkedNumber) { + this.logger.warn('No Signal account linked, skipping driver notification'); + return; + } + + // Get VIP names for the message + const vips = await this.prisma.vIP.findMany({ + where: { id: { in: event.vipIds || [] } }, + select: { name: true }, + }); + const vipNames = vips.map(v => v.name).join(', ') || 'VIP'; + + const message = `🚗 TRIP STARTED: ${event.title} + +📍 Pickup: ${event.pickupLocation || 'See schedule'} +📍 Dropoff: ${event.dropoffLocation || 'See schedule'} +👤 VIP: ${vipNames} +🚐 Vehicle: ${event.vehicle?.name || 'Not assigned'} + +Please confirm status: +1️⃣ = En route / Confirmed +2️⃣ = Delayed (explain in next message) +3️⃣ = Issue / Need help + +Reply with 1, 2, or 3`; + + const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone); + await this.signalService.sendMessage(linkedNumber, formattedPhone, message); + + this.logger.log(`Sent confirmation request to driver ${event.driver.name} for event ${event.id}`); + } catch (error) { + this.logger.error(`Failed to send Signal confirmation for event ${event.id}:`, error); + } + } + + /** + * Process a driver's response to a confirmation request + * Called by the Signal message handler when a driver replies with 1, 2, or 3 + */ + async processDriverResponse(driverPhone: string, response: string): Promise { + const responseNum = parseInt(response.trim(), 10); + if (![1, 2, 3].includes(responseNum)) { + return null; // Not a status response + } + + // Find the driver + const driver = await this.prisma.driver.findFirst({ + where: { + phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) }, + deletedAt: null, + }, + }); + + if (!driver) { + return null; + } + + // Find their current IN_PROGRESS event + const activeEvent = await this.prisma.scheduleEvent.findFirst({ + where: { + driverId: driver.id, + status: EventStatus.IN_PROGRESS, + deletedAt: null, + }, + include: { vehicle: true }, + }); + + if (!activeEvent) { + return 'No active trip found. Reply ignored.'; + } + + let replyMessage: string; + + switch (responseNum) { + case 1: // Confirmed + // Event is already IN_PROGRESS, this just confirms it + await this.prisma.scheduleEvent.update({ + where: { id: activeEvent.id }, + data: { + notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver confirmed en route`.trim(), + }, + }); + replyMessage = `✅ Confirmed! Safe travels. Reply when completed or if you need assistance.`; + break; + + case 2: // Delayed + await this.prisma.scheduleEvent.update({ + where: { id: activeEvent.id }, + data: { + notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported DELAY`.trim(), + }, + }); + replyMessage = `⏰ Delay noted. Please reply with details about the delay. Coordinator has been alerted.`; + break; + + case 3: // Issue + await this.prisma.scheduleEvent.update({ + where: { id: activeEvent.id }, + data: { + notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported ISSUE - needs help`.trim(), + }, + }); + replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the issue in your next message.`; + break; + + default: + return null; + } + + // Send the reply + try { + const linkedNumber = await this.signalService.getLinkedNumber(); + if (linkedNumber && driver.phone) { + const formattedPhone = this.signalService.formatPhoneNumber(driver.phone); + await this.signalService.sendMessage(linkedNumber, formattedPhone, replyMessage); + } + } catch (error) { + this.logger.error('Failed to send reply to driver:', error); + } + + return replyMessage; + } +} diff --git a/backend/src/events/events.module.ts b/backend/src/events/events.module.ts index 97662a9..5399f24 100644 --- a/backend/src/events/events.module.ts +++ b/backend/src/events/events.module.ts @@ -1,16 +1,25 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { EventsController } from './events.controller'; import { EventsService } from './events.service'; +import { EventStatusService } from './event-status.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { SignalModule } from '../signal/signal.module'; @Module({ + imports: [ + PrismaModule, + forwardRef(() => SignalModule), // forwardRef to avoid circular dependency + ], controllers: [ EventsController, ], providers: [ EventsService, + EventStatusService, ], exports: [ EventsService, + EventStatusService, ], }) export class EventsModule {} diff --git a/backend/src/events/events.service.ts b/backend/src/events/events.service.ts index a8595c1..99a5e47 100644 --- a/backend/src/events/events.service.ts +++ b/backend/src/events/events.service.ts @@ -300,10 +300,11 @@ export class EventsService { /** * Enrich event with VIP details fetched separately + * Returns both `vips` array and `vip` (first VIP) for backwards compatibility */ private async enrichEventWithVips(event: any) { if (!event.vipIds || event.vipIds.length === 0) { - return { ...event, vips: [] }; + return { ...event, vips: [], vip: null }; } const vips = await this.prisma.vIP.findMany({ @@ -313,6 +314,7 @@ export class EventsService { }, }); - return { ...event, vips }; + // Return both vips array and vip (first one) for backwards compatibility + return { ...event, vips, vip: vips[0] || null }; } } diff --git a/backend/src/main.ts b/backend/src/main.ts index 0c460bf..90d6d9a 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe, Logger } from '@nestjs/common'; +import { json, urlencoded } from 'express'; import { AppModule } from './app.module'; import { AllExceptionsFilter, HttpExceptionFilter } from './common/filters'; @@ -8,6 +9,10 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); + // Increase body size limit for PDF attachments (base64 encoded) + app.use(json({ limit: '5mb' })); + app.use(urlencoded({ extended: true, limit: '5mb' })); + // Global prefix for all routes // In production (App Platform), the ingress routes /api to this service // So we only need /v1 prefix here diff --git a/backend/src/seed/index.ts b/backend/src/seed/index.ts new file mode 100644 index 0000000..fdfd2c3 --- /dev/null +++ b/backend/src/seed/index.ts @@ -0,0 +1,3 @@ +export * from './seed.module'; +export * from './seed.service'; +export * from './seed.controller'; diff --git a/backend/src/seed/seed.controller.ts b/backend/src/seed/seed.controller.ts new file mode 100644 index 0000000..2169df1 --- /dev/null +++ b/backend/src/seed/seed.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Post, Delete, UseGuards, Body } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { SeedService } from './seed.service'; + +@Controller('seed') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('ADMINISTRATOR') +export class SeedController { + constructor(private readonly seedService: SeedService) {} + + /** + * Generate all test data in a single fast transaction + */ + @Post('generate') + async generateTestData(@Body() options?: { clearFirst?: boolean }) { + return this.seedService.generateAllTestData(options?.clearFirst ?? true); + } + + /** + * Clear all test data instantly + */ + @Delete('clear') + async clearAllData() { + return this.seedService.clearAllData(); + } + + /** + * Generate only events with dynamic times (keeps existing VIPs/drivers/vehicles) + */ + @Post('generate-events') + async generateDynamicEvents() { + return this.seedService.generateDynamicEvents(); + } +} diff --git a/backend/src/seed/seed.module.ts b/backend/src/seed/seed.module.ts new file mode 100644 index 0000000..f54761f --- /dev/null +++ b/backend/src/seed/seed.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SeedController } from './seed.controller'; +import { SeedService } from './seed.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [SeedController], + providers: [SeedService], +}) +export class SeedModule {} diff --git a/backend/src/seed/seed.service.ts b/backend/src/seed/seed.service.ts new file mode 100644 index 0000000..c7c1920 --- /dev/null +++ b/backend/src/seed/seed.service.ts @@ -0,0 +1,626 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Department, ArrivalMode, EventType, EventStatus, VehicleType, VehicleStatus } from '@prisma/client'; + +@Injectable() +export class SeedService { + private readonly logger = new Logger(SeedService.name); + + constructor(private prisma: PrismaService) {} + + /** + * Clear all data using fast deleteMany operations + */ + async clearAllData() { + const start = Date.now(); + + // Delete in order to respect foreign key constraints + const results = await this.prisma.$transaction([ + this.prisma.signalMessage.deleteMany(), + this.prisma.scheduleEvent.deleteMany(), + this.prisma.flight.deleteMany(), + this.prisma.vehicle.deleteMany(), + this.prisma.driver.deleteMany(), + this.prisma.vIP.deleteMany(), + ]); + + const elapsed = Date.now() - start; + this.logger.log(`Cleared all data in ${elapsed}ms`); + + return { + success: true, + elapsed: `${elapsed}ms`, + deleted: { + messages: results[0].count, + events: results[1].count, + flights: results[2].count, + vehicles: results[3].count, + drivers: results[4].count, + vips: results[5].count, + }, + }; + } + + /** + * Generate all test data in a single fast transaction + */ + async generateAllTestData(clearFirst: boolean = true) { + const start = Date.now(); + + if (clearFirst) { + await this.clearAllData(); + } + + // Create all entities in a transaction + const result = await this.prisma.$transaction(async (tx) => { + // 1. Create VIPs + const vipData = this.getVIPData(); + await tx.vIP.createMany({ data: vipData }); + const vips = await tx.vIP.findMany({ orderBy: { createdAt: 'asc' } }); + this.logger.log(`Created ${vips.length} VIPs`); + + // 2. Create Drivers with shifts + const driverData = this.getDriverData(); + await tx.driver.createMany({ data: driverData }); + const drivers = await tx.driver.findMany({ orderBy: { createdAt: 'asc' } }); + this.logger.log(`Created ${drivers.length} drivers`); + + // 3. Create Vehicles + const vehicleData = this.getVehicleData(); + await tx.vehicle.createMany({ data: vehicleData }); + const vehicles = await tx.vehicle.findMany({ orderBy: { createdAt: 'asc' } }); + this.logger.log(`Created ${vehicles.length} vehicles`); + + // 4. Create Flights for VIPs arriving by flight + const flightVips = vips.filter(v => v.arrivalMode === 'FLIGHT'); + const flightData = this.getFlightData(flightVips); + await tx.flight.createMany({ data: flightData }); + const flights = await tx.flight.findMany(); + this.logger.log(`Created ${flights.length} flights`); + + // 5. Create Events with dynamic times relative to NOW + const eventData = this.getEventData(vips, drivers, vehicles); + await tx.scheduleEvent.createMany({ data: eventData }); + const events = await tx.scheduleEvent.findMany(); + this.logger.log(`Created ${events.length} events`); + + return { vips, drivers, vehicles, flights, events }; + }); + + const elapsed = Date.now() - start; + this.logger.log(`Generated all test data in ${elapsed}ms`); + + return { + success: true, + elapsed: `${elapsed}ms`, + created: { + vips: result.vips.length, + drivers: result.drivers.length, + vehicles: result.vehicles.length, + flights: result.flights.length, + events: result.events.length, + }, + }; + } + + /** + * Generate only dynamic events (uses existing VIPs/drivers/vehicles) + */ + async generateDynamicEvents() { + const start = Date.now(); + + // Clear existing events + await this.prisma.scheduleEvent.deleteMany(); + + // Get existing entities + const [vips, drivers, vehicles] = await Promise.all([ + this.prisma.vIP.findMany({ where: { deletedAt: null } }), + this.prisma.driver.findMany({ where: { deletedAt: null } }), + this.prisma.vehicle.findMany({ where: { deletedAt: null } }), + ]); + + if (vips.length === 0) { + return { success: false, error: 'No VIPs found. Generate full test data first.' }; + } + + // Create events + const eventData = this.getEventData(vips, drivers, vehicles); + await this.prisma.scheduleEvent.createMany({ data: eventData }); + + const elapsed = Date.now() - start; + return { + success: true, + elapsed: `${elapsed}ms`, + created: { events: eventData.length }, + }; + } + + // ============================================================ + // DATA GENERATORS + // ============================================================ + + private getVIPData() { + return [ + // OFFICE_OF_DEVELOPMENT (10 VIPs) - Corporate sponsors, foundations, major donors + { name: 'Sarah Chen', organization: 'Microsoft Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Executive VP - prefers quiet vehicles. Allergic to peanuts.' }, + { name: 'Marcus Johnson', organization: 'The Coca-Cola Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing spouse. Needs wheelchair accessible transport.' }, + { name: 'Jennifer Wu', organization: 'JPMorgan Chase Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Major donor - $500K pledge. VIP treatment essential.' }, + { name: 'Roberto Gonzalez', organization: 'AT&T Inc', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'First time visitor. Interested in STEM programs.' }, + { name: 'Priya Sharma', organization: 'Google LLC', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Vegetarian meals required. Interested in technology merit badges.' }, + { name: 'David Okonkwo', organization: 'Bank of America', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Has rental car for airport. Needs venue transport only.' }, + { name: 'Maria Rodriguez', organization: 'Walmart Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(120), notes: 'Driving from nearby hotel. Call when 30 min out.' }, + { name: 'Yuki Tanaka', organization: 'Honda Motor Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Japanese executive - interpreter may be needed.' }, + { name: 'Thomas Anderson', organization: 'Verizon Communications', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Will use personal driver after airport pickup.' }, + { name: 'Isabella Costa', organization: 'Target Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Taking rideshare from airport. Venue transport needed.' }, + + // ADMIN (10 VIPs) - BSA Leadership and Staff + { name: 'Roger A. Krone', organization: 'BSA National President', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'HIGHEST PRIORITY VIP. Security detail traveling with him.' }, + { name: 'Emily Richardson', organization: 'BSA Chief Scout Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(60), notes: 'Has assigned BSA vehicle. No transport needed.' }, + { name: 'Dr. Maya Krishnan', organization: 'BSA National Director of Program', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(180), notes: 'Carpooling with regional directors.' }, + { name: "James O'Brien", organization: 'BSA Northeast Regional Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Traveling with 2 staff members.' }, + { name: 'Fatima Al-Rahman', organization: 'BSA Western Region Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Halal meals required. Prayer room access needed.' }, + { name: 'William Zhang', organization: 'BSA Southern Region Council', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing presentation equipment - need vehicle with cargo space.' }, + { name: 'Sophie Laurent', organization: 'BSA National Volunteer Training', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(240), notes: 'Training materials in personal vehicle.' }, + { name: 'Alexander Volkov', organization: 'BSA High Adventure Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Outdoor enthusiast - prefers walking when possible.' }, + { name: 'Dr. Aisha Patel', organization: 'BSA STEM & Innovation Programs', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(90), notes: 'Demo equipment for STEM showcase. Fragile items!' }, + { name: 'Henrik Larsson', organization: 'BSA International Commissioner', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(150), notes: 'Visiting from Sweden. International guest protocols apply.' }, + ]; + } + + private getDriverData() { + const now = new Date(); + const shiftStart = new Date(now); + shiftStart.setHours(6, 0, 0, 0); + const shiftEnd = new Date(now); + shiftEnd.setHours(22, 0, 0, 0); + + const lateShiftStart = new Date(now); + lateShiftStart.setHours(14, 0, 0, 0); + const lateShiftEnd = new Date(now); + lateShiftEnd.setHours(23, 59, 0, 0); + + return [ + { name: 'Michael Thompson', phone: '555-0101', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd }, + { name: 'Lisa Martinez', phone: '555-0102', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd }, + { name: 'David Kim', phone: '555-0103', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd }, + { name: 'Amanda Washington', phone: '555-0104', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd }, + { name: 'Carlos Hernandez', phone: '555-0105', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: false, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd }, // Off duty until 2pm + { name: 'Jessica Lee', phone: '555-0106', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd }, + { name: 'Brandon Jackson', phone: '555-0107', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd }, + { name: 'Nicole Brown', phone: '555-0108', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd }, + ]; + } + + private getVehicleData() { + return [ + { name: 'Blue Van', type: VehicleType.VAN, licensePlate: 'VAN-001', seatCapacity: 12, status: VehicleStatus.AVAILABLE, notes: 'Primary transport van with wheelchair accessibility' }, + { name: 'Suburban #1', type: VehicleType.SUV, licensePlate: 'SUV-101', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Leather interior, ideal for VIP comfort' }, + { name: 'Golf Cart Alpha', type: VehicleType.GOLF_CART, licensePlate: 'GC-A', seatCapacity: 6, status: VehicleStatus.AVAILABLE, notes: 'Quick campus transport, good for short distances' }, + { name: 'Red Van', type: VehicleType.VAN, licensePlate: 'VAN-002', seatCapacity: 8, status: VehicleStatus.AVAILABLE, notes: 'Standard transport van' }, + { name: 'Scout Bus', type: VehicleType.BUS, licensePlate: 'BUS-001', seatCapacity: 25, status: VehicleStatus.AVAILABLE, notes: 'Large group transport, AC equipped' }, + { name: 'Suburban #2', type: VehicleType.SUV, licensePlate: 'SUV-102', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Backup VIP transport' }, + { name: 'Golf Cart Bravo', type: VehicleType.GOLF_CART, licensePlate: 'GC-B', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Quick on-site transport' }, + { name: 'Equipment Truck', type: VehicleType.TRUCK, licensePlate: 'TRK-001', seatCapacity: 3, status: VehicleStatus.MAINTENANCE, notes: 'For equipment and supply runs - currently in maintenance' }, + { name: 'Executive Sedan', type: VehicleType.SEDAN, licensePlate: 'SED-001', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Premium sedan for executive VIPs' }, + { name: 'Golf Cart Charlie', type: VehicleType.GOLF_CART, licensePlate: 'GC-C', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Backup golf cart' }, + ]; + } + + private getFlightData(vips: any[]) { + const flights: any[] = []; + const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB']; + const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA']; + const destination = 'SLC'; // Assuming Salt Lake City for the Jamboree + + vips.forEach((vip, index) => { + const airline = airlines[index % airlines.length]; + const flightNum = `${airline}${1000 + index * 123}`; + const origin = origins[index % origins.length]; + + // Arrival flight - times relative to now + const arrivalOffset = (index % 8) * 30 - 60; // -60 to +150 minutes from now + const scheduledArrival = this.relativeTime(arrivalOffset); + const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000); // 3 hours before + + // Some flights are delayed, some landed, some on time + let status = 'scheduled'; + let actualArrival = null; + if (arrivalOffset < -30) { + status = 'landed'; + actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000); + } else if (arrivalOffset < 0) { + status = 'landing'; + } else if (index % 5 === 0) { + status = 'delayed'; + } + + flights.push({ + vipId: vip.id, + flightNumber: flightNum, + flightDate: new Date(), + segment: 1, + departureAirport: origin, + arrivalAirport: destination, + scheduledDeparture, + scheduledArrival, + actualArrival, + status, + }); + + // Some VIPs have connecting flights (segment 2) + if (index % 4 === 0) { + const connectOrigin = origins[(index + 3) % origins.length]; + flights.push({ + vipId: vip.id, + flightNumber: `${airline}${500 + index}`, + flightDate: new Date(), + segment: 2, + departureAirport: connectOrigin, + arrivalAirport: origin, + scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000), + scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000), + status: 'landed', + }); + } + }); + + return flights; + } + + private getEventData(vips: any[], drivers: any[], vehicles: any[]) { + const events: any[] = []; + const now = new Date(); + + // Track vehicle assignments to avoid conflicts + // Map of vehicleId -> array of { start: Date, end: Date } + const vehicleSchedule: Map> = new Map(); + const driverSchedule: Map> = new Map(); + + // Initialize schedules + vehicles.forEach(v => vehicleSchedule.set(v.id, [])); + drivers.forEach(d => driverSchedule.set(d.id, [])); + + // Check if a time slot conflicts with existing assignments + const hasConflict = (schedule: Array<{ start: Date; end: Date }>, start: Date, end: Date): boolean => { + return schedule.some(slot => + (start < slot.end && end > slot.start) // Overlapping + ); + }; + + // Find an available vehicle for a time slot + const findAvailableVehicle = (start: Date, end: Date, preferredIndex: number): any | null => { + if (vehicles.length === 0) return null; + + // Try preferred vehicle first + const preferred = vehicles[preferredIndex % vehicles.length]; + const preferredSchedule = vehicleSchedule.get(preferred.id) || []; + if (!hasConflict(preferredSchedule, start, end)) { + preferredSchedule.push({ start, end }); + return preferred; + } + + // Try other vehicles + for (const vehicle of vehicles) { + const schedule = vehicleSchedule.get(vehicle.id) || []; + if (!hasConflict(schedule, start, end)) { + schedule.push({ start, end }); + return vehicle; + } + } + return null; // No available vehicle + }; + + // Find an available driver for a time slot + const findAvailableDriver = (start: Date, end: Date, preferredIndex: number): any | null => { + if (drivers.length === 0) return null; + + // Try preferred driver first + const preferred = drivers[preferredIndex % drivers.length]; + const preferredSchedule = driverSchedule.get(preferred.id) || []; + if (!hasConflict(preferredSchedule, start, end)) { + preferredSchedule.push({ start, end }); + return preferred; + } + + // Try other drivers + for (const driver of drivers) { + const schedule = driverSchedule.get(driver.id) || []; + if (!hasConflict(schedule, start, end)) { + schedule.push({ start, end }); + return driver; + } + } + return null; // No available driver + }; + + vips.forEach((vip, vipIndex) => { + // ============================================================ + // CREATE VARIED EVENTS RELATIVE TO NOW + // ============================================================ + + // Event pattern based on VIP index to create variety: + // - Some VIPs have events IN_PROGRESS + // - Some have events starting VERY soon (5-15 min) + // - Some have events starting soon (30-60 min) + // - Some have just-completed events + // - All have future events throughout the day + + const eventPattern = vipIndex % 5; + + switch (eventPattern) { + case 0: { // IN_PROGRESS airport pickup + const start = this.relativeTime(-25); + const end = this.relativeTime(15); + const driver = findAvailableDriver(start, end, vipIndex); + const vehicle = findAvailableVehicle(start, end, vipIndex); + events.push({ + vipIds: [vip.id], + driverId: driver?.id, + vehicleId: vehicle?.id, + title: `Airport Pickup - ${vip.name}`, + type: EventType.TRANSPORT, + status: EventStatus.IN_PROGRESS, + pickupLocation: 'Airport - Terminal B', + dropoffLocation: 'Main Gate Registration', + startTime: start, + endTime: end, + actualStartTime: this.relativeTime(-23), + description: `ACTIVE: Driver en route with ${vip.name} from airport`, + notes: 'VIP collected from arrivals. ETA 15 minutes.', + }); + break; + } + + case 1: { // COMPLETED event + const start = this.relativeTime(-90); + const end = this.relativeTime(-45); + const driver = findAvailableDriver(start, end, vipIndex); + const vehicle = findAvailableVehicle(start, end, vipIndex); + events.push({ + vipIds: [vip.id], + driverId: driver?.id, + vehicleId: vehicle?.id, + title: `Airport Pickup - ${vip.name}`, + type: EventType.TRANSPORT, + status: EventStatus.COMPLETED, + pickupLocation: 'Airport - Terminal A', + dropoffLocation: 'VIP Lodge', + startTime: start, + endTime: end, + actualStartTime: this.relativeTime(-88), + actualEndTime: this.relativeTime(-42), + description: `Completed pickup for ${vip.name}`, + }); + break; + } + + case 2: { // Starting in 5-10 minutes (URGENT) + const start = this.relativeTime(7); + const end = this.relativeTime(22); + const driver = findAvailableDriver(start, end, vipIndex); + const vehicle = findAvailableVehicle(start, end, vipIndex); + events.push({ + vipIds: [vip.id], + driverId: driver?.id, + vehicleId: vehicle?.id, + title: `URGENT: Transport to Opening Ceremony - ${vip.name}`, + type: EventType.TRANSPORT, + status: EventStatus.SCHEDULED, + pickupLocation: 'VIP Lodge', + dropoffLocation: 'Main Arena - VIP Entrance', + startTime: start, + endTime: end, + description: `Pick up ${vip.name} for Opening Ceremony - STARTS SOON!`, + notes: 'Driver should be at pickup location NOW', + }); + break; + } + + case 3: { // Starting in 30-45 min + const start = this.relativeTime(35); + const end = this.relativeTime(50); + const driver = findAvailableDriver(start, end, vipIndex); + const vehicle = findAvailableVehicle(start, end, vipIndex); + events.push({ + vipIds: [vip.id], + driverId: driver?.id, + vehicleId: vehicle?.id, + title: `VIP Lodge Transfer - ${vip.name}`, + type: EventType.TRANSPORT, + status: EventStatus.SCHEDULED, + pickupLocation: 'Registration Tent', + dropoffLocation: 'VIP Lodge - Building A', + startTime: start, + endTime: end, + description: `Transfer ${vip.name} to VIP accommodation after registration`, + }); + break; + } + + case 4: // In-progress MEETING (no driver/vehicle needed) + events.push({ + vipIds: [vip.id], + title: `Donor Briefing Meeting`, + type: EventType.MEETING, + status: EventStatus.IN_PROGRESS, + location: 'Conference Center - Room 101', + startTime: this.relativeTime(-20), + endTime: this.relativeTime(25), + actualStartTime: this.relativeTime(-18), + description: `${vip.name} in donor briefing with development team`, + }); + break; + } + + // ============================================================ + // ADD STANDARD DAY EVENTS FOR ALL VIPS + // ============================================================ + + // Upcoming meal (1-2 hours out) - no driver/vehicle needed + events.push({ + vipIds: [vip.id], + title: vipIndex % 2 === 0 ? 'VIP Luncheon' : 'VIP Breakfast', + type: EventType.MEAL, + status: EventStatus.SCHEDULED, + location: 'VIP Dining Pavilion', + startTime: this.relativeTime(60 + (vipIndex % 4) * 15), + endTime: this.relativeTime(120 + (vipIndex % 4) * 15), + description: `Catered meal for ${vip.name} with other VIP guests`, + }); + + // Transport to main event (2-3 hours out) + { + const start = this.relativeTime(150 + vipIndex * 5); + const end = this.relativeTime(165 + vipIndex * 5); + const driver = findAvailableDriver(start, end, vipIndex + 3); + const vehicle = findAvailableVehicle(start, end, vipIndex + 2); + events.push({ + vipIds: [vip.id], + driverId: driver?.id, + vehicleId: vehicle?.id, + title: `Transport to Scout Exhibition`, + type: EventType.TRANSPORT, + status: EventStatus.SCHEDULED, + pickupLocation: 'VIP Lodge', + dropoffLocation: 'Exhibition Grounds', + startTime: start, + endTime: end, + description: `Transport ${vip.name} to Scout Exhibition area`, + }); + } + + // Main event (3-4 hours out) - no driver/vehicle needed + events.push({ + vipIds: [vip.id], + title: 'Scout Skills Exhibition', + type: EventType.EVENT, + status: EventStatus.SCHEDULED, + location: 'Exhibition Grounds - Zone A', + startTime: this.relativeTime(180 + vipIndex * 3), + endTime: this.relativeTime(270 + vipIndex * 3), + description: `${vip.name} tours Scout exhibitions and demonstrations`, + }); + + // Evening dinner (5-6 hours out) - no driver/vehicle needed + events.push({ + vipIds: [vip.id], + title: 'Gala Dinner', + type: EventType.MEAL, + status: EventStatus.SCHEDULED, + location: 'Grand Ballroom', + startTime: this.relativeTime(360), + endTime: this.relativeTime(480), + description: `Black-tie dinner event with ${vip.name} and other distinguished guests`, + }); + + // Next day departure (tomorrow morning) + if (vip.arrivalMode === 'FLIGHT') { + const start = this.relativeTime(60 * 24 + 120 + vipIndex * 20); + const end = this.relativeTime(60 * 24 + 165 + vipIndex * 20); + const driver = findAvailableDriver(start, end, vipIndex + 1); + const vehicle = findAvailableVehicle(start, end, vipIndex + 1); + events.push({ + vipIds: [vip.id], + driverId: driver?.id, + vehicleId: vehicle?.id, + title: `Airport Departure - ${vip.name}`, + type: EventType.TRANSPORT, + status: EventStatus.SCHEDULED, + pickupLocation: 'VIP Lodge', + dropoffLocation: 'Airport - Departures', + startTime: start, + endTime: end, + description: `Transport ${vip.name} to airport for departure flight`, + notes: 'Confirm flight status before pickup', + }); + } + }); + + // ============================================================ + // ADD MULTI-VIP GROUP EVENTS + // ============================================================ + + if (vips.length >= 4) { + // Group transport with multiple VIPs + { + const start = this.relativeTime(45); + const end = this.relativeTime(60); + const driver = findAvailableDriver(start, end, 0); + // Find a large vehicle (bus or van with capacity >= 8) + const largeVehicle = vehicles.find(v => + v.seatCapacity >= 8 && !hasConflict(vehicleSchedule.get(v.id) || [], start, end) + ); + if (largeVehicle) { + vehicleSchedule.get(largeVehicle.id)?.push({ start, end }); + } + events.push({ + vipIds: [vips[0].id, vips[1].id, vips[2].id], + driverId: driver?.id, + vehicleId: largeVehicle?.id, + title: 'Group Transport - Leadership Briefing', + type: EventType.TRANSPORT, + status: EventStatus.SCHEDULED, + pickupLocation: 'VIP Lodge - Main Entrance', + dropoffLocation: 'National HQ Building', + startTime: start, + endTime: end, + description: 'Multi-VIP transport for leadership briefing session', + notes: 'IMPORTANT: Picking up 3 VIPs - use large vehicle', + }); + } + + // Another group event + if (vips.length >= 5) { + const start = this.relativeTime(90); + const end = this.relativeTime(110); + const driver = findAvailableDriver(start, end, 1); + const vehicle = findAvailableVehicle(start, end, 1); + events.push({ + vipIds: [vips[3].id, vips[4].id], + driverId: driver?.id, + vehicleId: vehicle?.id, + title: 'Group Transport - Media Tour', + type: EventType.TRANSPORT, + status: EventStatus.SCHEDULED, + pickupLocation: 'Media Center', + dropoffLocation: 'Historical Site', + startTime: start, + endTime: end, + description: 'VIP media tour with photo opportunities', + }); + } + } + + // ============================================================ + // ADD SOME CANCELLED EVENTS FOR REALISM + // ============================================================ + + if (vips.length >= 6) { + events.push({ + vipIds: [vips[5].id], + title: 'Private Meeting - CANCELLED', + type: EventType.MEETING, + status: EventStatus.CANCELLED, + location: 'Conference Room B', + startTime: this.relativeTime(200), + endTime: this.relativeTime(260), + description: 'Meeting cancelled due to schedule conflict', + notes: 'VIP requested reschedule for tomorrow', + }); + } + + return events; + } + + // ============================================================ + // HELPER METHODS + // ============================================================ + + /** + * Get a date relative to now + * @param minutesOffset - Minutes from now (negative = past, positive = future) + */ + private relativeTime(minutesOffset: number): Date { + return new Date(Date.now() + minutesOffset * 60 * 1000); + } +} diff --git a/backend/src/settings/dto/update-pdf-settings.dto.ts b/backend/src/settings/dto/update-pdf-settings.dto.ts new file mode 100644 index 0000000..6504fa1 --- /dev/null +++ b/backend/src/settings/dto/update-pdf-settings.dto.ts @@ -0,0 +1,105 @@ +import { + IsString, + IsEmail, + IsBoolean, + IsEnum, + IsOptional, + IsHexColor, + MaxLength, +} from 'class-validator'; +import { PageSize } from '@prisma/client'; + +export class UpdatePdfSettingsDto { + // Branding + @IsOptional() + @IsString() + @MaxLength(100) + organizationName?: string; + + @IsOptional() + @IsHexColor() + accentColor?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + tagline?: string; + + // Contact Info + @IsOptional() + @IsEmail() + contactEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + contactPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + secondaryContactName?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + secondaryContactPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + contactLabel?: string; + + // Document Options + @IsOptional() + @IsBoolean() + showDraftWatermark?: boolean; + + @IsOptional() + @IsBoolean() + showConfidentialWatermark?: boolean; + + @IsOptional() + @IsBoolean() + showTimestamp?: boolean; + + @IsOptional() + @IsBoolean() + showAppUrl?: boolean; + + @IsOptional() + @IsEnum(PageSize) + pageSize?: PageSize; + + // Content Toggles + @IsOptional() + @IsBoolean() + showFlightInfo?: boolean; + + @IsOptional() + @IsBoolean() + showDriverNames?: boolean; + + @IsOptional() + @IsBoolean() + showVehicleNames?: boolean; + + @IsOptional() + @IsBoolean() + showVipNotes?: boolean; + + @IsOptional() + @IsBoolean() + showEventDescriptions?: boolean; + + // Custom Text + @IsOptional() + @IsString() + @MaxLength(500) + headerMessage?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + footerMessage?: string; +} diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts new file mode 100644 index 0000000..ad138d3 --- /dev/null +++ b/backend/src/settings/settings.controller.ts @@ -0,0 +1,61 @@ +import { + Controller, + Get, + Patch, + Post, + Delete, + Body, + UseGuards, + UseInterceptors, + UploadedFile, + ParseFilePipe, + MaxFileSizeValidator, + FileTypeValidator, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { SettingsService } from './settings.service'; +import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AbilitiesGuard } from '../auth/guards/abilities.guard'; +import { CanUpdate } from '../auth/decorators/check-ability.decorator'; + +@Controller('settings') +@UseGuards(JwtAuthGuard, AbilitiesGuard) +export class SettingsController { + constructor(private readonly settingsService: SettingsService) {} + + @Get('pdf') + @CanUpdate('Settings') // Admin-only (Settings subject is admin-only) + getPdfSettings() { + return this.settingsService.getPdfSettings(); + } + + @Patch('pdf') + @CanUpdate('Settings') + updatePdfSettings(@Body() dto: UpdatePdfSettingsDto) { + return this.settingsService.updatePdfSettings(dto); + } + + @Post('pdf/logo') + @CanUpdate('Settings') + @UseInterceptors(FileInterceptor('logo')) + uploadLogo( + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB + new FileTypeValidator({ fileType: /(png|jpeg|jpg|svg\+xml)/ }), + ], + }), + ) + file: Express.Multer.File, + ) { + return this.settingsService.uploadLogo(file); + } + + @Delete('pdf/logo') + @CanUpdate('Settings') + deleteLogo() { + return this.settingsService.deleteLogo(); + } +} diff --git a/backend/src/settings/settings.module.ts b/backend/src/settings/settings.module.ts new file mode 100644 index 0000000..fb766eb --- /dev/null +++ b/backend/src/settings/settings.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { SettingsController } from './settings.controller'; +import { SettingsService } from './settings.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [SettingsController], + providers: [SettingsService], + exports: [SettingsService], +}) +export class SettingsModule {} diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts new file mode 100644 index 0000000..cf875cf --- /dev/null +++ b/backend/src/settings/settings.service.ts @@ -0,0 +1,139 @@ +import { + Injectable, + Logger, + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto'; +import { PdfSettings } from '@prisma/client'; + +@Injectable() +export class SettingsService { + private readonly logger = new Logger(SettingsService.name); + private readonly MAX_LOGO_SIZE = 2 * 1024 * 1024; // 2MB in bytes + private readonly ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml']; + + constructor(private prisma: PrismaService) {} + + /** + * Get PDF settings - creates default if none exist (singleton pattern) + */ + async getPdfSettings(): Promise { + this.logger.log('Fetching PDF settings'); + + let settings = await this.prisma.pdfSettings.findFirst(); + + if (!settings) { + this.logger.log('No settings found, creating defaults'); + settings = await this.prisma.pdfSettings.create({ + data: { + organizationName: 'VIP Coordinator', + accentColor: '#2c3e50', + contactEmail: 'contact@example.com', + contactPhone: '555-0100', + contactLabel: 'Questions or Changes?', + pageSize: 'LETTER', + showDraftWatermark: false, + showConfidentialWatermark: false, + showTimestamp: true, + showAppUrl: false, + showFlightInfo: true, + showDriverNames: true, + showVehicleNames: true, + showVipNotes: true, + showEventDescriptions: true, + }, + }); + this.logger.log(`Created default settings: ${settings.id}`); + } + + return settings; + } + + /** + * Update PDF settings + */ + async updatePdfSettings(dto: UpdatePdfSettingsDto): Promise { + this.logger.log('Updating PDF settings'); + + // Get existing settings (or create if none exist) + const existing = await this.getPdfSettings(); + + try { + const updated = await this.prisma.pdfSettings.update({ + where: { id: existing.id }, + data: dto, + }); + + this.logger.log(`Settings updated: ${updated.id}`); + return updated; + } catch (error) { + this.logger.error(`Failed to update settings: ${error.message}`); + throw new InternalServerErrorException('Failed to update PDF settings'); + } + } + + /** + * Upload logo as base64 data URL + */ + async uploadLogo(file: Express.Multer.File): Promise { + this.logger.log(`Uploading logo: ${file.originalname} (${file.size} bytes)`); + + // Validate file size + if (file.size > this.MAX_LOGO_SIZE) { + throw new BadRequestException( + `Logo file too large. Maximum size is ${this.MAX_LOGO_SIZE / 1024 / 1024}MB`, + ); + } + + // Validate MIME type + if (!this.ALLOWED_MIME_TYPES.includes(file.mimetype)) { + throw new BadRequestException( + `Invalid file type. Allowed types: PNG, JPG, SVG`, + ); + } + + // Convert to base64 data URL + const base64 = file.buffer.toString('base64'); + const dataUrl = `data:${file.mimetype};base64,${base64}`; + + // Get existing settings + const existing = await this.getPdfSettings(); + + try { + const updated = await this.prisma.pdfSettings.update({ + where: { id: existing.id }, + data: { logoUrl: dataUrl }, + }); + + this.logger.log(`Logo uploaded: ${file.originalname}`); + return updated; + } catch (error) { + this.logger.error(`Failed to upload logo: ${error.message}`); + throw new InternalServerErrorException('Failed to upload logo'); + } + } + + /** + * Delete logo + */ + async deleteLogo(): Promise { + this.logger.log('Deleting logo'); + + const existing = await this.getPdfSettings(); + + try { + const updated = await this.prisma.pdfSettings.update({ + where: { id: existing.id }, + data: { logoUrl: null }, + }); + + this.logger.log('Logo deleted'); + return updated; + } catch (error) { + this.logger.error(`Failed to delete logo: ${error.message}`); + throw new InternalServerErrorException('Failed to delete logo'); + } + } +} diff --git a/backend/src/signal/messages.controller.ts b/backend/src/signal/messages.controller.ts new file mode 100644 index 0000000..9b3a107 --- /dev/null +++ b/backend/src/signal/messages.controller.ts @@ -0,0 +1,200 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + Query, + UseGuards, + Logger, + Res, +} from '@nestjs/common'; +import { Response } from 'express'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { MessagesService, SendMessageDto } from './messages.service'; + +// DTO for incoming Signal webhook +interface SignalWebhookPayload { + envelope: { + source: string; + sourceNumber?: string; + sourceName?: string; + timestamp: number; + dataMessage?: { + timestamp: number; + message: string; + }; + }; + account: string; +} + +@Controller('signal/messages') +export class MessagesController { + private readonly logger = new Logger(MessagesController.name); + + constructor(private readonly messagesService: MessagesService) {} + + /** + * Get messages for a specific driver + */ + @Get('driver/:driverId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR', 'COORDINATOR') + async getMessagesForDriver( + @Param('driverId') driverId: string, + @Query('limit') limit?: string, + ) { + const messages = await this.messagesService.getMessagesForDriver( + driverId, + limit ? parseInt(limit, 10) : 50, + ); + // Return in chronological order for display + return messages.reverse(); + } + + /** + * Send a message to a driver + */ + @Post('send') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR', 'COORDINATOR') + async sendMessage(@Body() dto: SendMessageDto) { + return this.messagesService.sendMessage(dto); + } + + /** + * Mark messages as read for a driver + */ + @Post('driver/:driverId/read') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR', 'COORDINATOR') + async markAsRead(@Param('driverId') driverId: string) { + const result = await this.messagesService.markMessagesAsRead(driverId); + return { success: true, count: result.count }; + } + + /** + * Get unread message counts for all drivers + */ + @Get('unread') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR', 'COORDINATOR') + async getUnreadCounts() { + return this.messagesService.getUnreadCounts(); + } + + /** + * Get unread count for a specific driver + */ + @Get('driver/:driverId/unread') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR', 'COORDINATOR') + async getUnreadCountForDriver(@Param('driverId') driverId: string) { + const count = await this.messagesService.getUnreadCountForDriver(driverId); + return { driverId, unread: count }; + } + + /** + * Webhook endpoint for incoming Signal messages + * This is called by signal-cli-rest-api when messages are received + * Public endpoint - no authentication required + */ + @Public() + @Post('webhook') + async handleWebhook(@Body() payload: SignalWebhookPayload) { + this.logger.debug('Received Signal webhook:', JSON.stringify(payload)); + + try { + const envelope = payload.envelope; + + if (!envelope?.dataMessage?.message) { + this.logger.debug('Webhook received but no message content'); + return { success: true, message: 'No message content' }; + } + + const fromNumber = envelope.sourceNumber || envelope.source; + const content = envelope.dataMessage.message; + const timestamp = envelope.dataMessage.timestamp?.toString(); + + const message = await this.messagesService.processIncomingMessage( + fromNumber, + content, + timestamp, + ); + + if (message) { + return { success: true, messageId: message.id }; + } else { + return { success: true, message: 'Unknown sender' }; + } + } catch (error: any) { + this.logger.error('Failed to process webhook:', error.message); + return { success: false, error: error.message }; + } + } + + /** + * Export all messages as a text file + */ + @Get('export') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR') + async exportMessages(@Res() res: Response) { + const exportData = await this.messagesService.exportAllMessages(); + + const filename = `signal-chats-${new Date().toISOString().split('T')[0]}.txt`; + + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(exportData); + } + + /** + * Delete all messages + */ + @Delete('all') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR') + async deleteAllMessages() { + const count = await this.messagesService.deleteAllMessages(); + return { success: true, deleted: count }; + } + + /** + * Get message statistics + */ + @Get('stats') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR', 'COORDINATOR') + async getMessageStats() { + return this.messagesService.getMessageStats(); + } + + /** + * Check which events have driver responses since the event started + * Used to determine if the "awaiting response" glow should show + */ + @Post('check-responses') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMINISTRATOR', 'COORDINATOR') + async checkDriverResponses( + @Body() + body: { + events: Array<{ eventId: string; driverId: string; startTime: string }>; + }, + ) { + const pairs = body.events.map((e) => ({ + eventId: e.eventId, + driverId: e.driverId, + sinceTime: new Date(e.startTime), + })); + + const respondedEventIds = + await this.messagesService.checkDriverResponsesSince(pairs); + return { respondedEventIds }; + } +} diff --git a/backend/src/signal/messages.service.ts b/backend/src/signal/messages.service.ts new file mode 100644 index 0000000..6ddab01 --- /dev/null +++ b/backend/src/signal/messages.service.ts @@ -0,0 +1,432 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SignalService } from './signal.service'; +import { MessageDirection, EventStatus } from '@prisma/client'; + +export interface SendMessageDto { + driverId: string; + content: string; +} + +export interface MessageWithDriver { + id: string; + driverId: string; + direction: MessageDirection; + content: string; + timestamp: Date; + isRead: boolean; + driver: { + id: string; + name: string; + phone: string; + }; +} + +@Injectable() +export class MessagesService { + private readonly logger = new Logger(MessagesService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly signalService: SignalService, + ) {} + + /** + * Get all messages for a driver + */ + async getMessagesForDriver(driverId: string, limit: number = 50) { + const driver = await this.prisma.driver.findFirst({ + where: { id: driverId, deletedAt: null }, + }); + + if (!driver) { + throw new NotFoundException(`Driver with ID ${driverId} not found`); + } + + return this.prisma.signalMessage.findMany({ + where: { driverId }, + orderBy: { timestamp: 'desc' }, + take: limit, + }); + } + + /** + * Send a message to a driver + */ + async sendMessage(dto: SendMessageDto) { + const driver = await this.prisma.driver.findFirst({ + where: { id: dto.driverId, deletedAt: null }, + }); + + if (!driver) { + throw new NotFoundException(`Driver with ID ${dto.driverId} not found`); + } + + // Get the linked Signal number + const fromNumber = await this.signalService.getLinkedNumber(); + if (!fromNumber) { + throw new Error('No Signal account linked. Please link an account in Admin Tools.'); + } + + // Check driver has a phone number + if (!driver.phone) { + throw new Error('Driver does not have a phone number configured.'); + } + + // Format the driver's phone number + const toNumber = this.signalService.formatPhoneNumber(driver.phone); + + // Send via Signal + const result = await this.signalService.sendMessage(fromNumber, toNumber, dto.content); + + if (!result.success) { + throw new Error(result.error || 'Failed to send message via Signal'); + } + + // Store the message in database + const message = await this.prisma.signalMessage.create({ + data: { + driverId: dto.driverId, + direction: MessageDirection.OUTBOUND, + content: dto.content, + isRead: true, // Outbound messages are always "read" + signalTimestamp: result.timestamp?.toString(), + }, + }); + + this.logger.log(`Message sent to driver ${driver.name} (${toNumber})`); + + return message; + } + + /** + * Process incoming message from Signal webhook + */ + async processIncomingMessage( + fromNumber: string, + content: string, + signalTimestamp?: string, + ) { + // Normalize phone number for matching + const normalizedPhone = this.normalizePhoneForSearch(fromNumber); + + // Find driver by phone number + const driver = await this.prisma.driver.findFirst({ + where: { + deletedAt: null, + OR: [ + { phone: fromNumber }, + { phone: normalizedPhone }, + { phone: { contains: normalizedPhone.slice(-10) } }, // Last 10 digits + ], + }, + }); + + if (!driver) { + this.logger.warn(`Received message from unknown number: ${fromNumber}`); + return null; + } + + // Check for duplicate message + if (signalTimestamp) { + const existing = await this.prisma.signalMessage.findFirst({ + where: { + driverId: driver.id, + signalTimestamp, + }, + }); + if (existing) { + this.logger.debug(`Duplicate message ignored: ${signalTimestamp}`); + return existing; + } + } + + // Store the message + const message = await this.prisma.signalMessage.create({ + data: { + driverId: driver.id, + direction: MessageDirection.INBOUND, + content, + isRead: false, + signalTimestamp, + }, + }); + + this.logger.log(`Incoming message from driver ${driver.name}: ${content.substring(0, 50)}...`); + + // Check if this is a status response (1, 2, or 3) + const trimmedContent = content.trim(); + if (['1', '2', '3'].includes(trimmedContent)) { + await this.processDriverStatusResponse(driver, parseInt(trimmedContent, 10)); + } + + return message; + } + + /** + * Process a driver's status response (1=Confirmed, 2=Delayed, 3=Issue) + */ + private async processDriverStatusResponse(driver: any, response: number) { + // Find the driver's current IN_PROGRESS event + const activeEvent = await this.prisma.scheduleEvent.findFirst({ + where: { + driverId: driver.id, + status: EventStatus.IN_PROGRESS, + deletedAt: null, + }, + include: { vehicle: true }, + }); + + if (!activeEvent) { + // No active event, send a clarification + await this.sendAutoReply(driver, 'No active trip found for your response. If you need assistance, please send a message to the coordinator.'); + return; + } + + const now = new Date(); + let replyMessage: string; + let noteText: string; + + switch (response) { + case 1: // Confirmed + noteText = `[${now.toLocaleTimeString()}] ✅ Driver confirmed en route`; + replyMessage = `✅ Confirmed! Safe travels with your VIP. Reply when completed or if you need assistance.`; + break; + + case 2: // Delayed + noteText = `[${now.toLocaleTimeString()}] ⏰ Driver reported DELAY - awaiting details`; + replyMessage = `⏰ Delay noted. Please reply with the reason for the delay. The coordinator has been alerted.`; + break; + + case 3: // Issue + noteText = `[${now.toLocaleTimeString()}] 🚨 Driver reported ISSUE - needs help`; + replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the problem in your next message.`; + break; + + default: + return; + } + + // Update the event with the driver's response + await this.prisma.scheduleEvent.update({ + where: { id: activeEvent.id }, + data: { + notes: activeEvent.notes + ? `${activeEvent.notes}\n${noteText}` + : noteText, + }, + }); + + this.logger.log(`Driver ${driver.name} responded with ${response} for event ${activeEvent.id}`); + + // Send auto-reply + await this.sendAutoReply(driver, replyMessage); + } + + /** + * Send an automated reply to a driver + */ + private async sendAutoReply(driver: any, message: string) { + try { + const fromNumber = await this.signalService.getLinkedNumber(); + if (!fromNumber) { + this.logger.warn('No Signal account linked, cannot send auto-reply'); + return; + } + + const toNumber = this.signalService.formatPhoneNumber(driver.phone); + await this.signalService.sendMessage(fromNumber, toNumber, message); + + // Store the outbound message + await this.prisma.signalMessage.create({ + data: { + driverId: driver.id, + direction: MessageDirection.OUTBOUND, + content: message, + isRead: true, + }, + }); + + this.logger.log(`Auto-reply sent to driver ${driver.name}`); + } catch (error) { + this.logger.error(`Failed to send auto-reply to driver ${driver.name}:`, error); + } + } + + /** + * Mark messages as read for a driver + */ + async markMessagesAsRead(driverId: string) { + return this.prisma.signalMessage.updateMany({ + where: { + driverId, + direction: MessageDirection.INBOUND, + isRead: false, + }, + data: { isRead: true }, + }); + } + + /** + * Get unread message count per driver + */ + async getUnreadCounts() { + const result = await this.prisma.signalMessage.groupBy({ + by: ['driverId'], + where: { + direction: MessageDirection.INBOUND, + isRead: false, + }, + _count: true, + }); + + return result.reduce((acc, item) => { + acc[item.driverId] = item._count; + return acc; + }, {} as Record); + } + + /** + * Get unread count for a specific driver + */ + async getUnreadCountForDriver(driverId: string) { + return this.prisma.signalMessage.count({ + where: { + driverId, + direction: MessageDirection.INBOUND, + isRead: false, + }, + }); + } + + /** + * Normalize phone number for database searching + */ + private normalizePhoneForSearch(phone: string): string { + return phone.replace(/\D/g, ''); + } + + /** + * Export all messages as formatted text + */ + async exportAllMessages(): Promise { + const messages = await this.prisma.signalMessage.findMany({ + include: { + driver: { + select: { id: true, name: true, phone: true }, + }, + }, + orderBy: [ + { driverId: 'asc' }, + { timestamp: 'asc' }, + ], + }); + + if (messages.length === 0) { + return 'No messages to export.'; + } + + // Group messages by driver + const byDriver: Record = {}; + for (const msg of messages) { + const driverId = msg.driverId; + if (!byDriver[driverId]) { + byDriver[driverId] = []; + } + byDriver[driverId].push(msg); + } + + // Format output + const lines: string[] = []; + lines.push('='.repeat(60)); + lines.push('SIGNAL CHAT EXPORT'); + lines.push(`Exported: ${new Date().toISOString()}`); + lines.push(`Total Messages: ${messages.length}`); + lines.push('='.repeat(60)); + lines.push(''); + + for (const [driverId, driverMessages] of Object.entries(byDriver)) { + const driver = driverMessages[0]?.driver; + lines.push('-'.repeat(60)); + lines.push(`DRIVER: ${driver?.name || 'Unknown'}`); + lines.push(`Phone: ${driver?.phone || 'N/A'}`); + lines.push(`Messages: ${driverMessages.length}`); + lines.push('-'.repeat(60)); + + for (const msg of driverMessages) { + const direction = msg.direction === 'INBOUND' ? '← IN ' : '→ OUT'; + const time = new Date(msg.timestamp).toLocaleString(); + lines.push(`[${time}] ${direction}: ${msg.content}`); + } + lines.push(''); + } + + return lines.join('\n'); + } + + /** + * Delete all messages + */ + async deleteAllMessages(): Promise { + const result = await this.prisma.signalMessage.deleteMany({}); + this.logger.log(`Deleted ${result.count} messages`); + return result.count; + } + + /** + * Check which driver-event pairs have driver responses since the event started + * @param pairs Array of {driverId, eventId, sinceTime} + * @returns Set of eventIds where the driver has responded since sinceTime + */ + async checkDriverResponsesSince( + pairs: Array<{ driverId: string; eventId: string; sinceTime: Date }>, + ): Promise { + const respondedEventIds: string[] = []; + + for (const pair of pairs) { + const hasResponse = await this.prisma.signalMessage.findFirst({ + where: { + driverId: pair.driverId, + direction: MessageDirection.INBOUND, + timestamp: { gte: pair.sinceTime }, + }, + }); + + if (hasResponse) { + respondedEventIds.push(pair.eventId); + } + } + + return respondedEventIds; + } + + /** + * Get message statistics + */ + async getMessageStats() { + const [total, inbound, outbound, unread] = await Promise.all([ + this.prisma.signalMessage.count(), + this.prisma.signalMessage.count({ + where: { direction: MessageDirection.INBOUND }, + }), + this.prisma.signalMessage.count({ + where: { direction: MessageDirection.OUTBOUND }, + }), + this.prisma.signalMessage.count({ + where: { direction: MessageDirection.INBOUND, isRead: false }, + }), + ]); + + const driversWithMessages = await this.prisma.signalMessage.groupBy({ + by: ['driverId'], + }); + + return { + total, + inbound, + outbound, + unread, + driversWithMessages: driversWithMessages.length, + }; + } +} diff --git a/backend/src/signal/signal-polling.service.ts b/backend/src/signal/signal-polling.service.ts new file mode 100644 index 0000000..bbd014c --- /dev/null +++ b/backend/src/signal/signal-polling.service.ts @@ -0,0 +1,115 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { SignalService } from './signal.service'; +import { MessagesService } from './messages.service'; + +@Injectable() +export class SignalPollingService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SignalPollingService.name); + private pollingInterval: NodeJS.Timeout | null = null; + private isPolling = false; + + // Poll every 5 seconds + private readonly POLL_INTERVAL_MS = 5000; + + constructor( + private readonly signalService: SignalService, + private readonly messagesService: MessagesService, + ) {} + + onModuleInit() { + this.startPolling(); + } + + onModuleDestroy() { + this.stopPolling(); + } + + private startPolling() { + this.logger.log('Starting Signal message polling...'); + this.pollingInterval = setInterval(() => this.pollMessages(), this.POLL_INTERVAL_MS); + // Also poll immediately on startup + this.pollMessages(); + } + + private stopPolling() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + this.logger.log('Stopped Signal message polling'); + } + } + + private async pollMessages() { + // Prevent concurrent polling + if (this.isPolling) { + return; + } + + this.isPolling = true; + + try { + const linkedNumber = await this.signalService.getLinkedNumber(); + if (!linkedNumber) { + // No account linked, skip polling + return; + } + + const messages = await this.signalService.receiveMessages(linkedNumber); + + if (messages && messages.length > 0) { + this.logger.log(`Received ${messages.length} message(s) from Signal`); + + for (const msg of messages) { + await this.processMessage(msg); + } + } + } catch (error: any) { + // Only log errors that aren't connection issues (Signal CLI might not be ready) + if (!error.message?.includes('ECONNREFUSED')) { + this.logger.error(`Error polling messages: ${error.message}`); + } + } finally { + this.isPolling = false; + } + } + + private async processMessage(msg: any) { + try { + // Signal CLI returns messages in various formats + // We're looking for envelope.dataMessage.message + const envelope = msg.envelope; + + if (!envelope) { + return; + } + + // Get the sender's phone number + const fromNumber = envelope.sourceNumber || envelope.source; + + // Check for data message (regular text message) + const dataMessage = envelope.dataMessage; + if (dataMessage?.message) { + const content = dataMessage.message; + const timestamp = dataMessage.timestamp?.toString(); + + this.logger.debug(`Processing message from ${fromNumber}: ${content.substring(0, 50)}...`); + + await this.messagesService.processIncomingMessage( + fromNumber, + content, + timestamp, + ); + } + + // Also handle sync messages (messages sent from other linked devices) + const syncMessage = envelope.syncMessage; + if (syncMessage?.sentMessage?.message) { + // This is a message we sent from another device, we can ignore it + // or store it if needed + this.logger.debug('Received sync message (sent from another device)'); + } + } catch (error: any) { + this.logger.error(`Error processing message: ${error.message}`); + } + } +} diff --git a/backend/src/signal/signal.controller.ts b/backend/src/signal/signal.controller.ts new file mode 100644 index 0000000..9c51eb0 --- /dev/null +++ b/backend/src/signal/signal.controller.ts @@ -0,0 +1,150 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { SignalService, SignalStatus } from './signal.service'; + +@Controller('signal') +@UseGuards(JwtAuthGuard, RolesGuard) +export class SignalController { + constructor(private readonly signalService: SignalService) {} + + /** + * Get Signal connection status + */ + @Get('status') + @Roles('ADMINISTRATOR') + async getStatus(): Promise { + return this.signalService.getStatus(); + } + + /** + * Get QR code for linking device + */ + @Get('qrcode') + @Roles('ADMINISTRATOR') + async getQRCode() { + const result = await this.signalService.getQRCodeLink(); + if (!result) { + return { + success: false, + message: 'Device already linked. Unlink first to re-link.', + }; + } + return { + success: true, + qrcode: result.qrcode, + }; + } + + /** + * Register a new phone number + */ + @Post('register') + @Roles('ADMINISTRATOR') + async registerNumber(@Body() body: { phoneNumber: string; captcha?: string }) { + return this.signalService.registerNumber(body.phoneNumber, body.captcha); + } + + /** + * Verify phone number with code + */ + @Post('verify') + @Roles('ADMINISTRATOR') + async verifyNumber(@Body() body: { phoneNumber: string; code: string }) { + return this.signalService.verifyNumber(body.phoneNumber, body.code); + } + + /** + * Unlink the current account + */ + @Delete('unlink/:phoneNumber') + @Roles('ADMINISTRATOR') + async unlinkAccount(@Param('phoneNumber') phoneNumber: string) { + return this.signalService.unlinkAccount(phoneNumber); + } + + /** + * Send a test message + */ + @Post('send') + @Roles('ADMINISTRATOR') + async sendMessage(@Body() body: { to: string; message: string }) { + const fromNumber = await this.signalService.getLinkedNumber(); + if (!fromNumber) { + return { + success: false, + error: 'No Signal account linked. Please link an account first.', + }; + } + + const formattedTo = this.signalService.formatPhoneNumber(body.to); + return this.signalService.sendMessage(fromNumber, formattedTo, body.message); + } + + /** + * Send message to multiple recipients + */ + @Post('send-bulk') + @Roles('ADMINISTRATOR', 'COORDINATOR') + async sendBulkMessage(@Body() body: { recipients: string[]; message: string }) { + const fromNumber = await this.signalService.getLinkedNumber(); + if (!fromNumber) { + return { + success: false, + error: 'No Signal account linked. Please link an account first.', + }; + } + + const formattedRecipients = body.recipients.map((r) => + this.signalService.formatPhoneNumber(r), + ); + return this.signalService.sendBulkMessage( + fromNumber, + formattedRecipients, + body.message, + ); + } + + /** + * Send a PDF or file attachment via Signal + */ + @Post('send-attachment') + @Roles('ADMINISTRATOR', 'COORDINATOR') + async sendAttachment( + @Body() + body: { + to: string; + message?: string; + attachment: string; // Base64 encoded file + filename: string; + mimeType?: string; + }, + ) { + const fromNumber = await this.signalService.getLinkedNumber(); + if (!fromNumber) { + return { + success: false, + error: 'No Signal account linked. Please link an account first.', + }; + } + + const formattedTo = this.signalService.formatPhoneNumber(body.to); + return this.signalService.sendMessageWithAttachment( + fromNumber, + formattedTo, + body.message || '', + body.attachment, + body.filename, + body.mimeType || 'application/pdf', + ); + } +} diff --git a/backend/src/signal/signal.module.ts b/backend/src/signal/signal.module.ts new file mode 100644 index 0000000..336e317 --- /dev/null +++ b/backend/src/signal/signal.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { SignalService } from './signal.service'; +import { SignalController } from './signal.controller'; +import { MessagesService } from './messages.service'; +import { MessagesController } from './messages.controller'; +import { SignalPollingService } from './signal-polling.service'; + +@Module({ + imports: [PrismaModule], + controllers: [SignalController, MessagesController], + providers: [SignalService, MessagesService, SignalPollingService], + exports: [SignalService, MessagesService], +}) +export class SignalModule {} diff --git a/backend/src/signal/signal.service.ts b/backend/src/signal/signal.service.ts new file mode 100644 index 0000000..50cee3a --- /dev/null +++ b/backend/src/signal/signal.service.ts @@ -0,0 +1,327 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios, { AxiosInstance } from 'axios'; + +interface SignalAccount { + number: string; + uuid: string; + username?: string; +} + +export interface SignalStatus { + isConnected: boolean; + isLinked: boolean; + phoneNumber: string | null; + error?: string; +} + +export interface QRCodeResponse { + qrcode: string; + expiresAt?: number; +} + +@Injectable() +export class SignalService { + private readonly logger = new Logger(SignalService.name); + private readonly client: AxiosInstance; + private readonly baseUrl: string; + + constructor() { + this.baseUrl = process.env.SIGNAL_API_URL || 'http://localhost:8080'; + this.client = axios.create({ + baseURL: this.baseUrl, + timeout: 30000, + }); + } + + /** + * Check if Signal API is available and get connection status + */ + async getStatus(): Promise { + try { + // Check if API is reachable + const response = await this.client.get('/v1/about'); + + // Try to get registered accounts + // API returns array of phone number strings: ["+1234567890"] + const accountsResponse = await this.client.get('/v1/accounts'); + const accounts: string[] = accountsResponse.data; + + if (accounts.length > 0) { + return { + isConnected: true, + isLinked: true, + phoneNumber: accounts[0], + }; + } + + return { + isConnected: true, + isLinked: false, + phoneNumber: null, + }; + } catch (error: any) { + this.logger.error('Failed to connect to Signal API:', error.message); + return { + isConnected: false, + isLinked: false, + phoneNumber: null, + error: error.code === 'ECONNREFUSED' + ? 'Signal API container is not running' + : error.message, + }; + } + } + + /** + * Get QR code for linking a new device + */ + async getQRCodeLink(deviceName: string = 'VIP Coordinator'): Promise { + try { + // First check if already linked + const status = await this.getStatus(); + if (status.isLinked) { + this.logger.warn('Device already linked to Signal'); + return null; + } + + // Request QR code for device linking - returns raw PNG image + const response = await this.client.get('/v1/qrcodelink', { + params: { device_name: deviceName }, + timeout: 60000, // QR generation can take a moment + responseType: 'arraybuffer', // Get raw binary data + }); + + // Convert to base64 + const base64 = Buffer.from(response.data, 'binary').toString('base64'); + + return { + qrcode: base64, + }; + } catch (error: any) { + this.logger.error('Failed to get QR code:', error.message); + throw error; + } + } + + /** + * Register a new phone number (requires verification) + */ + async registerNumber(phoneNumber: string, captcha?: string): Promise<{ success: boolean; message: string }> { + try { + const response = await this.client.post(`/v1/register/${phoneNumber}`, { + captcha, + use_voice: false, + }); + + return { + success: true, + message: 'Verification code sent. Check your phone.', + }; + } catch (error: any) { + this.logger.error('Failed to register number:', error.message); + return { + success: false, + message: error.response?.data?.error || error.message, + }; + } + } + + /** + * Verify a phone number with the code received + */ + async verifyNumber(phoneNumber: string, verificationCode: string): Promise<{ success: boolean; message: string }> { + try { + const response = await this.client.post(`/v1/register/${phoneNumber}/verify/${verificationCode}`); + + return { + success: true, + message: 'Phone number verified and linked successfully!', + }; + } catch (error: any) { + this.logger.error('Failed to verify number:', error.message); + return { + success: false, + message: error.response?.data?.error || error.message, + }; + } + } + + /** + * Unlink/unregister the current account + */ + async unlinkAccount(phoneNumber: string): Promise<{ success: boolean; message: string }> { + try { + await this.client.delete(`/v1/accounts/${phoneNumber}`); + + return { + success: true, + message: 'Account unlinked successfully', + }; + } catch (error: any) { + this.logger.error('Failed to unlink account:', error.message); + return { + success: false, + message: error.response?.data?.error || error.message, + }; + } + } + + /** + * Send a message to a recipient + */ + async sendMessage( + fromNumber: string, + toNumber: string, + message: string, + ): Promise<{ success: boolean; timestamp?: number; error?: string }> { + try { + const response = await this.client.post(`/v2/send`, { + number: fromNumber, + recipients: [toNumber], + message, + }); + + this.logger.log(`Message sent to ${toNumber}`); + return { + success: true, + timestamp: response.data.timestamp, + }; + } catch (error: any) { + this.logger.error(`Failed to send message to ${toNumber}:`, error.message); + return { + success: false, + error: error.response?.data?.error || error.message, + }; + } + } + + /** + * Send a message to multiple recipients + */ + async sendBulkMessage( + fromNumber: string, + toNumbers: string[], + message: string, + ): Promise<{ success: boolean; sent: number; failed: number; errors: string[] }> { + const results = { + success: true, + sent: 0, + failed: 0, + errors: [] as string[], + }; + + for (const toNumber of toNumbers) { + const result = await this.sendMessage(fromNumber, toNumber, message); + if (result.success) { + results.sent++; + } else { + results.failed++; + results.errors.push(`${toNumber}: ${result.error}`); + } + } + + results.success = results.failed === 0; + return results; + } + + /** + * Get the linked phone number (if any) + */ + async getLinkedNumber(): Promise { + try { + const response = await this.client.get('/v1/accounts'); + // API returns array of phone number strings directly: ["+1234567890"] + const accounts: string[] = response.data; + + if (accounts.length > 0) { + return accounts[0]; + } + return null; + } catch (error) { + return null; + } + } + + /** + * Format phone number for Signal (must include country code) + */ + formatPhoneNumber(phone: string): string { + // Remove all non-digit characters + let cleaned = phone.replace(/\D/g, ''); + + // Add US country code if not present + if (cleaned.length === 10) { + cleaned = '1' + cleaned; + } + + // Add + prefix + if (!cleaned.startsWith('+')) { + cleaned = '+' + cleaned; + } + + return cleaned; + } + + /** + * Receive pending messages for the account + * This fetches and removes messages from Signal's queue + */ + async receiveMessages(phoneNumber: string): Promise { + try { + const response = await this.client.get(`/v1/receive/${phoneNumber}`, { + timeout: 10000, + }); + + // Response is an array of message envelopes + return response.data || []; + } catch (error: any) { + // Don't log timeout errors or empty responses as errors + if (error.code === 'ECONNABORTED' || error.response?.status === 204) { + return []; + } + throw error; + } + } + + /** + * Send a message with a file attachment (PDF, image, etc.) + * @param fromNumber - The sender's phone number + * @param toNumber - The recipient's phone number + * @param message - Optional text message to accompany the attachment + * @param attachment - Base64 encoded file data + * @param filename - Name for the file + * @param mimeType - MIME type of the file (e.g., 'application/pdf') + */ + async sendMessageWithAttachment( + fromNumber: string, + toNumber: string, + message: string, + attachment: string, + filename: string, + mimeType: string = 'application/pdf', + ): Promise<{ success: boolean; timestamp?: number; error?: string }> { + try { + // Format: data:;filename=;base64, + const base64Attachment = `data:${mimeType};filename=${filename};base64,${attachment}`; + + const response = await this.client.post(`/v2/send`, { + number: fromNumber, + recipients: [toNumber], + message: message || '', + base64_attachments: [base64Attachment], + }); + + this.logger.log(`Message with attachment sent to ${toNumber}: ${filename}`); + return { + success: true, + timestamp: response.data.timestamp, + }; + } catch (error: any) { + this.logger.error(`Failed to send attachment to ${toNumber}:`, error.message); + return { + success: false, + error: error.response?.data?.error || error.message, + }; + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index f78812f..b9a6106 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,8 +35,22 @@ services: retries: 5 restart: unless-stopped + # Signal CLI REST API for messaging + signal-api: + image: bbernhard/signal-cli-rest-api:latest + container_name: vip-signal + environment: + - MODE=native + ports: + - "8080:8080" + volumes: + - signal_data:/home/.local/share/signal-cli + restart: unless-stopped + volumes: postgres_data: name: vip_postgres_data redis_data: name: vip_redis_data + signal_data: + name: vip_signal_data diff --git a/frontend/.env.example b/frontend/.env.example index c4bd19a..9d6b660 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -8,3 +8,8 @@ VITE_API_URL=http://localhost:3000/api/v1 VITE_AUTH0_DOMAIN=your-tenant.us.auth0.com VITE_AUTH0_CLIENT_ID=your-auth0-client-id VITE_AUTH0_AUDIENCE=https://your-api-identifier + +# Organization Contact Information (for PDF exports) +VITE_CONTACT_EMAIL=coordinator@example.com +VITE_CONTACT_PHONE=(555) 123-4567 +VITE_ORGANIZATION_NAME=VIP Coordinator diff --git a/frontend/index.html b/frontend/index.html index 59ce549..ca11fd5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,19 @@ VIP Coordinator + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d487eaf..833df42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@casl/ability": "^6.8.0", "@casl/react": "^5.0.1", "@heroicons/react": "^2.2.0", + "@react-pdf/renderer": "^4.3.2", "@tanstack/react-query": "^5.17.19", "axios": "^1.6.5", "clsx": "^2.1.0", @@ -19,7 +20,9 @@ "lucide-react": "^0.309.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.71.1", "react-hot-toast": "^2.6.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.21.3", "tailwind-merge": "^2.2.0" }, @@ -346,6 +349,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1070,6 +1082,180 @@ "node": ">=18" } }, + "node_modules/@react-pdf/fns": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz", + "integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==", + "license": "MIT" + }, + "node_modules/@react-pdf/font": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.4.tgz", + "integrity": "sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==", + "license": "MIT", + "dependencies": { + "@react-pdf/pdfkit": "^4.1.0", + "@react-pdf/types": "^2.9.2", + "fontkit": "^2.0.2", + "is-url": "^1.2.4" + } + }, + "node_modules/@react-pdf/image": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.4.tgz", + "integrity": "sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==", + "license": "MIT", + "dependencies": { + "@react-pdf/png-js": "^3.0.0", + "jay-peg": "^1.1.1" + } + }, + "node_modules/@react-pdf/layout": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.2.tgz", + "integrity": "sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "@react-pdf/image": "^3.0.4", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/stylesheet": "^6.1.2", + "@react-pdf/textkit": "^6.1.0", + "@react-pdf/types": "^2.9.2", + "emoji-regex-xs": "^1.0.0", + "queue": "^6.0.1", + "yoga-layout": "^3.2.1" + } + }, + "node_modules/@react-pdf/pdfkit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.1.0.tgz", + "integrity": "sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/png-js": "^3.0.0", + "browserify-zlib": "^0.2.0", + "crypto-js": "^4.2.0", + "fontkit": "^2.0.2", + "jay-peg": "^1.1.1", + "linebreak": "^1.1.0", + "vite-compatible-readable-stream": "^3.6.1" + } + }, + "node_modules/@react-pdf/png-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz", + "integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==", + "license": "MIT", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/@react-pdf/primitives": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz", + "integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==", + "license": "MIT" + }, + "node_modules/@react-pdf/reconciler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz", + "integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "scheduler": "0.25.0-rc-603e6108-20241029" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/reconciler/node_modules/scheduler": { + "version": "0.25.0-rc-603e6108-20241029", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", + "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "license": "MIT" + }, + "node_modules/@react-pdf/render": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.2.tgz", + "integrity": "sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.2", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/textkit": "^6.1.0", + "@react-pdf/types": "^2.9.2", + "abs-svg-path": "^0.1.1", + "color-string": "^1.9.1", + "normalize-svg-path": "^1.1.0", + "parse-svg-path": "^0.1.2", + "svg-arc-to-cubic-bezier": "^3.2.0" + } + }, + "node_modules/@react-pdf/renderer": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.2.tgz", + "integrity": "sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.2", + "@react-pdf/font": "^4.0.4", + "@react-pdf/layout": "^4.4.2", + "@react-pdf/pdfkit": "^4.1.0", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/reconciler": "^2.0.0", + "@react-pdf/render": "^4.3.2", + "@react-pdf/types": "^2.9.2", + "events": "^3.3.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "queue": "^6.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/stylesheet": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.2.tgz", + "integrity": "sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "@react-pdf/types": "^2.9.2", + "color-string": "^1.9.1", + "hsl-to-hex": "^1.0.0", + "media-engine": "^1.0.3", + "postcss-value-parser": "^4.1.0" + } + }, + "node_modules/@react-pdf/textkit": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.0.tgz", + "integrity": "sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "bidi-js": "^1.0.2", + "hyphen": "^1.6.4", + "unicode-properties": "^1.4.1" + } + }, + "node_modules/@react-pdf/types": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.2.tgz", + "integrity": "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==", + "license": "MIT", + "dependencies": { + "@react-pdf/font": "^4.0.4", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/stylesheet": "^6.1.2" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1436,6 +1622,15 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.20", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", @@ -1507,13 +1702,39 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1521,18 +1742,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1557,6 +1791,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -1795,7 +2035,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -1819,6 +2058,12 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1995,6 +2240,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2002,6 +2257,26 @@ "dev": true, "license": "MIT" }, + "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/baseline-browser-mapping": { "version": "2.9.18", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", @@ -2012,6 +2287,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2048,6 +2332,15 @@ "node": ">=8" } }, + "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/browser-tabs-lock": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", @@ -2058,6 +2351,15 @@ "lodash": ">=4.17.21" } }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2147,6 +2449,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2164,6 +2476,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2202,6 +2554,15 @@ "node": ">= 6" } }, + "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/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2228,9 +2589,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2243,6 +2613,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2282,6 +2662,12 @@ "node": ">= 8" } }, + "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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2316,7 +2702,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2330,6 +2715,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2346,6 +2744,34 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2416,6 +2842,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/es-cookie": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", @@ -2718,6 +3150,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2728,11 +3170,25 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2874,6 +3330,23 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -3155,6 +3628,77 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hsl-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", + "integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==", + "license": "MIT", + "dependencies": { + "hsl-to-rgb-for-reals": "^1.1.0" + } + }, + "node_modules/hsl-to-rgb-for-reals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz", + "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", + "license": "ISC" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hyphen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", + "integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3208,9 +3752,44 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3240,6 +3819,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3263,6 +3852,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3283,6 +3882,24 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3290,6 +3907,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jay-peg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz", + "integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==", + "license": "MIT", + "dependencies": { + "restructure": "^3.0.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3404,6 +4030,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "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/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -3440,6 +4085,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3480,6 +4135,165 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-engine": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", + "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3490,6 +4304,448 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3545,7 +4801,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3603,11 +4858,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3683,6 +4946,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3696,6 +4965,37 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3992,7 +5292,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -4005,6 +5304,27 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4021,6 +5341,15 @@ "node": ">=6" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4069,6 +5398,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", @@ -4086,6 +5431,39 @@ "react-dom": ">=16" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4151,6 +5529,48 @@ "node": ">=8.10.0" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4182,6 +5602,12 @@ "node": ">=4" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4279,6 +5705,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4324,6 +5770,15 @@ "node": ">=8" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4344,6 +5799,39 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4370,6 +5858,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4419,6 +5925,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -4497,6 +6009,12 @@ "node": ">=0.8" } }, + "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/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4559,6 +6077,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -4579,6 +6117,12 @@ "dev": true, "license": "Apache-2.0" }, + "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/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4620,6 +6164,119 @@ "node": ">=14.17" } }, + "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/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4665,9 +6322,36 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -4729,6 +6413,20 @@ } } }, + "node_modules/vite-compatible-readable-stream": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", + "integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4781,6 +6479,22 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 5920d58..9574cd0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@casl/ability": "^6.8.0", "@casl/react": "^5.0.1", "@heroicons/react": "^2.2.0", + "@react-pdf/renderer": "^4.3.2", "@tanstack/react-query": "^5.17.19", "axios": "^1.6.5", "clsx": "^2.1.0", @@ -26,7 +27,9 @@ "lucide-react": "^0.309.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.71.1", "react-hot-toast": "^2.6.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.21.3", "tailwind-merge": "^2.2.0" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 309aa90..77ae7fd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Auth0Provider } from '@auth0/auth0-react'; import { Toaster } from 'react-hot-toast'; import { AuthProvider } from '@/contexts/AuthContext'; import { AbilityProvider } from '@/contexts/AbilityContext'; +import { ThemeProvider } from '@/contexts/ThemeContext'; import { ProtectedRoute } from '@/components/ProtectedRoute'; import { Layout } from '@/components/Layout'; import { ErrorBoundary } from '@/components/ErrorBoundary'; @@ -20,6 +21,20 @@ import { EventList } from '@/pages/EventList'; import { FlightList } from '@/pages/FlightList'; import { UserList } from '@/pages/UserList'; import { AdminTools } from '@/pages/AdminTools'; +import { DriverProfile } from '@/pages/DriverProfile'; +import { MySchedule } from '@/pages/MySchedule'; +import { useAuth } from '@/contexts/AuthContext'; + +// Smart redirect based on user role +function HomeRedirect() { + const { backendUser } = useAuth(); + + // Drivers go to their schedule, everyone else goes to dashboard + if (backendUser?.role === 'DRIVER') { + return ; + } + return ; +} const queryClient = new QueryClient({ defaultOptions: { @@ -37,6 +52,7 @@ const audience = import.meta.env.VITE_AUTH0_AUDIENCE; function App() { return ( + } /> } /> } /> - } /> - } /> + } /> + } /> + } /> + } /> @@ -115,6 +135,7 @@ function App() { + ); } diff --git a/frontend/src/components/AICopilot.tsx b/frontend/src/components/AICopilot.tsx new file mode 100644 index 0000000..1135a2d --- /dev/null +++ b/frontend/src/components/AICopilot.tsx @@ -0,0 +1,375 @@ +import { useState, useRef, useEffect } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { copilotApi } from '@/lib/api'; +import { + X, + Send, + Bot, + User, + ImagePlus, + Loader2, + Sparkles, + Trash2, +} from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; + +interface ContentBlock { + type: 'text' | 'image'; + text?: string; + source?: { + type: 'base64'; + media_type: string; + data: string; + }; +} + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string | ContentBlock[]; + timestamp: Date; + toolCalls?: any[]; +} + +export function AICopilot() { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [pendingImage, setPendingImage] = useState<{ data: string; type: string } | null>(null); + const messagesEndRef = useRef(null); + const fileInputRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const chatMutation = useMutation({ + mutationFn: async (chatMessages: { role: string; content: string | ContentBlock[] }[]) => { + const { data } = await copilotApi.post('/copilot/chat', { messages: chatMessages }); + return data; + }, + onSuccess: (data) => { + const assistantMessage: Message = { + id: Date.now().toString(), + role: 'assistant', + content: data.response, + timestamp: new Date(), + toolCalls: data.toolCalls, + }; + setMessages((prev) => [...prev, assistantMessage]); + }, + onError: (error: any) => { + const errorMessage: Message = { + id: Date.now().toString(), + role: 'assistant', + content: `Sorry, I encountered an error: ${error.message || 'Unknown error'}. Please try again.`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + }, + }); + + const handleSend = () => { + if (!input.trim() && !pendingImage) return; + + // Build content + let content: string | ContentBlock[]; + if (pendingImage) { + content = []; + if (input.trim()) { + content.push({ type: 'text', text: input.trim() }); + } + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: pendingImage.type, + data: pendingImage.data, + }, + }); + } else { + content = input.trim(); + } + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content, + timestamp: new Date(), + }; + + const newMessages = [...messages, userMessage]; + setMessages(newMessages); + setInput(''); + setPendingImage(null); + + // Prepare messages for API + const apiMessages = newMessages.map((msg) => ({ + role: msg.role, + content: msg.content, + })); + + chatMutation.mutate(apiMessages); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + const base64 = (reader.result as string).split(',')[1]; + setPendingImage({ + data: base64, + type: file.type, + }); + }; + reader.readAsDataURL(file); + }; + + const clearChat = () => { + setMessages([]); + setPendingImage(null); + }; + + const renderContent = (content: string | ContentBlock[]) => { + if (typeof content === 'string') { + return ( +

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, + code: ({ children }) => ( + {children} + ), + pre: ({ children }) => ( +
    {children}
    + ), + }} + > + {content} +
    + ); + } + + return ( +
    + {content.map((block, i) => { + if (block.type === 'text') { + return

    {block.text}

    ; + } + if (block.type === 'image' && block.source) { + return ( + Uploaded + ); + } + return null; + })} +
    + ); + }; + + return ( + <> + {/* Floating button */} + + + {/* Chat panel */} +
    + {/* Header */} +
    +
    + + VIP Coordinator AI +
    +
    + + +
    +
    + + {/* Messages */} +
    + {messages.length === 0 && ( +
    + +

    Hi! I'm your AI assistant.

    +

    + I can help you with VIPs, drivers, events, and more. +
    + You can also upload screenshots of emails! +

    +
    +

    Try asking:

    + + + +
    +
    + )} + + {messages.map((message) => ( +
    + {message.role === 'assistant' && ( +
    + +
    + )} +
    +
    {renderContent(message.content)}
    + {message.toolCalls && message.toolCalls.length > 0 && ( +
    + Actions taken: + {message.toolCalls.map((tc) => tc.tool).join(', ')} +
    + )} +
    + {message.role === 'user' && ( +
    + +
    + )} +
    + ))} + + {chatMutation.isPending && ( +
    +
    + +
    +
    + +
    +
    + )} + +
    +
    + + {/* Pending image preview */} + {pendingImage && ( +
    +
    + To upload + +
    +
    + )} + + {/* Input */} +
    +
    + + +