diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3282865 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +postgres-data +uploads +logs +frontend/node_modules +frontend/dist +.git +*.log + + + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c4f311 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# JWT Configuration +JWT_SECRET=your-jwt-secret-key-here + +# Database Configuration +DATABASE_URL=jdbc:postgresql://localhost:5432/telemedicine +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=your-database-password + +# Email Configuration +MAIL_HOST=mail.gnxsoft.com +MAIL_PORT=587 +MAIL_USERNAME=support@gnxsoft.com +MAIL_PASSWORD=your-email-password + +# Gemini AI Configuration +GEMINI_API_KEY=your-gemini-api-key + +# TURN Server Configuration +TURN_ENABLED=true +TURN_HOST=turn +TURN_PUBLIC_IP=your-public-ip +TURN_USERNAME=telemedicine +TURN_PASSWORD=your-turn-password +TURN_SECRET=your-turn-secret diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bceaed0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.env + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..097e771 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ca9953 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM maven:4.0.0-rc-4-eclipse-temurin-25-alpine + +WORKDIR /app + +COPY pom.xml . +RUN mvn dependency:go-offline -B + +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e6ea5a --- /dev/null +++ b/README.md @@ -0,0 +1,425 @@ +# Telemedicine Platform API + +A comprehensive telemedicine platform API built with Spring Boot that enables secure patient-doctor interactions, appointment management, and AI-powered symptom triage services. + +## Features + +- 🔐 **JWT-based Authentication & Authorization** - Secure user authentication with role-based access control +- 👥 **Multi-role User Management** - Support for Patients, Doctors, and Admins +- 📅 **Appointment Scheduling** - Complete appointment lifecycle management +- 🤖 **AI-Powered Triage** - Intelligent symptom analysis using Google Gemini AI +- 📧 **Email Notifications** - Automated email notifications for appointments +- 📊 **Admin Dashboard** - System statistics and audit trail management +- 📝 **API Documentation** - Interactive Swagger/OpenAPI documentation + +## Tech Stack + +- **Framework**: Spring Boot 3.5.6 +- **Language**: Java 25 +- **Database**: PostgreSQL +- **Security**: Spring Security with JWT (jjwt 0.13.0) +- **AI Integration**: Google Gemini API 1.23.0 +- **Email**: Spring Boot Starter Mail (Gmail SMTP) +- **Migration**: Flyway 11.13.1 +- **Mapping**: MapStruct 1.6.3 +- **Validation**: Spring Boot Starter Validation +- **Documentation**: SpringDoc OpenAPI 2.8.13 +- **Configuration**: Spring DotEnv 4.0.0 +- **Build Tool**: Maven +- **Testing**: JUnit 5, Mockito, Spring Boot Test + +## Prerequisites + +- Java 17 or higher (Java 25 recommended) +- PostgreSQL 12 or higher +- Maven 3.6+ +- Gmail account (for email notifications) +- Google Gemini API key + +## Getting Started + +### 1. Clone the Repository + +```bash +git clone +cd telemedicine +``` + +### 2. Database Setup + +Create a PostgreSQL database: + +```sql +CREATE DATABASE telemedicine; +``` + +The database schema will be automatically created by Flyway migrations when you first run the application. + +#### Database Migrations + +The project uses Flyway for database version control. Migrations are located in `src/main/resources/db/migration/`: + +- **V1__initial_migration.sql** - Creates the `users` table with UUID support +- **V2__add_patients_and_doctors.sql** - Creates `patients` and `doctors` tables +- **V3__add_appointments.sql** - Creates `appointments` table +- **V4__add_ai_triage_audits.sql** - Creates `ai_triage_audits` table + +Migrations run automatically on application startup. To manually run Flyway commands: + +```bash +# Run migrations +mvn flyway:migrate + +# Get migration status +mvn flyway:info + +# Clean database (⚠️ WARNING: Deletes all data) +mvn flyway:clean + +# Repair migration history +mvn flyway:repair +``` + +**Note**: Update database credentials in `pom.xml` under the `flyway-maven-plugin` configuration if needed. + +### 3. Environment Configuration + +Create a `.env` file in the project root (use `.env.example` as template): + +```properties +JWT_SECRET=your-secret-key-at-least-32-characters +MAIL_USERNAME=your-gmail@gmail.com +MAIL_PASSWORD=your-app-specific-password +GEMINI_API_KEY=your-gemini-api-key +``` + +**Note**: For Gmail, you need to generate an [App Password](https://support.google.com/accounts/answer/185833) instead of using your regular password. + +### 4. Application Configuration + +Update `src/main/resources/application.yml` with your database credentials: + +```yaml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/telemedicine + username: postgres + password: your-password +``` + +### 5. Build and Run + +```bash +# Build the project +mvn clean install + +# Run the application +mvn spring-boot:run +``` + +The application will start on `http://localhost:8080` + +### 6. Access API Documentation + +Once the application is running, visit: +- Swagger UI: `http://localhost:8080/swagger-ui.html` +- OpenAPI Docs: `http://localhost:8080/v3/api-docs` + +## API Overview + +### Authentication Endpoints + +| Method | Endpoint | Description | Auth Required | +|--------|---------------------------|---------------------|---------------| +| POST | `/auth/login` | User login | No | +| POST | `/users/register/doctor` | Register as doctor | No | +| POST | `/users/register/patient` | Register as patient | No | +| POST | `/users/register/admin` | Register as admin | No* | + +*Admin registration requires email to match configured admin email + +### Appointment Endpoints + +| Method | Endpoint | Description | Role | +|--------|-------------------------------|--------------------------|---------------| +| GET | `/appointments/patient/{id}` | Get patient appointments | Authenticated | +| GET | `/appointments/doctor/{id}` | Get doctor appointments | Authenticated | +| POST | `/appointments` | Create appointment | DOCTOR | +| PATCH | `/appointments/{id}/cancel` | Cancel appointment | Authenticated | +| PATCH | `/appointments/{id}/confirm` | Confirm appointment | Authenticated | +| PATCH | `/appointments/{id}/complete` | Complete appointment | Authenticated | +| DELETE | `/appointments/{id}` | Delete appointment | ADMIN | + +### AI Triage Endpoints + +| Method | Endpoint | Description | Role | +|--------|----------------------------|--------------------------------|--------| +| POST | `/ai/triage/analyze` | Analyze patient symptoms | DOCTOR | +| POST | `/ai/triage/quick-analyze` | Quick analysis (no patient ID) | DOCTOR | + +### Admin Endpoints + +| Method | Endpoint | Description | +|--------|----------------------------------------|---------------------------| +| GET | `/admin/users` | Get all users | +| GET | `/admin/patients` | Get all patients | +| GET | `/admin/doctors` | Get all doctors | +| GET | `/admin/stats` | Get system statistics | +| GET | `/admin/triages` | Get AI triage audits | +| GET | `/admin/triage-audits/patient/{id}` | Get patient triage audits | +| GET | `/admin/triage-audits/urgency/{level}` | Get audits by urgency | +| PATCH | `/admin/users/{email}/activate` | Activate user | +| PATCH | `/admin/users/{email}/deactivate` | Deactivate user | +| PATCH | `/admin/doctors/{license}/verify` | Verify doctor | +| PATCH | `/admin/doctors/{license}/unverify` | Unverify doctor | +| DELETE | `/admin/users/{email}` | Delete user | + +## User Roles + +### PATIENT +- Book and manage appointments +- Use AI triage services (via doctor) +- View personal appointment history + +### DOCTOR +- Create and manage appointments +- Access AI symptom triage tools +- View patient appointment history +- Analyze patient symptoms + +### ADMIN +- Full system access +- User management (activate/deactivate/delete) +- Doctor verification +- View system statistics +- Access AI triage audit logs + +## Database Schema + +The application uses Flyway migrations to manage the database schema. The following tables are created: + +### Core Tables + +**users** (V1__initial_migration.sql) +- Core user information for all user types +- Fields: id (UUID), email, password (encrypted), role, first_name, last_name, phone_number, is_active, created_at, updated_at +- Unique constraint on email +- Uses UUID extension for primary keys + +**patients** (V2__add_patients_and_doctors.sql) +- Patient-specific data +- Fields: id (UUID), user_id (FK to users), emergency_contact_name, emergency_contact_phone, blood_type, allergies (array), created_at +- Blood type validation: A+, A-, B+, B-, AB+, AB-, O+, O- +- CASCADE delete on user deletion + +**doctors** (V2__add_patients_and_doctors.sql) +- Doctor-specific data +- Fields: id (UUID), user_id (FK to users), medical_license_number (unique), specialization, years_of_experience, biography, consultation_fee, is_verified, created_at +- CASCADE delete on user deletion + +**appointments** (V3__add_appointments.sql) +- Appointment scheduling and management +- Fields: id (UUID), patient_id (FK), doctor_id (FK), scheduled_date, scheduled_time, duration_minutes, status, created_at +- Status values: SCHEDULED, CANCELLED, CONFIRMED, COMPLETED +- CASCADE delete on patient/doctor deletion + +**ai_triage_audits** (V4__add_ai_triage_audits.sql) +- AI analysis audit trail +- Fields: id (UUID), patient_id (FK), user_input (symptoms), ai_output (analysis), urgency_level, created_at +- Urgency levels: LOW, MEDIUM, HIGH, EMERGENCY +- CASCADE delete on patient deletion + +### Database Relationships + +``` +users (1) ←→ (1) patients +users (1) ←→ (1) doctors +patients (1) ←→ (∞) appointments +doctors (1) ←→ (∞) appointments +patients (1) ←→ (∞) ai_triage_audits +``` + +### Migration Management + +To view the current migration status: + +```bash +mvn flyway:info +``` + +To apply pending migrations: + +```bash +mvn flyway:migrate +``` + +All migrations are idempotent and safe to run multiple times. + +## Project Structure + +``` +telemedicine/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── com/gnx/telemedicine/ +│ │ │ ├── config/ # Configuration(Swagger UI) +│ │ │ ├── controller/ # REST controllers +│ │ │ ├── dto/ # Data Transfer Objects +│ │ │ ├── exception/ # Custom exceptions +│ │ │ ├── mappers/ # MapStruct mappers +│ │ │ ├── model/ # JPA entities +│ │ │ ├── repository/ # Spring Data repositories +│ │ │ ├── security/ # Security configuration +│ │ │ └── service/ # Business logic +│ │ └── resources/ +│ │ ├── db/migration/ # Flyway migrations +│ │ └── application.yml # Application configuration +│ └── test/ # Test files +├── .env # Environment variables +├── .env.example # Example for .env file +├── .gitignore +├── pom.xml # Maven configuration +└── README.md +``` + +## AI Triage System + +The AI triage system uses Google Gemini to analyze patient symptoms and provides: +- **Urgency Level**: LOW, MEDIUM, HIGH, EMERGENCY +- **Recommended Specialty**: Suggested medical specialty +- **Key Questions**: Follow-up questions for doctors +- **Triage Notes**: Analysis summary +- **Audit Trail**: All analyses are logged for review + +### Example Triage Request + +```json +{ + "patientId": "uuid-here", + "symptomDescription": "Patient experiencing chest pain and shortness of breath" +} +``` + +### Example Response + +```json +{ + "patientId": "uuid-here", + "urgency": "EMERGENCY", + "recommendedSpecialty": "Cardiology", + "keyQuestions": [ + "When did symptoms start?", + "Any history of heart disease?" + ], + "triageNotes": "Severe symptoms requiring immediate attention", + "disclaimer": "AI analysis for triage assistance only. Not medical advice." +} +``` + +## Email Notifications + +The system automatically sends email notifications for: +- Appointment confirmations (to both patient and doctor) +- Appointment cancellations +- Status updates + +## Testing + +Run the test suite: + +```bash +# Run all tests +mvn test + +# Run with coverage +mvn test jacoco:report +``` + +The project includes: +- Unit tests for services +- Integration tests for controllers +- Repository tests +- Security tests + +## Security + +- Passwords are encrypted using BCrypt +- JWT tokens expire after 24 hours (configurable) +- Role-based access control (RBAC) +- CSRF protection disabled for REST API +- Stateless session management + +## Error Handling + +The API uses standard HTTP status codes: +- `200` - Success +- `201` - Created +- `204` - No Content +- `400` - Bad Request (validation errors) +- `401` - Unauthorized (authentication required) +- `403` - Forbidden (insufficient permissions) +- `500` - Internal Server Error + +All errors return a consistent JSON format: + +```json +{ + "status": "400", + "error": "Error Type", + "message": "Detailed error message", + "timestamp": "2025-10-22T10:30:00" +} +``` + +## Configuration Properties + +Key application properties in `application.yml`: + +```yaml +jwt: + secret: ${JWT_SECRET} # JWT signing key + expiration: 86400 # Token expiration (seconds) + +gemini: + api-key: ${GEMINI_API_KEY} # Google Gemini API key + +spring: + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} +``` + +## Development Tips + +1. **Database Migrations**: + - Flyway automatically runs migrations on startup + - Never modify existing migration files + - Create new migration files with format: `V{version}__{description}.sql` + - Check migration status: `mvn flyway:info` + +2. **API Testing**: Use Swagger UI for interactive API testing + +3. **Logging**: Check console logs for detailed application flow + +4. **Email Testing**: Use a test Gmail account for development + +5. **Maven Compilation**: The project uses annotation processors for Lombok and MapStruct + ```bash + # Clean build + mvn clean install + + # Skip tests during build + mvn clean install -DskipTests + ``` + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Author + +**GNX Soft LTD** +- Email: sales@gnxsoft.com \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3fa4ee1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,72 @@ + +services: + postgres: + image: postgres:16-alpine + container_name: telemedicine-postgres + restart: unless-stopped + environment: + POSTGRES_DB: telemedicine + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - ./postgres-data:/var/lib/postgresql/data + networks: + - telemedicine-network + app: + build: + context: . + dockerfile: Dockerfile + container_name: telemedicine-app + ports: + - "8080:8080" + - "18080:8080" + depends_on: + - postgres + - turn + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/telemedicine + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: password + + JWT_SECRET: ${JWT_SECRET} + JWT_EXPIRATION: 86400 + + SPRING_MAIL_HOST: smtp.gmail.com + SPRING_MAIL_PORT: 587 + SPRING_MAIL_USERNAME: ${MAIL_USERNAME} + SPRING_MAIL_PASSWORD: ${MAIL_PASSWORD} + GEMINI_API_KEY: ${GEMINI_API_KEY:-dummy-key} + networks: + - telemedicine-network + turn: + image: coturn/coturn:latest + container_name: telemedicine-turn + restart: unless-stopped + ports: + - "3478:3478/udp" + - "3478:3478/tcp" + - "49152-49251:49152-49251/udp" + environment: + TURN_ENABLED: "true" + TURN_USERNAME: "telemedicine" + TURN_PASSWORD: ${TURN_PASSWORD:-changeme} + TURN_REALM: "localdomain" + NO_AUTH_RELAY: "false" + LISTENING_IP: "0.0.0.0" + MIN_PORT: "49152" + MAX_PORT: "49251" + VERBOSE: "true" + volumes: + - ./turn-config:/etc/turnserver + networks: + - telemedicine-network + +volumes: + postgres-data: + driver: local + +networks: + telemedicine-network: + driver: bridge \ No newline at end of file diff --git a/frontend.log b/frontend.log new file mode 100644 index 0000000..056cefa --- /dev/null +++ b/frontend.log @@ -0,0 +1,53 @@ +❯ Building... +✔ Building... +Initial chunk files | Names | Raw size +main.js | main | 737.30 kB | +polyfills.js | polyfills | 90.20 kB | +styles.css | styles | 506 bytes | + + | Initial total | 828.01 kB + +Application bundle generation complete. [7.974 seconds] + +Watch mode enabled. Watching for file changes... +NOTE: Raw file sizes do not reflect development server per-request transformations. + ➜ Local: http://localhost:4200/ +❯ Changes detected. Rebuilding... +✔ Changes detected. Rebuilding... +Initial chunk files | Names | Raw size +main.js | main | 737.62 kB | + +Application bundle generation complete. [5.294 seconds] + +Page reload sent to client(s). +❯ Changes detected. Rebuilding... +✔ Changes detected. Rebuilding... +Initial chunk files | Names | Raw size +main.js | main | 737.78 kB | + +Application bundle generation complete. [1.604 seconds] + +Page reload sent to client(s). +❯ Changes detected. Rebuilding... +✔ Changes detected. Rebuilding... +Initial chunk files | Names | Raw size +main.js | main | 737.95 kB | + +Application bundle generation complete. [1.189 seconds] + +Page reload sent to client(s). +❯ Changes detected. Rebuilding... +✔ Changes detected. Rebuilding... +Initial chunk files | Names | Raw size +main.js | main | 738.12 kB | + +Application bundle generation complete. [0.539 seconds] + +Page reload sent to client(s). +❯ Changes detected. Rebuilding... +✔ Changes detected. Rebuilding... + +No output file changes. + +Application bundle generation complete. [1.312 seconds] + diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..cc7b141 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,42 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..4c20c0d --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,27 @@ +# Frontend + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.21. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. + +## Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Running end-to-end tests + +Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..d67d51d --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,111 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm", + "analytics": "645be366-fa02-4fa2-b192-dfa72b91a94d" + }, + "newProjectRoot": "projects", + "projects": { + "frontend": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/frontend", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kB", + "maximumError": "4kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "host": "0.0.0.0", + "port": 4200, + "ssl": false + }, + "configurations": { + "production": { + "buildTarget": "frontend:build:production" + }, + "development": { + "buildTarget": "frontend:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/frontend/backend.pid b/frontend/backend.pid new file mode 100644 index 0000000..61c8c0f --- /dev/null +++ b/frontend/backend.pid @@ -0,0 +1 @@ +19034 diff --git a/frontend/ng-serve.log b/frontend/ng-serve.log new file mode 100644 index 0000000..6c419b8 --- /dev/null +++ b/frontend/ng-serve.log @@ -0,0 +1,24 @@ +node:internal/modules/cjs/loader:1423 + throw err; + ^ + +Error: Cannot find module './bootstrap' +Require stack: +- /home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng + at Module._resolveFilename (node:internal/modules/cjs/loader:1420:15) + at defaultResolveImpl (node:internal/modules/cjs/loader:1058:19) + at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1063:22) + at Module._load (node:internal/modules/cjs/loader:1226:37) + at TracingChannel.traceSync (node:diagnostics_channel:328:14) + at wrapModuleLoad (node:internal/modules/cjs/loader:244:24) + at Module.require (node:internal/modules/cjs/loader:1503:12) + at require (node:internal/modules/helpers:152:16) + at Object. (/home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng:70:3) + at Module._compile (node:internal/modules/cjs/loader:1760:14) { + code: 'MODULE_NOT_FOUND', + requireStack: [ + '/home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng' + ] +} + +Node.js v24.11.0 diff --git a/frontend/npm-start.log b/frontend/npm-start.log new file mode 100644 index 0000000..19ba281 --- /dev/null +++ b/frontend/npm-start.log @@ -0,0 +1,28 @@ + +> frontend@0.0.0 start +> ng serve + +node:internal/modules/cjs/loader:1423 + throw err; + ^ + +Error: Cannot find module './bootstrap' +Require stack: +- /home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng + at Module._resolveFilename (node:internal/modules/cjs/loader:1420:15) + at defaultResolveImpl (node:internal/modules/cjs/loader:1058:19) + at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1063:22) + at Module._load (node:internal/modules/cjs/loader:1226:37) + at TracingChannel.traceSync (node:diagnostics_channel:328:14) + at wrapModuleLoad (node:internal/modules/cjs/loader:244:24) + at Module.require (node:internal/modules/cjs/loader:1503:12) + at require (node:internal/modules/helpers:152:16) + at Object. (/home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng:70:3) + at Module._compile (node:internal/modules/cjs/loader:1760:14) { + code: 'MODULE_NOT_FOUND', + requireStack: [ + '/home/gnx/Desktop/telemedicine-main/frontend/node_modules/.bin/ng' + ] +} + +Node.js v24.11.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..310e9b9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,14808 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@angular/animations": "^18.2.0", + "@angular/common": "^18.2.0", + "@angular/compiler": "^18.2.0", + "@angular/core": "^18.2.0", + "@angular/forms": "^18.2.0", + "@angular/platform-browser": "^18.2.0", + "@angular/platform-browser-dynamic": "^18.2.0", + "@angular/router": "^18.2.0", + "@stomp/stompjs": "^7.0.0", + "axios": "^1.13.1", + "rxjs": "~7.8.0", + "sockjs-client": "^1.6.1", + "tslib": "^2.3.0", + "zone.js": "~0.14.10" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^18.2.21", + "@angular/cli": "^18.2.21", + "@angular/compiler-cli": "^18.2.0", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.19", + "@types/jasmine": "~5.1.0", + "@types/node": "^24.9.2", + "@types/sockjs-client": "^1.5.4", + "autoprefixer": "^10.4.21", + "jasmine-core": "~5.2.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", + "typescript": "~5.5.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.1802.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.21.tgz", + "integrity": "sha512-+Ll+xtpKwZ3iLWN/YypvnCZV/F0MVbP+/7ZpMR+Xv/uB0OmribhBVj9WGaCd9I/bGgoYBw8wBV/NFNCKkf0k3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "18.2.21", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/architect/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/build-angular": { + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.21.tgz", + "integrity": "sha512-0pJfURFpEUV2USgZ2TL3nNAaJmF9bICx9OVddBoC+F9FeOpVKxkcVIb+c8Km5zHFo1iyVtPZ6Rb25vFk9Zm/ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.1802.21", + "@angular-devkit/build-webpack": "0.1802.21", + "@angular-devkit/core": "18.2.21", + "@angular/build": "18.2.21", + "@babel/core": "7.26.10", + "@babel/generator": "7.26.10", + "@babel/helper-annotate-as-pure": "7.25.9", + "@babel/helper-split-export-declaration": "7.24.7", + "@babel/plugin-transform-async-generator-functions": "7.26.8", + "@babel/plugin-transform-async-to-generator": "7.25.9", + "@babel/plugin-transform-runtime": "7.26.10", + "@babel/preset-env": "7.26.9", + "@babel/runtime": "7.26.10", + "@discoveryjs/json-ext": "0.6.1", + "@ngtools/webpack": "18.2.21", + "ansi-colors": "4.1.3", + "autoprefixer": "10.4.20", + "babel-loader": "9.1.3", + "browserslist": "^4.21.5", + "copy-webpack-plugin": "12.0.2", + "critters": "0.0.24", + "css-loader": "7.1.2", + "esbuild-wasm": "0.23.0", + "fast-glob": "3.3.2", + "http-proxy-middleware": "3.0.5", + "https-proxy-agent": "7.0.5", + "istanbul-lib-instrument": "6.0.3", + "jsonc-parser": "3.3.1", + "karma-source-map-support": "1.4.0", + "less": "4.2.0", + "less-loader": "12.2.0", + "license-webpack-plugin": "4.0.2", + "loader-utils": "3.3.1", + "magic-string": "0.30.11", + "mini-css-extract-plugin": "2.9.0", + "mrmime": "2.0.0", + "open": "10.1.0", + "ora": "5.4.1", + "parse5-html-rewriting-stream": "7.0.0", + "picomatch": "4.0.2", + "piscina": "4.6.1", + "postcss": "8.4.41", + "postcss-loader": "8.1.1", + "resolve-url-loader": "5.0.0", + "rxjs": "7.8.1", + "sass": "1.77.6", + "sass-loader": "16.0.0", + "semver": "7.6.3", + "source-map-loader": "5.0.0", + "source-map-support": "0.5.21", + "terser": "5.31.6", + "tree-kill": "1.2.2", + "tslib": "2.6.3", + "watchpack": "2.4.1", + "webpack": "5.94.0", + "webpack-dev-middleware": "7.4.2", + "webpack-dev-server": "5.2.2", + "webpack-merge": "6.0.1", + "webpack-subresource-integrity": "5.1.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "esbuild": "0.23.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^18.0.0", + "@angular/localize": "^18.0.0", + "@angular/platform-server": "^18.0.0", + "@angular/service-worker": "^18.0.0", + "@web/test-runner": "^0.18.0", + "browser-sync": "^3.0.2", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "karma": "^6.3.0", + "ng-packagr": "^18.0.0", + "protractor": "^7.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0", + "typescript": ">=5.4 <5.6" + }, + "peerDependenciesMeta": { + "@angular/localize": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@web/test-runner": { + "optional": true + }, + "browser-sync": { + "optional": true + }, + "jest": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "karma": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "protractor": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/postcss": { + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@angular-devkit/build-webpack": { + "version": "0.1802.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.21.tgz", + "integrity": "sha512-2jSVRhA3N4Elg8OLcBktgi+CMSjlAm/bBQJE6TQYbdQWnniuT7JAWUHA/iPf7MYlQE5qj4rnAni1CI/c1Bk4HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.1802.21", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "webpack": "^5.30.0", + "webpack-dev-server": "^5.0.2" + } + }, + "node_modules/@angular-devkit/build-webpack/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.21.tgz", + "integrity": "sha512-Lno6GNbJME85wpc/uqn+wamBxvfZJZFYSH8+oAkkyjU/hk8r5+X8DuyqsKAa0m8t46zSTUsonHsQhVe5vgrZeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.21.tgz", + "integrity": "sha512-yuC2vN4VL48JhnsaOa9J/o0Jl+cxOklRNQp5J2/ypMuRROaVCrZAPiX+ChSHh++kHYMpj8+ggNrrUwRNfMKACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "18.2.21", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.11", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular/animations": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.14.tgz", + "integrity": "sha512-Kp/MWShoYYO+R3lrrZbZgszbbLGVXHB+39mdJZwnIuZMDkeL3JsIBlSOzyJRTnpS1vITc+9jgHvP/6uKbMrW1Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/core": "18.2.14" + } + }, + "node_modules/@angular/build": { + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.21.tgz", + "integrity": "sha512-uvq3qP4cByJrUkV1ri0v3x6LxOFt4fDKiQdNwbQAqdxtfRs3ssEIoCGns4t89sTWXv6VZWBNDcDIKK9/Fa9mmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.1802.21", + "@babel/core": "7.25.2", + "@babel/helper-annotate-as-pure": "7.24.7", + "@babel/helper-split-export-declaration": "7.24.7", + "@babel/plugin-syntax-import-attributes": "7.24.7", + "@inquirer/confirm": "3.1.22", + "@vitejs/plugin-basic-ssl": "1.1.0", + "browserslist": "^4.23.0", + "critters": "0.0.24", + "esbuild": "0.23.0", + "fast-glob": "3.3.2", + "https-proxy-agent": "7.0.5", + "listr2": "8.2.4", + "lmdb": "3.0.13", + "magic-string": "0.30.11", + "mrmime": "2.0.0", + "parse5-html-rewriting-stream": "7.0.0", + "picomatch": "4.0.2", + "piscina": "4.6.1", + "rollup": "4.22.4", + "sass": "1.77.6", + "semver": "7.6.3", + "vite": "~5.4.17", + "watchpack": "2.4.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^18.0.0", + "@angular/localize": "^18.0.0", + "@angular/platform-server": "^18.0.0", + "@angular/service-worker": "^18.0.0", + "less": "^4.2.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0", + "typescript": ">=5.4 <5.6" + }, + "peerDependenciesMeta": { + "@angular/localize": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "less": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@angular/build/node_modules/@babel/core": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/build/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@angular/build/node_modules/@babel/helper-annotate-as-pure": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", + "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@angular/build/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/cli": { + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.21.tgz", + "integrity": "sha512-efweY4p8awRTbHs+HKdg6s44hl7Y0gdVlXYi3HeY8Z5JDC0abbka0K6sA/MrV9AXvn/5ovxYbxiL3AsOApjTpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.1802.21", + "@angular-devkit/core": "18.2.21", + "@angular-devkit/schematics": "18.2.21", + "@inquirer/prompts": "5.3.8", + "@listr2/prompt-adapter-inquirer": "2.0.15", + "@schematics/angular": "18.2.21", + "@yarnpkg/lockfile": "1.1.0", + "ini": "4.1.3", + "jsonc-parser": "3.3.1", + "listr2": "8.2.4", + "npm-package-arg": "11.0.3", + "npm-pick-manifest": "9.1.0", + "pacote": "18.0.6", + "resolve": "1.22.8", + "semver": "7.6.3", + "symbol-observable": "4.0.0", + "yargs": "17.7.2" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.14.tgz", + "integrity": "sha512-ZPRswzaVRiqcfZoowuAM22Hr2/z10ajWOUoFDoQ9tWqz/fH/773kJv2F9VvePIekgNPCzaizqv9gF6tGNqaAwg==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/core": "18.2.14", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.14.tgz", + "integrity": "sha512-Mpq3v/mztQzGAQAAFV+wAI1hlXxZ0m8eDBgaN2kD3Ue+r4S6bLm1Vlryw0iyUnt05PcFIdxPT6xkcphq5pl6lw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/core": "18.2.14" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + } + } + }, + "node_modules/@angular/compiler-cli": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.14.tgz", + "integrity": "sha512-BmmjyrFSBSYkm0tBSqpu4cwnJX/b/XvhM36mj2k8jah3tNS5zLDDx5w6tyHmaPJa/1D95MlXx2h6u7K9D+Mhew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "7.25.2", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^4.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^17.2.1" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js", + "ngcc": "bundles/ngcc/index.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/compiler": "18.2.14", + "typescript": ">=5.4 <5.6" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@angular/compiler-cli/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular/compiler-cli/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular/core": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.14.tgz", + "integrity": "sha512-BIPrCs93ZZTY9ym7yfoTgAQ5rs706yoYeAdrgc8kh/bDbM9DawxKlgeKBx2FLt09Y0YQ1bFhKVp0cV4gDEaMxQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.14.10" + } + }, + "node_modules/@angular/forms": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.14.tgz", + "integrity": "sha512-fZVwXctmBJa5VdopJae/T9MYKPXNd04+6j4k/6X819y+9fiyWLJt2QicSc5Rc+YD9mmhXag3xaljlrnotf9VGA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "18.2.14", + "@angular/core": "18.2.14", + "@angular/platform-browser": "18.2.14", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.14.tgz", + "integrity": "sha512-W+JTxI25su3RiZVZT3Yrw6KNUCmOIy7OZIZ+612skPgYK2f2qil7VclnW1oCwG896h50cMJU/lnAfxZxefQgyQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/animations": "18.2.14", + "@angular/common": "18.2.14", + "@angular/core": "18.2.14" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/platform-browser-dynamic": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.14.tgz", + "integrity": "sha512-QOv+o89u8HLN0LG8faTIVHKBxfkOBHVDB0UuXy19+HJofWZGGvho+vGjV0/IAkhZnMC4Sxdoy/mOHP2ytALX3A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "18.2.14", + "@angular/compiler": "18.2.14", + "@angular/core": "18.2.14", + "@angular/platform-browser": "18.2.14" + } + }, + "node_modules/@angular/router": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.14.tgz", + "integrity": "sha512-v/gweh8MBjjDfh1QssuyjISa+6SVVIvIZox7MaMs81RkaoVHwS9grDtPud1pTKHzms2KxSVpvwwyvkRJQplueg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "18.2.14", + "@angular/core": "18.2.14", + "@angular/platform-browser": "18.2.14", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", + "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.10", + "@babel/types": "^7.26.10", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator/node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", + "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", + "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", + "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", + "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.26.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz", + "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", + "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-classes/node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", + "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz", + "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", + "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz", + "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object/node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", + "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.26.10.tgz", + "integrity": "sha512-NWaL2qG6HRpONTnj4JvDU6th4jYeZOJgu3QhmFTCihib0ermtOJqktA5BduGm3suhhVe9EMP9c9+mfJ/I9slqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.11.0", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", + "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.8", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.26.8", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.26.5", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.26.3", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.26.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.26.3", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.26.8", + "@babel/plugin-transform-typeof-symbol": "^7.26.7", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.11.0", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.40.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.1.tgz", + "integrity": "sha512-boghen8F0Q8D+0/Q1/1r6DUEieUJ8w2a1gIknExMSHBsJFOr2+0KUfHiVYBvucPwl3+RU5PFBK833FjFCh3BhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", + "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", + "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", + "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", + "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", + "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", + "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", + "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", + "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", + "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", + "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", + "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", + "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", + "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", + "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", + "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", + "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", + "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", + "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", + "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", + "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", + "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", + "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", + "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", + "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.5.0.tgz", + "integrity": "sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/figures": "^1.0.5", + "@inquirer/type": "^1.5.3", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "3.1.22", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.1.22.tgz", + "integrity": "sha512-gsAKIOWBm2Q87CDfs9fEo7wJT3fwWIJfnDGMn9Qy74gBnNFOACDNfhUzovubbJjWnKLGBln7/NcSmZwj5DuEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.0.10", + "@inquirer/type": "^1.5.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz", + "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.6", + "@inquirer/type": "^2.0.0", + "@types/mute-stream": "^0.0.4", + "@types/node": "^22.5.5", + "@types/wrap-ansi": "^3.0.0", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^1.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/@inquirer/type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz", + "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==", + "dev": true, + "license": "MIT", + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/@types/node": { + "version": "22.18.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz", + "integrity": "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@inquirer/core/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@inquirer/editor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-2.2.0.tgz", + "integrity": "sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/expand": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-2.3.0.tgz", + "integrity": "sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz", + "integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.3.0.tgz", + "integrity": "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-1.1.0.tgz", + "integrity": "sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/password": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-2.2.0.tgz", + "integrity": "sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/prompts": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-5.3.8.tgz", + "integrity": "sha512-b2BudQY/Si4Y2a0PdZZL6BeJtl8llgeZa7U2j47aaJSCeAl1e4UI7y8a9bSkO3o/ZbZrgT5muy/34JbsjfIWxA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@inquirer/checkbox": "^2.4.7", + "@inquirer/confirm": "^3.1.22", + "@inquirer/editor": "^2.1.22", + "@inquirer/expand": "^2.1.22", + "@inquirer/input": "^2.2.9", + "@inquirer/number": "^1.0.10", + "@inquirer/password": "^2.1.22", + "@inquirer/rawlist": "^2.2.4", + "@inquirer/search": "^1.0.7", + "@inquirer/select": "^2.4.7" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/rawlist": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-2.3.0.tgz", + "integrity": "sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/search": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-1.1.0.tgz", + "integrity": "sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/figures": "^1.0.5", + "@inquirer/type": "^1.5.3", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/select": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.5.0.tgz", + "integrity": "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.1.0", + "@inquirer/figures": "^1.0.5", + "@inquirer/type": "^1.5.3", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", + "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.15.tgz", + "integrity": "sha512-MZrGem/Ujjd4cPTLYDfCZK2iKKeiO/8OX13S6jqxldLs0Prf2aGqVlJ77nMBqMv7fzqgXEgjrNHLXcKR8l9lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^1.5.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 6" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.0.13.tgz", + "integrity": "sha512-uiKPB0Fv6WEEOZjruu9a6wnW/8jrjzlZbxXscMB8kuCJ1k6kHpcBnuvaAWcqhbI7rqX5GKziwWEdD+wi2gNLfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.0.13.tgz", + "integrity": "sha512-bEVIIfK5mSQoG1R19qA+fJOvCB+0wVGGnXHT3smchBVahYBdlPn2OsZZKzlHWfb1E+PhLBmYfqB5zQXFP7hJig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.0.13.tgz", + "integrity": "sha512-Yml1KlMzOnXj/tnW7yX8U78iAzTk39aILYvCPbqeewAq1kSzl+w59k/fiVkTBfvDi/oW/5YRxL+Fq+Y1Fr1r2Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.0.13.tgz", + "integrity": "sha512-afbVrsMgZ9dUTNUchFpj5VkmJRxvht/u335jUJ7o23YTbNbnpmXif3VKQGCtnjSh+CZaqm6N3CPG8KO3zwyZ1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.0.13.tgz", + "integrity": "sha512-vOtxu0xC0SLdQ2WRXg8Qgd8T32ak4SPqk5zjItRszrJk2BdeXqfGxBJbP7o4aOvSPSmSSv46Lr1EP4HXU8v7Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.0.13.tgz", + "integrity": "sha512-UCrMJQY/gJnOl3XgbWRZZUvGGBuKy6i0YNSptgMzHBjs+QYDYR1Mt/RLTOPy4fzzves65O1EDmlL//OzEqoLlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ngtools/webpack": { + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.21.tgz", + "integrity": "sha512-mfLT7lXbyJRlsazuPyuF5AGsMcgzRJRwsDlgxFbiy1DBlaF1chRFsXrKYj1gQ/WXQWNcEd11aedU0Rt+iCNDVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^18.0.0", + "typescript": ">=5.4 <5.6", + "webpack": "^5.54.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", + "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", + "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.8.tgz", + "integrity": "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^7.0.0", + "ini": "^4.1.3", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^9.0.0", + "proc-log": "^4.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz", + "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", + "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.1.tgz", + "integrity": "sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^4.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/package-json/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", + "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-2.0.1.tgz", + "integrity": "sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-8.1.0.tgz", + "integrity": "sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.0.0", + "@npmcli/promise-spawn": "^7.0.0", + "node-gyp": "^10.0.0", + "proc-log": "^4.0.0", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/run-script/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/run-script/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@schematics/angular": { + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.21.tgz", + "integrity": "sha512-5Ai+NEflQZi67y4NsQ3o04iEp7zT0/BUFVCrJ3CueU3uYQGs8jrN1Lk6tvQ9c5HzGcTDrMXuTrCswyR9o6ecpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "18.2.21", + "@angular-devkit/schematics": "18.2.21", + "jsonc-parser": "3.3.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz", + "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz", + "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.3.tgz", + "integrity": "sha512-RpacQhBlwpBWd7KEJsRKcBQalbV28fvkxwTOJIqhIuDysMMaJW47V4OqW30iJB9uRpqOSxxEAQFdr8tTattReQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz", + "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^13.0.1", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz", + "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^2.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz", + "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.1.0", + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stomp/stompjs": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.2.1.tgz", + "integrity": "sha512-DLd/WeicnHS5SsWWSk3x6/pcivqchNaEvg9UEGVqAcfYEBVmS9D6980ckXjTtfpXLjdLDsd96M7IuX4w7nzq5g==", + "license": "Apache-2.0" + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz", + "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@tufjs/models/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/jasmine": { + "version": "5.1.12", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.12.tgz", + "integrity": "sha512-1BzPxNsFDLDfj9InVR3IeY0ZVf4o9XV+4mDqoCfyPkbsA7dYyKAPAb2co6wLFlHcvxPlt1wShm7zQdV7uTfLGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mute-stream": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", + "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "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", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/sockjs-client": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.4.tgz", + "integrity": "sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/wrap-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", + "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz", + "integrity": "sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-loader": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.3.tgz", + "integrity": "sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "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==", + "dev": true, + "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/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.21.tgz", + "integrity": "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", + "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clone-deep/node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "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/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", + "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^3.14.1" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.1", + "globby": "^14.0.0", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/core-js-compat": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", + "integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.26.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/critters": { + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.24.tgz", + "integrity": "sha512-Oyqew0FGM0wYUSNqR0L6AteO5MpMoUU0rhKRieXeiKs+PmRTxiJMyaunYB2KF6fQ3dzChXKCpbFOEJx3OQ1v/Q==", + "deprecated": "Ownership of Critters has moved to the Nuxt team, who will be maintaining the project going forward. If you'd like to keep using Critters, please switch to the actively-maintained fork at https://github.com/danielroe/beasties", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "chalk": "^4.1.0", + "css-select": "^5.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.2", + "htmlparser2": "^8.0.2", + "postcss": "^8.4.23", + "postcss-media-query-parser": "^0.2.3" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/debug": { + "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" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.243", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.243.tgz", + "integrity": "sha512-ZCphxFW3Q1TVhcgS9blfut1PX8lusVi2SvXQgmEEnK4TCmE1JhH2JkjJN+DNt0pJJwfBri5AROBnz2b/C+YU9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", + "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "punycode": "^1.4.1", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", + "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.0", + "@esbuild/android-arm": "0.23.0", + "@esbuild/android-arm64": "0.23.0", + "@esbuild/android-x64": "0.23.0", + "@esbuild/darwin-arm64": "0.23.0", + "@esbuild/darwin-x64": "0.23.0", + "@esbuild/freebsd-arm64": "0.23.0", + "@esbuild/freebsd-x64": "0.23.0", + "@esbuild/linux-arm": "0.23.0", + "@esbuild/linux-arm64": "0.23.0", + "@esbuild/linux-ia32": "0.23.0", + "@esbuild/linux-loong64": "0.23.0", + "@esbuild/linux-mips64el": "0.23.0", + "@esbuild/linux-ppc64": "0.23.0", + "@esbuild/linux-riscv64": "0.23.0", + "@esbuild/linux-s390x": "0.23.0", + "@esbuild/linux-x64": "0.23.0", + "@esbuild/netbsd-x64": "0.23.0", + "@esbuild/openbsd-arm64": "0.23.0", + "@esbuild/openbsd-x64": "0.23.0", + "@esbuild/sunos-x64": "0.23.0", + "@esbuild/win32-arm64": "0.23.0", + "@esbuild/win32-ia32": "0.23.0", + "@esbuild/win32-x64": "0.23.0" + } + }, + "node_modules/esbuild-wasm": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.23.0.tgz", + "integrity": "sha512-6jP8UmWy6R6TUUV8bMuC3ZyZ6lZKI56x0tkxyCIqWwRRJ/DgeQKneh/Oid5EoGoPFLrGNkz47ZEtWAYuiY/u9g==", + "dev": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "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": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-cache-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/globby/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "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": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz", + "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "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", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", + "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jasmine-core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.2.0.tgz", + "integrity": "sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", + "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/karma": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", + "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.7.2", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "bin": { + "karma": "bin/karma" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/karma-chrome-launcher": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", + "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "which": "^1.2.1" + } + }, + "node_modules/karma-coverage": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", + "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.0.5", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma-coverage/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/karma-jasmine": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", + "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jasmine-core": "^4.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "karma": "^6.0.0" + } + }, + "node_modules/karma-jasmine-html-reporter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", + "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "jasmine-core": "^4.0.0 || ^5.0.0", + "karma": "^6.0.0", + "karma-jasmine": "^5.0.0" + } + }, + "node_modules/karma-jasmine/node_modules/jasmine-core": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", + "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma-source-map-support": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", + "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map-support": "^0.5.5" + } + }, + "node_modules/karma/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/karma/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/karma/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/karma/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/karma/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/karma/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launch-editor": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", + "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.3" + } + }, + "node_modules/less": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", + "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "copy-anything": "^2.0.1", + "parse-node-version": "^1.0.1", + "tslib": "^2.3.0" + }, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=6" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "make-dir": "^2.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "source-map": "~0.6.0" + } + }, + "node_modules/less-loader": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-12.2.0.tgz", + "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "less": "^3.5.0 || ^4.0.0", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/less/node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/less/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/less/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/less/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/license-webpack-plugin": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", + "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "webpack-sources": "^3.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-sources": { + "optional": true + } + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", + "integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lmdb": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.0.13.tgz", + "integrity": "sha512-UGe+BbaSUQtAMZobTb4nHvFMrmvuAQKSeaqAX2meTEQjfsbpl5sxdHD8T72OnwD4GU9uwNhYXIVe4QGs8N9Zyw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "msgpackr": "^1.10.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.4.1", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.0.13", + "@lmdb/lmdb-darwin-x64": "3.0.13", + "@lmdb/lmdb-linux-arm": "3.0.13", + "@lmdb/lmdb-linux-arm64": "3.0.13", + "@lmdb/lmdb-linux-x64": "3.0.13", + "@lmdb/lmdb-win32-x64": "3.0.13" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.1.tgz", + "integrity": "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-fetch-happen": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", + "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^2.0.0", + "cacache": "^18.0.0", + "http-cache-semantics": "^4.1.1", + "is-lambda": "^1.0.1", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.50.0.tgz", + "integrity": "sha512-N0LUYQMUA1yS5tJKmMtU9yprPm6ZIg24yr/OVv/7t6q0kKDIho4cBbXRi1XKttUmNYDYgF/q45qrKE/UhGO0CA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.0.tgz", + "integrity": "sha512-Zs1YsZVfemekSZG+44vBsYTLQORkPMwnlv+aehcxK/NLKC+EGhDB39/YePYYqx/sTk6NnYpuqikhSn7+JIevTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz", + "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "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/needle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", + "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nice-napi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", + "integrity": "sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "!win32" + ], + "dependencies": { + "node-addon-api": "^3.0.0", + "node-gyp-build": "^4.2.2" + } + }, + "node_modules/nice-napi/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-gyp": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.3.1.tgz", + "integrity": "sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^4.1.0", + "semver": "^7.3.5", + "tar": "^6.2.1", + "which": "^4.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/node-gyp/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/node-gyp/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-bundled": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz", + "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-install-checks": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", + "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-packlist": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz", + "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^6.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.1.0.tgz", + "integrity": "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^11.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-17.1.0.tgz", + "integrity": "sha512-5+bKQRH0J1xG1uZ1zMNvxW0VEyoNWgJpY9UDuluPFLKDfJ9u2JmmjmTJV1srBGQOROfdBMiVvnH2Zvpbm+xkVA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^2.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^13.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minizlib": "^2.1.2", + "npm-package-arg": "^11.0.0", + "proc-log": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "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" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", + "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ordered-binary": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz", + "integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pacote": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-18.0.6.tgz", + "integrity": "sha512-+eK3G27SMwsB8kLIuj4h1FUhHtwiEUo21Tw8wNjmvdlpOEr613edv+8FUsTj/4F/VN5ywGE19X18N7CC2EJk6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/package-json": "^5.1.0", + "@npmcli/promise-spawn": "^7.0.0", + "@npmcli/run-script": "^8.0.0", + "cacache": "^18.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^11.0.0", + "npm-packlist": "^8.0.0", + "npm-pick-manifest": "^9.0.0", + "npm-registry-fetch": "^17.0.0", + "proc-log": "^4.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^2.2.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", + "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.3.0", + "parse5": "^7.0.0", + "parse5-sax-parser": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", + "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/piscina": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.6.1.tgz", + "integrity": "sha512-z30AwWGtQE+Apr+2WBZensP2lIvwoaMcOPkQlIEmSGMJNUvaYACylPYrQM6wSdUNJlnDVMSpLv7xTMJqlVshOA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "nice-napi": "^1.0.2" + } + }, + "node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-loader": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", + "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0", + "jiti": "^1.20.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "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/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.9" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/regex-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", + "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-url-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/resolve-url-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "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", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.77.6", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", + "integrity": "sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-loader": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.0.tgz", + "integrity": "sha512-n13Z+3rU9A177dk4888czcVFiC8CL9dii4qpXWUg3YIIgZEvi9TCFKjOQcbK0kJM7DJu9VucrZFddvNfYCPwtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz", + "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^2.3.2", + "@sigstore/tuf": "^2.3.4", + "@sigstore/verify": "^1.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/ssri": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", + "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/terser": { + "version": "5.31.6", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", + "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thingies": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", + "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "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", + "peer": true + }, + "node_modules/tuf-js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz", + "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "2.0.1", + "debug": "^4.3.4", + "make-fetch-happen": "^13.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-assert": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", + "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", + "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack": { + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.7.1", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", + "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.6.0", + "mime-types": "^2.1.31", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", + "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.21", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.21.2", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.6.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^2.4.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-subresource-integrity": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", + "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "typed-assert": "^1.0.8" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "html-webpack-plugin": ">= 5.0.0-beta.1 < 6", + "webpack": "^5.12.0" + }, + "peerDependenciesMeta": { + "html-webpack-plugin": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/webpack/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zone.js": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.10.tgz", + "integrity": "sha512-YGAhaO7J5ywOXW6InXNlLmfU194F8lVgu7bRntUF3TiG8Y3nBK0x1UJJuHUP/e8IyihkjCYqhCScpSwnlaSRkQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6b94a6f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,48 @@ +{ + "name": "frontend", + "version": "0.0.0", + "scripts": { + "ng": "node ./node_modules/@angular/cli/bin/ng.js", + "start": "node ./node_modules/@angular/cli/bin/ng.js serve", + "build": "node ./node_modules/@angular/cli/bin/ng.js build", + "watch": "node ./node_modules/@angular/cli/bin/ng.js build --watch --configuration development", + "test": "node ./node_modules/@angular/cli/bin/ng.js test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^18.2.0", + "@angular/common": "^18.2.0", + "@angular/compiler": "^18.2.0", + "@angular/core": "^18.2.0", + "@angular/forms": "^18.2.0", + "@angular/platform-browser": "^18.2.0", + "@angular/platform-browser-dynamic": "^18.2.0", + "@angular/router": "^18.2.0", + "@stomp/stompjs": "^7.0.0", + "axios": "^1.13.1", + "rxjs": "~7.8.0", + "sockjs-client": "^1.6.1", + "tslib": "^2.3.0", + "zone.js": "~0.14.10" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^18.2.21", + "@angular/cli": "^18.2.21", + "@angular/compiler-cli": "^18.2.0", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.19", + "@types/jasmine": "~5.1.0", + "@types/node": "^24.9.2", + "@types/sockjs-client": "^1.5.4", + "autoprefixer": "^10.4.21", + "jasmine-core": "~5.2.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", + "typescript": "~5.5.2" + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..57614f9 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html new file mode 100644 index 0000000..5032918 --- /dev/null +++ b/frontend/src/app/app.component.html @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts new file mode 100644 index 0000000..a6b0ab9 --- /dev/null +++ b/frontend/src/app/app.component.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'frontend' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('frontend'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); + }); +}); diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts new file mode 100644 index 0000000..0da00d1 --- /dev/null +++ b/frontend/src/app/app.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { ModalComponent } from './components/modal/modal.component'; +import { GdprConsentBannerComponent } from './components/gdpr-consent-banner/gdpr-consent-banner.component'; +import { ErrorHandlerService } from './services/error-handler.service'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet, ModalComponent, GdprConsentBannerComponent], + templateUrl: './app.component.html', + styleUrl: './app.component.scss' +}) +export class AppComponent { + title = 'frontend'; + + constructor(private errorHandler: ErrorHandlerService) { + // Initialize error handler to setup axios interceptor + // The service will automatically set up the interceptor in its constructor + } +} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..a1e7d6f --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] +}; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..2646544 --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -0,0 +1,23 @@ +import { Routes } from '@angular/router'; +import { LoginComponent } from './pages/login/login.component'; +import { AdminComponent } from './pages/admin/admin.component'; +import { DoctorComponent } from './pages/doctor/doctor.component'; +import { PatientComponent } from './pages/patient/patient.component'; +import { DoctorRegisterComponent } from './pages/register/doctor/doctor-register.component'; +import { PatientRegisterComponent } from './pages/register/patient/patient-register.component'; +import { ForgotPasswordComponent } from './pages/forgot-password/forgot-password.component'; +import { ResetPasswordComponent } from './pages/reset-password/reset-password.component'; +import { authGuard } from './guards/auth.guard'; + +export const routes: Routes = [ + { path: 'login', component: LoginComponent }, + { path: 'forgot-password', component: ForgotPasswordComponent }, + { path: 'reset-password', component: ResetPasswordComponent }, + { path: 'register/doctor', component: DoctorRegisterComponent }, + { path: 'register/patient', component: PatientRegisterComponent }, + { path: 'admin', component: AdminComponent, canActivate: [authGuard] }, + { path: 'doctor', component: DoctorComponent, canActivate: [authGuard] }, + { path: 'patient', component: PatientComponent, canActivate: [authGuard] }, + { path: '', pathMatch: 'full', redirectTo: 'login' }, + { path: '**', redirectTo: 'login' }, +]; diff --git a/frontend/src/app/components/call/call.component.html b/frontend/src/app/components/call/call.component.html new file mode 100644 index 0000000..22933b5 --- /dev/null +++ b/frontend/src/app/components/call/call.component.html @@ -0,0 +1,117 @@ +
+ +
+ +
+ + + + + + + +
+
+ +
+ {{ getOtherUserName().charAt(0) }} +
+
+ +
+
+ + +
+ +
+
Calling...
+ +
+ + +
+ + +
+ + +
+ + + + + +
+
+
+
+ diff --git a/frontend/src/app/components/call/call.component.scss b/frontend/src/app/components/call/call.component.scss new file mode 100644 index 0000000..c09bc24 --- /dev/null +++ b/frontend/src/app/components/call/call.component.scss @@ -0,0 +1,697 @@ +// Enterprise-Grade Call Component Styling +// Color Palette +$primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$primary-blue: #0066cc; +$primary-blue-light: #e6f2ff; +$primary-blue-dark: #0052a3; +$accent-teal: #0099a1; +$accent-purple: #8b5cf6; +$text-dark: #1a1a1a; +$text-medium: #4a4a4a; +$text-light: #6b6b6b; +$text-lighter: #9ca3af; +$border-color: #e8eaed; +$white: #ffffff; +$success-green: #10b981; +$success-green-light: #d1fae5; +$success-green-dark: #059669; +$error-red: #ef4444; +$error-red-light: #fee2e2; +$error-red-dark: #dc2626; +$warning-orange: #f59e0b; +$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$border-radius-sm: 8px; +$border-radius-md: 12px; +$border-radius-lg: 16px; +$border-radius-xl: 20px; +$transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); +$transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); +$transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + +.call-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10001; + pointer-events: none; + will-change: transform; + transform: translateZ(0); +} + +.call-overlay { + width: 100%; + height: 100%; + background: linear-gradient(135deg, rgba(0, 0, 0, 0.95) 0%, rgba(30, 41, 59, 0.98) 100%); + backdrop-filter: blur(20px); + display: flex; + flex-direction: column; + pointer-events: auto; + animation: fadeIn 0.3s ease-out; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.1) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(118, 75, 162, 0.1) 0%, transparent 50%); + pointer-events: none; + z-index: 0; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 45deg, + transparent, + transparent 100px, + rgba(255, 255, 255, 0.01) 100px, + rgba(255, 255, 255, 0.01) 200px + ); + pointer-events: none; + z-index: 0; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(1.05); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.call-media-area { + flex: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + z-index: 1; + + &.audio-only { + background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #1e293b 100%); + background-size: 200% 200%; + animation: gradientShift 8s ease infinite; + position: relative; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 30% 40%, rgba(102, 126, 234, 0.2) 0%, transparent 50%), + radial-gradient(circle at 70% 60%, rgba(118, 75, 162, 0.2) 0%, transparent 50%); + animation: pulse 4s ease-in-out infinite; + } + } +} + +@keyframes gradientShift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 0.6; + } + 50% { + opacity: 0.8; + } +} + +.remote-video { + width: 100%; + height: 100%; + object-fit: cover; + background: $text-dark; + filter: brightness(1.05) contrast(1.02); + transition: filter $transition-slow; + + &.hidden { + display: none; + } +} + +.local-video { + position: absolute; + bottom: 120px; + right: 24px; + width: 240px; + height: 180px; + border-radius: $border-radius-lg; + object-fit: cover; + border: 3px solid rgba(255, 255, 255, 0.9); + box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.1); + background: $text-dark; + z-index: 10; + transition: all $transition-base; + backdrop-filter: blur(10px); + + &:hover { + transform: scale(1.05); + box-shadow: $shadow-2xl, 0 0 0 2px rgba(255, 255, 255, 0.2); + } + + &.hidden { + display: none; + } +} + +.audio-call-ui { + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; + color: $white; + text-align: center; + position: relative; + z-index: 2; + padding: 40px; + animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.avatar-large { + width: 200px; + height: 200px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + border: 4px solid rgba(255, 255, 255, 0.2); + box-shadow: + $shadow-2xl, + 0 0 0 8px rgba(255, 255, 255, 0.05), + 0 0 40px rgba(102, 126, 234, 0.3); + overflow: hidden; + position: relative; + transition: all $transition-base; + + &::before { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + background: $primary-gradient; + opacity: 0; + transition: opacity $transition-base; + z-index: -1; + filter: blur(20px); + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + pointer-events: none; + } + + &:hover { + transform: scale(1.05); + box-shadow: + $shadow-2xl, + 0 0 0 8px rgba(255, 255, 255, 0.1), + 0 0 60px rgba(102, 126, 234, 0.5); + + &::before { + opacity: 0.3; + } + } +} + +.avatar-image-large { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + position: relative; + z-index: 1; +} + +.avatar-circle-large { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 80px; + font-weight: 700; + color: $white; + background: $primary-gradient; + border-radius: 50%; + position: relative; + z-index: 1; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.2) 0%, transparent 70%); + } +} + +.call-user-info { + display: flex; + flex-direction: column; + gap: 12px; + position: relative; + z-index: 2; +} + +.call-user-name { + font-size: 36px; + font-weight: 700; + margin: 0; + color: $white; + letter-spacing: -0.5px; + text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3); + background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0.9) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.2; +} + +.call-status { + font-size: 18px; + font-weight: 500; + margin: 0; + color: rgba(255, 255, 255, 0.85); + text-transform: capitalize; + letter-spacing: 0.3px; + text-shadow: 0 1px 10px rgba(0, 0, 0, 0.2); +} + +.call-controls { + padding: 32px 40px; + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.6) 100%); + backdrop-filter: blur(20px); + border-top: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + z-index: 10; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3); +} + +.incoming-controls, +.outgoing-controls, +.active-controls { + display: flex; + gap: 20px; + align-items: center; +} + +.outgoing-controls { + flex-direction: column; + gap: 16px; +} + +.call-status-text { + color: rgba(255, 255, 255, 0.95); + font-size: 18px; + font-weight: 600; + letter-spacing: 0.5px; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + animation: pulseText 2s ease-in-out infinite; +} + +@keyframes pulseText { + 0%, 100% { + opacity: 0.9; + } + 50% { + opacity: 1; + } +} + +.call-btn { + width: 72px; + height: 72px; + min-width: 72px; + min-height: 72px; + border-radius: 50%; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all $transition-base; + box-shadow: $shadow-xl; + position: relative; + overflow: hidden; + font-family: inherit; + + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + opacity: 0; + transition: opacity $transition-base; + } + + &::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + background: inherit; + opacity: 0; + transition: opacity $transition-base; + filter: blur(12px); + z-index: -1; + } + + svg { + width: 28px; + height: 28px; + position: relative; + z-index: 1; + transition: transform $transition-base; + stroke-width: 2.5; + } + + &:hover:not(:disabled) { + transform: scale(1.1); + box-shadow: $shadow-2xl; + + &::before { + opacity: 1; + } + + &::after { + opacity: 0.4; + } + + svg { + transform: scale(1.1); + } + } + + &:active:not(:disabled) { + transform: scale(1.05); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + + &:hover { + transform: none; + box-shadow: $shadow-xl; + } + } +} + +.call-btn-answer { + background: linear-gradient(135deg, $success-green 0%, $success-green-dark 100%); + color: $white; + box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, $success-green-dark 0%, #047857 100%); + box-shadow: 0 12px 32px rgba(16, 185, 129, 0.5); + + &::after { + background: linear-gradient(135deg, $success-green 0%, $success-green-dark 100%); + } + } + + &:active:not(:disabled) { + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3); + } +} + +.call-btn-reject, +.call-btn-end { + background: linear-gradient(135deg, $error-red 0%, $error-red-dark 100%); + color: $white; + box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, $error-red-dark 0%, #b91c1c 100%); + box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5); + + &::after { + background: linear-gradient(135deg, $error-red 0%, $error-red-dark 100%); + } + } + + &:active:not(:disabled) { + box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3); + } +} + +.call-btn-secondary { + width: 64px; + height: 64px; + min-width: 64px; + min-height: 64px; + background: rgba(255, 255, 255, 0.15); + color: $white; + backdrop-filter: blur(20px); + border: 2px solid rgba(255, 255, 255, 0.25); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + + svg { + width: 24px; + height: 24px; + } + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.4); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); + transform: scale(1.08); + + &::before { + opacity: 1; + } + } + + &:active:not(:disabled) { + transform: scale(1.02); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + } + + &.active { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); + box-shadow: 0 6px 20px rgba(255, 255, 255, 0.2); + + svg { + color: $white; + } + + &:hover { + background: rgba(255, 255, 255, 0.35); + border-color: rgba(255, 255, 255, 0.6); + } + } +} + +// Animation for incoming call +@keyframes pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + box-shadow: + $shadow-2xl, + 0 0 0 8px rgba(255, 255, 255, 0.05), + 0 0 40px rgba(102, 126, 234, 0.3); + } + 50% { + transform: scale(1.05); + opacity: 0.95; + box-shadow: + $shadow-2xl, + 0 0 0 12px rgba(255, 255, 255, 0.1), + 0 0 60px rgba(102, 126, 234, 0.5); + } +} + +.isRinging { + .avatar-large { + animation: pulse 2s ease-in-out infinite; + } + + .call-btn-answer { + animation: pulseButton 2s ease-in-out infinite; + } +} + +@keyframes pulseButton { + 0%, 100% { + box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4); + transform: scale(1); + } + 50% { + box-shadow: 0 12px 32px rgba(16, 185, 129, 0.6); + transform: scale(1.05); + } +} + +// Responsive Design +@media (max-width: 768px) { + .call-user-name { + font-size: 28px; + } + + .call-status { + font-size: 16px; + } + + .avatar-large { + width: 160px; + height: 160px; + border-width: 3px; + } + + .avatar-circle-large { + font-size: 64px; + } + + .call-btn { + width: 64px; + height: 64px; + min-width: 64px; + min-height: 64px; + + svg { + width: 24px; + height: 24px; + } + } + + .call-btn-secondary { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + + svg { + width: 20px; + height: 20px; + } + } + + .local-video { + width: 180px; + height: 135px; + bottom: 100px; + right: 16px; + } + + .call-controls { + padding: 24px 20px; + gap: 16px; + } + + .incoming-controls, + .outgoing-controls, + .active-controls { + gap: 16px; + } +} + +@media (max-width: 480px) { + .avatar-large { + width: 140px; + height: 140px; + } + + .avatar-circle-large { + font-size: 56px; + } + + .call-user-name { + font-size: 24px; + } + + .call-btn { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + + svg { + width: 22px; + height: 22px; + } + } + + .call-btn-secondary { + width: 52px; + height: 52px; + min-width: 52px; + min-height: 52px; + + svg { + width: 18px; + height: 18px; + } + } +} + +// Enhanced focus states for accessibility +.call-btn:focus-visible { + outline: 3px solid rgba(255, 255, 255, 0.6); + outline-offset: 4px; + border-radius: 50%; +} + +// Smooth transitions +.call-container * { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +// Performance optimizations +.call-overlay, +.call-media-area, +.remote-video, +.local-video { + will-change: transform; + transform: translateZ(0); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/app/components/call/call.component.ts b/frontend/src/app/components/call/call.component.ts new file mode 100644 index 0000000..eb24bbf --- /dev/null +++ b/frontend/src/app/components/call/call.component.ts @@ -0,0 +1,214 @@ +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CallService, CallInfo } from '../../services/call.service'; +import { UserService } from '../../services/user.service'; +import { LoggerService } from '../../services/logger.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-call', + standalone: true, + imports: [CommonModule], + templateUrl: './call.component.html', + styleUrl: './call.component.scss' +}) +export class CallComponent implements OnInit, OnDestroy { + @ViewChild('localVideo') localVideoRef!: ElementRef; + @ViewChild('remoteVideo') remoteVideoRef!: ElementRef; + + currentCall: CallInfo | null = null; + isRinging: boolean = false; + isCallActive: boolean = false; + isMuted: boolean = false; + isVideoEnabled: boolean = true; + isIncoming: boolean = false; + private subscriptions: Subscription[] = []; + private currentUser: any = null; // Cache current user to avoid repeated API calls + + constructor( + public callService: CallService, + public userService: UserService, + private logger: LoggerService + ) {} + + async ngOnInit(): Promise { + // Cache current user on init to avoid repeated calls + this.currentUser = await this.userService.getCurrentUser(); + + const callSub = this.callService.call$.subscribe(callInfo => { + this.handleCallUpdate(callInfo); + }); + + const signalSub = this.callService.signal$.subscribe(signal => { + if (signal.signalData?.remoteStreamReady && this.remoteVideoRef) { + const remoteStream = this.callService.getRemoteStream(); + if (remoteStream && this.remoteVideoRef.nativeElement) { + this.remoteVideoRef.nativeElement.srcObject = remoteStream; + } + } + }); + + this.subscriptions.push(callSub, signalSub); + + // Update video streams periodically + const interval = setInterval(() => { + this.updateVideoStreams(); + }, 1000); + + this.subscriptions.push(new Subscription(() => clearInterval(interval))); + } + + ngOnDestroy(): void { + this.subscriptions.forEach(sub => sub.unsubscribe()); + } + + private async handleCallUpdate(callInfo: CallInfo | null): Promise { + this.logger.debug('Call component received call update:', callInfo); + + // Handle null callInfo (when call is cleared) + if (!callInfo) { + this.logger.debug('Call info is null, clearing call state'); + this.currentCall = null; + this.isRinging = false; + this.isCallActive = false; + if (this.localVideoRef?.nativeElement) { + this.localVideoRef.nativeElement.srcObject = null; + } + if (this.remoteVideoRef?.nativeElement) { + this.remoteVideoRef.nativeElement.srcObject = null; + } + return; + } + + this.currentCall = callInfo; + + // Ensure we have current user (use cache if available, fetch if not) + if (!this.currentUser) { + this.currentUser = await this.userService.getCurrentUser(); + } + + // Determine if this is an incoming call (we are the receiver) or outgoing call (we are the sender) + if (this.currentUser) { + const isSender = callInfo.senderId === this.currentUser.id; + const isReceiver = callInfo.receiverId === this.currentUser.id; + + // It's incoming if we are the receiver (not the sender) and the call is ringing + // It's outgoing if we are the sender (not the receiver) and the call is ringing + this.isIncoming = isReceiver && !isSender && callInfo.callStatus === 'ringing'; + + this.logger.debug('Current user ID:', this.currentUser.id, + 'Sender ID:', callInfo.senderId, + 'Receiver ID:', callInfo.receiverId, + 'Is sender:', isSender, + 'Is receiver:', isReceiver, + 'Is incoming:', this.isIncoming); + } + + if (callInfo.callStatus === 'ringing') { + this.logger.debug('Call is ringing - showing UI'); + this.isRinging = true; + this.isCallActive = false; + // Update UI immediately + this.updateVideoStreams(); + } else if (callInfo.callStatus === 'accepted') { + this.logger.debug('Call accepted - starting active call'); + this.isRinging = false; + this.isCallActive = true; + setTimeout(() => this.updateVideoStreams(), 500); + } else { + this.isRinging = false; + this.isCallActive = false; + if (callInfo.callStatus === 'ended' || callInfo.callStatus === 'rejected' || callInfo.callStatus === 'cancelled') { + this.logger.debug('Call terminated:', callInfo.callStatus); + this.currentCall = null; + } + } + } + + private updateVideoStreams(): void { + if (this.localVideoRef?.nativeElement) { + const localStream = this.callService.getLocalStream(); + if (localStream) { + this.localVideoRef.nativeElement.srcObject = localStream; + } + } + + if (this.remoteVideoRef?.nativeElement && this.isCallActive) { + const remoteStream = this.callService.getRemoteStream(); + if (remoteStream) { + this.remoteVideoRef.nativeElement.srcObject = remoteStream; + } + } + } + + async answerCall(): Promise { + await this.callService.answerCall(); + this.isRinging = false; + this.isCallActive = true; + } + + async rejectCall(): Promise { + await this.callService.rejectCall(); + this.isRinging = false; + this.currentCall = null; + } + + async endCall(): Promise { + // If call is still ringing and we're the caller, cancel the call + if (this.isRinging && !this.isIncoming && this.currentCall) { + await this.callService.cancelCall(); + } else { + await this.callService.endCall(); + } + this.isCallActive = false; + this.currentCall = null; + this.isRinging = false; + if (this.localVideoRef?.nativeElement) { + this.localVideoRef.nativeElement.srcObject = null; + } + if (this.remoteVideoRef?.nativeElement) { + this.remoteVideoRef.nativeElement.srcObject = null; + } + } + + async toggleMute(): Promise { + this.isMuted = !await this.callService.toggleMute(); + } + + async toggleVideo(): Promise { + this.isVideoEnabled = !await this.callService.toggleVideo(); + } + + getOtherUserName(): string { + if (!this.currentCall) return ''; + // Use cached currentUser if available, otherwise return based on call info + // If we're the sender (incoming=false), show receiver name + // If we're the receiver (incoming=true), show sender name + if (this.isIncoming && this.currentCall.senderName) { + return this.currentCall.senderName; + } else if (!this.isIncoming && this.currentCall.receiverName) { + return this.currentCall.receiverName; + } + return this.currentCall.senderName || this.currentCall.receiverName || 'Unknown'; + } + + getOtherUserAvatarUrl(): string | undefined { + if (!this.currentCall) return undefined; + // Return sender's avatar if we're the receiver (incoming call) + // Return receiver's avatar if we're the sender (outgoing call) + if (this.isIncoming) { + return this.currentCall.senderAvatarUrl; + } + // For outgoing calls, return the receiver's avatar + return this.currentCall.receiverAvatarUrl; + } + + isVideoCall(): boolean { + return this.currentCall?.callType === 'video'; + } + + shouldShowCall(): boolean { + return this.currentCall !== null && (this.isRinging || this.isCallActive); + } +} + diff --git a/frontend/src/app/components/chat/chat.component.html b/frontend/src/app/components/chat/chat.component.html new file mode 100644 index 0000000..1d0b1c5 --- /dev/null +++ b/frontend/src/app/components/chat/chat.component.html @@ -0,0 +1,317 @@ +
+ +
+ +
+ +
+
+
+ +
+ {{ selectedConversation.otherUserName.charAt(0) || 'U' }} +
+
+
+ + + +
+
+ + + +
+
+ +
+
+ +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+
+
+
+ + +
+
+ + + +

No messages yet

+

Start the conversation by sending a message

+
+
+
+
+
{{ message.content }}
+ + +
+ +
+
+
+ + +
+
+ + + +
+ {{ typingUserName }} is typing... +
+
+
+ + +
+
+
+ + +
+
+
+
+ + + +

You have blocked this user. Unblock them to send messages.

+ +
+
+ + +
+
+ + + +

No conversation selected

+

Start a conversation to begin messaging

+
+
+
+ + + +
+ diff --git a/frontend/src/app/components/chat/chat.component.scss b/frontend/src/app/components/chat/chat.component.scss new file mode 100644 index 0000000..6d52164 --- /dev/null +++ b/frontend/src/app/components/chat/chat.component.scss @@ -0,0 +1,1184 @@ +// Enterprise Chat Component Styles + +// Color Variables +$primary-color: #2563eb; +$primary-hover: #1d4ed8; +$primary-light: #3b82f6; +$secondary-color: #64748b; +$success-color: #10b981; +$danger-color: #ef4444; +$warning-color: #f59e0b; +$background-primary: #ffffff; +$background-secondary: #f8fafc; +$background-tertiary: #f1f5f9; +$border-color: #e2e8f0; +$text-primary: #1e293b; +$text-secondary: #64748b; +$text-tertiary: #94a3b8; +$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +$font-size-xs: 0.75rem; +$font-size-sm: 0.875rem; +$font-size-base: 1rem; +$font-size-lg: 1.125rem; +$font-size-xl: 1.25rem; +$font-size-2xl: 1.5rem; + +// Spacing +$spacing-xs: 0.25rem; +$spacing-sm: 0.5rem; +$spacing-md: 1rem; +$spacing-lg: 1.5rem; +$spacing-xl: 2rem; +$spacing-2xl: 3rem; + +// Border Radius +$radius-sm: 0.375rem; +$radius-md: 0.5rem; +$radius-lg: 0.75rem; +$radius-xl: 1rem; +$radius-full: 9999px; + +// Transitions +$transition-fast: 150ms ease-in-out; +$transition-base: 200ms ease-in-out; +$transition-slow: 300ms ease-in-out; + +// Main Container +.chat-container { + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; + background: $background-secondary; + font-family: $font-family; + overflow: hidden; +} + +.chat-layout { + display: flex; + flex: 1; + overflow: hidden; + height: 100%; + width: 100%; +} + +// ============================================ +// Sidebar Styles - REMOVED +// ============================================ + +.icon-button { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + background: transparent; + color: $text-secondary; + border-radius: $radius-md; + cursor: pointer; + transition: all $transition-fast; + + &:hover { + background: $background-tertiary; + color: $text-primary; + } + + &:active { + transform: scale(0.95); + } + + &.active { + background: $primary-color; + color: white; + + &:hover { + background: $primary-hover; + } + } + + svg { + flex-shrink: 0; + } +} + +// Sidebar-specific styles removed + +.online-indicator { + position: absolute; + bottom: 2px; + right: 2px; + width: 12px; + height: 12px; + background: $success-color; + border: 2px solid white; + border-radius: $radius-full; + z-index: 1; +} + +.status-badge { + position: absolute; + bottom: 2px; + right: 2px; + width: 12px; + height: 12px; + background: $warning-color; + border: 2px solid white; + border-radius: $radius-full; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + + &.offline-badge { + background: $text-tertiary; + } + + svg { + width: 6px; + height: 6px; + fill: white; + } +} + +.conversation-content { + flex: 1; + min-width: 0; +} + +.conversation-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $spacing-xs; +} + +.conversation-name { + font-size: $font-size-base; + font-weight: 500; + color: $text-primary; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-meta { + display: flex; + align-items: center; + gap: $spacing-xs; + flex-shrink: 0; +} + +.conversation-time { + font-size: $font-size-xs; + color: $text-tertiary; +} + +.conversation-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; +} + +.preview-text { + font-size: $font-size-sm; + color: $text-secondary; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.unread-badge { + display: flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 $spacing-xs; + background: $primary-color; + color: white; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: 600; + flex-shrink: 0; +} + +// Search Results & Blocked Users +.search-results-list, +.blocked-users-list { + padding: $spacing-xs 0; +} + +.search-result-item, +.blocked-user-item { + display: flex; + align-items: center; + gap: $spacing-md; + padding: $spacing-md $spacing-lg; + cursor: pointer; + transition: all $transition-fast; + + &:hover { + background: $background-tertiary; + } +} + +.user-avatar { + position: relative; + flex-shrink: 0; + width: 40px; + height: 40px; + + img { + width: 100%; + height: 100%; + border-radius: $radius-full; + object-fit: cover; + } + + .avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: $radius-full; + background: linear-gradient(135deg, $primary-color, $primary-light); + color: white; + font-weight: 600; + font-size: $font-size-sm; + } +} + +.user-info { + flex: 1; + min-width: 0; +} + +.user-name { + font-size: $font-size-base; + font-weight: 500; + color: $text-primary; + margin-bottom: $spacing-xs; +} + +.user-role { + font-size: $font-size-xs; + color: $text-tertiary; + text-transform: capitalize; +} + +.chevron-icon { + flex-shrink: 0; + color: $text-tertiary; +} + +.unblock-button { + display: flex; + align-items: center; + gap: $spacing-xs; + padding: $spacing-xs $spacing-md; + border: 1px solid $primary-color; + background: transparent; + color: $primary-color; + border-radius: $radius-md; + cursor: pointer; + font-size: $font-size-sm; + font-weight: 500; + transition: all $transition-fast; + + &:hover { + background: $primary-color; + color: white; + } +} + +// ============================================ +// Main Chat Area +// ============================================ + +.chat-main { + flex: 1; + width: 100%; + display: flex; + flex-direction: column; + background: $background-secondary; + overflow: hidden; +} + +.chat-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: $background-secondary; + + .empty-content { + text-align: center; + color: $text-tertiary; + + svg { + margin-bottom: $spacing-lg; + opacity: 0.5; + } + + h2 { + font-size: $font-size-xl; + font-weight: 600; + color: $text-primary; + margin: $spacing-md 0; + } + + p { + font-size: $font-size-sm; + color: $text-secondary; + } + } +} + +// Chat Header +.chat-header { + display: flex; + align-items: center; + gap: $spacing-md; + padding: $spacing-md $spacing-lg; + background: $background-primary; + border-bottom: 1px solid $border-color; + box-shadow: $shadow-sm; + position: sticky; + top: 0; + z-index: 5; +} + + +.chat-header-info { + display: flex; + align-items: center; + gap: $spacing-md; + flex: 1; + min-width: 0; +} + +.chat-user-avatar { + position: relative; + flex-shrink: 0; + width: 40px; + height: 40px; + + img { + width: 100%; + height: 100%; + border-radius: $radius-full; + object-fit: cover; + } + + .avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: $radius-full; + background: linear-gradient(135deg, $primary-color, $primary-light); + color: white; + font-weight: 600; + font-size: $font-size-base; + } +} + +.chat-user-info { + flex: 1; + min-width: 0; +} + +.chat-user-name { + font-size: $font-size-base; + font-weight: 600; + color: $text-primary; + margin-bottom: $spacing-xs; +} + +.chat-user-status { + font-size: $font-size-xs; + color: $text-tertiary; +} + +.chat-header-actions { + display: flex; + align-items: center; + gap: $spacing-xs; +} + +// Status Selector +.status-selector { + position: relative; +} + +.status-badge-button { + display: flex; + align-items: center; + gap: $spacing-xs; + padding: $spacing-xs $spacing-sm; + border: 1px solid $border-color; + background: $background-primary; + border-radius: $radius-md; + cursor: pointer; + transition: all $transition-fast; + font-size: $font-size-sm; + color: $text-secondary; + + &:hover { + background: $background-tertiary; + border-color: $primary-light; + color: $text-primary; + } + + &:active { + transform: scale(0.98); + } +} + +.status-badge-indicator { + width: 12px; + height: 12px; + border-radius: $radius-full; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &.status-online { + background: $success-color; + } + + &.status-busy { + background: $warning-color; + } + + &.status-offline { + background: $text-tertiary; + } + + svg { + width: 8px; + height: 8px; + fill: white; + } +} + +.status-label { + font-weight: 500; + font-size: $font-size-sm; +} + +.status-menu-dropdown { + position: absolute; + top: calc(100% + $spacing-xs); + right: 0; + background: $background-primary; + border: 1px solid $border-color; + border-radius: $radius-md; + box-shadow: $shadow-lg; + min-width: 160px; + z-index: 100; + overflow: hidden; + margin-top: $spacing-xs; +} + +.status-menu-item { + display: flex; + align-items: center; + gap: $spacing-sm; + width: 100%; + padding: $spacing-sm $spacing-md; + border: none; + background: transparent; + text-align: left; + cursor: pointer; + transition: all $transition-fast; + color: $text-primary; + font-size: $font-size-sm; + + &:hover { + background: $background-tertiary; + } + + &.active { + background: rgba(37, 99, 235, 0.05); + color: $primary-color; + font-weight: 500; + } + + &:first-child { + border-top-left-radius: $radius-md; + border-top-right-radius: $radius-md; + } + + &:last-child { + border-bottom-left-radius: $radius-md; + border-bottom-right-radius: $radius-md; + } +} + +.status-menu-indicator { + width: 12px; + height: 12px; + border-radius: $radius-full; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &.status-online { + background: $success-color; + } + + &.status-busy { + background: $warning-color; + } + + &.status-offline { + background: $text-tertiary; + } + + svg { + width: 8px; + height: 8px; + fill: white; + } +} + +.status-menu-item svg:last-child { + margin-left: auto; + color: $primary-color; + flex-shrink: 0; +} + +.call-button { + color: $primary-color; + + &:hover { + background: rgba(37, 99, 235, 0.1); + color: $primary-hover; + } +} + +.more-menu { + position: relative; +} + +.more-menu-dropdown { + position: absolute; + top: calc(100% + $spacing-xs); + right: 0; + background: $background-primary; + border: 1px solid $border-color; + border-radius: $radius-md; + box-shadow: $shadow-lg; + min-width: 200px; + z-index: 100; + overflow: hidden; +} + +.more-menu-item { + display: flex; + align-items: center; + gap: $spacing-sm; + width: 100%; + padding: $spacing-sm $spacing-md; + border: none; + background: transparent; + text-align: left; + cursor: pointer; + transition: background $transition-fast; + font-size: $font-size-sm; + color: $text-primary; + + &:hover { + background: $background-tertiary; + } + + &.danger { + color: $danger-color; + + &:hover { + background: rgba(239, 68, 68, 0.1); + } + } +} + +// Messages Container +.messages-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: $spacing-lg; + display: flex; + flex-direction: column; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: $border-color; + border-radius: $radius-full; + + &:hover { + background: $text-tertiary; + } + } +} + +.messages-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + color: $text-tertiary; + + svg { + margin-bottom: $spacing-md; + opacity: 0.5; + } + + p { + font-size: $font-size-base; + margin: $spacing-xs 0; + } + + .empty-subtitle { + font-size: $font-size-sm; + color: $text-tertiary; + } +} + +.messages-list { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.message-wrapper { + display: flex; + width: 100%; + + &.message-sent { + justify-content: flex-end; + + .message-bubble { + background: $primary-color; + color: white; + border-bottom-right-radius: $radius-sm; + + .message-time { + color: rgba(255, 255, 255, 0.8); + } + + .read-indicator { + color: rgba(255, 255, 255, 0.8); + + &.read { + color: rgba(255, 255, 255, 1); + } + } + } + } + + &.message-received { + justify-content: flex-start; + + .message-bubble { + background: $background-primary; + color: $text-primary; + border-bottom-left-radius: $radius-sm; + box-shadow: $shadow-sm; + } + } +} + +.message-bubble { + max-width: 70%; + min-width: 120px; + padding: $spacing-sm $spacing-md; + border-radius: $radius-lg; + position: relative; + word-wrap: break-word; + transition: all $transition-fast; + + @media (max-width: 768px) { + max-width: 85%; + } +} + +.message-content { + font-size: $font-size-base; + line-height: 1.5; + margin-bottom: $spacing-xs; +} + +.message-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $spacing-xs; + margin-top: $spacing-xs; +} + +.message-time { + font-size: $font-size-xs; + color: $text-tertiary; +} + +.read-indicator { + flex-shrink: 0; + color: $text-tertiary; + transition: color $transition-fast; + + &.read { + color: $primary-color; + } +} + +.message-actions-button { + position: absolute; + top: $spacing-xs; + right: $spacing-xs; + display: none; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: rgba(0, 0, 0, 0.1); + color: currentColor; + border-radius: $radius-sm; + cursor: pointer; + transition: all $transition-fast; + opacity: 0.7; + + .message-bubble:hover & { + display: flex; + } + + &:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.2); + } +} + +.message-actions-menu { + position: absolute; + top: calc(100% + $spacing-xs); + right: 0; + background: $background-primary; + border: 1px solid $border-color; + border-radius: $radius-md; + box-shadow: $shadow-lg; + min-width: 140px; + z-index: 10; + overflow: hidden; +} + +.message-action-item { + display: flex; + align-items: center; + gap: $spacing-sm; + width: 100%; + padding: $spacing-sm $spacing-md; + border: none; + background: transparent; + text-align: left; + cursor: pointer; + transition: background $transition-fast; + font-size: $font-size-sm; + color: $text-primary; + + &:hover { + background: $background-tertiary; + } + + &.danger { + color: $danger-color; + + &:hover { + background: rgba(239, 68, 68, 0.1); + } + } +} + +// Typing Indicator +.typing-indicator { + display: flex; + align-items: center; + gap: $spacing-sm; + padding: $spacing-sm $spacing-md; + color: $text-tertiary; + font-size: $font-size-sm; +} + +.typing-dots { + display: flex; + gap: 4px; + + span { + width: 6px; + height: 6px; + background: $text-tertiary; + border-radius: $radius-full; + animation: typing 1.4s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } +} + +@keyframes typing { + 0%, 60%, 100% { + transform: translateY(0); + opacity: 0.5; + } + 30% { + transform: translateY(-8px); + opacity: 1; + } +} + +.typing-text { + font-style: italic; +} + +// Message Input Area +.message-input-area { + padding: $spacing-md $spacing-lg; + background: $background-primary; + border-top: 1px solid $border-color; +} + +.message-form { + width: 100%; +} + +.input-wrapper { + display: flex; + align-items: flex-end; + gap: $spacing-sm; + padding: $spacing-sm; + background: $background-secondary; + border: 1px solid $border-color; + border-radius: $radius-lg; + transition: all $transition-fast; + + &:focus-within { + border-color: $primary-color; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + background: $background-primary; + } +} + +.message-input { + flex: 1; + min-height: 40px; + max-height: 120px; + padding: $spacing-sm $spacing-md; + border: none; + background: transparent; + color: $text-primary; + font-size: $font-size-base; + font-family: inherit; + resize: none; + outline: none; + line-height: 1.5; + + &::placeholder { + color: $text-tertiary; + } +} + +.send-button { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + background: $primary-color; + color: white; + border-radius: $radius-md; + cursor: pointer; + transition: all $transition-fast; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: $primary-hover; + transform: scale(1.05); + } + + &:active:not(:disabled) { + transform: scale(0.95); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.blocked-notice { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $spacing-md; + padding: $spacing-xl; + background: $background-primary; + border-top: 1px solid $border-color; + text-align: center; + color: $text-secondary; + + svg { + color: $danger-color; + opacity: 0.7; + } + + p { + font-size: $font-size-sm; + margin: 0; + } +} + +.unblock-action-button { + padding: $spacing-sm $spacing-lg; + border: 1px solid $primary-color; + background: $primary-color; + color: white; + border-radius: $radius-md; + cursor: pointer; + font-size: $font-size-sm; + font-weight: 500; + transition: all $transition-fast; + + &:hover { + background: $primary-hover; + border-color: $primary-hover; + } +} + +// ============================================ +// Modal Styles +// ============================================ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: $spacing-lg; + animation: fadeIn $transition-base; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal-content { + background: $background-primary; + border-radius: $radius-lg; + box-shadow: $shadow-xl; + max-width: 480px; + width: 100%; + max-height: 90vh; + overflow: hidden; + animation: slideUp $transition-base; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-lg; + border-bottom: 1px solid $border-color; +} + +.modal-title { + font-size: $font-size-xl; + font-weight: 600; + color: $text-primary; + margin: 0; +} + +.modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: transparent; + color: $text-secondary; + border-radius: $radius-md; + cursor: pointer; + transition: all $transition-fast; + + &:hover { + background: $background-tertiary; + color: $text-primary; + } +} + +.modal-body { + padding: $spacing-lg; + color: $text-secondary; + line-height: 1.6; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: $spacing-sm; + padding: $spacing-lg; + border-top: 1px solid $border-color; +} + +.modal-button { + padding: $spacing-sm $spacing-lg; + border: 1px solid $border-color; + background: $background-primary; + color: $text-primary; + border-radius: $radius-md; + cursor: pointer; + font-size: $font-size-sm; + font-weight: 500; + transition: all $transition-fast; + + &.secondary { + &:hover { + background: $background-tertiary; + } + } + + &.primary { + background: $primary-color; + color: white; + border-color: $primary-color; + + &:hover { + background: $primary-hover; + border-color: $primary-hover; + } + + &.danger { + background: $danger-color; + border-color: $danger-color; + + &:hover { + background: #dc2626; + border-color: #dc2626; + } + } + } +} + +// ============================================ +// Responsive Adjustments +// ============================================ + +@media (max-width: 768px) { + .chat-container { + height: 100vh; + height: 100dvh; // Dynamic viewport height for mobile + } + + .chat-header { + padding: $spacing-sm $spacing-md; + } + + .messages-container { + padding: $spacing-md; + } + + .message-input-area { + padding: $spacing-sm $spacing-md; + } + + .modal-content { + margin: $spacing-md; + max-width: calc(100% - 2rem); + } +} + +@media (max-width: 480px) { + .chat-user-avatar { + width: 40px; + height: 40px; + } + + .message-bubble { + max-width: 90%; + padding: $spacing-xs $spacing-sm; + } + + .modal-content { + margin: $spacing-sm; + max-width: calc(100% - 1rem); + } +} + +// Accessibility +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +// Focus styles for accessibility +button:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: 2px solid $primary-color; + outline-offset: 2px; +} + diff --git a/frontend/src/app/components/chat/chat.component.ts b/frontend/src/app/components/chat/chat.component.ts new file mode 100644 index 0000000..3aecf0a --- /dev/null +++ b/frontend/src/app/components/chat/chat.component.ts @@ -0,0 +1,870 @@ +import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ChatService, ChatUser, Conversation, Message } from '../../services/chat.service'; +import { NotificationService } from '../../services/notification.service'; +import { UserService, UserInfo } from '../../services/user.service'; +import { CallService } from '../../services/call.service'; +import { LoggerService } from '../../services/logger.service'; +import { CallComponent } from '../call/call.component'; +import { Subscription, debounceTime, distinctUntilChanged, Subject } from 'rxjs'; +import { ModalService } from '../../services/modal.service'; + +@Component({ + selector: 'app-chat', + standalone: true, + imports: [CommonModule, FormsModule, CallComponent], + templateUrl: './chat.component.html', + styleUrl: './chat.component.scss' +}) +export class ChatComponent implements OnInit, OnDestroy, AfterViewChecked { + @ViewChild('messagesContainer') private messagesContainer!: ElementRef; + + conversations: Conversation[] = []; + selectedConversation: Conversation | null = null; + messages: Message[] = []; + newMessage: string = ''; + unreadCount: number = 0; + isOtherUserTyping: boolean = false; + typingUserName: string = ''; + private subscriptions: Subscription[] = []; + private shouldScrollToBottom = false; + private typingTimeout: any = null; + private stopTypingTimeout: any = null; + private lastTypingNotification: number = 0; + + // Modal state + showDeleteModal: boolean = false; + deleteModalType: 'message' | 'conversation' = 'conversation'; + messageToDelete: Message | null = null; + + currentUserId: string | null = null; + currentUser: UserInfo | null = null; + + // User status + currentUserStatus: 'ONLINE' | 'OFFLINE' | 'BUSY' = 'ONLINE'; + showStatusMenu: boolean = false; + + // Search functionality + showSearch: boolean = false; + searchQuery: string = ''; + searchResults: ChatUser[] = []; + isSearching: boolean = false; + private searchSubject = new Subject(); + + // Blocked users functionality + showBlockedUsers: boolean = false; + blockedUsers: ChatUser[] = []; + isLoadingBlockedUsers: boolean = false; + isUserBlocked: boolean = false; + + // UI state + showMoreMenu: boolean = false; + showMessageActions: string | null = null; + + + constructor( + private chatService: ChatService, + private notificationService: NotificationService, + public userService: UserService, + private callService: CallService, + private modalService: ModalService, + private logger: LoggerService + ) {} + + async ngOnInit(): Promise { + this.currentUser = await this.userService.getCurrentUser(); + if (this.currentUser) { + this.currentUserId = this.currentUser.id; + // Load current user status from backend or default to ONLINE + this.currentUserStatus = (this.currentUser.status as 'ONLINE' | 'OFFLINE' | 'BUSY') || 'ONLINE'; + } + + // Close status menu when clicking outside + document.addEventListener('click', (event) => { + const target = event.target as HTMLElement; + if (this.showStatusMenu && !target.closest('.status-selector')) { + this.showStatusMenu = false; + } + if (this.showMoreMenu && !target.closest('.more-menu')) { + this.showMoreMenu = false; + } + }); + await this.chatService.connect(); + + // Setup search debounce + const searchSub = this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(query => { + this.performSearch(query); + }); + this.subscriptions.push(searchSub); + + // Subscribe to new messages + const messageSub = this.chatService.messages$.subscribe((message: Message) => { + if (this.selectedConversation && + (message.senderId === this.selectedConversation.otherUserId || + message.receiverId === this.selectedConversation.otherUserId)) { + // Remove optimistic message if exists (same content) + const existingIndex = this.messages.findIndex(m => + !m.id && m.content === message.content && m.senderId === this.currentUserId + ); + if (existingIndex !== -1) { + this.messages[existingIndex] = message; + } else { + this.messages.push(message); + } + this.shouldScrollToBottom = true; + } + this.refreshConversations(); + }); + + // Subscribe to online status updates + const statusSub = this.chatService.onlineStatus$.subscribe(status => { + this.logger.debug('Received online status update:', status); + + // Update current user's own status if it's their update + if (status.userId === this.currentUserId && status.status) { + this.currentUserStatus = status.status as 'ONLINE' | 'OFFLINE' | 'BUSY'; + } + + // Update conversation status for other users + const conversation = this.conversations.find(c => c.otherUserId === status.userId); + if (conversation) { + conversation.isOnline = status.isOnline; + conversation.otherUserStatus = status.status as 'ONLINE' | 'OFFLINE' | 'BUSY'; + conversation.lastSeen = status.lastSeen; + // Trigger change detection + this.conversations = [...this.conversations]; + } + if (this.selectedConversation?.otherUserId === status.userId) { + this.selectedConversation.isOnline = status.isOnline; + this.selectedConversation.otherUserStatus = status.status as 'ONLINE' | 'OFFLINE' | 'BUSY'; + this.selectedConversation.lastSeen = status.lastSeen; + // Trigger change detection for selected conversation + this.selectedConversation = { ...this.selectedConversation }; + } + }); + + // Subscribe to conversations updates + const conversationsSub = this.chatService.conversations$.subscribe(conversations => { + // Deduplicate conversations by otherUserId to prevent duplicate entries + const uniqueConversations = conversations.reduce((acc, conversation) => { + const existingIndex = acc.findIndex(c => c.otherUserId === conversation.otherUserId); + if (existingIndex === -1) { + acc.push(conversation); + } else { + // If duplicate exists, keep the one with more recent lastMessage or higher unreadCount + const existing = acc[existingIndex]; + if (conversation.lastMessage && (!existing.lastMessage || + new Date(conversation.lastMessage.createdAt) > new Date(existing.lastMessage.createdAt))) { + acc[existingIndex] = conversation; + } else if (conversation.unreadCount > existing.unreadCount) { + acc[existingIndex] = conversation; + } else { + // Update status if the duplicate has a newer status + if (conversation.otherUserStatus && (!existing.otherUserStatus || + conversation.isOnline !== existing.isOnline)) { + existing.otherUserStatus = conversation.otherUserStatus; + existing.isOnline = conversation.isOnline; + existing.lastSeen = conversation.lastSeen; + } + } + } + return acc; + }, [] as Conversation[]); + + this.conversations = uniqueConversations; + this.unreadCount = uniqueConversations.reduce((sum, c) => sum + c.unreadCount, 0); + + // Update current user status from conversation data if available + // This is a workaround - ideally we'd get it from user profile + // For now, we'll initialize from the first conversation that has status + if (uniqueConversations.length > 0 && !this.currentUserStatus) { + // Status will be initialized when user logs in + } + }); + + // Subscribe to typing notifications + const typingSub = this.chatService.typing$.subscribe(typingData => { + if (this.selectedConversation && + typingData.userId === this.selectedConversation.otherUserId) { + this.isOtherUserTyping = typingData.isTyping; + this.typingUserName = typingData.senderName || this.selectedConversation.otherUserName; + + // Auto-hide typing indicator after 3 seconds if no new typing event + if (typingData.isTyping) { + if (this.typingTimeout) { + clearTimeout(this.typingTimeout); + } + // Scroll to bottom when typing indicator appears + this.shouldScrollToBottom = true; + // Also scroll immediately and after a small delay to ensure typing indicator is visible + setTimeout(() => this.scrollToBottom(), 100); + this.typingTimeout = setTimeout(() => { + this.isOtherUserTyping = false; + }, 3000); + } else { + // Clear timeout if typing stops + if (this.typingTimeout) { + clearTimeout(this.typingTimeout); + this.typingTimeout = null; + } + } + } + }); + + this.subscriptions.push(messageSub, statusSub, conversationsSub, typingSub); + this.loadConversations(); + } + + ngAfterViewChecked(): void { + if (this.shouldScrollToBottom) { + // Use setTimeout to ensure DOM is fully updated before scrolling + setTimeout(() => { + this.scrollToBottom(); + this.shouldScrollToBottom = false; + }, 0); + } + } + + ngOnDestroy(): void { + // Stop typing indicator when component is destroyed + if (this.selectedConversation) { + this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false); + } + + // Clear active conversation in notification service + this.notificationService.clearActiveConversation(); + + // Clear typing timeouts + if (this.typingTimeout) { + clearTimeout(this.typingTimeout); + this.typingTimeout = null; + } + if (this.stopTypingTimeout) { + clearTimeout(this.stopTypingTimeout); + this.stopTypingTimeout = null; + } + this.subscriptions.forEach(sub => sub.unsubscribe()); + // Don't disconnect - keep connection alive while app is running + } + + async loadConversations(): Promise { + this.conversations = await this.chatService.getConversations(); + this.unreadCount = this.conversations.reduce((sum, c) => sum + c.unreadCount, 0); + } + + async selectConversation(conversation: Conversation): Promise { + // Stop any typing indicators from previous conversation + if (this.selectedConversation) { + this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false); + } + + this.selectedConversation = conversation; + + // Set active conversation in notification service to prevent notifications when chat is open + if (conversation.otherUserId) { + this.notificationService.setActiveConversation(conversation.otherUserId); + } + + this.messages = await this.chatService.getConversation(conversation.otherUserId); + this.isOtherUserTyping = false; // Reset typing indicator + await this.chatService.markAsRead(conversation.otherUserId); + this.shouldScrollToBottom = true; + + // Check if user is blocked + if (conversation.otherUserId) { + this.isUserBlocked = await this.chatService.isBlocked(conversation.otherUserId); + } + + await this.refreshConversations(); + } + + async sendMessage(): Promise { + if (!this.newMessage.trim() || !this.selectedConversation) return; + + const content = this.newMessage.trim(); + this.newMessage = ''; + + // Stop typing indicator + if (this.selectedConversation) { + this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false); + } + + // Clear typing timeouts + if (this.typingTimeout) { + clearTimeout(this.typingTimeout); + this.typingTimeout = null; + } + if (this.stopTypingTimeout) { + clearTimeout(this.stopTypingTimeout); + this.stopTypingTimeout = null; + } + + this.chatService.sendMessage(this.selectedConversation!.otherUserId, content); + + // Optimistically add message to UI - will be replaced by server response + if (this.currentUserId) { + const optimisticMessage: Message = { + senderId: this.currentUserId, + senderName: 'You', + receiverId: this.selectedConversation!.otherUserId, + receiverName: this.selectedConversation!.otherUserName, + content, + isRead: false, + createdAt: new Date().toISOString() + }; + this.messages.push(optimisticMessage); + this.shouldScrollToBottom = true; + } + } + + onMessageInput(): void { + if (!this.selectedConversation) return; + + // Clear any existing stop typing timeout + if (this.stopTypingTimeout) { + clearTimeout(this.stopTypingTimeout); + this.stopTypingTimeout = null; + } + + const now = Date.now(); + // Debounce typing notifications - send max once per second + if (now - this.lastTypingNotification > 1000) { + this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, true); + this.lastTypingNotification = now; + } + + // If user stops typing for 2 seconds, send stop typing notification + this.stopTypingTimeout = setTimeout(() => { + if (this.selectedConversation) { + this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false); + } + }, 2000); + } + + onMessageKeyDown(event: KeyboardEvent): void { + if (!this.selectedConversation) return; + + // When Enter is pressed without Shift, send message and prevent new line + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); // Prevent default behavior (new line) + // Stop typing indicator + this.chatService.sendTypingNotification(this.selectedConversation.otherUserId, false); + // Send message if there's content + if (this.newMessage.trim()) { + this.sendMessage(); + } + } + // Shift+Enter will allow default behavior (new line) + } + + async refreshConversations(): Promise { + await this.chatService.refreshConversations(); + } + + getLastSeenTime(lastSeen?: string): string { + if (!lastSeen) return 'recently'; + + const lastSeenDate = new Date(lastSeen); + const now = new Date(); + const diffMs = now.getTime() - lastSeenDate.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; + + return lastSeenDate.toLocaleDateString(); + } + + getMessageTime(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; + + return date.toLocaleDateString(); + } + + isMyMessage(message: Message): boolean { + if (!this.currentUserId) return false; + return message.senderId === this.currentUserId; + } + + private scrollToBottom(): void { + try { + if (this.messagesContainer) { + this.messagesContainer.nativeElement.scrollTop = + this.messagesContainer.nativeElement.scrollHeight; + } + } catch (err) { + this.logger.error('Error scrolling to bottom:', err); + } + } + + getConversationPreview(conversation: Conversation): string { + if (!conversation.lastMessage) return 'No messages yet'; + const preview = conversation.lastMessage.content.substring(0, 50); + return preview.length < conversation.lastMessage.content.length ? preview + '...' : preview; + } + + toggleSearch(): void { + this.showSearch = !this.showSearch; + if (this.showSearch) { + this.searchQuery = ''; + this.searchResults = []; + this.loadAllUsers(); + } + } + + onSearchInput(): void { + this.searchSubject.next(this.searchQuery); + } + + private async performSearch(query: string): Promise { + if (!query.trim()) { + await this.loadAllUsers(); + return; + } + + this.isSearching = true; + try { + if (this.currentUser?.role === 'PATIENT') { + this.searchResults = await this.chatService.searchDoctors(query); + } else if (this.currentUser?.role === 'DOCTOR') { + this.searchResults = await this.chatService.searchPatients(query); + } + } catch (error) { + this.logger.error('Search error:', error); + this.searchResults = []; + } finally { + this.isSearching = false; + } + } + + private async loadAllUsers(): Promise { + this.isSearching = true; + try { + if (this.currentUser?.role === 'PATIENT') { + this.searchResults = await this.chatService.searchDoctors(''); + } else if (this.currentUser?.role === 'DOCTOR') { + this.searchResults = await this.chatService.searchPatients(''); + } + } catch (error) { + this.logger.error('Error loading users:', error); + this.searchResults = []; + } finally { + this.isSearching = false; + } + } + + async startConversation(user: ChatUser): Promise { + // Check if conversation already exists + const existing = this.conversations.find(c => c.otherUserId === user.userId); + if (existing) { + this.selectConversation(existing); + this.showSearch = false; + this.searchQuery = ''; + return; + } + + // Create new conversation + const conversation = await this.chatService.startConversationWithUser(user.userId); + if (conversation) { + // Add to conversations list + this.conversations.unshift(conversation); + this.selectConversation(conversation); + this.showSearch = false; + this.searchQuery = ''; + // Load messages for this conversation + this.messages = await this.chatService.getConversation(user.userId); + this.shouldScrollToBottom = true; + } + } + + getUserFullName(user: ChatUser): string { + return `${user.firstName} ${user.lastName}`; + } + + trackByUserId(index: number, user: ChatUser): string { + return user.userId; + } + + trackByMessageId(index: number, message: Message): string { + return message.id || `${message.senderId}-${message.createdAt}-${index}`; + } + + + // Public method to open a conversation by userId (called from parent components) + async openConversation(userId: string): Promise { + this.logger.debug('ChatComponent: Opening conversation for user:', userId); + + // Check if conversation already exists + const existingConversation = this.conversations.find(c => c.otherUserId === userId); + if (existingConversation) { + await this.selectConversation(existingConversation); + return; + } + + // If conversation doesn't exist, try to start a new one + try { + const conversation = await this.chatService.startConversationWithUser(userId); + if (conversation) { + // Add to conversations if not already there + if (!this.conversations.find(c => c.otherUserId === userId)) { + this.conversations.unshift(conversation); + } + await this.selectConversation(conversation); + } + } catch (error) { + this.logger.error('Error opening conversation:', error); + } + } + + onImageError(event: Event): void { + const img = event.target as HTMLImageElement; + if (img) { + // Hide the failed image + img.style.display = 'none'; + // Show the avatar fallback (first letter) when image fails to load + const avatarContainer = img.parentElement; + if (avatarContainer) { + const fallback = avatarContainer.querySelector('.avatar-fallback') as HTMLElement; + if (fallback) { + fallback.style.display = 'flex'; + // Remove the data-visible attribute to allow manual control + fallback.removeAttribute('data-visible'); + } + } + } + } + + async deleteMessage(message: Message): Promise { + if (!message.id) { + this.logger.warn('Cannot delete message without ID'); + return; + } + + this.messageToDelete = message; + this.deleteModalType = 'message'; + this.showDeleteModal = true; + } + + async deleteConversation(): Promise { + if (!this.selectedConversation) return; + + this.deleteModalType = 'conversation'; + this.showDeleteModal = true; + } + + async confirmDelete(): Promise { + if (this.deleteModalType === 'message' && this.messageToDelete?.id) { + try { + await this.chatService.deleteMessage(this.messageToDelete.id); + // Remove message from local list + this.messages = this.messages.filter(m => m.id !== this.messageToDelete?.id); + // Refresh conversations to update unread counts + await this.refreshConversations(); + this.closeDeleteModal(); + } catch (error: any) { + this.logger.error('Error deleting message:', error); + await this.modalService.alert( + error?.response?.data?.message || 'Failed to delete message. Please try again.', + 'error', + 'Delete Error' + ); + this.closeDeleteModal(); + } + } else if (this.deleteModalType === 'conversation' && this.selectedConversation) { + try { + // Delete conversation for current user only (backend handles this) + await this.chatService.deleteConversation(this.selectedConversation.otherUserId); + // Clear messages from current user's view + this.messages = []; + // Deselect conversation + this.selectedConversation = null; + // Clear active conversation in notification service + this.notificationService.clearActiveConversation(); + // Refresh conversations list (conversation will be removed for current user only) + await this.refreshConversations(); + this.closeDeleteModal(); + } catch (error: any) { + this.logger.error('Error deleting conversation:', error); + await this.modalService.alert( + error?.response?.data?.message || 'Failed to delete conversation. Please try again.', + 'error', + 'Delete Error' + ); + this.closeDeleteModal(); + } + } + } + + closeDeleteModal(): void { + this.showDeleteModal = false; + this.messageToDelete = null; + this.deleteModalType = 'conversation'; + } + + getDeleteModalTitle(): string { + if (this.deleteModalType === 'message') { + return 'Delete Message'; + } + return 'Delete All Messages'; + } + + getDeleteModalMessage(): string { + if (this.deleteModalType === 'message') { + return 'Are you sure you want to delete this message? This action cannot be undone.'; + } + if (this.selectedConversation) { + return `Are you sure you want to delete this conversation? This will remove all messages from your chat history. The other person will still be able to see their messages. This action cannot be undone.`; + } + return 'Are you sure you want to delete all messages?'; + } + + async startVideoCall(): Promise { + if (!this.selectedConversation) { + await this.modalService.alert( + 'Please select a conversation first', + 'warning', + 'No Conversation Selected' + ); + return; + } + + // Check if the other user is offline + if (!this.selectedConversation.isOnline || this.selectedConversation.otherUserStatus === 'OFFLINE') { + await this.modalService.alert( + `${this.selectedConversation.otherUserName} is currently offline and cannot receive calls. Please try again later.`, + 'warning', + 'User Offline' + ); + return; + } + + try { + this.logger.debug('Starting video call to:', this.selectedConversation.otherUserId); + await this.callService.initiateCall(this.selectedConversation.otherUserId, 'video'); + } catch (error: any) { + this.logger.error('Error starting video call:', error); + await this.modalService.alert( + error?.message || 'Failed to start video call. Please ensure you are connected and try again.', + 'error', + 'Call Error' + ); + } + } + + async startAudioCall(): Promise { + if (!this.selectedConversation) { + await this.modalService.alert( + 'Please select a conversation first', + 'warning', + 'No Conversation Selected' + ); + return; + } + + // Check if the other user is offline + if (!this.selectedConversation.isOnline || this.selectedConversation.otherUserStatus === 'OFFLINE') { + await this.modalService.alert( + `${this.selectedConversation.otherUserName} is currently offline and cannot receive calls. Please try again later.`, + 'warning', + 'User Offline' + ); + return; + } + + try { + this.logger.debug('Starting audio call to:', this.selectedConversation.otherUserId); + await this.callService.initiateCall(this.selectedConversation.otherUserId, 'audio'); + } catch (error: any) { + this.logger.error('Error starting audio call:', error); + await this.modalService.alert( + error?.message || 'Failed to start audio call. Please ensure you are connected and try again.', + 'error', + 'Call Error' + ); + } + } + + async setUserStatus(status: 'ONLINE' | 'OFFLINE' | 'BUSY'): Promise { + try { + await this.chatService.updateUserStatus(status); + // Update local state immediately for responsive UI + this.currentUserStatus = status; + // Clear user cache to force reload of user profile with new status + this.userService.clearUserCache(); + // Update current user object if available + if (this.currentUser) { + this.currentUser.status = status; + this.currentUser.isOnline = (status === 'ONLINE' || status === 'BUSY'); + } + this.showStatusMenu = false; + this.logger.debug('User status updated to:', status); + } catch (error: any) { + this.logger.error('Error updating user status:', error); + await this.modalService.alert( + error?.message || 'Failed to update status. Please try again.', + 'error', + 'Update Error' + ); + } + } + + getStatusLabel(status?: 'ONLINE' | 'OFFLINE' | 'BUSY'): string { + if (!status) { + return 'Offline'; + } + switch (status) { + case 'ONLINE': + return 'Online'; + case 'BUSY': + return 'Busy'; + case 'OFFLINE': + default: + return 'Offline'; + } + } + + toggleBlockedUsers(): void { + this.showBlockedUsers = !this.showBlockedUsers; + if (this.showBlockedUsers) { + this.loadBlockedUsers(); + } + // Close search if open + if (this.showSearch) { + this.showSearch = false; + } + } + + async loadBlockedUsers(): Promise { + this.isLoadingBlockedUsers = true; + try { + this.blockedUsers = await this.chatService.getBlockedUsersWithDetails(); + } catch (error) { + this.logger.error('Error loading blocked users:', error); + this.blockedUsers = []; + } finally { + this.isLoadingBlockedUsers = false; + } + } + + async blockUser(): Promise { + if (!this.selectedConversation) return; + + try { + const confirmed = await this.modalService.confirm( + `Are you sure you want to block ${this.selectedConversation.otherUserName}? You won't be able to send or receive messages from them.`, + 'Block User', + 'Block', + 'Cancel' + ); + + if (!confirmed) return; + + await this.chatService.blockUser(this.selectedConversation.otherUserId); + this.isUserBlocked = true; + + // Refresh conversations and blocked users list + await this.refreshConversations(); + if (this.showBlockedUsers) { + await this.loadBlockedUsers(); + } + + // Show success message + await this.modalService.alert( + `${this.selectedConversation.otherUserName} has been blocked successfully.`, + 'success', + 'User Blocked' + ); + } catch (error: any) { + this.logger.error('Error blocking user:', error); + let errorMessage = 'Failed to block user. Please try again.'; + if (error?.response?.data?.message) { + errorMessage = error.response.data.message; + } else if (error?.response?.data?.error) { + errorMessage = error.response.data.error; + } else if (error?.message) { + errorMessage = error.message; + } + + await this.modalService.alert(errorMessage, 'error', 'Error'); + } + } + + async unblockUser(user: ChatUser): Promise { + try { + this.logger.debug('Unblocking user:', user.userId, user); + // Ensure userId is a string and trim any whitespace + const userId = String(user.userId).trim(); + if (!userId) { + throw new Error('Invalid user ID'); + } + await this.chatService.unblockUser(userId); + + // Update isUserBlocked if this is the currently selected user + if (this.selectedConversation && this.selectedConversation.otherUserId === userId) { + this.isUserBlocked = false; + } + + // Remove from blocked users list + this.blockedUsers = this.blockedUsers.filter(u => u.userId !== user.userId); + // Refresh conversations to show unblocked user + await this.refreshConversations(); + // Show success message + const userName = user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.userId || 'User'; + await this.modalService.alert( + `${userName} has been unblocked successfully.`, + 'success', + 'User Unblocked' + ); + } catch (error: any) { + this.logger.error('Error unblocking user:', error, { + message: error?.message, + response: error?.response?.data, + status: error?.response?.status + }); + let errorMessage = 'Failed to unblock user. Please try again.'; + if (error?.response?.data?.message) { + errorMessage = error.response.data.message; + } else if (error?.message) { + errorMessage = error.message; + } + await this.modalService.alert( + errorMessage, + 'error', + 'Unblock Error' + ); + } + } + + async unblockSelectedUser(): Promise { + if (!this.selectedConversation) return; + + const user: ChatUser = { + userId: this.selectedConversation.otherUserId, + firstName: this.selectedConversation.otherUserName.split(' ')[0] || '', + lastName: this.selectedConversation.otherUserName.split(' ').slice(1).join(' ') || '', + avatarUrl: this.selectedConversation.otherUserAvatarUrl || '', + isOnline: this.selectedConversation.isOnline || false, + status: this.selectedConversation.otherUserStatus + }; + + await this.unblockUser(user); + } +} + diff --git a/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.html b/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.html new file mode 100644 index 0000000..36dcb96 --- /dev/null +++ b/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.html @@ -0,0 +1,47 @@ + + diff --git a/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.scss b/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.scss new file mode 100644 index 0000000..5d83c19 --- /dev/null +++ b/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.scss @@ -0,0 +1,124 @@ +.gdpr-consent-banner { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #ffffff; + border-top: 3px solid #007bff; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); + z-index: 10000; + padding: 20px; + max-height: 80vh; + overflow-y: auto; +} + +.banner-content { + max-width: 1200px; + margin: 0 auto; +} + +.banner-header { + margin-bottom: 20px; + + h3 { + margin: 0 0 10px 0; + color: #333; + font-size: 1.25rem; + font-weight: 600; + } + + p { + margin: 0; + color: #666; + font-size: 0.95rem; + line-height: 1.5; + } +} + +.consent-options { + margin: 20px 0; + padding: 15px; + background: #f8f9fa; + border-radius: 5px; + + .consent-option { + display: block; + margin: 10px 0; + cursor: pointer; + + input[type="checkbox"] { + margin-right: 10px; + cursor: pointer; + } + + span { + color: #333; + font-size: 0.95rem; + } + } +} + +.banner-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; + + .btn { + padding: 10px 20px; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + transition: all 0.3s ease; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.btn-primary { + background: #007bff; + color: white; + + &:hover:not(:disabled) { + background: #0056b3; + } + } + + &.btn-secondary { + background: #6c757d; + color: white; + + &:hover:not(:disabled) { + background: #545b62; + } + } + + &.btn-link { + background: transparent; + color: #007bff; + text-decoration: underline; + + &:hover:not(:disabled) { + color: #0056b3; + } + } + } +} + +@media (max-width: 768px) { + .gdpr-consent-banner { + padding: 15px; + } + + .banner-actions { + flex-direction: column; + + .btn { + width: 100%; + } + } +} + diff --git a/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.ts b/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.ts new file mode 100644 index 0000000..4ec0b18 --- /dev/null +++ b/frontend/src/app/components/gdpr-consent-banner/gdpr-consent-banner.component.ts @@ -0,0 +1,139 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GdprService, ConsentType } from '../../services/gdpr.service'; +import { AuthService } from '../../services/auth.service'; +import { LoggerService } from '../../services/logger.service'; + +@Component({ + selector: 'app-gdpr-consent-banner', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './gdpr-consent-banner.component.html', + styleUrl: './gdpr-consent-banner.component.scss' +}) +export class GdprConsentBannerComponent implements OnInit, OnDestroy { + showBanner = false; + isLoading = false; + showDetails = false; + + consents = { + privacyPolicy: true, // Required + cookies: false, + dataProcessing: true, // Required + analytics: false, + marketing: false + }; + + constructor( + private gdprService: GdprService, + private authService: AuthService, + private logger: LoggerService + ) {} + + ngOnInit() { + this.checkConsentStatus(); + } + + ngOnDestroy() { + // Cleanup if needed + } + + async checkConsentStatus() { + const isAuthenticated = this.authService.isAuthenticated(); + if (!isAuthenticated) { + // For unauthenticated users, check if banner was shown + this.showBanner = this.gdprService.shouldShowConsentBanner(); + return; + } + + try { + const hasRequiredConsents = await this.gdprService.hasRequiredConsents(); + if (!hasRequiredConsents) { + this.showBanner = true; + } + } catch (error) { + this.logger.error('Error checking consent status:', error); + // Show banner if there's an error + this.showBanner = true; + } + } + + async acceptAll() { + this.isLoading = true; + try { + const isAuthenticated = this.authService.isAuthenticated(); + + if (isAuthenticated) { + // Grant all consents + await Promise.all([ + this.gdprService.grantConsent(ConsentType.PRIVACY_POLICY, '1.0'), + this.gdprService.grantConsent(ConsentType.COOKIES, '1.0'), + this.gdprService.grantConsent(ConsentType.DATA_PROCESSING, '1.0'), + this.gdprService.grantConsent(ConsentType.ANALYTICS, '1.0'), + this.gdprService.grantConsent(ConsentType.MARKETING, '1.0') + ]); + } + + // Mark banner as shown + this.gdprService.markConsentBannerShown(); + this.showBanner = false; + } catch (error) { + this.logger.error('Error accepting consents:', error); + alert('Error accepting consents. Please try again.'); + } finally { + this.isLoading = false; + } + } + + async acceptSelected() { + this.isLoading = true; + try { + const isAuthenticated = this.authService.isAuthenticated(); + + if (isAuthenticated) { + const promises: Promise[] = []; + + if (this.consents.privacyPolicy) { + promises.push(this.gdprService.grantConsent(ConsentType.PRIVACY_POLICY, '1.0')); + } + if (this.consents.cookies) { + promises.push(this.gdprService.grantConsent(ConsentType.COOKIES, '1.0')); + } + if (this.consents.dataProcessing) { + promises.push(this.gdprService.grantConsent(ConsentType.DATA_PROCESSING, '1.0')); + } + if (this.consents.analytics) { + promises.push(this.gdprService.grantConsent(ConsentType.ANALYTICS, '1.0')); + } + if (this.consents.marketing) { + promises.push(this.gdprService.grantConsent(ConsentType.MARKETING, '1.0')); + } + + await Promise.all(promises); + } + + // Mark banner as shown + this.gdprService.markConsentBannerShown(); + this.showBanner = false; + } catch (error) { + this.logger.error('Error accepting selected consents:', error); + alert('Error accepting consents. Please try again.'); + } finally { + this.isLoading = false; + } + } + + rejectAll() { + // Mark banner as shown even if rejecting + this.gdprService.markConsentBannerShown(); + this.showBanner = false; + } + + toggleDetails() { + this.showDetails = !this.showDetails; + // Optionally navigate to privacy policy page + // window.location.href = '/privacy-policy'; + } +} + diff --git a/frontend/src/app/components/modal/modal.component.html b/frontend/src/app/components/modal/modal.component.html new file mode 100644 index 0000000..43f6565 --- /dev/null +++ b/frontend/src/app/components/modal/modal.component.html @@ -0,0 +1,43 @@ + + diff --git a/frontend/src/app/components/modal/modal.component.scss b/frontend/src/app/components/modal/modal.component.scss new file mode 100644 index 0000000..81f8b94 --- /dev/null +++ b/frontend/src/app/components/modal/modal.component.scss @@ -0,0 +1,300 @@ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal-container { + background: white; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header { + padding: 24px 24px 16px; + display: flex; + align-items: center; + gap: 12px; + border-bottom: 1px solid #e5e7eb; +} + +.modal-header-info { + border-bottom-color: #3b82f6; +} + +.modal-header-success { + border-bottom-color: #10b981; +} + +.modal-header-warning { + border-bottom-color: #f59e0b; +} + +.modal-header-error { + border-bottom-color: #ef4444; +} + +.modal-header-confirm { + border-bottom-color: #6366f1; +} + +.modal-header-prompt { + border-bottom-color: #6366f1; +} + +.modal-icon-wrapper { + flex-shrink: 0; +} + +.modal-icon { + width: 24px; + height: 24px; + color: currentColor; +} + +.modal-header-info .modal-icon { + color: #3b82f6; +} + +.modal-header-success .modal-icon { + color: #10b981; +} + +.modal-header-warning .modal-icon { + color: #f59e0b; +} + +.modal-header-error .modal-icon { + color: #ef4444; +} + +.modal-header-confirm .modal-icon { + color: #6366f1; +} + +.modal-header-prompt .modal-icon { + color: #6366f1; +} + +.modal-title { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #111827; +} + +.modal-body { + padding: 20px 24px; + flex: 1; + overflow-y: auto; +} + +.modal-message { + margin: 0; + font-size: 16px; + line-height: 1.5; + color: #4b5563; + white-space: pre-wrap; + word-wrap: break-word; + margin-bottom: 16px; +} + +.modal-input-group { + margin-top: 16px; +} + +.modal-input-label { + display: block; + font-size: 14px; + font-weight: 500; + color: #374151; + margin-bottom: 8px; +} + +.modal-input { + width: 100%; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 16px; + color: #111827; + background-color: #fff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + box-sizing: border-box; +} + +.modal-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.modal-input::placeholder { + color: #9ca3af; +} + +.modal-footer { + padding: 16px 24px; + display: flex; + justify-content: flex-end; + gap: 12px; + border-top: 1px solid #e5e7eb; +} + +.modal-btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.2s ease; + min-width: 80px; +} + +.modal-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.modal-btn:active { + transform: translateY(0); +} + +.modal-btn-cancel { + background-color: #f3f4f6; + color: #374151; +} + +.modal-btn-cancel:hover { + background-color: #e5e7eb; +} + +.modal-btn-confirm { + background-color: #3b82f6; + color: white; +} + +.modal-btn-confirm:hover { + background-color: #2563eb; +} + +.modal-btn-info { + background-color: #3b82f6; + color: white; +} + +.modal-btn-info:hover { + background-color: #2563eb; +} + +.modal-btn-success { + background-color: #10b981; + color: white; +} + +.modal-btn-success:hover { + background-color: #059669; +} + +.modal-btn-warning { + background-color: #f59e0b; + color: white; +} + +.modal-btn-warning:hover { + background-color: #d97706; +} + +.modal-btn-error { + background-color: #ef4444; + color: white; +} + +.modal-btn-error:hover { + background-color: #dc2626; +} + +.modal-btn-confirm { + background-color: #6366f1; + color: white; +} + +.modal-btn-confirm:hover { + background-color: #4f46e5; +} + +.modal-btn-confirm:disabled { + background-color: #9ca3af; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.modal-btn-confirm:disabled:hover { + background-color: #9ca3af; + transform: none; + box-shadow: none; +} + +/* Responsive */ +@media (max-width: 640px) { + .modal-container { + width: 95%; + max-height: 85vh; + } + + .modal-header { + padding: 20px 20px 14px; + } + + .modal-body { + padding: 16px 20px; + } + + .modal-footer { + padding: 14px 20px; + flex-direction: column-reverse; + } + + .modal-btn { + width: 100%; + } +} + diff --git a/frontend/src/app/components/modal/modal.component.ts b/frontend/src/app/components/modal/modal.component.ts new file mode 100644 index 0000000..89e53a0 --- /dev/null +++ b/frontend/src/app/components/modal/modal.component.ts @@ -0,0 +1,76 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ModalService, ModalConfig } from '../../services/modal.service'; +import { LoggerService } from '../../services/logger.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-modal', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './modal.component.html', + styleUrl: './modal.component.scss' +}) +export class ModalComponent implements OnInit, OnDestroy { + config: ModalConfig | null = null; + inputValue: string = ''; + private subscription?: Subscription; + + constructor( + private modalService: ModalService, + private logger: LoggerService + ) {} + + ngOnInit() { + this.subscription = this.modalService.getModal().subscribe(config => { + this.logger.debug('ModalComponent: Received config:', config); + this.config = config; + if (config && config.type === 'prompt') { + this.inputValue = config.inputValue || ''; + } + }); + } + + ngOnDestroy() { + this.subscription?.unsubscribe(); + } + + confirm() { + this.logger.debug('ModalComponent: confirm() called, config:', this.config); + if (this.config) { + if (this.config.type === 'prompt') { + this.modalService.closeModal({ confirmed: true, inputValue: this.inputValue }); + } else { + this.modalService.closeModal({ confirmed: true }); + } + } + } + + cancel() { + this.logger.debug('ModalComponent: cancel() called'); + this.modalService.closeModal({ confirmed: false }); + } + + getIconPath(): string { + if (!this.config) return ''; + switch (this.config.type) { + case 'error': + return 'M12 9V11M12 15H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z'; + case 'warning': + return 'M12 9V11M12 15H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z'; + case 'success': + return 'M20 6L9 17L4 12'; + case 'confirm': + return 'M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z'; + default: + return 'M13 16H12V12H11M12 8H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z'; + } + } + + getIconViewBox(): string { + if (!this.config) return '0 0 24 24'; + return this.config.type === 'success' ? '0 0 24 24' : '0 0 24 24'; + } +} + diff --git a/frontend/src/app/config/api.config.ts b/frontend/src/app/config/api.config.ts new file mode 100644 index 0000000..56ba085 --- /dev/null +++ b/frontend/src/app/config/api.config.ts @@ -0,0 +1,41 @@ +// API Configuration +// Use localhost when accessing from the same machine +// Use the server's IP when accessing from other devices on the network +export const API_CONFIG = { + // Automatically detect if we're on localhost or network access + // If accessed via IP address, use that IP for backend too + // For localhost, always use HTTP to avoid SSL certificate issues with self-signed certs + getBaseUrl(): string { + const hostname = window.location.hostname; + // Always use HTTP for localhost to avoid SSL certificate validation issues + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return 'http://localhost:8080'; + } + // For network access, use same protocol as frontend + const protocol = window.location.protocol; + return `${protocol}//${hostname}:8080`; + }, + + getWsUrl(): string { + const hostname = window.location.hostname; + // SockJS uses HTTP/HTTPS, not WS/WSS directly + // It will upgrade to WebSocket automatically + // Always use HTTP for localhost to avoid SSL certificate validation issues + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return 'http://localhost:8080/ws'; + } + // For network access, use same protocol as frontend + const protocol = window.location.protocol; + return `${protocol}//${hostname}:8080/ws`; + } +}; + +// Export functions that compute URLs dynamically at call time +// This ensures the URL is always computed based on current window.location +export const getBaseUrl = () => API_CONFIG.getBaseUrl(); +export const getWsUrl = () => API_CONFIG.getWsUrl(); + +// For backward compatibility, export as constants (but they'll be computed at runtime) +export const BASE_URL = getBaseUrl(); +export const WS_URL = getWsUrl(); + diff --git a/frontend/src/app/guards/auth.guard.ts b/frontend/src/app/guards/auth.guard.ts new file mode 100644 index 0000000..ff3ea4c --- /dev/null +++ b/frontend/src/app/guards/auth.guard.ts @@ -0,0 +1,10 @@ +import { CanActivateFn, Router } from '@angular/router'; +import { inject } from '@angular/core'; + +export const authGuard: CanActivateFn = () => { + const router = inject(Router); + const token = localStorage.getItem('jwtToken'); + if (token) return true; + router.navigateByUrl('/login'); + return false; +}; diff --git a/frontend/src/app/pages/admin/admin.component.html b/frontend/src/app/pages/admin/admin.component.html new file mode 100644 index 0000000..f632087 --- /dev/null +++ b/frontend/src/app/pages/admin/admin.component.html @@ -0,0 +1,1011 @@ +
+ +
+
+
+
+ + + + +
+

Admin Dashboard

+

Telemedicine Portal

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

Loading dashboard data...

+
+ + +
+
+ + + + +
+

Error Loading Data

+

{{ error }}

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

Overview Statistics

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

Total Users

+

{{ stats.totalUsers || 0 }}

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

Doctors

+

{{ stats.totalDoctors || 0 }}

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

Patients

+

{{ stats.totalPatients || 0 }}

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

Appointments

+

{{ stats.totalAppointments || 0 }}

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

Active Users

+

{{ stats.activeUsers || 0 }}

+
+
+
+
+ + +
+

System Metrics

+ + +
+

Authentication

+
+
+

Login Attempts

+

{{ (metrics.loginAttempts || 0) | number:'1.0-0' }}

+
+
+

Successful Logins

+

{{ (metrics.loginSuccess || 0) | number:'1.0-0' }}

+
+
+

Failed Logins

+

{{ (metrics.loginFailure || 0) | number:'1.0-0' }}

+
+
+

2FA Enabled

+

{{ (metrics.twoFactorAuthEnabled || 0) | number:'1.0-0' }}

+
+
+

2FA Verified

+

{{ (metrics.twoFactorAuthVerified || 0) | number:'1.0-0' }}

+
+
+

Password Resets

+

{{ (metrics.passwordResetSuccess || 0) | number:'1.0-0' }}

+
+
+
+ + +
+

Appointments

+
+
+

Created

+

{{ (metrics.appointmentsCreated || 0) | number:'1.0-0' }}

+
+
+

Cancelled

+

{{ (metrics.appointmentsCancelled || 0) | number:'1.0-0' }}

+
+
+

Completed

+

{{ (metrics.appointmentsCompleted || 0) | number:'1.0-0' }}

+
+
+

Active

+

{{ metrics.activeAppointments || 0 }}

+
+
+

Total

+

{{ metrics.totalAppointments || 0 }}

+
+
+
+ + +
+

Prescriptions

+
+
+

Created

+

{{ (metrics.prescriptionsCreated || 0) | number:'1.0-0' }}

+
+
+

Active

+

{{ metrics.activePrescriptions || 0 }}

+
+
+

Total

+

{{ metrics.totalPrescriptions || 0 }}

+
+
+
+ + +
+

Communication

+
+
+

Messages Sent

+

{{ (metrics.messagesSent || 0) | number:'1.0-0' }}

+
+
+
+ + +
+

API Performance

+
+
+

Total Requests

+

{{ (metrics.apiRequests || 0) | number:'1.0-0' }}

+
+
+

Errors

+

{{ (metrics.apiErrors || 0) | number:'1.0-0' }}

+
+
+

Avg Response Time

+

{{ (metrics.apiResponseTime.mean || 0) | number:'1.2-2' }}ms

+
+
+

Max Response Time

+

{{ (metrics.apiResponseTime.max || 0) | number:'1.2-2' }}ms

+
+
+
+ + +
+

Database Performance

+
+
+

Avg Query Time

+

{{ (metrics.databaseQueryTime.mean || 0) | number:'1.2-2' }}ms

+
+
+

Max Query Time

+

{{ (metrics.databaseQueryTime.max || 0) | number:'1.2-2' }}ms

+
+
+

Total Queries

+

{{ (metrics.databaseQueryTime.count || 0) | number:'1.0-0' }}

+
+
+
+ + +
+

Security & Compliance

+
+
+

PHI Access Events

+

{{ (metrics.phiAccessCount || 0) | number:'1.0-0' }}

+
+
+

Breach Notifications

+

{{ (metrics.breachNotifications || 0) | number:'1.0-0' }}

+
+
+
+
+
+ + +
+
+
+

All Users

+
+ {{ users.length }} {{ users.length === 1 ? 'User' : 'Users' }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
NameEmailRoleStatusCreatedActions
+
+ {{ u.firstName }} {{ u.lastName }} +
+
+ + + + {{ u.role }} + + + + + {{ u.isActive ? 'Active' : 'Inactive' }} + + + {{ u.createdAt | date:'short' }} + +
+ + +
+
+
+
+ + + + +

No Users Found

+

There are no users to display at this time.

+
+
+
+ + +
+
+
+

Doctors

+
+ {{ doctors.length }} {{ doctors.length === 1 ? 'Doctor' : 'Doctors' }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
NameEmailMedical LicenseStatusVerificationCreatedActions
+
+ {{ d.firstName }} {{ d.lastName }} +
+
+ + + {{ d.medicalLicenseNumber }} + Not provided + + + + {{ d.isActive ? 'Active' : 'Inactive' }} + + + + + {{ d.isVerified ? 'Verified' : 'Unverified' }} + + + {{ d.createdAt | date:'short' }} + +
+ + + + +
+
+
+
+ + + + + +

No Doctors Found

+

There are no doctors registered in the system.

+
+
+
+ + +
+
+
+

Patients

+
+ {{ patients.length }} {{ patients.length === 1 ? 'Patient' : 'Patients' }} +
+
+
+ + + + + + + + + + + + + + + + + + + +
NameEmailStatusCreatedActions
+
+ {{ p.firstName }} {{ p.lastName }} +
+
+ + + + + {{ p.isActive ? 'Active' : 'Inactive' }} + + + {{ p.createdAt | date:'short' }} + +
+ + +
+
+
+
+ + + + +

No Patients Found

+

There are no patients registered in the system.

+
+
+
+ + + +
+
+
+

All Appointments

+
+ {{ appointments.length }} {{ appointments.length === 1 ? 'Appointment' : 'Appointments' }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
PatientDoctorDateTimeDurationStatusActions
+
+ {{ apt.patientFirstName }} {{ apt.patientLastName }} +
+
+
+ {{ apt.doctorFirstName }} {{ apt.doctorLastName }} +
+
+ {{ formatDate(apt.scheduledDate) }} + + {{ apt.scheduledTime }} + + {{ apt.durationInMinutes }} min + + + + {{ apt.status }} + + +
+ +
+
+
+
+ + + +

No Appointments Found

+

There are no appointments in the system.

+
+
+
+ + +
+
+
+
+

My Profile

+

View and manage your administrator account information

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

{{ currentUser?.firstName }} {{ currentUser?.lastName }}

+

{{ currentUser?.email }}

+
+
+
+
+ Phone Number + {{ currentUser?.phoneNumber || 'N/A' }} +
+
+ Role + {{ currentUser?.role || 'N/A' }} +
+
+ Account Status + + + {{ currentUser?.isActive ? 'Active' : 'Inactive' }} + + +
+
+
+ + +
+
+

+ + + + + Edit Profile +

+

Update your administrator account information

+
+
+
+
+

+ + + + + Basic Information +

+

Your personal contact details

+
+
+
+ + + 2-50 characters, letters only +
+
+ + + 2-50 characters, letters only +
+
+ + + 10-15 digits, can include country code with + +
+
+
+ +
+ + +
+
+
+
+
+ + +
+
+

HIPAA Audit Logs & Compliance

+ +
+ + +
+

Report Security Breach

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

View Audit Logs by Patient

+
+ + +
+ + +
+

HIPAA Audit Logs

+
+
+ {{ log.actionType }} + {{ formatDate(log.timestamp) }} +
+

User: {{ log.userName || log.userId }}

+

Resource: {{ log.resourceType }}

+

Success: {{ log.success ? 'Yes' : 'No' }}

+

Failure Reason: {{ log.failureReason }}

+

IP Address: {{ log.ipAddress }}

+
+
+ + +
+ +
+

PHI Access Logs

+
+
+ {{ log.accessType }} + {{ formatDate(log.timestamp) }} +
+

User: {{ log.userName || log.userId }}

+

Accessed Fields: {{ log.accessedFields.join(', ') }}

+

Purpose: {{ log.purpose }}

+

IP Address: {{ log.ipAddress }}

+
+
+
+
+ + +
+

Security Breach Notifications

+
No breach notifications
+
+
+ {{ breach.breachType }} + + {{ breach.status }} + +
+

Description: {{ breach.description }}

+

Affected Patients: {{ breach.affectedPatientsCount }}

+

Mitigation Steps: {{ breach.mitigationSteps }}

+

Reported: {{ formatDate(breach.createdAt) }}

+

Notified: {{ formatDate(breach.reportedAt) }}

+
+
+
+
+
+
diff --git a/frontend/src/app/pages/admin/admin.component.scss b/frontend/src/app/pages/admin/admin.component.scss new file mode 100644 index 0000000..88c5597 --- /dev/null +++ b/frontend/src/app/pages/admin/admin.component.scss @@ -0,0 +1,1587 @@ +// Color Palette - Enterprise Medical Theme (matching login page) +$primary-blue: #0066cc; +$primary-blue-dark: #0052a3; +$primary-blue-light: #e6f2ff; +$accent-teal: #0099a1; +$text-dark: #1a1a1a; +$text-medium: #4a4a4a; +$text-light: #6b6b6b; +$border-color: #e0e0e0; +$border-focus: #0066cc; +$error-red: #d32f2f; +$error-bg: #ffebee; +$success-green: #2e7d32; +$success-bg: #e8f5e9; +$warning-orange: #ed6c02; +$warning-bg: #fff3e0; +$info-blue: #0288d1; +$info-bg: #e3f2fd; +$white: #ffffff; +$background-light: #f8f9fa; +$background-page: #f5f7fa; +$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.15); +$shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.1); + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + +.admin-wrapper { + min-height: 100vh; + background: $background-page; + display: flex; + flex-direction: column; +} + +// Header Styles +.admin-header { + background: $white; + border-bottom: 1px solid $border-color; + box-shadow: $shadow-sm; + position: sticky; + top: 0; + z-index: 100; +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + padding: 1.25rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; + + @media (max-width: 768px) { + padding: 1rem; + flex-direction: column; + align-items: stretch; + gap: 1rem; + } +} + +.header-left { + display: flex; + align-items: center; + flex: 1; +} + +.logo-section { + display: flex; + align-items: center; + gap: 1rem; +} + +.medical-icon { + width: 40px; + height: 40px; + color: $primary-blue; + flex-shrink: 0; +} + +.header-text { + display: flex; + flex-direction: column; +} + +.dashboard-title { + font-size: 1.5rem; + font-weight: 700; + color: $text-dark; + margin: 0; + letter-spacing: -0.5px; + font-family: $font-family; +} + +.dashboard-subtitle { + font-size: 0.875rem; + color: $text-medium; + margin: 0; + font-weight: 400; +} + +.header-right { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +.refresh-button, +.logout-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + font-size: 0.9375rem; + font-weight: 500; + font-family: $font-family; + border: 2px solid $border-color; + border-radius: 8px; + background: $white; + color: $text-dark; + cursor: pointer; + transition: all 0.2s ease; + outline: none; + + svg { + width: 18px; + height: 18px; + } + + &:hover:not(:disabled) { + border-color: $primary-blue; + color: $primary-blue; + background: $primary-blue-light; + } + + &:active:not(:disabled) { + transform: scale(0.98); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + } +} + +.logout-button { + border-color: $error-red; + color: $error-red; + + &:hover:not(:disabled) { + background: $error-bg; + border-color: $error-red; + } +} + +// Main Content +.admin-main { + flex: 1; + max-width: 1400px; + width: 100%; + margin: 0 auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 2rem; + + @media (max-width: 768px) { + padding: 1rem; + gap: 1.5rem; + } +} + +// Loading State +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + min-height: 400px; +} + +.spinner-wrapper { + margin-bottom: 1.5rem; +} + +.spinner { + width: 48px; + height: 48px; + color: $primary-blue; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.loading-text { + font-size: 1rem; + color: $text-medium; + margin: 0; + font-family: $font-family; +} + +// Error State +.error-container { + width: 100%; + padding: 2rem 0; +} + +.error-card { + background: $error-bg; + border: 1px solid rgba($error-red, 0.3); + border-radius: 12px; + padding: 2rem; + display: flex; + gap: 1.5rem; + align-items: flex-start; + max-width: 600px; + margin: 0 auto; + + .error-icon { + width: 32px; + height: 32px; + color: $error-red; + flex-shrink: 0; + } + + .error-content { + flex: 1; + + h3 { + font-size: 1.125rem; + font-weight: 600; + color: $error-red; + margin: 0 0 0.5rem 0; + font-family: $font-family; + } + + p { + font-size: 0.9375rem; + color: $text-dark; + margin: 0 0 1rem 0; + font-family: $font-family; + } + } +} + +.retry-button { + padding: 0.625rem 1.25rem; + font-size: 0.9375rem; + font-weight: 500; + font-family: $font-family; + background: $error-red; + color: $white; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: darken($error-red, 10%); + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } +} + +// Stats Section +.stats-section { + width: 100%; +} + +.section-title { + font-size: 1.5rem; + font-weight: 700; + color: $text-dark; + margin: 0 0 1.5rem 0; + letter-spacing: -0.5px; + font-family: $font-family; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1.5rem; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.stat-card { + background: $white; + border-radius: 12px; + padding: 1.5rem; + display: flex; + align-items: center; + gap: 1.25rem; + box-shadow: $shadow-md; + transition: all 0.3s ease; + border: 2px solid transparent; + + &:hover { + transform: translateY(-4px); + box-shadow: $shadow-lg; + } + + .stat-icon { + width: 48px; + height: 48px; + flex-shrink: 0; + padding: 0.75rem; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + background: $primary-blue-light; + + svg { + width: 100%; + height: 100%; + color: $primary-blue; + } + } + + .stat-content { + flex: 1; + min-width: 0; + } + + .stat-label { + font-size: 0.875rem; + color: $text-medium; + margin: 0 0 0.5rem 0; + font-weight: 500; + font-family: $font-family; + } + + .stat-value { + font-size: 2rem; + font-weight: 700; + color: $text-dark; + margin: 0; + font-family: $font-family; + line-height: 1; + } +} + +.stat-card-primary { + .stat-icon { + background: $primary-blue-light; + svg { + color: $primary-blue; + } + } +} + +.stat-card-success { + .stat-icon { + background: $success-bg; + svg { + color: $success-green; + } + } + .stat-value { + color: $success-green; + } +} + +.stat-card-info { + .stat-icon { + background: $info-bg; + svg { + color: $info-blue; + } + } + .stat-value { + color: $info-blue; + } +} + +.stat-card-warning { + .stat-icon { + background: $warning-bg; + svg { + color: $warning-orange; + } + } + .stat-value { + color: $warning-orange; + } +} + +.stat-card-active { + .stat-icon { + background: linear-gradient(135deg, $primary-blue-light 0%, lighten($accent-teal, 40%) 100%); + svg { + color: $accent-teal; + } + } + .stat-value { + color: $accent-teal; + } +} + +// Users Section +.users-section { + width: 100%; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.users-count { + display: flex; + align-items: center; +} + +.count-badge { + display: inline-flex; + align-items: center; + padding: 0.5rem 1rem; + background: $primary-blue-light; + color: $primary-blue; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 600; + font-family: $font-family; +} + +.table-container { + background: $white; + border-radius: 12px; + box-shadow: $shadow-md; + overflow: hidden; + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-family: $font-family; + + thead { + background: $background-light; + border-bottom: 2px solid $border-color; + } + + th { + padding: 1rem 1.5rem; + text-align: left; + font-size: 0.875rem; + font-weight: 600; + color: $text-medium; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + } + + .th-content { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .th-icon { + width: 16px; + height: 16px; + color: $text-light; + } + + tbody { + tr { + border-bottom: 1px solid $border-color; + transition: background 0.2s ease; + + &:hover { + background: $background-light; + } + + &:last-child { + border-bottom: none; + } + } + + td { + padding: 1.25rem 1.5rem; + font-size: 0.9375rem; + color: $text-dark; + } + } +} + +.td-content { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.email-text { + color: $text-dark; + font-weight: 500; +} + +.role-badge { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.875rem; + border-radius: 6px; + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-family: $font-family; +} + +.role-admin { + background: rgba($error-red, 0.1); + color: $error-red; +} + +.role-doctor { + background: rgba($info-blue, 0.1); + color: $info-blue; +} + +.role-patient { + background: rgba($success-green, 0.1); + color: $success-green; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.875rem; + border-radius: 6px; + font-size: 0.8125rem; + font-weight: 500; + font-family: $font-family; +} + +.status-active { + background: rgba($success-green, 0.1); + color: $success-green; + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: $success-green; + display: inline-block; + } +} + +.status-inactive { + background: rgba($text-light, 0.1); + color: $text-light; + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: $text-light; + display: inline-block; + } +} + +// Empty State +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + min-height: 400px; + + .empty-icon { + width: 64px; + height: 64px; + color: $text-light; + margin-bottom: 1.5rem; + } + + h3 { + font-size: 1.25rem; + font-weight: 600; + color: $text-dark; + margin: 0 0 0.5rem 0; + font-family: $font-family; + } + + p { + font-size: 0.9375rem; + color: $text-medium; + margin: 0; + font-family: $font-family; + } +} + +// Tabs Navigation +.tabs-container { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid $border-color; + margin-bottom: 2rem; + overflow-x: auto; + scrollbar-width: thin; + + &::-webkit-scrollbar { + height: 4px; + } + + &::-webkit-scrollbar-thumb { + background: $border-color; + border-radius: 2px; + } + + @media (max-width: 768px) { + gap: 0.25rem; + } +} + +.tab-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 1.5rem; + font-size: 0.9375rem; + font-weight: 500; + font-family: $font-family; + color: $text-medium; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + position: relative; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + color: $primary-blue; + background: rgba($primary-blue, 0.05); + } + + &.active { + color: $primary-blue; + border-bottom-color: $primary-blue; + font-weight: 600; + } + + .tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 0.5rem; + background: $primary-blue-light; + color: $primary-blue; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + margin-left: 0.25rem; + } + + &.active .tab-badge { + background: $primary-blue; + color: $white; + } + + @media (max-width: 768px) { + padding: 0.75rem 1rem; + font-size: 0.875rem; + + svg { + width: 16px; + height: 16px; + } + } +} + +.tab-content { + width: 100%; +} + +.tab-panel { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Action Buttons +.action-buttons { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid $border-color; + border-radius: 6px; + background: $white; + cursor: pointer; + transition: all 0.2s ease; + outline: none; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + transform: translateY(-1px); + box-shadow: $shadow-sm; + } + + &:active { + transform: translateY(0); + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + } +} + +.action-btn-toggle { + color: $text-medium; + + &:hover { + border-color: $warning-orange; + color: $warning-orange; + background: $warning-bg; + } +} + +.action-btn-delete { + color: $error-red; + + &:hover { + border-color: $error-red; + background: $error-bg; + } +} + +// User Name +.user-name { + font-weight: 500; + color: $text-dark; +} + +.date-text { + color: $text-medium; + font-size: 0.875rem; +} + +// Urgency Badges +.urgency-badge { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.875rem; + border-radius: 6px; + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-family: $font-family; +} + +.urgency-emergency { + background: rgba($error-red, 0.1); + color: $error-red; +} + +.urgency-high { + background: rgba($warning-orange, 0.1); + color: $warning-orange; +} + +.urgency-medium { + background: rgba($info-blue, 0.1); + color: $info-blue; +} + +.urgency-low { + background: rgba($success-green, 0.1); + color: $success-green; +} + +// Section Controls +.section-controls { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.urgency-filter { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-family: $font-family; + color: $text-dark; + background: $white; + border: 2px solid $border-color; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + outline: none; + + &:hover { + border-color: $primary-blue; + } + + &:focus { + border-color: $border-focus; + box-shadow: 0 0 0 3px rgba($border-focus, 0.1); + } +} + +// Text Truncation +.input-text, +.output-text { + color: $text-dark; + font-size: 0.875rem; + display: block; + max-width: 300px; +} + +.patient-name { + font-weight: 500; + color: $text-dark; +} + +// Responsive Design +@media (max-width: 1024px) { + .stats-grid { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } +} + +@media (max-width: 768px) { + .admin-main { + padding: 1rem; + } + + .section-title { + font-size: 1.25rem; + } + + .stat-card { + padding: 1.25rem; + } + + .stat-value { + font-size: 1.75rem; + } + + .table-container { + border-radius: 8px; + } + + .data-table { + th, td { + padding: 0.875rem 1rem; + font-size: 0.875rem; + } + } +} + +// Dark mode support +@media (prefers-color-scheme: dark) { + .admin-wrapper { + background: #1e1e1e; + } + + .admin-header { + background: #2a2a2a; + border-color: #404040; + } + + .dashboard-title { + color: $white; + } + + .dashboard-subtitle { + color: rgba($white, 0.7); + } + + .refresh-button, + .logout-button { + background: #2a2a2a; + border-color: #404040; + color: rgba($white, 0.9); + } + + .stat-card { + background: #2a2a2a; + } + + .stat-value { + color: $white; + } + + .table-container { + background: #2a2a2a; + } + + .data-table { + thead { + background: #1e1e1e; + } + + tbody tr { + border-color: #404040; + + &:hover { + background: #1e1e1e; + } + } + + th, td { + color: rgba($white, 0.9); + } + } + + .email-text { + color: rgba($white, 0.9); + } +} + +// Profile Section Styles +.profile-section { + width: 100%; + max-width: 1000px; + margin: 0 auto; +} + +.profile-header-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + gap: 1.5rem; + + @media (max-width: 768px) { + flex-direction: column; + align-items: stretch; + } +} + +.profile-header-content { + flex: 1; + + .section-description { + font-size: 0.9375rem; + color: $text-medium; + margin-top: 0.5rem; + margin-bottom: 0; + } +} + +.edit-profile-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: $primary-blue; + color: $white; + border: none; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: $shadow-sm; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: $primary-blue-dark; + box-shadow: $shadow-md; + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } +} + +.profile-card { + background: $white; + border-radius: 16px; + padding: 2.5rem; + box-shadow: $shadow-md; + transition: all 0.2s ease; + + &:hover { + box-shadow: $shadow-lg; + } +} + +.profile-header { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 2rem; + padding-bottom: 2rem; + border-bottom: 2px solid $border-color; +} + +.profile-avatar { + width: 90px; + height: 90px; + border-radius: 50%; + background: linear-gradient(135deg, $primary-blue-light 0%, $accent-teal 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: $shadow-md; + + svg { + width: 45px; + height: 45px; + color: $primary-blue; + } +} + +.profile-name { + flex: 1; + + h3 { + font-size: 1.75rem; + font-weight: 700; + color: $text-dark; + margin: 0 0 0.5rem 0; + letter-spacing: -0.5px; + } + + p { + font-size: 1rem; + color: $text-medium; + margin: 0; + } +} + +.profile-details { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1.25rem 0; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: none; + } +} + +.detail-label { + font-size: 0.9375rem; + font-weight: 600; + color: $text-dark; + min-width: 180px; +} + +.detail-value { + font-size: 0.9375rem; + color: $text-medium; + text-align: right; + max-width: 60%; + word-wrap: break-word; +} + +// Edit Profile Form Styles +.edit-profile-form { + background: $white; + border-radius: 16px; + padding: 2.5rem; + box-shadow: $shadow-lg; + margin-top: 2rem; + + @media (max-width: 768px) { + padding: 1.5rem; + } + + // Scoped form styles for edit profile form + .form-header { + margin-bottom: 2.5rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid $border-color; + + .form-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.75rem; + font-weight: 700; + color: $text-dark; + margin: 0 0 0.5rem 0; + + svg { + width: 28px; + height: 28px; + color: $primary-blue; + } + } + + .form-subtitle { + font-size: 1rem; + color: $text-medium; + margin: 0; + margin-left: 2.75rem; + } + } + + .profile-form { + display: flex; + flex-direction: column; + gap: 2.5rem; + } + + .form-section { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .section-header { + margin-bottom: 0.5rem; + + .section-subtitle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + color: $text-dark; + margin: 0 0 0.25rem 0; + + svg { + width: 20px; + height: 20px; + color: $primary-blue; + } + } + + .section-description { + font-size: 0.875rem; + color: $text-medium; + margin-left: 1.75rem; + margin-top: 0.25rem; + } + } + + .form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + .form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + + &.form-group-full { + grid-column: 1 / -1; + } + } + + .form-label { + font-size: 0.9375rem; + font-weight: 600; + color: $text-dark; + display: flex; + align-items: center; + gap: 0.25rem; + + .required-indicator { + color: $error-red; + font-weight: 700; + } + } + + .form-input { + width: 100%; + padding: 0.875rem 1rem; + border: 2px solid $border-color; + border-radius: 8px; + font-size: 0.9375rem; + font-family: $font-family; + color: $text-dark; + background: $white; + transition: all 0.2s ease; + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); + } + + &:invalid:not(:placeholder-shown) { + border-color: $error-red; + } + + &:valid:not(:placeholder-shown) { + border-color: $success-green; + } + + &::placeholder { + color: $text-light; + } + + &.form-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%234a4a4a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; + padding-right: 2.5rem; + } + + &.form-textarea { + resize: vertical; + min-height: 120px; + font-family: $font-family; + line-height: 1.6; + } + } + + .input-with-icon { + position: relative; + display: flex; + align-items: center; + + .input-icon { + position: absolute; + left: 1rem; + font-size: 1rem; + font-weight: 600; + color: $text-medium; + pointer-events: none; + } + + .form-input { + padding-left: 2.5rem; + } + } + + .form-hint { + font-size: 0.8125rem; + color: $text-light; + margin-top: 0.25rem; + } + + .textarea-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.5rem; + + .char-counter { + font-size: 0.8125rem; + font-weight: 600; + color: $text-medium; + } + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding-top: 2rem; + margin-top: 1rem; + border-top: 2px solid $border-color; + + @media (max-width: 768px) { + flex-direction: column-reverse; + + button { + width: 100%; + } + } + } + + .cancel-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.875rem 1.75rem; + background: $white; + color: $text-dark; + border: 2px solid $border-color; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: $background-light; + border-color: $text-medium; + } + } + + .submit-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.875rem 1.75rem; + background: $primary-blue; + color: $white; + border: none; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: $shadow-sm; + + svg { + width: 18px; + height: 18px; + } + + &:hover:not(:disabled) { + background: $primary-blue-dark; + box-shadow: $shadow-md; + transform: translateY(-1px); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} + +// Metrics Section Styles - Enterprise Design +.metrics-section { + margin-top: 2.5rem; + padding: 2.5rem; + background: linear-gradient(135deg, $white 0%, #fafbfc 100%); + border-radius: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.05); + border: 1px solid rgba(0, 102, 204, 0.08); + + .section-title { + font-size: 1.75rem; + font-weight: 700; + color: $text-dark; + margin-bottom: 2.5rem; + padding-bottom: 1.25rem; + border-bottom: 3px solid $primary-blue; + position: relative; + letter-spacing: -0.3px; + + &::after { + content: ''; + position: absolute; + bottom: -3px; + left: 0; + width: 80px; + height: 3px; + background: linear-gradient(90deg, $primary-blue 0%, $accent-teal 100%); + border-radius: 2px; + } + } + + .metrics-category { + margin-bottom: 3rem; + padding: 1.5rem; + background: $white; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + border: 1px solid rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 102, 204, 0.1); + transform: translateY(-2px); + } + + &:last-child { + margin-bottom: 0; + } + + .category-title { + font-size: 1.25rem; + font-weight: 600; + color: $text-dark; + margin-bottom: 1.5rem; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, $primary-blue-light 0%, rgba(0, 153, 161, 0.05) 100%); + border-left: 5px solid $primary-blue; + border-radius: 6px; + display: flex; + align-items: center; + gap: 0.75rem; + letter-spacing: -0.2px; + + &::before { + content: ''; + width: 4px; + height: 20px; + background: linear-gradient(180deg, $primary-blue 0%, $accent-teal 100%); + border-radius: 2px; + } + } + + .metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1.25rem; + + @media (max-width: 1200px) { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + } + + @media (max-width: 768px) { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; + } + + @media (max-width: 480px) { + grid-template-columns: 1fr; + } + } + + .metric-card { + background: linear-gradient(135deg, $white 0%, #f8f9fa 100%); + border: 1.5px solid rgba(0, 102, 204, 0.12); + border-radius: 10px; + padding: 1.5rem; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, $primary-blue 0%, $accent-teal 100%); + opacity: 0; + transition: opacity 0.3s ease; + } + + &:hover { + box-shadow: 0 8px 24px rgba(0, 102, 204, 0.15), 0 2px 8px rgba(0, 0, 0, 0.08); + transform: translateY(-4px); + border-color: $primary-blue; + background: linear-gradient(135deg, $white 0%, $primary-blue-light 100%); + + &::before { + opacity: 1; + } + + .metric-value { + color: $primary-blue; + transform: scale(1.05); + } + } + + .metric-label { + font-size: 0.8125rem; + color: $text-medium; + margin: 0 0 0.75rem 0; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.85; + } + + .metric-value { + font-size: 2rem; + font-weight: 700; + color: $primary-blue; + margin: 0; + line-height: 1.1; + transition: all 0.3s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, $primary-blue 0%, $accent-teal 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + } + } + + // Special styling for different metric types + .metrics-category:nth-child(1) .metric-card { + border-left: 4px solid $primary-blue; + } + + .metrics-category:nth-child(2) .metric-card { + border-left: 4px solid $success-green; + } + + .metrics-category:nth-child(3) .metric-card { + border-left: 4px solid $info-blue; + } + + .metrics-category:nth-child(4) .metric-card { + border-left: 4px solid $accent-teal; + } + + .metrics-category:nth-child(5) .metric-card { + border-left: 4px solid $warning-orange; + } + + .metrics-category:nth-child(6) .metric-card { + border-left: 4px solid $primary-blue; + } + + .metrics-category:nth-child(7) .metric-card { + border-left: 4px solid $error-red; + } +} + diff --git a/frontend/src/app/pages/admin/admin.component.spec.ts b/frontend/src/app/pages/admin/admin.component.spec.ts new file mode 100644 index 0000000..617b55b --- /dev/null +++ b/frontend/src/app/pages/admin/admin.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminComponent } from './admin.component'; + +describe('AdminComponent', () => { + let component: AdminComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdminComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/admin/admin.component.ts b/frontend/src/app/pages/admin/admin.component.ts new file mode 100644 index 0000000..1e433eb --- /dev/null +++ b/frontend/src/app/pages/admin/admin.component.ts @@ -0,0 +1,348 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AdminService } from '../../services/admin.service'; +import { AuthService } from '../../services/auth.service'; +import { AppointmentService, Appointment } from '../../services/appointment.service'; +import { UserService, UserInfo, UserUpdateRequest } from '../../services/user.service'; +import { HipaaAuditService, HipaaAuditLog, PhiAccessLog, BreachNotification } from '../../services/hipaa-audit.service'; +import { ModalService } from '../../services/modal.service'; +import { LoggerService } from '../../services/logger.service'; + +@Component({ + selector: 'app-admin', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin.component.html', + styleUrl: './admin.component.scss' +}) +export class AdminComponent implements OnInit { + stats: any = null; + metrics: any = null; + users: any[] = []; + doctors: any[] = []; + patients: any[] = []; + appointments: Appointment[] = []; + loading = false; + error: string | null = null; + activeTab: string = 'overview'; + currentUser: UserInfo | null = null; + showEditProfile = false; + editUserData: UserUpdateRequest = { + firstName: '', + lastName: '', + phoneNumber: '' + }; + + // HIPAA Audit data + selectedPatientIdForAudit: string | null = null; + selectedUserIdForAudit: string | null = null; + hipaaAuditLogs: HipaaAuditLog[] = []; + phiAccessLogs: PhiAccessLog[] = []; + breachNotifications: BreachNotification[] = []; + showCreateBreachNotification = false; + newBreachNotification = { + breachType: 'UNAUTHORIZED_ACCESS' as 'UNAUTHORIZED_ACCESS' | 'DISCLOSURE' | 'LOSS' | 'THEFT', + description: '', + affectedPatientsCount: 0, + mitigationSteps: '', + incidentDate: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format + discoveryDate: new Date().toISOString().split('T')[0] // Today's date in YYYY-MM-DD format + }; + + constructor( + private admin: AdminService, + private auth: AuthService, + private router: Router, + private appointmentService: AppointmentService, + private userService: UserService, + private hipaaAuditService: HipaaAuditService, + private modalService: ModalService, + private logger: LoggerService + ) {} + + ngOnInit() { + this.refresh(); + } + + hasObjectKeys(obj: any): boolean { + return obj && typeof obj === 'object' && Object.keys(obj).length > 0; + } + + async refresh() { + this.loading = true; + this.error = null; + try { + this.currentUser = await this.userService.getCurrentUserProfile(); + const [stats, metrics, users, doctors, patients] = await Promise.all([ + this.admin.getStats(), + this.admin.getMetrics(), + this.admin.getUsers(), + this.admin.getDoctors(), + this.admin.getPatients() + ]); + + this.stats = stats; + this.metrics = metrics; + this.users = users; + this.doctors = doctors; + this.patients = patients; + if (this.activeTab === 'appointments') { + await this.loadAppointments(); + } + if (this.activeTab === 'hipaa-audit') { + await this.loadBreachNotifications(); + } + } catch (e: any) { + if (e?.response?.status === 401) { + await this.auth.logout(); + this.router.navigateByUrl('/login'); + return; + } + this.error = e?.response?.data?.message || 'Failed to load admin data'; + } finally { + this.loading = false; + } + } + + async toggleUserStatus(user: any) { + try { + if (user.isActive) { + await this.admin.deactivateUser(user.email); + } else { + await this.admin.activateUser(user.email); + } + await this.refresh(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to update user status'; + } + } + + async deleteUser(user: any) { + const confirmed = await this.modalService.confirm( + `Are you sure you want to delete user ${user.email}? This action cannot be undone.`, + 'Delete User', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + + try { + await this.admin.deleteUser(user.email); + await this.refresh(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to delete user'; + } + } + + async verifyDoctor(doctor: any) { + if (!doctor.medicalLicenseNumber) { + this.error = 'Medical license number not available for this doctor'; + return; + } + + const confirmed = await this.modalService.confirm( + `Are you sure you want to verify Dr. ${doctor.firstName} ${doctor.lastName}?\n\nMedical License: ${doctor.medicalLicenseNumber}`, + 'Verify Doctor', + 'Verify', + 'Cancel' + ); + if (!confirmed) return; + + try { + await this.admin.verifyDoctor(doctor.medicalLicenseNumber); + await this.refresh(); + this.error = null; + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to verify doctor'; + } + } + + async unverifyDoctor(doctor: any) { + if (!doctor.medicalLicenseNumber) { + this.error = 'Medical license number not available for this doctor'; + return; + } + + const confirmed = await this.modalService.confirm( + `Are you sure you want to unverify Dr. ${doctor.firstName} ${doctor.lastName}?\n\nMedical License: ${doctor.medicalLicenseNumber}\n\nThis will remove the verified status from this doctor.`, + 'Unverify Doctor', + 'Unverify', + 'Cancel' + ); + if (!confirmed) return; + + try { + await this.admin.unverifyDoctor(doctor.medicalLicenseNumber); + await this.refresh(); + this.error = null; + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to unverify doctor'; + } + } + + async deleteAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available'; + return; + } + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this appointment? This action cannot be undone.', + 'Delete Appointment', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + + try { + await this.admin.deleteAppointment(appointment.id); + await this.loadAppointments(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to delete appointment'; + } + } + + async loadAppointments() { + try { + this.appointments = await this.admin.getAllAppointments(); + } catch (e: any) { + this.logger.error('Failed to load appointments:', e); + this.appointments = []; + } + } + + setTab(tab: string) { + this.activeTab = tab; + } + + async logout() { + await this.auth.logout(); + this.router.navigateByUrl('/login'); + } + + formatDate(dateString: string): string { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + async updateUserProfile() { + try { + await this.userService.updateUserProfile(this.editUserData); + await this.refresh(); + this.showEditProfile = false; + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to update profile'; + } + } + + editProfile() { + // Ensure currentUser is loaded + if (!this.currentUser) { + this.logger.error('Current user not loaded'); + return; + } + this.editUserData = { + firstName: this.currentUser.firstName || '', + lastName: this.currentUser.lastName || '', + phoneNumber: this.currentUser.phoneNumber || '' + }; + this.showEditProfile = true; + } + + // HIPAA Audit methods + async loadHipaaAuditLogsByPatient(patientId: string) { + try { + this.hipaaAuditLogs = await this.hipaaAuditService.getHipaaAuditLogsByPatientId(patientId); + this.selectedPatientIdForAudit = patientId; + } catch (e: any) { + this.logger.error('Error loading HIPAA audit logs:', e); + this.hipaaAuditLogs = []; + } + } + + async loadHipaaAuditLogsByUser(userId: string) { + try { + this.hipaaAuditLogs = await this.hipaaAuditService.getHipaaAuditLogsByUserId(userId); + this.selectedUserIdForAudit = userId; + } catch (e: any) { + this.logger.error('Error loading HIPAA audit logs:', e); + this.hipaaAuditLogs = []; + } + } + + async loadPhiAccessLogsByPatient(patientId: string) { + try { + this.phiAccessLogs = await this.hipaaAuditService.getPhiAccessLogsByPatientId(patientId); + this.selectedPatientIdForAudit = patientId; + } catch (e: any) { + this.logger.error('Error loading PHI access logs:', e); + this.phiAccessLogs = []; + } + } + + async loadBreachNotifications() { + try { + this.breachNotifications = await this.hipaaAuditService.getBreachNotifications(); + } catch (e: any) { + this.logger.error('Error loading breach notifications:', e); + this.breachNotifications = []; + } + } + + async createBreachNotification() { + // Ensure dates are set + if (!this.newBreachNotification.incidentDate) { + this.newBreachNotification.incidentDate = new Date().toISOString().split('T')[0]; + } + if (!this.newBreachNotification.discoveryDate) { + this.newBreachNotification.discoveryDate = new Date().toISOString().split('T')[0]; + } + + if (!this.newBreachNotification.description || !this.newBreachNotification.mitigationSteps || + !this.newBreachNotification.incidentDate || !this.newBreachNotification.discoveryDate) { + this.error = 'Please fill in all required fields'; + return; + } + + this.logger.debug('Creating breach notification with data:', this.newBreachNotification); + + try { + await this.hipaaAuditService.createBreachNotification(this.newBreachNotification); + this.showCreateBreachNotification = false; + this.newBreachNotification = { + breachType: 'UNAUTHORIZED_ACCESS', + description: '', + affectedPatientsCount: 0, + mitigationSteps: '', + incidentDate: new Date().toISOString().split('T')[0], + discoveryDate: new Date().toISOString().split('T')[0] + }; + await this.loadBreachNotifications(); + } catch (e: any) { + this.logger.error('Error creating breach notification:', e); + if (e?.response?.status === 401 || e?.message?.includes('Authentication required')) { + // Check if token exists + const token = localStorage.getItem('jwtToken'); + if (!token || token === 'null' || token === 'undefined') { + this.error = 'You are not logged in. Please log in to continue.'; + setTimeout(() => { + this.auth.logout(); + this.router.navigate(['/login']); + }, 2000); + } else { + // Token exists but is invalid/expired - give user option to retry or login + this.error = 'Your session may have expired. Please try refreshing the page or logging in again.'; + // Don't auto-logout - let user decide + } + } else { + this.error = e?.response?.data?.error || e?.response?.data?.message || e?.message || 'Failed to create breach notification'; + } + } + } +} diff --git a/frontend/src/app/pages/doctor/components/appointments/appointments.component.html b/frontend/src/app/pages/doctor/components/appointments/appointments.component.html new file mode 100644 index 0000000..5ad9736 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/appointments/appointments.component.html @@ -0,0 +1,111 @@ +
+

My Appointments

+ + +
+

Upcoming

+
+
+
+
+

{{ apt.patientFirstName }} {{ apt.patientLastName }}

+

+ + + + {{ formatDate(apt.scheduledDate) }} at {{ formatTime(apt.scheduledTime) }} +

+

+ + + + + {{ apt.durationInMinutes }} minutes +

+
+ + {{ apt.status }} + +
+
+ + + +
+
+ + + +
+
+
+
+ + +
+

Past

+
+
+
+
+

{{ apt.patientFirstName }} {{ apt.patientLastName }}

+

+ + + + {{ formatDate(apt.scheduledDate) }} at {{ formatTime(apt.scheduledTime) }} +

+

+ + + + + {{ apt.durationInMinutes }} minutes +

+
+ + {{ apt.status }} + +
+
+ +
+
+
+
+ + +
+ + + +

No Appointments

+

You don't have any appointments scheduled yet.

+
+
\ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/appointments/appointments.component.scss b/frontend/src/app/pages/doctor/components/appointments/appointments.component.scss new file mode 100644 index 0000000..80087ea --- /dev/null +++ b/frontend/src/app/pages/doctor/components/appointments/appointments.component.scss @@ -0,0 +1,543 @@ +// ============================================================================ +// Enterprise Appointments Component Styles +// Modern, Professional, and Responsive Styling +// ============================================================================ + +// ============================================================================ +// Component Root & Variables +// ============================================================================ + +:host, +app-appointments { + display: block; + width: 100%; +} + +// ============================================================================ +// Main Section Container +// ============================================================================ + +.appointments-section { + width: 100%; + padding: var(--space-xl, 2rem); + background: transparent; + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Section Title +// ============================================================================ + +.section-title { + font-size: var(--font-size-3xl, 1.875rem); + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-2xl, 3rem) 0; + line-height: var(--line-height-tight, 1.25); + position: relative; + padding-bottom: var(--space-md, 1rem); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 60px; + height: 4px; + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + border-radius: var(--radius-full, 9999px); + } + + @media (max-width: 768px) { + font-size: var(--font-size-2xl, 1.5rem); + margin-bottom: var(--space-xl, 2rem); + } +} + +// ============================================================================ +// Appointment Group +// ============================================================================ + +.appointment-group { + margin-bottom: var(--space-3xl, 4rem); + + &:last-child { + margin-bottom: 0; + } +} + +.group-title { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-secondary, #4b5563); + margin: 0 0 var(--space-lg, 1.5rem) 0; + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + + &::before { + content: ''; + width: 4px; + height: 20px; + background: linear-gradient(180deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + border-radius: var(--radius-full, 9999px); + } + + @media (max-width: 768px) { + font-size: var(--font-size-lg, 1.125rem); + } +} + +// ============================================================================ +// Appointments Grid +// ============================================================================ + +.appointments-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: var(--space-lg, 1.5rem); + + @media (max-width: 1024px) { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: var(--space-md, 1rem); + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Appointment Card +// ============================================================================ + +.appointment-card { + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + padding: var(--space-lg, 1.5rem); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + transform: scaleX(0); + transform-origin: left; + transition: transform var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1)); + } + + &:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)); + border-color: var(--color-primary, #2563eb); + + &::before { + transform: scaleX(1); + } + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Appointment Header +// ============================================================================ + +.appointment-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-md, 1rem); + + @media (max-width: 480px) { + flex-direction: column; + gap: var(--space-sm, 0.5rem); + } +} + +.appointment-info { + flex: 1; + min-width: 0; +} + +// ============================================================================ +// Patient Name +// ============================================================================ + +.patient-name { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + line-height: var(--line-height-tight, 1.25); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (max-width: 768px) { + font-size: var(--font-size-lg, 1.125rem); + } +} + +// ============================================================================ +// Appointment Date & Duration +// ============================================================================ + +.appointment-date, +.appointment-duration { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); + margin: 0 0 var(--space-xs, 0.25rem) 0; + line-height: var(--line-height-normal, 1.5); + + svg { + width: 16px; + height: 16px; + color: var(--color-primary, #2563eb); + flex-shrink: 0; + } + + &:last-child { + margin-bottom: 0; + } +} + +.appointment-duration { + color: var(--text-tertiary, #6b7280); + + svg { + color: var(--color-accent, #8b5cf6); + } +} + +// ============================================================================ +// Status Badge +// ============================================================================ + +.status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-xs, 0.25rem) var(--space-md, 1rem); + border-radius: var(--radius-full, 9999px); + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-medium, 500); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + flex-shrink: 0; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + // Status-specific styles + &.status-scheduled { + background: var(--color-info-light, #dbeafe); + color: var(--color-info, #3b82f6); + border: 1px solid var(--color-info, #3b82f6); + } + + &.status-confirmed { + background: var(--color-success-light, #d1fae5); + color: var(--color-success, #10b981); + border: 1px solid var(--color-success, #10b981); + } + + &.status-completed { + background: var(--color-gray-100, #f3f4f6); + color: var(--color-gray-700, #374151); + border: 1px solid var(--color-gray-300, #d1d5db); + } + + &.status-cancelled { + background: var(--color-danger-light, #fee2e2); + color: var(--color-danger, #ef4444); + border: 1px solid var(--color-danger, #ef4444); + } + + @media (max-width: 480px) { + font-size: 0.625rem; + padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem); + } +} + +// ============================================================================ +// Appointment Actions +// ============================================================================ + +.appointment-actions { + display: flex; + gap: var(--space-sm, 0.5rem); + margin-top: var(--space-md, 1rem); + padding-top: var(--space-md, 1rem); + border-top: 1px solid var(--color-gray-200, #e5e7eb); + flex-wrap: wrap; + + @media (max-width: 480px) { + flex-direction: column; + + .action-btn { + width: 100%; + justify-content: center; + } + } +} + +// ============================================================================ +// Action Buttons +// ============================================================================ + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs, 0.25rem); + padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: hidden; + flex: 1; + min-width: 120px; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover::before { + width: 300px; + height: 300px; + } + + &:active { + transform: scale(0.98); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } + + // Confirm Button + &.action-btn-confirm { + background: linear-gradient(135deg, var(--color-success, #10b981) 0%, var(--color-secondary-dark, #059669) 100%); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-secondary-dark, #059669) 0%, var(--color-success, #10b981) 100%); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + } + } + + // Complete Button + &.action-btn-complete { + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-primary-dark, #1e40af) 100%); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-primary-dark, #1e40af) 0%, var(--color-primary, #2563eb) 100%); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + } + } + + // Cancel Button + &.action-btn-cancel { + background: var(--bg-primary, #ffffff); + color: var(--color-danger, #ef4444); + border: 2px solid var(--color-danger, #ef4444); + box-shadow: var(--shadow-xs, 0 1px 2px 0 rgba(0, 0, 0, 0.05)); + + &:hover:not(:disabled) { + background: var(--color-danger, #ef4444); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + } + } + + @media (max-width: 480px) { + min-width: 100%; + } +} + +// ============================================================================ +// Empty State +// ============================================================================ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem) var(--space-xl, 2rem); + text-align: center; + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + border: 2px dashed var(--color-gray-300, #d1d5db); + + .empty-icon { + width: 80px; + height: 80px; + color: var(--color-gray-400, #9ca3af); + margin-bottom: var(--space-lg, 1.5rem); + opacity: 0.6; + animation: float 3s ease-in-out infinite; + } + + h3 { + font-size: var(--font-size-2xl, 1.5rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + } + + p { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: 0; + max-width: 400px; + } + + @media (max-width: 768px) { + padding: var(--space-2xl, 3rem) var(--space-lg, 1.5rem); + + .empty-icon { + width: 60px; + height: 60px; + } + + h3 { + font-size: var(--font-size-xl, 1.25rem); + } + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +// ============================================================================ +// Loading State (if needed) +// ============================================================================ + +.appointments-loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem); + color: var(--text-secondary, #4b5563); + font-size: var(--font-size-lg, 1.125rem); +} + +// ============================================================================ +// Error State (if needed) +// ============================================================================ + +.appointment-error { + padding: var(--space-md, 1rem); + background: var(--color-danger-light, #fee2e2); + border: 1px solid var(--color-danger, #ef4444); + border-radius: var(--radius-md, 0.5rem); + color: var(--color-danger, #ef4444); + font-size: var(--font-size-sm, 0.875rem); + margin-bottom: var(--space-lg, 1.5rem); + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + + &::before { + content: '⚠'; + font-size: var(--font-size-lg, 1.125rem); + } +} + +// ============================================================================ +// Responsive Enhancements +// ============================================================================ + +@media (max-width: 640px) { + .appointment-card { + border-radius: var(--radius-lg, 0.75rem); + } + + .appointment-header { + flex-wrap: wrap; + } + + .status-badge { + position: absolute; + top: var(--space-md, 1rem); + right: var(--space-md, 1rem); + } + + .appointment-info { + padding-right: var(--space-xl, 2rem); + } +} + +// ============================================================================ +// Print Styles (Optional) +// ============================================================================ + +@media print { + .appointments-section { + padding: 0; + } + + .appointment-card { + break-inside: avoid; + box-shadow: none; + border: 1px solid var(--color-gray-300, #d1d5db); + margin-bottom: var(--space-md, 1rem); + + &:hover { + transform: none; + box-shadow: none; + } + } + + .appointment-actions { + display: none; + } +} + diff --git a/frontend/src/app/pages/doctor/components/appointments/appointments.component.ts b/frontend/src/app/pages/doctor/components/appointments/appointments.component.ts new file mode 100644 index 0000000..5ea3685 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/appointments/appointments.component.ts @@ -0,0 +1,144 @@ +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AppointmentService, Appointment } from '../../../../services/appointment.service'; +import { ModalService } from '../../../../services/modal.service'; + +@Component({ + selector: 'app-appointments', + standalone: true, + imports: [CommonModule], + templateUrl: './appointments.component.html', + styleUrl: './appointments.component.scss' +}) +export class AppointmentsComponent implements OnInit { + @Input() appointments: Appointment[] = []; + @Input() error: string | null = null; + @Output() refreshRequested = new EventEmitter(); + + loading = false; + + constructor( + private appointmentService: AppointmentService, + private modalService: ModalService + ) {} + + ngOnInit() {} + + formatDate(dateString: string): string { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString(); + } + + formatTime(timeString: string): string { + if (!timeString) return 'N/A'; + return timeString.substring(0, 5); // Format HH:MM + } + + getStatusClass(status: string): string { + const statusLower = status?.toLowerCase() || ''; + if (statusLower === 'completed') return 'status-completed'; + if (statusLower === 'confirmed') return 'status-confirmed'; + if (statusLower === 'cancelled') return 'status-cancelled'; + return 'status-scheduled'; + } + + getUpcomingAppointments(): Appointment[] { + const now = new Date(); + return this.appointments + .filter(apt => { + const aptDate = new Date(`${apt.scheduledDate}T${apt.scheduledTime}`); + return aptDate >= now && apt.status !== 'CANCELLED' && apt.status !== 'COMPLETED'; + }) + .sort((a, b) => { + const dateA = new Date(`${a.scheduledDate}T${a.scheduledTime}`); + const dateB = new Date(`${b.scheduledDate}T${b.scheduledTime}`); + return dateA.getTime() - dateB.getTime(); + }); + } + + getPastAppointments(): Appointment[] { + const now = new Date(); + return this.appointments + .filter(apt => { + const aptDate = new Date(`${apt.scheduledDate}T${apt.scheduledTime}`); + return aptDate < now || apt.status === 'CANCELLED' || apt.status === 'COMPLETED'; + }) + .sort((a, b) => { + const dateA = new Date(`${a.scheduledDate}T${a.scheduledTime}`); + const dateB = new Date(`${b.scheduledDate}T${b.scheduledTime}`); + return dateB.getTime() - dateA.getTime(); + }); + } + + async confirmAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + try { + await this.appointmentService.confirmAppointment(appointment.id); + this.refreshRequested.emit(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to confirm appointment'; + } + } + + async cancelAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + const confirmed = await this.modalService.confirm( + 'Are you sure you want to cancel this appointment?', + 'Cancel Appointment', + 'Cancel Appointment', + 'Keep Appointment' + ); + if (!confirmed) return; + + try { + await this.appointmentService.cancelAppointment(appointment.id); + this.refreshRequested.emit(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to cancel appointment'; + } + } + + async completeAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + try { + await this.appointmentService.completeAppointment(appointment.id); + this.refreshRequested.emit(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to complete appointment'; + } + } + + async deleteAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this appointment? This action cannot be undone.', + 'Delete Appointment', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + + try { + await this.appointmentService.deleteAppointment(appointment.id); + this.refreshRequested.emit(); + this.error = null; + } catch (e: any) { + this.error = e?.response?.data?.message || e?.message || 'Failed to delete appointment'; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/availability/availability.component.html b/frontend/src/app/pages/doctor/components/availability/availability.component.html new file mode 100644 index 0000000..a9bb2cd --- /dev/null +++ b/frontend/src/app/pages/doctor/components/availability/availability.component.html @@ -0,0 +1,193 @@ +
+
+

My Availability

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

Add Availability Slot

+
+ Error: {{ error }} + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

Bulk Add Availability

+
+ Error: {{ error }} + +
+
+
+
+

Availability Slots

+ +
+
+
+
+ Slot {{ i + 1 }} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+

No slots added yet. Click "Add Slot" to create availability slots.

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

{{ slot.dayOfWeek }}

+

+ + + + + {{ formatTime(slot.startTime) }} - {{ formatTime(slot.endTime) }} +

+
+ + {{ slot.isAvailable ? 'Available' : 'Unavailable' }} + +
+
+ +
+
+
+ + +
+ + + + +

No Availability Slots

+

Add your availability slots so patients can book appointments with you.

+
+
\ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/availability/availability.component.scss b/frontend/src/app/pages/doctor/components/availability/availability.component.scss new file mode 100644 index 0000000..60fd6eb --- /dev/null +++ b/frontend/src/app/pages/doctor/components/availability/availability.component.scss @@ -0,0 +1,957 @@ +// ============================================================================ +// Enterprise Availability Component Styles +// Modern, Professional, and Responsive Styling +// ============================================================================ + +// ============================================================================ +// Component Root & Variables +// ============================================================================ + +:host, +app-availability { + display: block; + width: 100%; +} + +// ============================================================================ +// Main Section Container +// ============================================================================ + +.availability-section { + width: 100%; + padding: var(--space-xl, 2rem); + background: transparent; + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Section Header +// ============================================================================ + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-lg, 1.5rem); + margin-bottom: var(--space-2xl, 3rem); + flex-wrap: wrap; + + @media (max-width: 1024px) { + flex-direction: column; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-xl, 2rem); + } +} + +.section-title { + font-size: var(--font-size-3xl, 1.875rem); + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary, #111827); + margin: 0; + line-height: var(--line-height-tight, 1.25); + position: relative; + padding-bottom: var(--space-md, 1rem); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 60px; + height: 4px; + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + border-radius: var(--radius-full, 9999px); + } + + @media (max-width: 768px) { + font-size: var(--font-size-2xl, 1.5rem); + } +} + +// ============================================================================ +// Section Controls +// ============================================================================ + +.section-controls { + display: flex; + align-items: center; + gap: var(--space-md, 1rem); + flex-wrap: wrap; + + @media (max-width: 640px) { + width: 100%; + flex-direction: column; + + .filter-controls, + .action-buttons-group { + width: 100%; + } + } +} + +// ============================================================================ +// Filter Controls +// ============================================================================ + +.filter-controls { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); +} + +.filter-select { + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + padding-right: var(--space-2xl, 3rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9L12 15L18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--space-sm, 0.5rem) center; + background-size: 20px; + + &:hover { + border-color: var(--color-primary, #2563eb); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + } + + &:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + option { + padding: var(--space-sm, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + } + + @media (max-width: 640px) { + width: 100%; + } +} + +// ============================================================================ +// Action Buttons Group +// ============================================================================ + +.action-buttons-group { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + flex-wrap: wrap; + + @media (max-width: 640px) { + width: 100%; + + .request-btn { + flex: 1; + min-width: 0; + } + } +} + +// ============================================================================ +// Request Buttons +// ============================================================================ + +.request-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs, 0.25rem); + padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem); + border: 2px solid var(--color-primary, #2563eb); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--color-primary, #2563eb); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + box-shadow: var(--shadow-xs, 0 1px 2px 0 rgba(0, 0, 0, 0.05)); + position: relative; + overflow: hidden; + + svg { + width: 18px; + height: 18px; + flex-shrink: 0; + } + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(37, 99, 235, 0.1); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover { + background: var(--color-primary, #2563eb); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + + &::before { + width: 300px; + height: 300px; + } + } + + &:active { + transform: translateY(0); + } + + // Danger variant + &.request-btn-danger { + border-color: var(--color-danger, #ef4444); + color: var(--color-danger, #ef4444); + + &:hover { + background: var(--color-danger, #ef4444); + color: var(--text-inverse, #ffffff); + } + } + + @media (max-width: 480px) { + font-size: var(--font-size-xs, 0.75rem); + padding: var(--space-xs, 0.25rem) var(--space-md, 1rem); + + svg { + width: 16px; + height: 16px; + } + } +} + +// ============================================================================ +// Form Container +// ============================================================================ + +.request-form-container { + margin-bottom: var(--space-2xl, 3rem); + animation: slideDown var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1)); +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.create-form-container { + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + padding: var(--space-xl, 2rem); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + border: 1px solid var(--color-gray-200, #e5e7eb); + + h3 { + font-size: var(--font-size-2xl, 1.5rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-lg, 1.5rem) 0; + padding-bottom: var(--space-md, 1rem); + border-bottom: 2px solid var(--color-gray-200, #e5e7eb); + } + + @media (max-width: 768px) { + padding: var(--space-lg, 1.5rem); + + h3 { + font-size: var(--font-size-xl, 1.25rem); + } + } +} + +// ============================================================================ +// Form Elements +// ============================================================================ + +.create-form { + display: flex; + flex-direction: column; + gap: var(--space-lg, 1.5rem); +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs, 0.25rem); +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-md, 1rem); + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} + +.form-label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #111827); + margin-bottom: var(--space-xs, 0.25rem); +} + +.form-input { + width: 100%; + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + font-size: var(--font-size-base, 1rem); + font-family: var(--font-family-base, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + border-color: var(--color-primary, #2563eb); + } + + &:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + &::placeholder { + color: var(--color-gray-400, #9ca3af); + } + + &:disabled { + background: var(--color-gray-100, #f3f4f6); + color: var(--text-disabled, #9ca3af); + cursor: not-allowed; + } +} + +// ============================================================================ +// Form Error Message +// ============================================================================ + +.form-error-message { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md, 1rem); + padding: var(--space-md, 1rem); + background: var(--color-danger-light, #fee2e2); + border: 1px solid var(--color-danger, #ef4444); + border-radius: var(--radius-md, 0.5rem); + color: var(--color-danger, #ef4444); + font-size: var(--font-size-sm, 0.875rem); + margin-bottom: var(--space-lg, 1.5rem); + + strong { + font-weight: var(--font-weight-semibold, 600); + } + + button { + background: transparent; + border: none; + color: var(--color-danger, #ef4444); + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-bold, 700); + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm, 0.375rem); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + flex-shrink: 0; + + &:hover { + background: var(--color-danger, #ef4444); + color: var(--text-inverse, #ffffff); + } + } +} + +// ============================================================================ +// Form Actions +// ============================================================================ + +.form-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-md, 1rem); + padding-top: var(--space-md, 1rem); + border-top: 1px solid var(--color-gray-200, #e5e7eb); + + @media (max-width: 640px) { + flex-direction: column-reverse; + + button { + width: 100%; + } + } +} + +.submit-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-sm, 0.5rem) var(--space-xl, 2rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + color: var(--text-inverse, #ffffff); + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-accent, #8b5cf6) 0%, var(--color-primary, #2563eb) 100%); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + + &::before { + width: 300px; + height: 300px; + } + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } +} + +.cancel-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-sm, 0.5rem) var(--space-xl, 2rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--text-secondary, #4b5563); + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + border-color: var(--color-gray-400, #9ca3af); + background: var(--color-gray-50, #f9fafb); + color: var(--text-primary, #111827); + } + + &:active { + transform: scale(0.98); + } +} + +// ============================================================================ +// Bulk Availability Form +// ============================================================================ + +.bulk-slots-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-md, 1rem); + + h4 { + font-size: var(--font-size-lg, 1.125rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0; + } +} + +.add-slot-btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + padding: var(--space-xs, 0.25rem) var(--space-md, 1rem); + border: 2px dashed var(--color-primary, #2563eb); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--color-primary, #2563eb); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + svg { + width: 16px; + height: 16px; + } + + &:hover { + border-style: solid; + background: var(--color-primary, #2563eb); + color: var(--text-inverse, #ffffff); + transform: translateY(-2px); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + } +} + +.bulk-slots-container { + display: flex; + flex-direction: column; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-lg, 1.5rem); +} + +.bulk-slot-item { + background: var(--bg-secondary, #f9fafb); + border: 1px solid var(--color-gray-200, #e5e7eb); + border-radius: var(--radius-lg, 0.75rem); + padding: var(--space-md, 1rem); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + border-color: var(--color-primary, #2563eb); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + } +} + +.slot-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-md, 1rem); +} + +.slot-number { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-secondary, #4b5563); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.remove-slot-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: var(--radius-sm, 0.375rem); + background: var(--color-danger-light, #fee2e2); + color: var(--color-danger, #ef4444); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--color-danger, #ef4444); + color: var(--text-inverse, #ffffff); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } +} + +.empty-bulk-slots { + padding: var(--space-xl, 2rem); + text-align: center; + background: var(--bg-secondary, #f9fafb); + border: 2px dashed var(--color-gray-300, #d1d5db); + border-radius: var(--radius-lg, 0.75rem); + color: var(--text-secondary, #4b5563); + font-size: var(--font-size-sm, 0.875rem); +} + +// ============================================================================ +// Availability Grid +// ============================================================================ + +.availability-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: var(--space-lg, 1.5rem); + + @media (max-width: 1024px) { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-md, 1rem); + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Availability Card +// ============================================================================ + +.availability-card { + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + padding: var(--space-lg, 1.5rem); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + transform: scaleX(0); + transform-origin: left; + transition: transform var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1)); + } + + &:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + border-color: var(--color-primary, #2563eb); + + &::before { + transform: scaleX(1); + } + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Availability Header +// ============================================================================ + +.availability-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-md, 1rem); + + @media (max-width: 480px) { + flex-direction: column; + gap: var(--space-sm, 0.5rem); + } +} + +.availability-info { + flex: 1; + min-width: 0; +} + +.day-name { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + line-height: var(--line-height-tight, 1.25); + + @media (max-width: 768px) { + font-size: var(--font-size-lg, 1.125rem); + } +} + +.time-range { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: 0; + line-height: var(--line-height-normal, 1.5); + + svg { + width: 18px; + height: 18px; + color: var(--color-primary, #2563eb); + flex-shrink: 0; + } +} + +// ============================================================================ +// Status Badge +// ============================================================================ + +.status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-xs, 0.25rem) var(--space-md, 1rem); + border-radius: var(--radius-full, 9999px); + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-medium, 500); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + flex-shrink: 0; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &.status-confirmed { + background: var(--color-success-light, #d1fae5); + color: var(--color-success, #10b981); + border: 1px solid var(--color-success, #10b981); + } + + &.status-cancelled { + background: var(--color-danger-light, #fee2e2); + color: var(--color-danger, #ef4444); + border: 1px solid var(--color-danger, #ef4444); + } + + @media (max-width: 480px) { + font-size: 0.625rem; + padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem); + } +} + +// ============================================================================ +// Availability Actions +// ============================================================================ + +.availability-actions { + display: flex; + gap: var(--space-sm, 0.5rem); + margin-top: var(--space-md, 1rem); + padding-top: var(--space-md, 1rem); + border-top: 1px solid var(--color-gray-200, #e5e7eb); + + @media (max-width: 480px) { + flex-direction: column; + + .action-btn { + width: 100%; + justify-content: center; + } + } +} + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs, 0.25rem); + padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: hidden; + flex: 1; + min-width: 120px; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover::before { + width: 300px; + height: 300px; + } + + &:active { + transform: scale(0.98); + } + + &.action-btn-cancel { + background: var(--bg-primary, #ffffff); + color: var(--color-danger, #ef4444); + border: 2px solid var(--color-danger, #ef4444); + box-shadow: var(--shadow-xs, 0 1px 2px 0 rgba(0, 0, 0, 0.05)); + + &:hover { + background: var(--color-danger, #ef4444); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + } + } + + @media (max-width: 480px) { + min-width: 100%; + } +} + +// ============================================================================ +// Empty State +// ============================================================================ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem) var(--space-xl, 2rem); + text-align: center; + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + border: 2px dashed var(--color-gray-300, #d1d5db); + + .empty-icon { + width: 80px; + height: 80px; + color: var(--color-gray-400, #9ca3af); + margin-bottom: var(--space-lg, 1.5rem); + opacity: 0.6; + animation: float 3s ease-in-out infinite; + } + + h3 { + font-size: var(--font-size-2xl, 1.5rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + } + + p { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: 0; + max-width: 400px; + } + + @media (max-width: 768px) { + padding: var(--space-2xl, 3rem) var(--space-lg, 1.5rem); + + .empty-icon { + width: 60px; + height: 60px; + } + + h3 { + font-size: var(--font-size-xl, 1.25rem); + } + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +// ============================================================================ +// Responsive Enhancements +// ============================================================================ + +@media (max-width: 640px) { + .availability-card { + border-radius: var(--radius-lg, 0.75rem); + } + + .availability-header { + flex-wrap: wrap; + } + + .status-badge { + position: absolute; + top: var(--space-md, 1rem); + right: var(--space-md, 1rem); + } + + .availability-info { + padding-right: var(--space-xl, 2rem); + } +} + +// ============================================================================ +// Print Styles (Optional) +// ============================================================================ + +@media print { + .availability-section { + padding: 0; + } + + .section-controls, + .availability-actions, + .request-form-container { + display: none; + } + + .availability-card { + break-inside: avoid; + box-shadow: none; + border: 1px solid var(--color-gray-300, #d1d5db); + margin-bottom: var(--space-md, 1rem); + + &:hover { + transform: none; + box-shadow: none; + } + } +} + diff --git a/frontend/src/app/pages/doctor/components/availability/availability.component.ts b/frontend/src/app/pages/doctor/components/availability/availability.component.ts new file mode 100644 index 0000000..e6d1995 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/availability/availability.component.ts @@ -0,0 +1,194 @@ +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AvailabilityService, AvailabilityRequest, AvailabilityResponse, BulkAvailabilityRequest } from '../../../../services/availability.service'; +import { ModalService } from '../../../../services/modal.service'; +import { LoggerService } from '../../../../services/logger.service'; + +@Component({ + selector: 'app-availability', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './availability.component.html', + styleUrl: './availability.component.scss' +}) +export class AvailabilityComponent implements OnInit, OnChanges { + @Input() doctorId: string | null = null; + @Output() availabilityChanged = new EventEmitter(); + + availability: AvailabilityResponse[] = []; + showAvailabilityForm = false; + showBulkAvailabilityForm = false; + dayFilter: string = 'all'; + error: string | null = null; + + newAvailability: AvailabilityRequest = { + doctorId: '', + dayOfWeek: 'MONDAY', + startTime: '09:00', + endTime: '17:00' + }; + + bulkAvailability: BulkAvailabilityRequest = { + doctorId: '', + availabilities: [] + }; + + constructor( + private availabilityService: AvailabilityService, + private modalService: ModalService, + private logger: LoggerService + ) {} + + ngOnInit() { + if (this.doctorId) { + this.newAvailability.doctorId = this.doctorId; + this.bulkAvailability.doctorId = this.doctorId; + this.loadAvailability(); + } + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['doctorId'] && this.doctorId) { + this.newAvailability.doctorId = this.doctorId; + this.bulkAvailability.doctorId = this.doctorId; + this.loadAvailability(); + } + } + + formatTime(timeString: string): string { + if (!timeString) return 'N/A'; + return timeString.substring(0, 5); + } + + async loadAvailability() { + if (!this.doctorId) return; + + try { + if (this.dayFilter !== 'all') { + this.availability = await this.availabilityService.getAvailabilityByDay(this.doctorId, this.dayFilter); + } else { + this.availability = await this.availabilityService.getDoctorAvailability(this.doctorId); + } + } catch (e: any) { + this.logger.error('Error loading availability:', e); + this.availability = []; + } + } + + onDayFilterChange() { + this.loadAvailability(); + } + + async createAvailability() { + if (!this.newAvailability.doctorId || !this.newAvailability.startTime || !this.newAvailability.endTime) { + this.error = 'Please fill in all required fields'; + return; + } + + if (!this.doctorId) { + this.error = 'Doctor ID not available'; + return; + } + + this.newAvailability.doctorId = this.doctorId; + this.error = null; + + try { + await this.availabilityService.createAvailability(this.newAvailability); + this.showAvailabilityForm = false; + this.newAvailability = { + doctorId: this.doctorId, + dayOfWeek: 'MONDAY', + startTime: '09:00', + endTime: '17:00' + }; + this.error = null; + await this.loadAvailability(); + this.availabilityChanged.emit(); + } catch (e: any) { + this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to create availability slot'; + } + } + + async deleteAvailability(availabilityId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this availability slot?', + 'Delete Availability Slot', + 'Delete', + 'Cancel' + ); + if (!confirmed) return; + + try { + await this.availabilityService.deleteAvailability(availabilityId); + await this.loadAvailability(); + this.availabilityChanged.emit(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to delete availability slot'; + } + } + + async deleteAllAvailability() { + if (!this.doctorId) { + this.error = 'Doctor ID not available'; + return; + } + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete ALL availability slots? This action cannot be undone.', + 'Delete All Availability Slots', + 'Delete All', + 'Cancel' + ); + if (!confirmed) return; + + try { + await this.availabilityService.deleteAllDoctorAvailability(this.doctorId); + await this.loadAvailability(); + this.availabilityChanged.emit(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to delete all availability slots'; + } + } + + async createBulkAvailability() { + if (!this.bulkAvailability.doctorId || !this.bulkAvailability.availabilities.length) { + this.error = 'Please add at least one availability slot'; + return; + } + + if (!this.doctorId) { + this.error = 'Doctor ID not available'; + return; + } + + this.bulkAvailability.doctorId = this.doctorId; + this.error = null; + + try { + await this.availabilityService.createBulkAvailability(this.bulkAvailability); + this.showBulkAvailabilityForm = false; + this.bulkAvailability = { + doctorId: this.doctorId, + availabilities: [] + }; + this.error = null; + await this.loadAvailability(); + this.availabilityChanged.emit(); + } catch (e: any) { + this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to create bulk availability'; + } + } + + addBulkAvailabilitySlot() { + this.bulkAvailability.availabilities.push({ + dayOfWeek: 'MONDAY', + startTime: '09:00', + endTime: '17:00' + }); + } + + removeBulkAvailabilitySlot(index: number) { + this.bulkAvailability.availabilities.splice(index, 1); + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.html b/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.html new file mode 100644 index 0000000..613eda1 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.html @@ -0,0 +1,259 @@ +
+

Create New Appointment

+
+ +
+ + + + +
+ Note: Patient list is extracted from your appointments. To create appointments for new patients, you may need to enter the patient ID manually or contact an administrator. +
+
+
+
+ + + +
+ +
+ + + + + +
+
+ + + + +
+
+ + + +
+ + Patient Found & Selected + + + {{ selectedPatientName }} + +
+
+
+

+ Loading all registered patients from the system... +

+

+ No patients found matching "{{ patientSearchQuery }}" +

+

+ No patients available +

+ + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+ ✓ Appointment Created Successfully! + {{ successMessage }} +
+ +
+
+ + +
+
+ + + +
+ ✗ Failed to Create Appointment + {{ error }} +
+ +
+
+ + +
+
+ + +
+ + +
+ + +
\ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.scss b/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.scss new file mode 100644 index 0000000..3b4a256 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.scss @@ -0,0 +1,842 @@ +// ============================================================================ +// Enterprise Create Appointment Component Styles +// Modern, Professional, and Responsive Styling +// ============================================================================ + +// ============================================================================ +// Component Root & Variables +// ============================================================================ + +:host, +app-create-appointment { + display: block; + width: 100%; +} + +// ============================================================================ +// Main Section Container +// ============================================================================ + +.create-section { + width: 100%; + padding: var(--space-xl, 2rem); + background: transparent; + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Section Title +// ============================================================================ + +.section-title { + font-size: var(--font-size-3xl, 1.875rem); + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-2xl, 3rem) 0; + line-height: var(--line-height-tight, 1.25); + position: relative; + padding-bottom: var(--space-md, 1rem); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 60px; + height: 4px; + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + border-radius: var(--radius-full, 9999px); + } + + @media (max-width: 768px) { + font-size: var(--font-size-2xl, 1.5rem); + margin-bottom: var(--space-xl, 2rem); + } +} + +// ============================================================================ +// Form Container +// ============================================================================ + +.create-form-container { + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + padding: var(--space-xl, 2rem); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + } + + &:hover { + box-shadow: var(--shadow-xl, 0 20px 25px -5px rgba(0, 0, 0, 0.1)); + border-color: var(--color-primary, #2563eb); + } + + @media (max-width: 768px) { + padding: var(--space-lg, 1.5rem); + border-radius: var(--radius-lg, 0.75rem); + } +} + +// ============================================================================ +// Info Message +// ============================================================================ + +.info-message { + display: flex; + align-items: flex-start; + gap: var(--space-md, 1rem); + padding: var(--space-md, 1rem) var(--space-lg, 1.5rem); + background: var(--color-info-light, #dbeafe); + border: 1px solid var(--color-info, #3b82f6); + border-radius: var(--radius-lg, 0.75rem); + color: var(--color-info, #3b82f6); + font-size: var(--font-size-sm, 0.875rem); + line-height: var(--line-height-relaxed, 1.75); + margin-bottom: var(--space-xl, 2rem); + position: relative; + + svg { + width: 24px; + height: 24px; + flex-shrink: 0; + margin-top: var(--space-xs, 0.25rem); + opacity: 0.8; + } + + div { + flex: 1; + + strong { + font-weight: var(--font-weight-semibold, 600); + display: block; + margin-bottom: var(--space-xs, 0.25rem); + } + } + + @media (max-width: 768px) { + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + flex-direction: column; + gap: var(--space-sm, 0.5rem); + + svg { + align-self: flex-start; + } + } +} + +// ============================================================================ +// Form Elements +// ============================================================================ + +.create-form { + display: flex; + flex-direction: column; + gap: var(--space-xl, 2rem); + + @media (max-width: 768px) { + gap: var(--space-lg, 1.5rem); + } +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs, 0.25rem); + + &:last-child { + margin-bottom: 0; + } +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-lg, 1.5rem); + + @media (max-width: 1024px) { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-md, 1rem); + } + + @media (max-width: 640px) { + grid-template-columns: 1fr; + gap: var(--space-md, 1rem); + } +} + +.form-label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #111827); + margin-bottom: var(--space-xs, 0.25rem); + display: flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + + &::after { + content: '*'; + color: var(--color-danger, #ef4444); + font-weight: var(--font-weight-bold, 700); + margin-left: var(--space-xs, 0.25rem); + } + + // Remove asterisk for non-required fields if needed + &.optional::after { + display: none; + } +} + +.form-input { + width: 100%; + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + font-size: var(--font-size-base, 1rem); + font-family: var(--font-family-base, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + appearance: none; + + &:hover { + border-color: var(--color-primary, #2563eb); + } + + &:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + &::placeholder { + color: var(--color-gray-400, #9ca3af); + } + + &:disabled { + background: var(--color-gray-100, #f3f4f6); + color: var(--text-disabled, #9ca3af); + cursor: not-allowed; + border-color: var(--color-gray-200, #e5e7eb); + } + + // Number input styling + &[type="number"] { + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + opacity: 1; + height: auto; + cursor: pointer; + } + } + + // Date and time input styling + &[type="date"], + &[type="time"] { + position: relative; + + &::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + transition: opacity var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + opacity: 1; + } + } + } +} + +// Select dropdown specific styling +select.form-input { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9L12 15L18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--space-sm, 0.5rem) center; + background-size: 20px; + padding-right: var(--space-2xl, 3rem); + cursor: pointer; + + option { + padding: var(--space-sm, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + } +} + +// Manual input margin +.form-input-margin { + margin-top: var(--space-md, 1rem); +} + +// ============================================================================ +// Form Error Message +// ============================================================================ + +.form-error-message { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md, 1rem); + padding: var(--space-md, 1rem) var(--space-lg, 1.5rem); + background: var(--color-danger-light, #fee2e2); + border: 1px solid var(--color-danger, #ef4444); + border-radius: var(--radius-md, 0.5rem); + color: var(--color-danger, #ef4444); + font-size: var(--font-size-sm, 0.875rem); + margin-top: calc(var(--space-lg, 1.5rem) * -1); + margin-bottom: var(--space-lg, 1.5rem); + animation: slideDown var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + strong { + font-weight: var(--font-weight-semibold, 600); + } + + button { + background: transparent; + border: none; + color: var(--color-danger, #ef4444); + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-bold, 700); + cursor: pointer; + padding: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm, 0.375rem); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + flex-shrink: 0; + line-height: 1; + + &:hover { + background: var(--color-danger, #ef4444); + color: var(--text-inverse, #ffffff); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + gap: var(--space-sm, 0.5rem); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ============================================================================ +// Submit Button +// ============================================================================ + +.submit-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-md, 1rem) var(--space-2xl, 3rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + color: var(--text-inverse, #ffffff); + font-size: var(--font-size-lg, 1.125rem); + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + position: relative; + overflow: hidden; + align-self: flex-start; + min-width: 200px; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; + } + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-accent, #8b5cf6) 0%, var(--color-primary, #2563eb) 100%); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + + &::before { + width: 300px; + height: 300px; + } + + &::after { + left: 100%; + } + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + } + + // Loading state (if needed) + &.loading { + position: relative; + color: transparent; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: var(--text-inverse, #ffffff); + border-radius: 50%; + transform: translate(-50%, -50%); + animation: spin 0.8s linear infinite; + } + } + + @media (max-width: 640px) { + width: 100%; + align-self: stretch; + min-width: 0; + padding: var(--space-md, 1rem) var(--space-xl, 2rem); + } +} + +@keyframes spin { + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +// ============================================================================ +// Form Validation States +// ============================================================================ + +.form-group { + &.has-error { + .form-input { + border-color: var(--color-danger, #ef4444); + + &:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); + } + } + + .form-label { + color: var(--color-danger, #ef4444); + } + } + + &.has-success { + .form-input { + border-color: var(--color-success, #10b981); + + &:focus { + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); + } + } + } +} + +// ============================================================================ +// Patient Selection Enhancement +// ============================================================================ + +.form-group { + // Special styling for patient selection with manual input + &:has(input.form-input-margin) { + position: relative; + + .form-input-margin { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + border-color: var(--color-gray-300, #d1d5db); + background: var(--bg-secondary, #f9fafb); + + &::before { + content: 'OR'; + position: absolute; + top: -12px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-primary, #ffffff); + padding: 0 var(--space-sm, 0.5rem); + color: var(--text-secondary, #4b5563); + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-medium, 500); + } + + &:focus { + background: var(--bg-primary, #ffffff); + border-color: var(--color-primary, #2563eb); + } + } + } +} + +// ============================================================================ +// Responsive Enhancements +// ============================================================================ + +@media (max-width: 768px) { + .create-form-container { + border-radius: var(--radius-lg, 0.75rem); + } + + .form-row { + grid-template-columns: 1fr; + } + + .submit-button { + font-size: var(--font-size-base, 1rem); + } +} + +@media (max-width: 480px) { + .create-section { + padding: var(--space-sm, 0.5rem); + } + + .create-form-container { + padding: var(--space-md, 1rem); + } + + .form-input { + font-size: var(--font-size-sm, 0.875rem); + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + } +} + +// ============================================================================ +// Focus Visible for Accessibility +// ============================================================================ + +.form-input:focus-visible, +.submit-button:focus-visible { + outline: 2px solid var(--color-primary, #2563eb); + outline-offset: 2px; +} + +// ============================================================================ +// Print Styles (Optional) +// ============================================================================ + +@media print { + .create-section { + padding: 0; + } + + .create-form-container { + box-shadow: none; + border: 1px solid var(--color-gray-300, #d1d5db); + padding: var(--space-md, 1rem); + } + + .submit-button, + .info-message { + display: none; + } + + .form-input { + border: 1px solid var(--color-gray-400, #9ca3af); + background: transparent; + } +} + +// ============================================================================ +// Loading State (if needed) +// ============================================================================ + +.create-form-container { + &.loading { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-xl, 1rem); + z-index: 10; + } + } +} + +// ============================================================================ +// Notification Popup +// ============================================================================ + +.notification-popup { + position: fixed; + top: var(--space-xl, 2rem); + right: var(--space-xl, 2rem); + z-index: var(--z-modal, 1050); + min-width: 350px; + max-width: 500px; + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-lg, 0.75rem); + box-shadow: var(--shadow-2xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25)); + border: 1px solid var(--color-gray-200, #e5e7eb); + animation: slideInRight var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1)); + overflow: hidden; + + @media (max-width: 768px) { + top: var(--space-md, 1rem); + right: var(--space-md, 1rem); + left: var(--space-md, 1rem); + min-width: auto; + max-width: none; + } +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.popup-content { + display: flex; + align-items: flex-start; + gap: var(--space-md, 1rem); + padding: var(--space-lg, 1.5rem); + position: relative; +} + +.popup-icon { + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: var(--radius-full, 9999px); + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 24px; + height: 24px; + } +} + +.popup-success .popup-icon { + background: var(--color-success-light, #d1fae5); + color: var(--color-success, #10b981); +} + +.popup-error .popup-icon { + background: var(--color-danger-light, #fee2e2); + color: var(--color-danger, #ef4444); +} + +.popup-message { + flex: 1; + min-width: 0; +} + +.popup-title { + font-size: var(--font-size-lg, 1.125rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-xs, 0.25rem) 0; + line-height: var(--line-height-tight, 1.25); +} + +.popup-text { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); + margin: 0; + line-height: var(--line-height-normal, 1.5); +} + +.popup-close { + flex-shrink: 0; + width: 32px; + height: 32px; + padding: 0; + border: none; + background: transparent; + color: var(--text-tertiary, #6b7280); + cursor: pointer; + border-radius: var(--radius-sm, 0.375rem); + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--color-gray-100, #f3f4f6); + color: var(--text-primary, #111827); + } + + &:active { + transform: scale(0.95); + } +} + +.popup-refresh-btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + margin-top: var(--space-md, 1rem); + padding: var(--space-xs, 0.25rem) var(--space-md, 1rem); + border: 2px solid var(--color-primary, #2563eb); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--color-primary, #2563eb); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--color-primary, #2563eb); + color: var(--text-inverse, #ffffff); + transform: translateY(-2px); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + } + + &:active { + transform: translateY(0); + } +} + +.popup-progress { + position: absolute; + bottom: 0; + left: 0; + height: 4px; + background: linear-gradient(90deg, var(--color-success, #10b981) 0%, var(--color-secondary-dark, #059669) 100%); + animation: progressBar 3s linear forwards; +} + +@keyframes progressBar { + from { + width: 100%; + } + to { + width: 0; + } +} + +// Popup variants +.popup-success { + border-left: 4px solid var(--color-success, #10b981); +} + +.popup-error { + border-left: 4px solid var(--color-danger, #ef4444); +} + +// Responsive adjustments +@media (max-width: 640px) { + .notification-popup { + border-radius: var(--radius-md, 0.5rem); + } + + .popup-content { + padding: var(--space-md, 1rem); + gap: var(--space-sm, 0.5rem); + } + + .popup-icon { + width: 40px; + height: 40px; + + svg { + width: 20px; + height: 20px; + } + } + + .popup-title { + font-size: var(--font-size-base, 1rem); + } + + .popup-text { + font-size: var(--font-size-xs, 0.75rem); + } + + .popup-close { + width: 28px; + height: 28px; + + svg { + width: 16px; + height: 16px; + } + } +} + diff --git a/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.ts b/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.ts new file mode 100644 index 0000000..a2beed6 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/create-appointment/create-appointment.component.ts @@ -0,0 +1,466 @@ +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AppointmentService } from '../../../../services/appointment.service'; +import { UserService } from '../../../../services/user.service'; +import { LoggerService } from '../../../../services/logger.service'; + +@Component({ + selector: 'app-create-appointment', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './create-appointment.component.html', + styleUrl: './create-appointment.component.scss' +}) +export class CreateAppointmentComponent implements OnInit, OnChanges { + @Input() patients: any[] = []; + @Input() doctorId: string | null = null; + @Input() selectedPatientId: string | null = null; + @Output() appointmentCreated = new EventEmitter(); + + error: string | null = null; + successMessage: string | null = null; + showPopup: boolean = false; + popupType: 'success' | 'error' = 'success'; + creatingAppointment: boolean = false; // Loading state during creation + + // Patient search + patientSearchQuery: string = ''; + filteredPatients: any[] = []; + allRegisteredPatients: any[] = []; // All patients in the system + loadingPatients: boolean = false; + selectedPatientName: string = ''; // Store selected patient name for display + + newAppointment = { + patientId: '', + scheduledDate: '', + scheduledTime: '', + durationInMinutes: 30 + }; + + constructor( + private appointmentService: AppointmentService, + private userService: UserService, + private cdr: ChangeDetectorRef, + private logger: LoggerService + ) {} + + async ngOnInit() { + // Load all registered patients from the system first + await this.loadAllPatients(); + + // Auto-fill patient ID if provided + if (this.selectedPatientId) { + this.newAppointment.patientId = this.selectedPatientId; + this.logger.debug('[CreateAppointmentComponent] Auto-filled patient ID on init:', this.selectedPatientId); + // Find and set the patient name + const allPatients = [...this.allRegisteredPatients, ...(this.patients || [])]; + const selectedPatient = allPatients.find(p => p.id === this.selectedPatientId); + if (selectedPatient) { + this.selectedPatientName = selectedPatient.displayName || `${selectedPatient.firstName} ${selectedPatient.lastName}`; + } + } + + // Initialize filtered patients + this.filterPatients(); + } + + ngOnChanges(changes: SimpleChanges) { + // Auto-fill patient ID when selectedPatientId changes + if (changes['selectedPatientId'] && this.selectedPatientId) { + this.newAppointment.patientId = this.selectedPatientId; + this.logger.debug('[CreateAppointmentComponent] Auto-filled patient ID on change:', this.selectedPatientId); + // Find and set the patient name + const allPatients = [...this.allRegisteredPatients, ...(this.patients || [])]; + const selectedPatient = allPatients.find(p => p.id === this.selectedPatientId); + if (selectedPatient) { + this.selectedPatientName = selectedPatient.displayName || `${selectedPatient.firstName} ${selectedPatient.lastName}`; + } + } + // Update filtered patients when patients list changes (for backward compatibility) + if (changes['patients']) { + this.filterPatients(); + } + } + + async loadAllPatients() { + try { + this.loadingPatients = true; + const allPatients = await this.userService.getAllPatients(); + // Convert PatientProfile to the format expected by the component + this.allRegisteredPatients = allPatients.map(patient => ({ + id: patient.id, + userId: patient.userId, + firstName: patient.firstName, + lastName: patient.lastName, + displayName: `${patient.firstName} ${patient.lastName}`, + email: patient.email, + phoneNumber: patient.phoneNumber + })); + this.logger.debug('[CreateAppointmentComponent] Loaded all registered patients:', this.allRegisteredPatients.length); + // Update filtered patients after loading + this.filterPatients(); + } catch (e: any) { + this.logger.error('[CreateAppointmentComponent] Failed to load all patients:', e); + // Fall back to the patients passed from parent if loading fails + this.allRegisteredPatients = this.patients || []; + } finally { + this.loadingPatients = false; + } + } + + getTodayDate(): string { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + shouldShowManualPatientIdInput(): boolean { + if (!this.newAppointment.patientId) { + return true; + } + // Check both all registered patients and the passed patients list + const allPatients = [...this.allRegisteredPatients, ...(this.patients || [])]; + return !allPatients.some(p => p.id === this.newAppointment.patientId); + } + + async createAppointment() { + // Clear previous messages - but keep selectedPatientName for display + this.error = null; + this.successMessage = null; + this.showPopup = false; // Ensure popup is closed + + if (!this.newAppointment.patientId || !this.newAppointment.scheduledDate || !this.newAppointment.scheduledTime) { + this.error = 'Please fill in all required fields'; + this.showErrorPopup('Please fill in all required fields: Patient, Date, and Time are required.'); + return; + } + + if (!this.doctorId) { + this.error = 'Doctor ID not available. Please refresh the page.'; + this.showErrorPopup('Doctor ID not available. Please refresh the page.'); + return; + } + + this.creatingAppointment = true; + + try { + await this.appointmentService.createAppointment({ + patientId: this.newAppointment.patientId, + doctorId: this.doctorId, + scheduledDate: this.newAppointment.scheduledDate, + scheduledTime: this.newAppointment.scheduledTime, + durationMinutes: this.newAppointment.durationInMinutes || 30 + }); + + // Success - clear form and show success message + const patientName = this.selectedPatientName || 'Patient'; + const appointmentDate = new Date(this.newAppointment.scheduledDate).toLocaleDateString(); + const appointmentTime = this.newAppointment.scheduledTime; + + this.newAppointment = { + patientId: '', + scheduledDate: '', + scheduledTime: '', + durationInMinutes: 30 + }; + this.selectedPatientName = ''; + this.patientSearchQuery = ''; + this.error = null; + + // Show comprehensive success message + const successMsg = `Appointment created successfully! Scheduled for ${appointmentDate} at ${appointmentTime} with ${patientName}.`; + this.logger.debug('[CreateAppointment] Showing success popup:', successMsg); + + // Set success message first (for inline display) + this.successMessage = successMsg; + this.popupType = 'success'; + this.showPopup = true; + + // Force change detection immediately + this.cdr.detectChanges(); + + this.logger.debug('[CreateAppointment] showPopup after setting:', this.showPopup); + this.logger.debug('[CreateAppointment] successMessage after setting:', this.successMessage); + + // Verify popup element exists in DOM after a short delay + setTimeout(() => { + // Try to find popup in the component's view first + const popupElement = document.querySelector('app-create-appointment .notification-popup') || + document.querySelector('.notification-popup'); + this.logger.debug('[CreateAppointment] Popup element in DOM:', popupElement); + if (popupElement) { + const styles = window.getComputedStyle(popupElement); + this.logger.debug('[CreateAppointment] Popup computed styles:', { + display: styles.display, + visibility: styles.visibility, + opacity: styles.opacity, + zIndex: styles.zIndex, + position: styles.position, + top: styles.top, + right: styles.right + }); + + // Force show if hidden + if (styles.display === 'none' || styles.visibility === 'hidden' || parseFloat(styles.opacity) === 0) { + this.logger.warn('[CreateAppointment] Popup is hidden! Forcing visibility...'); + (popupElement as HTMLElement).style.setProperty('display', 'block', 'important'); + (popupElement as HTMLElement).style.setProperty('visibility', 'visible', 'important'); + (popupElement as HTMLElement).style.setProperty('opacity', '1', 'important'); + } + } else { + this.logger.error('[CreateAppointment] Popup element NOT found in DOM!'); + this.logger.debug('[CreateAppointment] showPopup value:', this.showPopup); + this.logger.debug('[CreateAppointment] popupType value:', this.popupType); + this.logger.debug('[CreateAppointment] successMessage value:', this.successMessage); + + // Try to manually create and append popup to body as fallback + this.createFallbackPopup(successMsg); + } + }, 100); + + this.appointmentCreated.emit(); + + // Auto-close popup after 5 seconds + setTimeout(() => { + this.closePopup(); + this.cdr.detectChanges(); + }, 5000); + + // Reload patients after creating appointment + await this.loadAllPatients(); + } catch (e: any) { + this.logger.error('Error creating appointment:', e); + + // Extract error message from various possible locations + let errorMessage = 'Failed to create appointment'; + let shouldRedirect = false; + + if (e?.response?.data) { + errorMessage = e.response.data.message || + e.response.data.error || + e.response.data.details || + (typeof e.response.data === 'string' ? e.response.data : errorMessage); + } else if (e?.message) { + errorMessage = e.message; + } + + // Provide more specific error messages based on status code + if (e?.response?.status === 401) { + errorMessage = 'Your session has expired. Please refresh the page or log in again.'; + // Don't auto-redirect - let user decide + shouldRedirect = false; // Changed: don't auto-redirect, let user handle it + } else if (e?.response?.status === 400) { + if (!errorMessage || errorMessage === 'Failed to create appointment') { + errorMessage = 'Invalid appointment details. Please check all fields and try again.'; + } + } else if (e?.response?.status === 403) { + errorMessage = 'You do not have permission to create appointments.'; + } else if (e?.response?.status === 404) { + errorMessage = 'Doctor or patient not found. Please refresh the page.'; + } else if (e?.response?.status === 409) { + errorMessage = errorMessage || 'This appointment conflicts with an existing appointment.'; + } + + this.error = errorMessage; + // Show comprehensive error message + const errorMsg = `Failed to create appointment: ${errorMessage}`; + this.showErrorPopup(errorMsg); + + // Only redirect if we got a 401 from the server and user hasn't manually dismissed + // Note: Removed auto-redirect to prevent redirect loops - user can manually refresh/login + // if (shouldRedirect) { + // setTimeout(() => { + // window.location.href = '/login'; + // }, 3000); + // } + } finally { + this.creatingAppointment = false; + } + } + + showSuccessPopup(message: string) { + this.logger.debug('[showSuccessPopup] Called with message:', message); + this.successMessage = message; + this.popupType = 'success'; + this.showPopup = true; + this.logger.debug('[showSuccessPopup] showPopup set to:', this.showPopup); + this.logger.debug('[showSuccessPopup] successMessage set to:', this.successMessage); + this.logger.debug('[showSuccessPopup] popupType set to:', this.popupType); + + // Force change detection + this.cdr.detectChanges(); + + // Keep success message visible longer so user can see it + setTimeout(() => { + this.closePopup(); + this.cdr.detectChanges(); + // Keep success message visible even after popup closes + // It will be cleared when user starts creating a new appointment + }, 5000); // Auto-close after 5 seconds (increased from 3) + } + + showErrorPopup(message: string) { + this.error = message; + this.popupType = 'error'; + this.showPopup = true; + + // For authentication errors, show longer and don't auto-close + const autoCloseDelay = message.includes('session') || message.includes('expired') || message.includes('log in') + ? 8000 // 8 seconds for auth errors so user can read + : 5000; // 5 seconds for other errors + + setTimeout(() => { + this.closePopup(); + }, autoCloseDelay); + } + + refreshPage() { + window.location.reload(); + } + + closePopup() { + this.showPopup = false; + // Don't clear successMessage here - keep it visible in inline message + // Only clear error when popup closes (user dismissed it) + if (this.popupType === 'error') { + // Keep error visible in inline message + } + } + + filterPatients() { + // Use all registered patients for search, but also include patients from parent (for backward compatibility) + const allPatients = [...this.allRegisteredPatients]; + // Add patients from parent that aren't already in allRegisteredPatients + (this.patients || []).forEach(patient => { + if (!allPatients.some(p => p.id === patient.id)) { + allPatients.push(patient); + } + }); + + if (!this.patientSearchQuery || this.patientSearchQuery.trim() === '') { + this.filteredPatients = allPatients; + // Update selected patient name if patient is still in the list + if (this.newAppointment.patientId) { + const selectedPatient = allPatients.find(p => p.id === this.newAppointment.patientId); + if (selectedPatient) { + this.selectedPatientName = selectedPatient.displayName || `${selectedPatient.firstName} ${selectedPatient.lastName}`; + } else { + // Clear selection if patient is no longer in the list + this.newAppointment.patientId = ''; + this.selectedPatientName = ''; + } + } + return; + } + + const query = this.patientSearchQuery.toLowerCase().trim(); + this.filteredPatients = allPatients.filter(patient => { + const firstName = (patient.firstName || '').toLowerCase(); + const lastName = (patient.lastName || '').toLowerCase(); + const fullName = `${firstName} ${lastName}`.trim(); + const displayName = (patient.displayName || fullName).toLowerCase(); + const email = (patient.email || '').toLowerCase(); + + return firstName.includes(query) || + lastName.includes(query) || + fullName.includes(query) || + displayName.includes(query) || + email.includes(query); + }); + + // Auto-fill if exactly one patient is found + if (this.filteredPatients.length === 1) { + const foundPatient = this.filteredPatients[0]; + if (this.newAppointment.patientId !== foundPatient.id) { + this.newAppointment.patientId = foundPatient.id; + this.selectedPatientName = foundPatient.displayName || `${foundPatient.firstName} ${foundPatient.lastName}`; + // Don't show popup for auto-selection - just update the name + // The green banner below the dropdown will show the patient is selected + } + } else if (this.filteredPatients.length === 0) { + // Clear selection if no patients found + this.newAppointment.patientId = ''; + this.selectedPatientName = ''; + } + } + + onPatientSearchChange() { + this.filterPatients(); + } + + clearPatientSearch() { + this.patientSearchQuery = ''; + this.filterPatients(); + } + + createFallbackPopup(message: string) { + // Create a fallback popup directly in the DOM if Angular isn't rendering it + const existingPopup = document.getElementById('appointment-success-popup'); + if (existingPopup) { + existingPopup.remove(); + } + + const popup = document.createElement('div'); + popup.id = 'appointment-success-popup'; + popup.className = 'notification-popup popup-success'; + popup.style.cssText = 'position: fixed !important; top: 20px !important; right: 20px !important; z-index: 99999 !important; min-width: 350px; max-width: 500px; background: white !important; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.2) !important; border: 2px solid #4caf50; animation: slideInRight 0.3s ease-out; display: block !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important;'; + + popup.innerHTML = ` +
+
+ + + + +
+
+

✓ Success!

+

${message}

+
+ +
+
+ `; + + document.body.appendChild(popup); + this.logger.debug('[CreateAppointment] Fallback popup created and appended to body'); + + // Auto-remove after 5 seconds + setTimeout(() => { + if (popup.parentElement) { + popup.remove(); + } + }, 5000); + } + + showPatientFoundNotification(patient: any) { + const patientName = patient.displayName || `${patient.firstName} ${patient.lastName}`; + // Show a simple inline message for patient selection, not the appointment creation popup + // Don't use the same popup system - just update the selectedPatientName which shows in the green banner + // The successMessage and popup are only for appointment creation + this.logger.debug(`Patient found and selected: ${patientName}`); + } + + onPatientSelect(patientId: string) { + if (patientId) { + const selectedPatient = this.filteredPatients.find(p => p.id === patientId); + if (selectedPatient) { + this.selectedPatientName = selectedPatient.displayName || `${selectedPatient.firstName} ${selectedPatient.lastName}`; + // Don't show popup for patient selection - just update the name + // The green banner below the dropdown will show the patient is selected + // Clear search after selection to show all patients in dropdown + this.patientSearchQuery = ''; + this.filterPatients(); + } + } else { + this.selectedPatientName = ''; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/ehr/ehr.component.html b/frontend/src/app/pages/doctor/components/ehr/ehr.component.html new file mode 100644 index 0000000..0c9b218 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/ehr/ehr.component.html @@ -0,0 +1,701 @@ +
+ +
+
+

EHR Management

+

Manage electronic health records for your patients

+
+
+ +
+
+ + +
+ Error: {{ error }} + +
+ + +
+
+
+

Select Patient

+

Choose a patient to view their EHR records, vital signs, and lab results.

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

+ No patients found matching "{{ patientSearchQuery }}" +

+

+ No patients available +

+
+
+ + +
+
+
+

Current Patient

+

+ {{ patient.firstName }} {{ patient.lastName }} +

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

Loading...

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

{{ isUpdatingMedicalRecord() ? 'Update' : 'Create' }} Medical Record

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

{{ showAllRecords ? 'All Medical Records' : 'Patient Medical Records' }} ({{ getCurrentRecords().length }})

+ +
+
+
+
+
+

{{ record.title }}

+
+ {{ record.recordType }} + {{ formatDateTime(record.createdAt) }} +
+
+
+ + +
+
+
+

{{ record.content }}

+
+
+ Diagnosis Code: + {{ record.diagnosisCode }} +
+
+ Patient: + {{ record.patientName || record.patientId }} +
+
+ Doctor: + {{ record.doctorName }} +
+
+
+
+
+
+ + + + +

No Medical Records

+

Select a patient or view all records to see medical records.

+

No medical records found. Create your first record to get started.

+ +
+
+
+ + +
+ +
+

Latest Vital Signs

+
+
+ Temperature + {{ latestVitalSigns.temperature }}°F +
+
+ Blood Pressure + {{ latestVitalSigns.bloodPressureSystolic }}/{{ latestVitalSigns.bloodPressureDiastolic }} mmHg +
+
+ Heart Rate + {{ latestVitalSigns.heartRate }} bpm +
+
+ Respiratory Rate + {{ latestVitalSigns.respiratoryRate }} /min +
+
+ O2 Saturation + {{ latestVitalSigns.oxygenSaturation }}% +
+
+ Weight + {{ latestVitalSigns.weight }} lbs +
+
+ Height + {{ latestVitalSigns.height }} in +
+
+ BMI + {{ latestVitalSigns.bmi | number:'1.1-1' }} +
+
+

Recorded: {{ formatDateTime(latestVitalSigns.recordedAt) }}

+
+ + +
+

Record Vital Signs

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

Vital Signs History ({{ vitalSigns.length }})

+ +
+
+
+
+ {{ formatDateTime(vital.recordedAt) }} + +
+
+
+ Temp: + {{ vital.temperature }}°F +
+
+ BP: + {{ vital.bloodPressureSystolic }}/{{ vital.bloodPressureDiastolic }} mmHg +
+
+ HR: + {{ vital.heartRate }} bpm +
+
+ RR: + {{ vital.respiratoryRate }} /min +
+
+ O2 Sat: + {{ vital.oxygenSaturation }}% +
+
+ Weight: + {{ vital.weight }} lbs +
+
+ Height: + {{ vital.height }} in +
+
+ BMI: + {{ vital.bmi | number:'1.1-1' }} +
+
+
+

Notes: {{ vital.notes }}

+
+
+
+
+ + + +

No Vital Signs Records

+

No vital signs have been recorded for this patient yet.

+ +
+
+
+ + +
+ +
+

{{ isUpdatingLabResult() ? 'Update' : 'Create' }} Lab Result

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

Lab Results ({{ labResults.length }})

+ +
+
+
+
+
+

{{ result.testName }}

+
+ {{ result.status }} + Ordered: {{ formatDate(result.orderedDate) }} +
+
+
+ + +
+
+
+
+ Result: {{ result.resultValue }} {{ result.unit }} +
+
+
+ Reference Range: + {{ result.referenceRange }} +
+
+ Result Date: + {{ formatDate(result.resultDate) }} +
+
+ Notes: + {{ result.notes }} +
+
+
+
+
+
+ + + +

No Lab Results

+

No lab results have been recorded for this patient yet.

+ +
+
+
+
+ + + diff --git a/frontend/src/app/pages/doctor/components/ehr/ehr.component.scss b/frontend/src/app/pages/doctor/components/ehr/ehr.component.scss new file mode 100644 index 0000000..a5325a1 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/ehr/ehr.component.scss @@ -0,0 +1,1490 @@ +// ============================================================================ +// Enterprise EHR Component Styles +// Modern, Professional, and Responsive Styling +// ============================================================================ + +// ============================================================================ +// Component Root & Variables +// ============================================================================ + +:host, +app-ehr { + display: block; + width: 100%; +} + +// ============================================================================ +// Main Section Container +// ============================================================================ + +.ehr-section { + width: 100%; + padding: var(--space-xl, 2rem); + background: transparent; + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Section Header +// ============================================================================ + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-lg, 1.5rem); + margin-bottom: var(--space-2xl, 3rem); + flex-wrap: wrap; + + @media (max-width: 1024px) { + flex-direction: column; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-xl, 2rem); + } +} + +.section-title { + font-size: var(--font-size-3xl, 1.875rem); + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + line-height: var(--line-height-tight, 1.25); + position: relative; + padding-bottom: var(--space-md, 1rem); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 60px; + height: 4px; + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + border-radius: var(--radius-full, 9999px); + } + + @media (max-width: 768px) { + font-size: var(--font-size-2xl, 1.5rem); + } +} + +.section-description { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: 0; + line-height: var(--line-height-normal, 1.5); + + @media (max-width: 768px) { + font-size: var(--font-size-sm, 0.875rem); + } +} + +.header-actions { + display: flex; + align-items: center; + gap: var(--space-md, 1rem); + flex-wrap: wrap; + + @media (max-width: 640px) { + width: 100%; + } +} + +// ============================================================================ +// Error Message +// ============================================================================ + +.error-message { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md, 1rem); + padding: var(--space-md, 1rem) var(--space-lg, 1.5rem); + background: var(--color-danger-light, #fee2e2); + border: 1px solid var(--color-danger, #ef4444); + border-radius: var(--radius-lg, 0.75rem); + color: var(--color-danger, #ef4444); + font-size: var(--font-size-sm, 0.875rem); + margin-bottom: var(--space-xl, 2rem); + animation: slideDown var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + strong { + font-weight: var(--font-weight-semibold, 600); + } + + button { + background: transparent; + border: none; + color: var(--color-danger, #ef4444); + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-bold, 700); + cursor: pointer; + padding: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm, 0.375rem); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + flex-shrink: 0; + line-height: 1; + + &:hover { + background: var(--color-danger, #ef4444); + color: var(--text-inverse, #ffffff); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + gap: var(--space-sm, 0.5rem); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ============================================================================ +// Card Component +// ============================================================================ + +.card { + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + padding: var(--space-xl, 2rem); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: hidden; + margin-bottom: var(--space-xl, 2rem); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + } + + &:hover { + box-shadow: var(--shadow-xl, 0 20px 25px -5px rgba(0, 0, 0, 0.1)); + border-color: var(--color-primary, #2563eb); + } + + h3 { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-lg, 1.5rem) 0; + line-height: var(--line-height-tight, 1.25); + } + + @media (max-width: 768px) { + padding: var(--space-lg, 1.5rem); + border-radius: var(--radius-lg, 0.75rem); + margin-bottom: var(--space-lg, 1.5rem); + } +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-lg, 1.5rem); + flex-wrap: wrap; + + h3 { + margin: 0; + } + + @media (max-width: 640px) { + flex-direction: column; + align-items: stretch; + + h3 { + text-align: center; + } + } +} + +// ============================================================================ +// Patient Selection & Info +// ============================================================================ + +.patient-info-card { + background: linear-gradient(135deg, var(--color-primary-light, #dbeafe) 0%, var(--color-accent-light, #ede9fe) 100%); + border-color: var(--color-primary, #2563eb); + + h3 { + color: var(--color-primary-dark, #1e40af); + } + + p { + font-size: var(--font-size-lg, 1.125rem); + color: var(--text-primary, #111827); + margin: 0; + font-weight: var(--font-weight-medium, 500); + + strong { + font-weight: var(--font-weight-bold, 700); + color: var(--color-primary-dark, #1e40af); + } + } +} + +.patient-info-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-md, 1rem); + + .view-profile-btn { + padding: var(--space-xs, 0.5rem) var(--space-md, 1rem); + background: var(--color-primary, #2563eb); + color: var(--text-inverse, #ffffff); + border: none; + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-xs, 0.5rem); + transition: all var(--transition-base, 200ms); + + &:hover { + background: var(--color-primary-dark, #1e40af); + transform: translateY(-1px); + } + + svg { + width: 16px; + height: 16px; + } + } +} + +// ============================================================================ +// Loading State +// ============================================================================ + +.loading-container { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem); + min-height: 300px; +} + +.spinner-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-lg, 1.5rem); + + p { + font-size: var(--font-size-lg, 1.125rem); + color: var(--text-secondary, #4b5563); + margin: 0; + font-weight: var(--font-weight-medium, 500); + } +} + +.spinner { + width: 48px; + height: 48px; + color: var(--color-primary, #2563eb); + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +// ============================================================================ +// Tabs Navigation +// ============================================================================ + +.ehr-tabs { + display: flex; + gap: var(--space-sm, 0.5rem); + margin-bottom: var(--space-xl, 2rem); + border-bottom: 2px solid var(--color-gray-200, #e5e7eb); + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-gray-300, #d1d5db) transparent; + + &::-webkit-scrollbar { + height: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--color-gray-300, #d1d5db); + border-radius: var(--radius-full, 9999px); + + &:hover { + background: var(--color-gray-400, #9ca3af); + } + } + + @media (max-width: 640px) { + gap: var(--space-xs, 0.25rem); + } +} + +.tab-button { + display: inline-flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-md, 1rem) var(--space-lg, 1.5rem); + border: none; + background: transparent; + color: var(--text-secondary, #4b5563); + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + white-space: nowrap; + + svg { + width: 20px; + height: 20px; + flex-shrink: 0; + transition: color var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + } + + .badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 var(--space-xs, 0.25rem); + background: var(--color-gray-200, #e5e7eb); + color: var(--text-secondary, #4b5563); + border-radius: var(--radius-full, 9999px); + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-semibold, 600); + } + + &:hover { + color: var(--color-primary, #2563eb); + + svg { + color: var(--color-primary, #2563eb); + } + + .badge { + background: var(--color-primary-light, #dbeafe); + color: var(--color-primary, #2563eb); + } + } + + &.active { + color: var(--color-primary, #2563eb); + border-bottom-color: var(--color-primary, #2563eb); + font-weight: var(--font-weight-semibold, 600); + + svg { + color: var(--color-primary, #2563eb); + } + + .badge { + background: var(--color-primary, #2563eb); + color: var(--text-inverse, #ffffff); + } + } + + @media (max-width: 640px) { + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + font-size: var(--font-size-sm, 0.875rem); + + svg { + width: 18px; + height: 18px; + } + + .badge { + min-width: 18px; + height: 18px; + font-size: 0.625rem; + } + } +} + +.tab-content { + animation: fadeIn var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ============================================================================ +// Form Elements +// ============================================================================ + +form { + display: flex; + flex-direction: column; + gap: var(--space-xl, 2rem); + + @media (max-width: 768px) { + gap: var(--space-lg, 1.5rem); + } +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs, 0.25rem); +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-lg, 1.5rem); + + @media (max-width: 1024px) { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-md, 1rem); + } + + @media (max-width: 640px) { + grid-template-columns: 1fr; + gap: var(--space-md, 1rem); + } +} + +.form-label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #111827); + margin-bottom: var(--space-xs, 0.25rem); + display: flex; + align-items: center; + gap: var(--space-xs, 0.25rem); +} + +.required-asterisk { + color: var(--color-danger, #ef4444); + font-weight: var(--font-weight-bold, 700); +} + +.form-input { + width: 100%; + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + font-size: var(--font-size-base, 1rem); + font-family: var(--font-family-base, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + appearance: none; + + &:hover { + border-color: var(--color-primary, #2563eb); + } + + &:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + &::placeholder { + color: var(--color-gray-400, #9ca3af); + } + + &:disabled { + background: var(--color-gray-100, #f3f4f6); + color: var(--text-disabled, #9ca3af); + cursor: not-allowed; + border-color: var(--color-gray-200, #e5e7eb); + } + + &[type="number"] { + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + opacity: 1; + height: auto; + cursor: pointer; + } + } + + &[type="date"] { + position: relative; + + &::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + transition: opacity var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + opacity: 1; + } + } + } +} + +select.form-input { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9L12 15L18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--space-sm, 0.5rem) center; + background-size: 20px; + padding-right: var(--space-2xl, 3rem); + cursor: pointer; + + option { + padding: var(--space-sm, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + } +} + +.form-textarea { + min-height: 120px; + resize: vertical; + font-family: var(--font-family-base, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + + &.form-input { + line-height: var(--line-height-relaxed, 1.75); + } +} + +.form-actions { + display: flex; + gap: var(--space-md, 1rem); + justify-content: flex-end; + margin-top: var(--space-md, 1rem); + flex-wrap: wrap; + + @media (max-width: 640px) { + flex-direction: column-reverse; + + button { + width: 100%; + } + } +} + +// ============================================================================ +// Buttons +// ============================================================================ + +.primary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + color: var(--text-inverse, #ffffff); + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + position: relative; + overflow: hidden; + + svg { + width: 18px; + height: 18px; + flex-shrink: 0; + } + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-accent, #8b5cf6) 0%, var(--color-primary, #2563eb) 100%); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + + &::before { + width: 300px; + height: 300px; + } + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + } + + @media (max-width: 640px) { + width: 100%; + justify-content: center; + } +} + +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem); + border: 2px solid var(--color-primary, #2563eb); + border-radius: var(--radius-md, 0.5rem); + background: var(--bg-primary, #ffffff); + color: var(--color-primary, #2563eb); + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover:not(:disabled) { + background: var(--color-primary, #2563eb); + color: var(--text-inverse, #ffffff); + transform: translateY(-2px); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + } + + @media (max-width: 640px) { + width: 100%; + justify-content: center; + } +} + +.form-actions button { + padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &[type="submit"] { + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-accent, #8b5cf6) 0%, var(--color-primary, #2563eb) 100%); + transform: translateY(-2px); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + } + } + + &[type="button"] { + background: var(--bg-primary, #ffffff); + color: var(--text-secondary, #4b5563); + border: 2px solid var(--color-gray-300, #d1d5db); + + &:hover:not(:disabled) { + background: var(--color-gray-50, #f9fafb); + border-color: var(--color-gray-400, #9ca3af); + color: var(--text-primary, #111827); + } + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + } +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: none; + border-radius: var(--radius-md, 0.5rem); + background: transparent; + color: var(--text-secondary, #4b5563); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + svg { + width: 20px; + height: 20px; + } + + &:hover { + background: var(--color-gray-100, #f3f4f6); + color: var(--color-primary, #2563eb); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + &.delete-button { + &:hover { + background: var(--color-danger-light, #fee2e2); + color: var(--color-danger, #ef4444); + } + } +} + +// ============================================================================ +// Medical Records +// ============================================================================ + +.medical-records-list { + display: flex; + flex-direction: column; + gap: var(--space-lg, 1.5rem); +} + +.medical-record-card { + background: var(--bg-secondary, #f9fafb); + border-radius: var(--radius-lg, 0.75rem); + padding: var(--space-lg, 1.5rem); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + + &:hover { + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + border-color: var(--color-primary, #2563eb); + transform: translateY(-2px); + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +.record-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-md, 1rem); + + h4 { + font-size: var(--font-size-lg, 1.125rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + line-height: var(--line-height-tight, 1.25); + } + + @media (max-width: 640px) { + flex-direction: column; + gap: var(--space-sm, 0.5rem); + } +} + +.record-meta { + display: flex; + align-items: center; + gap: var(--space-md, 1rem); + flex-wrap: wrap; +} + +.record-type { + display: inline-flex; + align-items: center; + padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem); + background: var(--color-primary-light, #dbeafe); + color: var(--color-primary, #2563eb); + border-radius: var(--radius-full, 9999px); + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-medium, 500); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.record-date { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); +} + +.record-actions { + display: flex; + gap: var(--space-xs, 0.25rem); + flex-shrink: 0; +} + +.record-content { + p { + font-size: var(--font-size-base, 1rem); + color: var(--text-primary, #111827); + line-height: var(--line-height-relaxed, 1.75); + margin: 0 0 var(--space-md, 1rem) 0; + white-space: pre-wrap; + word-wrap: break-word; + } +} + +.record-details { + display: flex; + flex-direction: column; + gap: var(--space-xs, 0.25rem); + padding-top: var(--space-md, 1rem); + border-top: 1px solid var(--color-gray-200, #e5e7eb); +} + +.detail-row { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + + .detail-label { + font-weight: var(--font-weight-medium, 500); + color: var(--text-secondary, #4b5563); + } + + .detail-value { + color: var(--text-primary, #111827); + } +} + +// ============================================================================ +// Vital Signs +// ============================================================================ + +.vital-signs-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-lg, 1.5rem); + margin-bottom: var(--space-lg, 1.5rem); + + @media (max-width: 768px) { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-md, 1rem); + } + + @media (max-width: 480px) { + grid-template-columns: 1fr; + } +} + +.vital-sign-item { + display: flex; + flex-direction: column; + gap: var(--space-xs, 0.25rem); + padding: var(--space-md, 1rem); + background: var(--bg-secondary, #f9fafb); + border-radius: var(--radius-md, 0.5rem); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + background: var(--color-primary-light, #dbeafe); + border-color: var(--color-primary, #2563eb); + transform: translateY(-2px); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + } + + .vital-label { + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-medium, 500); + color: var(--text-secondary, #4b5563); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .vital-value { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary, #111827); + } +} + +.vital-date { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); + margin: var(--space-md, 1rem) 0 0 0; + text-align: center; + font-style: italic; +} + +.vital-signs-list { + display: flex; + flex-direction: column; + gap: var(--space-lg, 1.5rem); +} + +.vital-signs-card { + background: var(--bg-secondary, #f9fafb); + border-radius: var(--radius-lg, 0.75rem); + padding: var(--space-lg, 1.5rem); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + border-color: var(--color-primary, #2563eb); + transform: translateY(-2px); + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +.vital-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-md, 1rem); + padding-bottom: var(--space-md, 1rem); + border-bottom: 1px solid var(--color-gray-200, #e5e7eb); +} + +.vital-signs-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-md, 1rem); + + @media (max-width: 480px) { + grid-template-columns: 1fr; + } +} + +.vital-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-sm, 0.375rem); + + .vital-label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--text-secondary, #4b5563); + } + + .vital-value { + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + } +} + +.vital-notes { + margin-top: var(--space-md, 1rem); + padding-top: var(--space-md, 1rem); + border-top: 1px solid var(--color-gray-200, #e5e7eb); + + p { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); + margin: 0; + line-height: var(--line-height-normal, 1.5); + + strong { + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + } + } +} + +// ============================================================================ +// Lab Results +// ============================================================================ + +.lab-results-list { + display: flex; + flex-direction: column; + gap: var(--space-lg, 1.5rem); +} + +.lab-result-card { + background: var(--bg-secondary, #f9fafb); + border-radius: var(--radius-lg, 0.75rem); + padding: var(--space-lg, 1.5rem); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + + &:hover { + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + border-color: var(--color-primary, #2563eb); + transform: translateY(-2px); + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +.lab-result-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-md, 1rem); + + h4 { + font-size: var(--font-size-lg, 1.125rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + line-height: var(--line-height-tight, 1.25); + } + + @media (max-width: 640px) { + flex-direction: column; + gap: var(--space-sm, 0.5rem); + } +} + +.lab-result-meta { + display: flex; + align-items: center; + gap: var(--space-md, 1rem); + flex-wrap: wrap; +} + +.lab-status { + display: inline-flex; + align-items: center; + padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem); + border-radius: var(--radius-full, 9999px); + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-medium, 500); + text-transform: uppercase; + letter-spacing: 0.05em; + + &.status-pending { + background: var(--color-info-light, #dbeafe); + color: var(--color-info, #3b82f6); + border: 1px solid var(--color-info, #3b82f6); + } + + &.status-normal { + background: var(--color-success-light, #d1fae5); + color: var(--color-success, #10b981); + border: 1px solid var(--color-success, #10b981); + } + + &.status-abnormal { + background: var(--color-warning-light, #fef3c7); + color: var(--color-warning, #f59e0b); + border: 1px solid var(--color-warning, #f59e0b); + } + + &.status-critical { + background: var(--color-danger-light, #fee2e2); + color: var(--color-danger, #ef4444); + border: 1px solid var(--color-danger, #ef4444); + } +} + +.lab-date { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); +} + +.lab-result-actions { + display: flex; + gap: var(--space-xs, 0.25rem); + flex-shrink: 0; +} + +.lab-result-content { + .lab-result-value { + font-size: var(--font-size-lg, 1.125rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin-bottom: var(--space-md, 1rem); + + strong { + font-weight: var(--font-weight-bold, 700); + color: var(--color-primary, #2563eb); + } + } +} + +.lab-result-details { + display: flex; + flex-direction: column; + gap: var(--space-xs, 0.25rem); + padding-top: var(--space-md, 1rem); + border-top: 1px solid var(--color-gray-200, #e5e7eb); +} + +// ============================================================================ +// Empty State +// ============================================================================ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem) var(--space-xl, 2rem); + text-align: center; + background: var(--bg-secondary, #f9fafb); + border-radius: var(--radius-lg, 0.75rem); + border: 2px dashed var(--color-gray-300, #d1d5db); + margin-top: var(--space-lg, 1.5rem); + + .empty-icon { + width: 80px; + height: 80px; + color: var(--color-gray-400, #9ca3af); + margin-bottom: var(--space-lg, 1.5rem); + opacity: 0.6; + animation: float 3s ease-in-out infinite; + } + + h3 { + font-size: var(--font-size-2xl, 1.5rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + } + + p { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: 0 0 var(--space-lg, 1.5rem) 0; + max-width: 400px; + line-height: var(--line-height-normal, 1.5); + } + + button { + margin-top: var(--space-md, 1rem); + } + + @media (max-width: 768px) { + padding: var(--space-2xl, 3rem) var(--space-lg, 1.5rem); + + .empty-icon { + width: 60px; + height: 60px; + } + + h3 { + font-size: var(--font-size-xl, 1.25rem); + } + + p { + font-size: var(--font-size-sm, 0.875rem); + } + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +// ============================================================================ +// Responsive Enhancements +// ============================================================================ + +@media (max-width: 640px) { + .ehr-section { + padding: var(--space-sm, 0.5rem); + } + + .card { + border-radius: var(--radius-lg, 0.75rem); + padding: var(--space-md, 1rem); + } + + .form-input { + font-size: var(--font-size-sm, 0.875rem); + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + } + + .primary-button, + .secondary-button { + font-size: var(--font-size-sm, 0.875rem); + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + } +} + +// ============================================================================ +// Focus Visible for Accessibility +// ============================================================================ + +.form-input:focus-visible, +.primary-button:focus-visible, +.secondary-button:focus-visible, +.tab-button:focus-visible, +.icon-button:focus-visible { + outline: 2px solid var(--color-primary, #2563eb); + outline-offset: 2px; +} + +// ============================================================================ +// Print Styles +// ============================================================================ + +@media print { + .ehr-section { + padding: 0; + } + + .card { + box-shadow: none; + border: 1px solid var(--color-gray-300, #d1d5db); + padding: var(--space-md, 1rem); + break-inside: avoid; + page-break-inside: avoid; + + &:hover { + transform: none; + box-shadow: none; + } + } + + .header-actions, + .form-actions, + .record-actions, + .vital-header, + .lab-result-actions, + .icon-button, + .primary-button, + .secondary-button { + display: none; + } + + .form-input { + border: 1px solid var(--color-gray-400, #9ca3af); + background: transparent; + } + + .ehr-tabs { + border-bottom: 2px solid var(--color-gray-400, #9ca3af); + } + + .tab-button { + &.active { + border-bottom-color: var(--color-gray-900, #111827); + color: var(--color-gray-900, #111827); + } + } +} + +// ============================================================================ +// Dark Mode Support (Optional) +// ============================================================================ + +// ============================================================================ +// Modal Styles (for Patient Profile) +// ============================================================================ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: var(--space-xl, 2rem); +} + +.modal-content { + background: var(--color-white, #ffffff); + border-radius: var(--radius-lg, 0.75rem); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + max-width: 500px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; + + &.profile-modal { + max-width: 800px; + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-lg, 1.5rem); + border-bottom: 1px solid var(--color-gray-200, #e5e7eb); + + h2 { + margin: 0; + font-size: var(--font-size-2xl, 1.5rem); + color: var(--text-primary, #111827); + } + + .modal-close { + background: none; + border: none; + font-size: var(--font-size-xl, 1.25rem); + color: var(--text-secondary, #6b7280); + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all var(--transition-base, 200ms); + + &:hover { + background: var(--color-gray-100, #f3f4f6); + color: var(--text-primary, #111827); + } + + svg { + width: 20px; + height: 20px; + } + } +} + +.modal-body { + padding: var(--space-lg, 1.5rem); +} + +// Profile Full View Styles (shared with patient component) +.profile-full-view { + .profile-section { + margin-bottom: var(--space-xl, 2rem); + padding-bottom: var(--space-lg, 1.5rem); + border-bottom: 1px solid var(--color-gray-200, #e5e7eb); + + &:last-child { + border-bottom: none; + margin-bottom: 0; + } + + h3 { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-md, 1rem) 0; + } + } + + .info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--space-md, 1rem); + } + + .info-item { + display: flex; + flex-direction: column; + gap: var(--space-xs, 0.25rem); + + .info-label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-secondary, #6b7280); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .info-value { + font-size: var(--font-size-base, 1rem); + color: var(--text-primary, #111827); + } + } + + .tags-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs, 0.5rem); + margin-top: var(--space-xs, 0.5rem); + } + + .tag { + padding: var(--space-xs, 0.5rem) var(--space-md, 1rem); + background: var(--color-primary-light, #dbeafe); + color: var(--color-primary-dark, #1e40af); + border-radius: 9999px; + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + } + + .biography-text { + line-height: 1.8; + color: var(--text-primary, #111827); + white-space: pre-wrap; + } +} + +@media (prefers-color-scheme: dark) { + // Add dark mode styles if needed + // This is a placeholder for future dark mode support +} + diff --git a/frontend/src/app/pages/doctor/components/ehr/ehr.component.ts b/frontend/src/app/pages/doctor/components/ehr/ehr.component.ts new file mode 100644 index 0000000..b76f9dc --- /dev/null +++ b/frontend/src/app/pages/doctor/components/ehr/ehr.component.ts @@ -0,0 +1,626 @@ +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MedicalRecordService, MedicalRecord, MedicalRecordRequest, VitalSigns, VitalSignsRequest, LabResultRequest } from '../../../../services/medical-record.service'; +import { UserService, PatientProfile } from '../../../../services/user.service'; +import { ModalService } from '../../../../services/modal.service'; +import { LoggerService } from '../../../../services/logger.service'; + +@Component({ + selector: 'app-ehr', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './ehr.component.html', + styleUrl: './ehr.component.scss' +}) +export class EhrComponent implements OnInit, OnChanges { + @Input() doctorId: string | null = null; + @Input() selectedPatientId: string | null = null; + @Input() patients: any[] = []; + @Output() patientSelected = new EventEmitter(); + @Output() dataChanged = new EventEmitter(); + + // State + showAllRecords = false; + activeTab: 'records' | 'vitals' | 'labResults' = 'records'; + loading = false; + error: string | null = null; + + // Local patient selection for dropdown (separate from Input) + localSelectedPatientId: string | null = null; + + // Patient search + patientSearchQuery: string = ''; + filteredPatients: any[] = []; + + // Medical Records + medicalRecords: MedicalRecord[] = []; + allMedicalRecords: MedicalRecord[] = []; + showCreateMedicalRecord = false; + newMedicalRecord: MedicalRecordRequest = { + patientId: '', + doctorId: '', + recordType: 'NOTE', + title: '', + content: '', + diagnosisCode: '' + }; + + // Vital Signs + vitalSigns: VitalSigns[] = []; + latestVitalSigns: VitalSigns | null = null; + showCreateVitalSigns = false; + newVitalSigns: VitalSignsRequest = { + patientId: '', + temperature: undefined, + bloodPressureSystolic: undefined, + bloodPressureDiastolic: undefined, + heartRate: undefined, + respiratoryRate: undefined, + oxygenSaturation: undefined, + weight: undefined, + height: undefined, + notes: '' + }; + + // Lab Results + labResults: any[] = []; + showCreateLabResult = false; + showUpdateLabResult = false; + selectedLabResult: any = null; + newLabResult: LabResultRequest = { + patientId: undefined as any, + doctorId: '', + testName: '', + resultValue: '', + status: 'PENDING', + orderedDate: new Date().toISOString().split('T')[0] + }; + + // Patient Profile Modal + selectedPatientProfile: PatientProfile | null = null; + showPatientProfileModal = false; + + constructor( + private medicalRecordService: MedicalRecordService, + private userService: UserService, + private modalService: ModalService, + private logger: LoggerService + ) {} + + ngOnInit() { + if (this.doctorId) { + this.newMedicalRecord.doctorId = this.doctorId; + this.newLabResult.doctorId = this.doctorId; + this.loadAllDoctorRecords(); + } + // Initialize local selection from Input if provided, but don't auto-load + // This allows parent to set initial selection, but user must explicitly choose + if (this.selectedPatientId) { + this.localSelectedPatientId = this.selectedPatientId; + } + // Initialize filtered patients + this.filterPatients(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['doctorId'] && this.doctorId) { + this.newMedicalRecord.doctorId = this.doctorId; + this.newLabResult.doctorId = this.doctorId; + if (!this.allMedicalRecords.length) { + this.loadAllDoctorRecords(); + } + } + // Update local selection when Input changes, but don't auto-load data + // User must explicitly select a patient from the dropdown + if (changes['selectedPatientId']) { + if (this.selectedPatientId) { + this.localSelectedPatientId = this.selectedPatientId; + } else { + this.localSelectedPatientId = null; + this.showAllRecords = true; + } + } + // Update filtered patients when patients list changes + if (changes['patients']) { + this.filterPatients(); + } + } + + async loadAllDoctorRecords() { + if (!this.doctorId) return; + try { + this.loading = true; + this.allMedicalRecords = await this.medicalRecordService.getMedicalRecordsByDoctorId(this.doctorId); + } catch (e: any) { + this.logger.error('Error loading all doctor records:', e); + this.error = e?.response?.data?.error || 'Failed to load medical records'; + this.allMedicalRecords = []; + } finally { + this.loading = false; + } + } + + async loadPatientData() { + if (!this.localSelectedPatientId) return; + await Promise.all([ + this.loadPatientMedicalRecords(), + this.loadPatientVitalSigns(), + this.loadPatientLabResults() + ]); + } + + async loadPatientMedicalRecords() { + if (!this.localSelectedPatientId) return; + try { + this.loading = true; + this.medicalRecords = await this.medicalRecordService.getMedicalRecordsByPatientId(this.localSelectedPatientId); + } catch (e: any) { + this.logger.error('Error loading medical records:', e); + this.error = e?.response?.data?.error || 'Failed to load medical records'; + this.medicalRecords = []; + } finally { + this.loading = false; + } + } + + async loadPatientVitalSigns() { + if (!this.localSelectedPatientId) return; + try { + this.loading = true; + [this.vitalSigns, this.latestVitalSigns] = await Promise.all([ + this.medicalRecordService.getVitalSignsByPatientId(this.localSelectedPatientId), + this.medicalRecordService.getLatestVitalSignsByPatientId(this.localSelectedPatientId) + ]); + } catch (e: any) { + this.logger.error('Error loading vital signs:', e); + this.error = e?.response?.data?.error || 'Failed to load vital signs'; + this.vitalSigns = []; + this.latestVitalSigns = null; + } finally { + this.loading = false; + } + } + + async loadPatientLabResults() { + if (!this.localSelectedPatientId) return; + try { + this.loading = true; + this.labResults = await this.medicalRecordService.getLabResultsByPatientId(this.localSelectedPatientId); + } catch (e: any) { + this.logger.error('Error loading lab results:', e); + this.error = e?.response?.data?.error || 'Failed to load lab results'; + this.labResults = []; + } finally { + this.loading = false; + } + } + + selectPatient(patientId: string | null) { + if (!patientId) { + // Clear selection + this.localSelectedPatientId = null; + this.selectedPatientId = null; + this.showAllRecords = true; + this.patientSelected.emit(''); + // Clear patient-specific data + this.medicalRecords = []; + this.vitalSigns = []; + this.latestVitalSigns = null; + this.labResults = []; + return; + } + + // User explicitly selected a patient - load their data + this.localSelectedPatientId = patientId; + this.selectedPatientId = patientId; + this.showAllRecords = false; + this.patientSelected.emit(patientId); + this.loadPatientData(); + } + + async toggleRecordsView() { + this.showAllRecords = !this.showAllRecords; + if (this.showAllRecords) { + await this.loadAllDoctorRecords(); + // Clear patient selection when showing all records + this.localSelectedPatientId = null; + } else if (this.localSelectedPatientId) { + // Only load if patient is explicitly selected + await this.loadPatientData(); + } + } + + // Medical Records Methods + openCreateMedicalRecord() { + this.showCreateMedicalRecord = !this.showCreateMedicalRecord; + if (this.showCreateMedicalRecord) { + this.newMedicalRecord = { + patientId: this.localSelectedPatientId || '', + doctorId: this.doctorId || '', + recordType: 'NOTE', + title: '', + content: '', + diagnosisCode: '' + }; + } + } + + async createMedicalRecord() { + if (!this.newMedicalRecord.patientId || !this.newMedicalRecord.title || !this.newMedicalRecord.content) { + this.error = 'Please fill in all required fields'; + return; + } + if (!this.newMedicalRecord.doctorId) { + this.newMedicalRecord.doctorId = this.doctorId || ''; + } + try { + this.loading = true; + const recordId = (this.newMedicalRecord as any).recordId; + if (recordId) { + await this.medicalRecordService.updateMedicalRecord(recordId, this.newMedicalRecord); + } else { + await this.medicalRecordService.createMedicalRecord(this.newMedicalRecord); + } + this.showCreateMedicalRecord = false; + this.newMedicalRecord = { + patientId: '', + doctorId: this.doctorId || '', + recordType: 'NOTE', + title: '', + content: '', + diagnosisCode: '' + }; + delete (this.newMedicalRecord as any).recordId; + await Promise.all([ + this.localSelectedPatientId ? this.loadPatientMedicalRecords() : Promise.resolve(), + this.loadAllDoctorRecords() + ]); + this.dataChanged.emit(); + } catch (e: any) { + this.logger.error('Error creating/updating medical record:', e); + const recordId = (this.newMedicalRecord as any).recordId; + const errorMessage = e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + (recordId ? 'Failed to update medical record' : 'Failed to create medical record'); + this.error = errorMessage; + // Auto-hide error after 5 seconds + setTimeout(() => { + this.error = null; + }, 5000); + } finally { + this.loading = false; + } + } + + editMedicalRecord(record: MedicalRecord) { + this.newMedicalRecord = { + patientId: record.patientId, + doctorId: record.doctorId, + appointmentId: record.appointmentId, + recordType: record.recordType, + title: record.title, + content: record.content, + diagnosisCode: record.diagnosisCode + }; + this.showCreateMedicalRecord = true; + (this.newMedicalRecord as any).recordId = record.id; + } + + async deleteMedicalRecord(recordId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this medical record? This action cannot be undone.', + 'Delete Medical Record', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + try { + this.loading = true; + await this.medicalRecordService.deleteMedicalRecord(recordId); + if (this.showAllRecords) { + await this.loadAllDoctorRecords(); + } else if (this.localSelectedPatientId) { + await this.loadPatientMedicalRecords(); + } + this.dataChanged.emit(); + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to delete medical record'; + } finally { + this.loading = false; + } + } + + // Vital Signs Methods + openCreateVitalSigns() { + this.showCreateVitalSigns = !this.showCreateVitalSigns; + if (this.showCreateVitalSigns) { + this.newVitalSigns = { + patientId: this.localSelectedPatientId || '', + temperature: undefined, + bloodPressureSystolic: undefined, + bloodPressureDiastolic: undefined, + heartRate: undefined, + respiratoryRate: undefined, + oxygenSaturation: undefined, + weight: undefined, + height: undefined, + notes: '' + }; + } + } + + async createVitalSigns() { + if (!this.newVitalSigns.patientId) { + this.error = 'Please select a patient'; + return; + } + try { + this.loading = true; + await this.medicalRecordService.createVitalSigns(this.newVitalSigns); + this.showCreateVitalSigns = false; + this.newVitalSigns = { + patientId: '', + temperature: undefined, + bloodPressureSystolic: undefined, + bloodPressureDiastolic: undefined, + heartRate: undefined, + respiratoryRate: undefined, + oxygenSaturation: undefined, + weight: undefined, + height: undefined, + notes: '' + }; + if (this.localSelectedPatientId) { + await this.loadPatientVitalSigns(); + } + this.dataChanged.emit(); + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to create vital signs'; + } finally { + this.loading = false; + } + } + + async deleteVitalSigns(vitalSignsId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this vital signs record?', + 'Delete Vital Signs', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + try { + this.loading = true; + await this.medicalRecordService.deleteVitalSigns(vitalSignsId); + if (this.localSelectedPatientId) { + await this.loadPatientVitalSigns(); + } + this.dataChanged.emit(); + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to delete vital signs'; + } finally { + this.loading = false; + } + } + + // Lab Results Methods + openCreateLabResult() { + this.showCreateLabResult = !this.showCreateLabResult; + this.showUpdateLabResult = false; + if (this.showCreateLabResult) { + this.newLabResult = { + patientId: undefined as any, + doctorId: this.doctorId || '', + testName: '', + resultValue: '', + status: 'PENDING', + orderedDate: new Date().toISOString().split('T')[0] + }; + if (this.localSelectedPatientId) { + this.newLabResult.patientId = this.localSelectedPatientId as any; + } + delete (this.newLabResult as any).labResultId; + } + } + + async createLabResult() { + // Auto-fill patientId from context if missing + if (!this.newLabResult.patientId) { + if (this.localSelectedPatientId) { + this.newLabResult.patientId = this.localSelectedPatientId as any; + } else if (Array.isArray(this.patients) && this.patients.length === 1) { + this.newLabResult.patientId = this.patients[0].id as any; + } + } + + // If patientId is not in known patient IDs, try to resolve from userId + if (this.newLabResult.patientId && Array.isArray(this.patients) && !this.patients.some(p => p.id === this.newLabResult.patientId)) { + try { + const resolved = await this.userService.getPatientIdByUserId(this.newLabResult.patientId as any); + if (resolved) { + this.newLabResult.patientId = resolved as any; + } + } catch {} + } + + const missing: string[] = []; + if (!this.newLabResult.patientId) missing.push('patient'); + if (!this.newLabResult.testName) missing.push('test name'); + if (!this.newLabResult.resultValue) missing.push('result value'); + + if (missing.length) { + this.error = `Please fill in all required fields: ${missing.join(', ')}`; + return; + } + + try { + this.loading = true; + const labResultId = (this.newLabResult as any).labResultId; + if (labResultId) { + await this.medicalRecordService.updateLabResult(labResultId, this.newLabResult); + } else { + await this.medicalRecordService.createLabResult(this.newLabResult); + } + this.showCreateLabResult = false; + this.showUpdateLabResult = false; + this.newLabResult = { + patientId: undefined as any, + doctorId: this.doctorId || '', + testName: '', + resultValue: '', + status: 'PENDING', + orderedDate: new Date().toISOString().split('T')[0] + }; + delete (this.newLabResult as any).labResultId; + if (this.localSelectedPatientId) { + await this.loadPatientLabResults(); + } + this.dataChanged.emit(); + } catch (e: any) { + this.logger.error('[CreateLabResult] Error:', e); + let errorMsg = 'Failed to create lab result'; + if (e?.response?.data) { + if (typeof e.response.data === 'string') { + errorMsg = e.response.data; + } else if (e.response.data.error) { + errorMsg = e.response.data.error; + } else if (e.response.data.message) { + errorMsg = e.response.data.message; + } + } else if (e?.message) { + errorMsg = e.message; + } + this.error = errorMsg; + } finally { + this.loading = false; + } + } + + editLabResult(labResult: any) { + this.selectedLabResult = labResult; + this.newLabResult = { + patientId: labResult.patientId, + doctorId: labResult.doctorId, + testName: labResult.testName, + resultValue: labResult.resultValue, + referenceRange: labResult.referenceRange, + unit: labResult.unit, + status: labResult.status, + orderedDate: labResult.orderedDate, + resultDate: labResult.resultDate, + notes: labResult.notes + }; + this.showCreateLabResult = true; + this.showUpdateLabResult = true; + (this.newLabResult as any).labResultId = labResult.id; + } + + async deleteLabResult(labResultId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this lab result?', + 'Delete Lab Result', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + try { + this.loading = true; + await this.medicalRecordService.deleteLabResult(labResultId); + if (this.localSelectedPatientId) { + await this.loadPatientLabResults(); + } + this.dataChanged.emit(); + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to delete lab result'; + } finally { + this.loading = false; + } + } + + formatDate(dateString: string): string { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString(); + } + + formatDateTime(dateString: string): string { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleString(); + } + + getCurrentRecords() { + return this.showAllRecords ? this.allMedicalRecords : this.medicalRecords; + } + + isUpdatingMedicalRecord(): boolean { + return !!(this.newMedicalRecord as any).recordId; + } + + isUpdatingLabResult(): boolean { + return !!(this.newLabResult as any).labResultId; + } + + cancelMedicalRecordForm() { + this.showCreateMedicalRecord = false; + delete (this.newMedicalRecord as any).recordId; + } + + cancelLabResultForm() { + this.showCreateLabResult = false; + this.showUpdateLabResult = false; + delete (this.newLabResult as any).labResultId; + } + + async viewPatientProfile(patientId: string) { + try { + this.selectedPatientProfile = await this.userService.getPatientProfileById(patientId); + this.showPatientProfileModal = true; + } catch (e: any) { + this.error = e?.message || 'Failed to load patient profile'; + this.logger.error('Failed to load patient profile:', e); + } + } + + closePatientProfileModal() { + this.showPatientProfileModal = false; + this.selectedPatientProfile = null; + } + + filterPatients() { + if (!this.patientSearchQuery || this.patientSearchQuery.trim() === '') { + this.filteredPatients = this.patients || []; + return; + } + + const query = this.patientSearchQuery.toLowerCase().trim(); + this.filteredPatients = (this.patients || []).filter(patient => { + const firstName = (patient.firstName || '').toLowerCase(); + const lastName = (patient.lastName || '').toLowerCase(); + const fullName = `${firstName} ${lastName}`.trim(); + const email = (patient.email || '').toLowerCase(); + + return firstName.includes(query) || + lastName.includes(query) || + fullName.includes(query) || + email.includes(query); + }); + } + + onPatientSearchChange() { + this.filterPatients(); + } + + clearPatientSearch() { + this.patientSearchQuery = ''; + this.filterPatients(); + } +} diff --git a/frontend/src/app/pages/doctor/components/patients/patients.component.html b/frontend/src/app/pages/doctor/components/patients/patients.component.html new file mode 100644 index 0000000..ce652bc --- /dev/null +++ b/frontend/src/app/pages/doctor/components/patients/patients.component.html @@ -0,0 +1,118 @@ +
+
+
+

Search Patients

+

Search all patients in the system. Create appointments or start chats with them.

+
+ +
+ + +
+ +
+ Showing {{ filteredPatients.length }} of {{ patients.length }} patient{{ patients.length !== 1 ? 's' : '' }} +
+
+ +
+
+
+
+ + + + +
+
+

{{ patient.firstName }} {{ patient.lastName }}

+

{{ patient.email }}

+

ID: {{ patient.id }}

+
+
+
+
+ Phone: + {{ patient.phoneNumber }} +
+
+ Blood Type: + {{ patient.bloodType }} +
+
+
+ + + +
+
+
+
+ + + + +

No Patients Found

+

No patients are registered in the system yet. Use the search bar to find patients.

+
+ +
+
+

Loading patients...

+
+ +
+ + + +

No Patients Found

+

No patients match your search criteria. Try adjusting your search terms.

+ +
+
\ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/patients/patients.component.scss b/frontend/src/app/pages/doctor/components/patients/patients.component.scss new file mode 100644 index 0000000..bc61611 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/patients/patients.component.scss @@ -0,0 +1,816 @@ +// ============================================================================ +// Enterprise Patients Component Styles +// Modern, Professional, and Responsive Styling +// ============================================================================ + +// ============================================================================ +// Component Root & Variables +// ============================================================================ + +:host, +app-patients { + display: block; + width: 100%; +} + +// ============================================================================ +// Main Section Container +// ============================================================================ + +.patients-section { + width: 100%; + padding: var(--space-xl, 2rem); + background: transparent; + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Section Header +// ============================================================================ + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-lg, 1.5rem); + margin-bottom: var(--space-2xl, 3rem); + + @media (max-width: 768px) { + flex-direction: column; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-xl, 2rem); + } +} + +// ============================================================================ +// Section Title +// ============================================================================ + +.section-title { + font-size: var(--font-size-3xl, 1.875rem); + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + line-height: var(--line-height-tight, 1.25); + position: relative; + display: inline-block; + + &::after { + content: ''; + position: absolute; + bottom: -8px; + left: 0; + width: 60px; + height: 4px; + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + border-radius: var(--radius-full, 9999px); + } + + @media (max-width: 768px) { + font-size: var(--font-size-2xl, 1.5rem); + } +} + +.section-description { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: var(--space-md, 1rem) 0 0 0; + line-height: var(--line-height-normal, 1.5); + + @media (max-width: 768px) { + font-size: var(--font-size-sm, 0.875rem); + } +} + +// ============================================================================ +// Refresh Button +// ============================================================================ + +.refresh-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-primary-dark, #1e40af) 100%); + color: var(--text-inverse, #ffffff); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + position: relative; + overflow: hidden; + white-space: nowrap; + + svg { + width: 18px; + height: 18px; + transition: transform var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + flex-shrink: 0; + } + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-primary-dark, #1e40af) 0%, var(--color-primary, #2563eb) 100%); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + + svg { + transform: rotate(180deg); + } + + &::before { + width: 300px; + height: 300px; + } + } + + &:active:not(:disabled) { + transform: translateY(0) scale(0.98); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + } + + @media (max-width: 768px) { + width: 100%; + justify-content: center; + } +} + +// ============================================================================ +// Search Container +// ============================================================================ + +.search-container { + display: block; + width: 100%; + margin-bottom: var(--space-xl, 2rem); + visibility: visible; + opacity: 1; + + @media (max-width: 768px) { + margin-bottom: var(--space-lg, 1.5rem); + } +} + +.search-box { + position: relative; + display: flex !important; + align-items: center; + width: 100%; + min-height: 48px; + background: var(--bg-primary, #ffffff); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-lg, 0.75rem); + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + visibility: visible; + opacity: 1; + + &:focus-within { + border-color: var(--color-primary, #2563eb); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)), 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + .search-icon { + width: 20px; + height: 20px; + color: var(--text-secondary, #6b7280); + flex-shrink: 0; + margin-right: var(--space-sm, 0.5rem); + } + + .search-input { + flex: 1; + border: none; + outline: none; + font-size: var(--font-size-base, 1rem); + color: var(--text-primary, #111827); + background: transparent; + min-width: 200px; + width: 100%; + + &::placeholder { + color: var(--text-tertiary, #9ca3af); + } + + &:focus { + outline: none; + } + } + + .clear-search-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary, #6b7280); + cursor: pointer; + border-radius: var(--radius-sm, 0.25rem); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + flex-shrink: 0; + margin-left: var(--space-xs, 0.25rem); + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--color-gray-100, #f3f4f6); + color: var(--text-primary, #111827); + } + + &:active { + transform: scale(0.95); + } + } + + @media (max-width: 768px) { + padding: var(--space-xs, 0.375rem) var(--space-sm, 0.75rem); + + .search-icon { + width: 18px; + height: 18px; + } + + .search-input { + font-size: var(--font-size-sm, 0.875rem); + } + } +} + +.search-results-info { + margin-top: var(--space-sm, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #6b7280); + padding-left: var(--space-xs, 0.25rem); + + @media (max-width: 768px) { + font-size: var(--font-size-xs, 0.75rem); + } +} + +.clear-search-link { + margin-top: var(--space-md, 1rem); + padding: var(--space-xs, 0.5rem) var(--space-md, 1rem); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--color-primary, #2563eb); + background: var(--color-primary-50, #eff6ff); + border: 1px solid var(--color-primary-200, #bfdbfe); + border-radius: var(--radius-md, 0.5rem); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + background: var(--color-primary-100, #dbeafe); + border-color: var(--color-primary-300, #93c5fd); + color: var(--color-primary-700, #1d4ed8); + } + + &:active { + transform: scale(0.98); + } +} + +// ============================================================================ +// Patients Grid +// ============================================================================ + +.patients-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: var(--space-lg, 1.5rem); + + @media (max-width: 1024px) { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: var(--space-md, 1rem); + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Patient Card +// ============================================================================ + +.patient-card { + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + padding: var(--space-lg, 1.5rem); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + transform: scaleX(0); + transform-origin: left; + transition: transform var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1)); + } + + &:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)); + border-color: var(--color-primary, #2563eb); + + &::before { + transform: scaleX(1); + } + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + border-radius: var(--radius-lg, 0.75rem); + } +} + +// ============================================================================ +// Patient Header +// ============================================================================ + +.patient-header { + display: flex; + align-items: flex-start; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-lg, 1.5rem); + padding-bottom: var(--space-lg, 1.5rem); + border-bottom: 1px solid var(--color-gray-200, #e5e7eb); + + @media (max-width: 480px) { + gap: var(--space-sm, 0.5rem); + } +} + +// ============================================================================ +// Patient Avatar +// ============================================================================ + +.patient-avatar { + width: 64px; + height: 64px; + min-width: 64px; + border-radius: var(--radius-full, 9999px); + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + svg { + width: 32px; + height: 32px; + } + + .patient-card:hover & { + transform: scale(1.05); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + } + + @media (max-width: 480px) { + width: 56px; + height: 56px; + min-width: 56px; + + svg { + width: 28px; + height: 28px; + } + } +} + +// ============================================================================ +// Patient Info +// ============================================================================ + +.patient-info { + flex: 1; + min-width: 0; +} + +.patient-name { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-xs, 0.25rem) 0; + line-height: var(--line-height-tight, 1.25); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (max-width: 768px) { + font-size: var(--font-size-lg, 1.125rem); + } +} + +.patient-email { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); + margin: 0 0 var(--space-xs, 0.25rem) 0; + line-height: var(--line-height-normal, 1.5); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + + &::before { + content: '✉'; + font-size: var(--font-size-xs, 0.75rem); + opacity: 0.6; + } +} + +.patient-id { + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-tertiary, #6b7280); + margin: 0; + line-height: var(--line-height-normal, 1.5); + font-family: 'Monaco', 'Courier New', monospace; + background: var(--color-gray-100, #f3f4f6); + padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem); + border-radius: var(--radius-sm, 0.25rem); + display: inline-block; + + &::before { + content: 'ID: '; + opacity: 0.7; + } +} + +// ============================================================================ +// Patient Details +// ============================================================================ + +.patient-details { + display: flex; + flex-direction: column; + gap: var(--space-sm, 0.5rem); +} + +.detail-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm, 0.5rem); + padding: var(--space-sm, 0.5rem) 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--color-gray-100, #f3f4f6); + } + + @media (max-width: 480px) { + flex-direction: column; + align-items: flex-start; + gap: var(--space-xs, 0.25rem); + } +} + +.detail-label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--text-secondary, #4b5563); + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; +} + +.detail-value { + font-size: var(--font-size-base, 1rem); + color: var(--text-primary, #111827); + font-weight: var(--font-weight-medium, 500); + text-align: right; + word-break: break-word; + + @media (max-width: 480px) { + text-align: left; + } +} + +// ============================================================================ +// Empty State +// ============================================================================ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem) var(--space-xl, 2rem); + text-align: center; + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + border: 2px dashed var(--color-gray-300, #d1d5db); + + .empty-icon { + width: 80px; + height: 80px; + color: var(--color-gray-400, #9ca3af); + margin-bottom: var(--space-lg, 1.5rem); + opacity: 0.6; + animation: float 3s ease-in-out infinite; + + @media (max-width: 768px) { + width: 60px; + height: 60px; + } + } + + h3 { + font-size: var(--font-size-2xl, 1.5rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + + @media (max-width: 768px) { + font-size: var(--font-size-xl, 1.25rem); + } + } + + p { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: 0; + max-width: 400px; + line-height: var(--line-height-normal, 1.5); + + @media (max-width: 768px) { + font-size: var(--font-size-sm, 0.875rem); + } + } + + @media (max-width: 768px) { + padding: var(--space-2xl, 3rem) var(--space-lg, 1.5rem); + } +} + +.search-empty { + border-color: var(--color-gray-300, #d1d5db); + background: var(--bg-primary, #ffffff); + + .empty-icon { + color: var(--color-gray-400, #9ca3af); + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +// ============================================================================ +// Loading State +// ============================================================================ + +.patients-loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem); + color: var(--text-secondary, #4b5563); + font-size: var(--font-size-lg, 1.125rem); + + &::after { + content: ''; + width: 20px; + height: 20px; + margin-left: var(--space-sm, 0.5rem); + border: 3px solid var(--color-gray-300, #d1d5db); + border-top-color: var(--color-primary, #2563eb); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +// ============================================================================ +// Patient Actions +// ============================================================================ + +.patient-actions { + display: flex; + gap: var(--space-sm, 0.5rem); + margin-top: var(--space-md, 1rem); + padding-top: var(--space-md, 1rem); + border-top: 1px solid var(--color-gray-200, #e5e7eb); + flex-wrap: wrap; +} + +.btn-action { + display: flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + padding: var(--space-xs, 0.5rem) var(--space-sm, 0.75rem); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + border-radius: var(--radius-md, 0.5rem); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + border: 1px solid; + + svg { + width: 16px; + height: 16px; + stroke-width: 2; + } + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + @media (max-width: 480px) { + padding: var(--space-xs, 0.375rem) var(--space-xs, 0.5rem); + font-size: var(--font-size-xs, 0.75rem); + flex: 1; + justify-content: center; + + svg { + width: 14px; + height: 14px; + } + } +} + +.btn-chat { + color: var(--color-primary, #2563eb); + background: var(--color-primary-50, #eff6ff); + border-color: var(--color-primary-200, #bfdbfe); + + &:hover:not(:disabled) { + background: var(--color-primary-100, #dbeafe); + border-color: var(--color-primary-300, #93c5fd); + color: var(--color-primary-700, #1d4ed8); + } +} + +.btn-appointment { + color: var(--color-green-600, #16a34a); + background: var(--color-green-50, #f0fdf4); + border-color: var(--color-green-200, #bbf7d0); + + &:hover:not(:disabled) { + background: var(--color-green-100, #dcfce7); + border-color: var(--color-green-300, #86efac); + color: var(--color-green-700, #15803d); + } +} + +.btn-remove { + color: var(--color-red-600, #dc2626); + background: var(--color-red-50, #fef2f2); + border-color: var(--color-red-200, #fecaca); + + &:hover:not(:disabled) { + background: var(--color-red-100, #fee2e2); + border-color: var(--color-red-300, #fca5a5); + color: var(--color-red-700, #b91c1c); + } +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem) var(--space-xl, 2rem); + text-align: center; + + .spinner { + width: 40px; + height: 40px; + border: 4px solid var(--color-gray-200, #e5e7eb); + border-top-color: var(--color-primary, #2563eb); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: var(--space-md, 1rem); + } + + p { + color: var(--text-secondary, #6b7280); + font-size: var(--font-size-base, 1rem); + } +} + +// ============================================================================ +// Responsive Enhancements +// ============================================================================ + +@media (max-width: 640px) { + .patient-card { + border-radius: var(--radius-lg, 0.75rem); + } + + .patient-header { + padding-bottom: var(--space-md, 1rem); + } + + .patient-actions { + flex-direction: column; + gap: var(--space-xs, 0.5rem); + } + + .section-header { + align-items: stretch; + } +} + +// ============================================================================ +// Print Styles +// ============================================================================ + +@media print { + .patients-section { + padding: 0; + } + + .section-header { + margin-bottom: var(--space-md, 1rem); + } + + .refresh-button { + display: none; + } + + .patient-card { + break-inside: avoid; + box-shadow: none; + border: 1px solid var(--color-gray-300, #d1d5db); + margin-bottom: var(--space-md, 1rem); + + &:hover { + transform: none; + box-shadow: none; + } + + &::before { + display: none; + } + } + + .patients-grid { + display: block; + } +} + diff --git a/frontend/src/app/pages/doctor/components/patients/patients.component.ts b/frontend/src/app/pages/doctor/components/patients/patients.component.ts new file mode 100644 index 0000000..cde4a9b --- /dev/null +++ b/frontend/src/app/pages/doctor/components/patients/patients.component.ts @@ -0,0 +1,264 @@ +import { Component, Input, OnInit, OnChanges, SimpleChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { UserService } from '../../../../services/user.service'; +import { AppointmentService } from '../../../../services/appointment.service'; +import { ModalService } from '../../../../services/modal.service'; +import { ChatService } from '../../../../services/chat.service'; +import { LoggerService } from '../../../../services/logger.service'; + +@Component({ + selector: 'app-patients', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './patients.component.html', + styleUrl: './patients.component.scss' +}) +export class PatientsComponent implements OnInit, OnChanges { + @Input() patients: any[] = []; // Fallback from appointments + @Output() patientRemoved = new EventEmitter(); + @Output() patientSelected = new EventEmitter(); + @Output() createAppointmentRequested = new EventEmitter(); + @Output() startChatRequested = new EventEmitter(); + + loading = false; + loadingPatients = false; + searchTerm: string = ''; + filteredPatients: any[] = []; + allPatients: any[] = []; // All patients from system + + constructor( + private userService: UserService, + private appointmentService: AppointmentService, + private modalService: ModalService, + private chatService: ChatService, + private cdr: ChangeDetectorRef, + private logger: LoggerService + ) {} + + async ngOnInit() { + this.logger.debug('[PatientsComponent] ngOnInit - patients:', this.patients.length); + // Load all patients from system + await this.loadAllPatients(); + // Initialize filtered patients + this.applySearch(); + } + + async loadAllPatients() { + try { + this.loadingPatients = true; + const patients = await this.userService.getAllPatients(); + this.logger.debug('[PatientsComponent] Loaded all patients from system:', patients.length); + + // Map PatientProfile to the format we need + // PatientProfile has firstName, lastName, email, phoneNumber directly (not nested in user) + // Now also includes userId for chat functionality + this.allPatients = patients.map(p => ({ + id: p.id, + userId: p.userId, // Include userId for chat functionality + firstName: p.firstName || '', + lastName: p.lastName || '', + email: p.email || '', + phoneNumber: p.phoneNumber || '', + displayName: `${p.firstName || ''} ${p.lastName || ''}`.trim(), + bloodType: p.bloodType, + allergies: p.allergies || [] + })); + + // Merge with patients from appointments (if any) to avoid duplicates + const existingIds = new Set(this.allPatients.map(p => p.id)); + const newPatients = this.patients.filter(p => p.id && !existingIds.has(p.id)); + this.allPatients = [...this.allPatients, ...newPatients]; + + // Update patients array to include all + this.patients = this.allPatients; + this.applySearch(); + } catch (error: any) { + this.logger.error('[PatientsComponent] Error loading all patients:', error); + // Fallback to patients from input + this.patients = this.patients || []; + this.applySearch(); + } finally { + this.loadingPatients = false; + this.cdr.detectChanges(); + } + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['patients'] && !changes['patients'].firstChange) { + this.logger.debug('[PatientsComponent] ngOnChanges - patients changed:', { + previousValue: changes['patients'].previousValue?.length, + currentValue: changes['patients'].currentValue?.length + }); + // Merge with all patients if needed + if (this.allPatients.length > 0) { + const existingIds = new Set(this.allPatients.map(p => p.id)); + const newPatients = this.patients.filter(p => p.id && !existingIds.has(p.id)); + this.allPatients = [...this.allPatients, ...newPatients]; + this.patients = this.allPatients; + } + this.applySearch(); + } + } + + applySearch() { + this.logger.debug('[PatientsComponent] applySearch called', { + searchTerm: this.searchTerm, + patientsCount: this.patients.length, + filteredCount: this.filteredPatients.length + }); + + // If no search term, show all patients + if (!this.searchTerm || this.searchTerm.trim() === '') { + this.filteredPatients = [...this.patients]; + this.logger.debug('[PatientsComponent] No search term, showing all patients:', this.filteredPatients.length); + return; + } + + // Filter patients based on search term + const search = this.searchTerm.toLowerCase().trim(); + this.logger.debug('[PatientsComponent] Filtering with search term:', search); + + this.filteredPatients = this.patients.filter(patient => { + // Search by name (first name + last name) + const fullName = `${patient.firstName || ''} ${patient.lastName || ''}`.toLowerCase(); + const nameMatch = fullName.includes(search); + + // Search by email + const emailMatch = patient.email && patient.email.toLowerCase().includes(search); + + // Search by phone number + const phoneMatch = patient.phoneNumber && patient.phoneNumber.toLowerCase().includes(search); + + const matches = nameMatch || emailMatch || phoneMatch; + if (matches) { + this.logger.debug('[PatientsComponent] Patient matches:', patient.firstName, patient.lastName); + } + return matches; + }); + + this.logger.debug('[PatientsComponent] Filtered results:', this.filteredPatients.length); + } + + onSearchChange() { + this.logger.debug('[PatientsComponent] onSearchChange called, searchTerm:', this.searchTerm); + this.applySearch(); + this.cdr.detectChanges(); // Force change detection + } + + clearSearch() { + this.searchTerm = ''; + this.applySearch(); + } + + async loadPatients() { + // Reload all patients from system + await this.loadAllPatients(); + } + + async createAppointment(patient: any) { + this.createAppointmentRequested.emit(patient); + } + + async startChat(patient: any) { + try { + // Get the user ID for the patient + // PatientProfile now includes userId directly + let userId: string | null = patient.userId || null; + + // If userId is not available, try to find it by searching (fallback) + if (!userId) { + this.logger.warn('[PatientsComponent] userId not found in patient object, searching...', patient); + + // Try searching by full name + if (patient.firstName && patient.lastName) { + try { + const fullName = `${patient.firstName} ${patient.lastName}`; + const chatUsers = await this.chatService.searchPatients(fullName); + const matchingUser = chatUsers.find(u => + u.firstName?.toLowerCase() === patient.firstName?.toLowerCase() && + u.lastName?.toLowerCase() === patient.lastName?.toLowerCase() + ); + if (matchingUser) { + userId = matchingUser.userId; + this.logger.debug('[PatientsComponent] Found user ID by name search:', userId); + } + } catch (e) { + this.logger.warn('[PatientsComponent] Could not search by name:', e); + } + } + } + + if (!userId) { + this.logger.error('[PatientsComponent] Could not find user ID for patient:', { + id: patient.id, + userId: patient.userId, + firstName: patient.firstName, + lastName: patient.lastName, + email: patient.email + }); + await this.modalService.alert( + `Could not find patient user information for ${patient.firstName} ${patient.lastName}. The patient may not be available for chat.`, + 'error', + 'Error' + ); + return; + } + + // Start chat with patient + this.logger.debug('[PatientsComponent] Starting chat with userId:', userId); + this.startChatRequested.emit({ + userId: userId, + patientId: patient.id, + patientName: `${patient.firstName} ${patient.lastName}` + }); + } catch (error: any) { + this.logger.error('[PatientsComponent] Error starting chat:', error); + await this.modalService.alert( + error?.message || 'Failed to start chat with patient', + 'error', + 'Error' + ); + } + } + + viewPatient(patient: any) { + this.patientSelected.emit(patient); + } + + async removePatientFromHistory(patient: any) { + const confirmed = await this.modalService.confirm( + `Are you sure you want to remove ${patient.firstName} ${patient.lastName} from your history? This will hide all appointments with this patient from your view. This action cannot be undone.`, + 'Remove Patient from History', + 'warning' + ); + + if (!confirmed) return; + + try { + this.loading = true; + await this.appointmentService.removePatientFromHistory(patient.id); + await this.modalService.alert( + 'Patient removed from history successfully', + 'success', + 'Success' + ); + + // Remove from local list immediately (optimistic update) + this.patients = this.patients.filter(p => p.id !== patient.id); + this.filteredPatients = this.filteredPatients.filter(p => p.id !== patient.id); + + // Emit event to parent to refresh data (will update the @Input binding) + this.patientRemoved.emit(); + } catch (error: any) { + this.logger.error('Error removing patient from history:', error); + await this.modalService.alert( + error?.message || 'Failed to remove patient from history', + 'error', + 'Error' + ); + } finally { + this.loading = false; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.html b/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.html new file mode 100644 index 0000000..28a19a4 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.html @@ -0,0 +1,627 @@ +
+ +
+
+

Prescription Management

+

Manage prescriptions for your patients

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

{{ getPrescriptionStatistics().total }}

+

Total Prescriptions

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

{{ getPrescriptionStatistics().active }}

+

Active

+
+
+
+
+ + + + +
+
+

{{ getPrescriptionStatistics().completed }}

+

Completed

+ {{ getPrescriptionStatistics().completionRate }}% completion rate +
+
+
+
+ + + + +
+
+

{{ getPrescriptionStatistics().recent }}

+

Last 30 Days

+
+
+
+
+ + + + +
+
+

{{ getPrescriptionStatistics().ePrescriptionsSent }}

+

E-Prescriptions Sent

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

Loading prescriptions...

+
+
+ + +
+
+ + + +
+ + to + +
+
+
+
+ + +
+ +
+
+ + +
+ {{ selectedPrescriptions.length }} prescription(s) selected +
+ + + + + +
+
+ + +
+

View Patient Prescriptions

+ +
+ + +
+
+
+

Create New Prescription

+

Fill in the medication details below

+
+ +
+
+ +
+
{{ prescriptionDebug | json }}
+
+
+ +
+ + + + +
+
+
+ +
+ + + + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+

Prescriptions ({{ filteredPrescriptions.length > 0 ? filteredPrescriptions.length : (showAllRecords ? allPrescriptions.length : prescriptions.length) }})

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + Prescription # + + {{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }} + + + Medication + + {{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }} + + + Patient + + {{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }} + + DosageFrequencyQuantityRefills + Status + + {{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }} + + + Start Date + + {{ prescriptionSortOrder === 'asc' ? '↑' : '↓' }} + + E-PrescriptionActions
+ + + #{{ prescription.prescriptionNumber }} + {{ prescription.medicationName }}{{ prescription.patientName || prescription.patientId }}{{ prescription.dosage }}{{ prescription.frequency }}{{ prescription.quantity }}{{ prescription.refills || 0 }} + + {{ prescription.status }} + + {{ formatDate(prescription.startDate) }} + + + + + Sent + + + +
+ + + + +
+
+
+ + +
+
+

{{ showAllRecords ? 'All My Prescriptions' : 'Patient Prescriptions' }} ({{ filteredPrescriptions.length > 0 ? filteredPrescriptions.length : (showAllRecords ? allPrescriptions.length : prescriptions.length) }})

+
+
+
+
+ +
+
+
+

{{ prescription.medicationName }}

+

#{{ prescription.prescriptionNumber }}

+

Patient: {{ prescription.patientName || prescription.patientId }}

+
+
+ + {{ prescription.status }} + +
+ + + + +
+
+
+
+
+ Dosage: + {{ prescription.dosage }} +
+
+ Frequency: + {{ prescription.frequency }} +
+
+ Quantity: + {{ prescription.quantity }} +
+
+ Refills: + {{ prescription.refills }} +
+
+ Start Date: + {{ formatDate(prescription.startDate) }} +
+
+ End Date: + {{ formatDate(prescription.endDate) }} +
+
+ Instructions: + {{ prescription.instructions }} +
+
+ Pharmacy: + {{ prescription.pharmacyName }} +
+
+ + + + E-Prescription Sent {{ prescription.ePrescriptionSentAt ? '(' + formatDate(prescription.ePrescriptionSentAt) + ')' : '' }} +
+
+
+
+
+ + +
+
+
+ + + +
+
+

No Prescriptions Found

+

Select a patient or create a new prescription to get started.

+

No prescriptions match your search criteria. Try adjusting your filters.

+

No prescriptions found for this patient. Create their first prescription to get started.

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

Update Prescription

+

Modify prescription details

+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
diff --git a/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.scss b/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.scss new file mode 100644 index 0000000..22b96f4 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.scss @@ -0,0 +1,1802 @@ +// ============================================================================ +// Enterprise Prescriptions Component Styles +// Modern, Professional, and Responsive Styling +// ============================================================================ + +// ============================================================================ +// Component Root & Variables +// ============================================================================ + +:host, +app-prescriptions { + display: block; + width: 100%; + overflow-x: hidden; + min-height: 100%; +} + +// ============================================================================ +// Main Section Container +// ============================================================================ + +.prescriptions-section { + width: 100%; + padding: var(--space-xl, 2rem); + background: transparent; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: visible; + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +// ============================================================================ +// Section Header +// ============================================================================ + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-lg, 1.5rem); + margin-bottom: var(--space-2xl, 3rem); + + @media (max-width: 768px) { + flex-direction: column; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-xl, 2rem); + } +} + +// ============================================================================ +// Section Title +// ============================================================================ + +.section-title { + font-size: var(--font-size-3xl, 1.875rem); + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + line-height: var(--line-height-tight, 1.25); + position: relative; + display: inline-block; + + &::after { + content: ''; + position: absolute; + bottom: -8px; + left: 0; + width: 60px; + height: 4px; + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + border-radius: var(--radius-full, 9999px); + } + + @media (max-width: 768px) { + font-size: var(--font-size-2xl, 1.5rem); + } +} + +.section-description { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: var(--space-md, 1rem) 0 0 0; + line-height: var(--line-height-normal, 1.5); + + @media (max-width: 768px) { + font-size: var(--font-size-sm, 0.875rem); + } +} + +// ============================================================================ +// Header Actions +// ============================================================================ + +.header-actions { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + flex-wrap: wrap; + + @media (max-width: 768px) { + width: 100%; + + button { + flex: 1; + min-width: 120px; + } + } +} + +// ============================================================================ +// Buttons +// ============================================================================ + +.primary-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-sm, 0.5rem) var(--space-lg, 1.5rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: hidden; + white-space: nowrap; + + svg { + width: 18px; + height: 18px; + flex-shrink: 0; + } + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover::before { + width: 300px; + height: 300px; + } + + &:active { + transform: scale(0.98); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + } +} + +.primary-button { + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-primary-dark, #1e40af) 100%); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-primary-dark, #1e40af) 0%, var(--color-primary, #2563eb) 100%); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + } +} + +.secondary-button { + background: var(--bg-primary, #ffffff); + color: var(--color-primary, #2563eb); + border: 2px solid var(--color-primary, #2563eb); + box-shadow: var(--shadow-xs, 0 1px 2px 0 rgba(0, 0, 0, 0.05)); + + &:hover:not(:disabled) { + background: var(--color-primary, #2563eb); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + transform: translateY(-2px); + } +} + +// ============================================================================ +// Prescription Analytics +// ============================================================================ + +.prescription-analytics { + margin-bottom: var(--space-2xl, 3rem); + padding: var(--space-lg, 1.5rem); + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + border: 1px solid var(--color-gray-200, #e5e7eb); + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + } +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-lg, 1.5rem); + width: 100%; + box-sizing: border-box; + overflow: visible; + + @media (max-width: 768px) { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-md, 1rem); + } + + @media (max-width: 480px) { + grid-template-columns: 1fr; + gap: var(--space-md, 1rem); + } +} + +.stat-card { + display: flex; + align-items: center; + gap: var(--space-md, 1rem); + padding: var(--space-lg, 1.5rem); + background: linear-gradient(135deg, var(--color-gray-50, #f9fafb) 0%, var(--bg-primary, #ffffff) 100%); + border-radius: var(--radius-lg, 0.75rem); + border: 1px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + border-color: var(--color-primary, #2563eb); + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + flex-direction: column; + text-align: center; + } +} + +.stat-icon { + width: 48px; + height: 48px; + min-width: 48px; + border-radius: var(--radius-full, 9999px); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-inverse, #ffffff); + + svg { + width: 24px; + height: 24px; + } + + &.total { + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-primary-dark, #1e40af) 100%); + } + + &.active { + background: linear-gradient(135deg, var(--color-success, #10b981) 0%, var(--color-secondary-dark, #059669) 100%); + } + + &.completed { + background: linear-gradient(135deg, var(--color-accent, #8b5cf6) 0%, var(--color-accent-dark, #7c3aed) 100%); + } + + &.recent { + background: linear-gradient(135deg, var(--color-info, #3b82f6) 0%, var(--color-info-dark, #2563eb) 100%); + } + + &.eprescription { + background: linear-gradient(135deg, var(--color-warning, #f59e0b) 0%, var(--color-warning-dark, #d97706) 100%); + } + + @media (max-width: 768px) { + width: 40px; + height: 40px; + min-width: 40px; + + svg { + width: 20px; + height: 20px; + } + } +} + +.stat-content { + flex: 1; + min-width: 0; + + h3 { + font-size: var(--font-size-2xl, 1.5rem); + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-xs, 0.25rem) 0; + line-height: var(--line-height-tight, 1.25); + } + + p { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); + margin: 0; + line-height: var(--line-height-normal, 1.5); + } + + small { + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-tertiary, #6b7280); + display: block; + margin-top: var(--space-xs, 0.25rem); + } +} + +// ============================================================================ +// Error Message +// ============================================================================ + +.error-message { + padding: var(--space-md, 1rem) var(--space-lg, 1.5rem); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%); + border-left: 4px solid var(--color-danger, #ef4444); + border-radius: var(--radius-md, 0.5rem); + color: var(--color-danger, #ef4444); + font-size: var(--font-size-sm, 0.875rem); + margin-bottom: var(--space-lg, 1.5rem); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md, 1rem); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + animation: slideInDown 0.3s ease-out; + + .error-content { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + flex: 1; + } + + .error-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + } + + .error-text { + flex: 1; + + strong { + font-weight: var(--font-weight-semibold, 600); + } + } + + .error-close { + background: none; + border: none; + color: var(--color-danger, #ef4444); + font-size: var(--font-size-xl, 1.25rem); + cursor: pointer; + padding: var(--space-xs, 0.25rem); + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm, 0.25rem); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + flex-shrink: 0; + + &:hover { + background: var(--color-danger, #ef4444); + color: var(--text-inverse, #ffffff); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } +} + +@keyframes slideInDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ============================================================================ +// Loading Overlay +// ============================================================================ + +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: fadeIn 0.2s ease-out; +} + +.loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-lg, 1.5rem); +} + +.spinner { + width: 50px; + height: 50px; + animation: rotate 1s linear infinite; +} + +.spinner .path { + stroke: var(--color-primary, #2563eb); + stroke-linecap: round; + stroke-dasharray: 90, 150; + stroke-dashoffset: 0; + animation: dash 1.5s ease-in-out infinite; +} + +.loading-text { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + font-weight: var(--font-weight-medium, 500); + margin: 0; +} + +@keyframes rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes dash { + 0% { + stroke-dasharray: 1, 150; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -35; + } + 100% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -124; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +// ============================================================================ +// Prescription Controls +// ============================================================================ + +.prescription-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-lg, 1.5rem); + margin-bottom: var(--space-lg, 1.5rem); + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + align-items: stretch; + } +} + +.controls-left { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + flex-wrap: wrap; + flex: 1; + + @media (max-width: 768px) { + flex-direction: column; + + > * { + width: 100%; + } + } +} + +.controls-right { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + flex-wrap: wrap; + + @media (max-width: 768px) { + width: 100%; + + .secondary-button { + flex: 1; + } + } +} + +// ============================================================================ +// Search Box +// ============================================================================ + +.search-box { + position: relative; + display: flex; + align-items: center; + flex: 1; + min-width: 250px; + + .search-icon { + position: absolute; + left: var(--space-md, 1rem); + width: 20px; + height: 20px; + color: var(--text-secondary, #4b5563); + pointer-events: none; + z-index: 1; + transition: color var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + } + + .search-input { + width: 100%; + padding: var(--space-sm, 0.5rem) var(--space-xl, 2.5rem) var(--space-sm, 0.5rem) var(--space-2xl, 3rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + background: var(--bg-primary, #ffffff); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + + + .search-icon { + color: var(--color-primary, #2563eb); + } + } + + &::placeholder { + color: var(--text-tertiary, #6b7280); + } + } + + .clear-search { + position: absolute; + right: var(--space-sm, 0.5rem); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-gray-200, #e5e7eb); + border: none; + border-radius: var(--radius-full, 9999px); + color: var(--text-secondary, #4b5563); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + z-index: 1; + + svg { + width: 14px; + height: 14px; + } + + &:hover { + background: var(--color-gray-300, #d1d5db); + color: var(--text-primary, #111827); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } +} + +// ============================================================================ +// Filter Select +// ============================================================================ + +.filter-select { + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + &:hover { + border-color: var(--color-primary, #2563eb); + } +} + +// ============================================================================ +// Date Range +// ============================================================================ + +.date-range { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + + input[type="date"] { + padding: var(--space-sm, 0.5rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-sm, 0.875rem); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + } + + span { + color: var(--text-secondary, #4b5563); + font-size: var(--font-size-sm, 0.875rem); + } +} + +// ============================================================================ +// View Toggle +// ============================================================================ + +.view-toggle { + display: flex; + gap: var(--space-xs, 0.25rem); + background: var(--color-gray-100, #f3f4f6); + border-radius: var(--radius-md, 0.5rem); + padding: var(--space-xs, 0.25rem); +} + +.toggle-btn { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-sm, 0.5rem); + border: none; + background: transparent; + border-radius: var(--radius-sm, 0.25rem); + color: var(--text-secondary, #4b5563); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + svg { + width: 20px; + height: 20px; + } + + &:hover { + background: var(--color-gray-200, #e5e7eb); + color: var(--text-primary, #111827); + } + + &.active { + background: var(--bg-primary, #ffffff); + color: var(--color-primary, #2563eb); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + } +} + +// ============================================================================ +// Bulk Actions Bar +// ============================================================================ + +.bulk-actions-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-md, 1rem); + padding: var(--space-md, 1rem); + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-primary-dark, #1e40af) 100%); + border-radius: var(--radius-lg, 0.75rem); + margin-bottom: var(--space-lg, 1.5rem); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + + .selection-count { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + } + + .bulk-actions { + display: flex; + gap: var(--space-sm, 0.5rem); + flex-wrap: wrap; + } + + .secondary-button { + background: var(--bg-primary, #ffffff); + color: var(--color-primary, #2563eb); + border-color: var(--bg-primary, #ffffff); + + &:hover { + background: var(--color-gray-100, #f3f4f6); + color: var(--color-primary-dark, #1e40af); + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: stretch; + + .bulk-actions { + width: 100%; + + .secondary-button { + flex: 1; + } + } + } +} + +// ============================================================================ +// Card Container +// ============================================================================ + +.card { + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + padding: var(--space-lg, 1.5rem); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + border: 1px solid var(--color-gray-200, #e5e7eb); + margin-bottom: var(--space-lg, 1.5rem); + width: 100%; + box-sizing: border-box; + overflow: visible; + position: relative; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + } + + h3 { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-lg, 1.5rem) 0; + line-height: var(--line-height-tight, 1.25); + word-wrap: break-word; + overflow-wrap: break-word; + } + + .card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-lg, 1.5rem); + padding-bottom: var(--space-lg, 1.5rem); + border-bottom: 1px solid var(--color-gray-200, #e5e7eb); + + .header-content { + flex: 1; + + h3 { + margin: 0 0 var(--space-xs, 0.25rem) 0; + } + } + + .card-subtitle { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); + margin: 0; + line-height: var(--line-height-normal, 1.5); + } + + .close-button { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-gray-100, #f3f4f6); + border: none; + border-radius: var(--radius-md, 0.5rem); + color: var(--text-secondary, #4b5563); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + flex-shrink: 0; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--color-gray-200, #e5e7eb); + color: var(--text-primary, #111827); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + border-radius: var(--radius-lg, 0.75rem); + } +} + +.create-form-card, +.update-form-card { + animation: slideInUp 0.3s ease-out; + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ============================================================================ +// Forms +// ============================================================================ + +.form-group { + margin-bottom: var(--space-lg, 1.5rem); + + &:last-child { + margin-bottom: 0; + } +} + +.form-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-md, 1rem); + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.form-label { + display: block; + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #111827); + margin-bottom: var(--space-sm, 0.5rem); + + .required-asterisk { + color: var(--color-danger, #ef4444); + margin-left: var(--space-xs, 0.25rem); + } +} + +.input-wrapper { + position: relative; + display: flex; + align-items: center; + + .input-icon { + position: absolute; + left: var(--space-md, 1rem); + width: 20px; + height: 20px; + color: var(--text-secondary, #4b5563); + pointer-events: none; + z-index: 1; + transition: color var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + } + + .form-input, + .form-textarea { + padding-left: var(--space-2xl, 3rem); + + &:focus + .input-icon { + color: var(--color-primary, #2563eb); + } + } + + select.form-input { + padding-left: var(--space-2xl, 3rem); + padding-right: var(--space-2xl, 3rem); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234b5563' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--space-md, 1rem) center; + appearance: none; + cursor: pointer; + + &:focus { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%232563eb' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + } + } +} + +.form-input, +.form-textarea { + width: 100%; + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border: 2px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-base, 1rem); + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #111827); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + &:disabled { + background: var(--color-gray-100, #f3f4f6); + cursor: not-allowed; + opacity: 0.7; + } + + &::placeholder { + color: var(--text-tertiary, #6b7280); + } + + &:invalid:not(:placeholder-shown) { + border-color: var(--color-danger, #ef4444); + } + + &:valid:not(:placeholder-shown) { + border-color: var(--color-success, #10b981); + } +} + +.form-textarea { + min-height: 120px; + resize: vertical; + font-family: inherit; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-md, 1rem); + margin-top: var(--space-xl, 2rem); + padding-top: var(--space-lg, 1.5rem); + border-top: 1px solid var(--color-gray-200, #e5e7eb); + align-items: center; + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + width: 100%; + + button { + width: 100%; + } + } + + .cancel-button, + .submit-button { + padding: var(--space-md, 1rem) var(--space-xl, 2rem); + border: none; + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-base, 1rem); + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: visible; + min-width: 140px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm, 0.5rem); + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + pointer-events: none; + } + } + + .cancel-button { + background: var(--bg-primary, #ffffff); + color: var(--text-secondary, #4b5563); + border: 2px solid var(--color-gray-300, #d1d5db); + box-shadow: var(--shadow-xs, 0 1px 2px 0 rgba(0, 0, 0, 0.05)); + + &:hover:not(:disabled) { + background: var(--color-gray-100, #f3f4f6); + border-color: var(--color-gray-400, #9ca3af); + box-shadow: var(--shadow-sm, 0 1px 3px 0 rgba(0, 0, 0, 0.1)); + transform: translateY(-1px); + } + + &:active:not(:disabled) { + transform: translateY(0) scale(0.98); + } + } + + .submit-button { + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-primary-dark, #1e40af) 100%); + color: var(--text-inverse, #ffffff); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)); + position: relative; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover:not(:disabled) { + background: linear-gradient(135deg, var(--color-primary-dark, #1e40af) 0%, var(--color-primary, #2563eb) 100%); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)); + transform: translateY(-2px); + + &::before { + width: 300px; + height: 300px; + } + } + + &:active:not(:disabled) { + transform: translateY(0) scale(0.98); + } + + .loading-content { + display: inline-flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + } + + .spinner-small { + width: 16px; + height: 16px; + animation: spin 0.8s linear infinite; + } + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +// ============================================================================ +// Prescription Grid (Card View) +// ============================================================================ + +.prescription-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: var(--space-lg, 1.5rem); + width: 100%; + box-sizing: border-box; + overflow: visible; + + @media (max-width: 1024px) { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: var(--space-md, 1rem); + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: var(--space-md, 1rem); + } + + @media (max-width: 480px) { + grid-template-columns: 1fr; + gap: var(--space-sm, 0.5rem); + } +} + +.prescription-card { + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 1rem); + padding: var(--space-lg, 1.5rem); + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1)); + border: 2px solid var(--color-gray-200, #e5e7eb); + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + position: relative; + overflow: visible; + width: 100%; + box-sizing: border-box; + min-height: 200px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + transform: scaleX(0); + transform-origin: left; + transition: transform var(--transition-slow, 300ms cubic-bezier(0.4, 0, 0.2, 1)); + z-index: 1; + } + + &:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + border-color: var(--color-primary, #2563eb); + z-index: 10; + + &::before { + transform: scaleX(1); + } + } + + &.selected { + border-color: var(--color-primary, #2563eb); + background: linear-gradient(135deg, rgba(37, 99, 235, 0.05) 0%, rgba(255, 255, 255, 1) 100%); + z-index: 5; + } + + @media (max-width: 768px) { + padding: var(--space-md, 1rem); + min-height: auto; + } +} + +.prescription-card-checkbox { + margin-bottom: var(--space-md, 1rem); + + input[type="checkbox"] { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: var(--color-primary, #2563eb); + } +} + +.prescription-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-lg, 1.5rem); + padding-bottom: var(--space-lg, 1.5rem); + border-bottom: 1px solid var(--color-gray-200, #e5e7eb); + flex-wrap: wrap; + min-width: 0; + + > div:first-child { + flex: 1; + min-width: 0; + overflow: hidden; + + h3 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + word-wrap: break-word; + + @media (max-width: 480px) { + white-space: normal; + } + } + } + + @media (max-width: 640px) { + flex-direction: column; + align-items: stretch; + } +} + +.prescription-header-right { + display: flex; + align-items: flex-start; + gap: var(--space-sm, 0.5rem); + flex-direction: column; + flex-shrink: 0; + + @media (max-width: 640px) { + flex-direction: row; + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } +} + +.prescription-actions { + display: flex; + gap: var(--space-xs, 0.25rem); +} + +.prescription-status { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-xs, 0.25rem) var(--space-md, 1rem); + border-radius: var(--radius-full, 9999px); + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-medium, 500); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + + &.status-active { + background: var(--color-success-light, #d1fae5); + color: var(--color-success, #10b981); + border: 1px solid var(--color-success, #10b981); + } + + &.status-completed { + background: var(--color-info-light, #dbeafe); + color: var(--color-info, #3b82f6); + border: 1px solid var(--color-info, #3b82f6); + } + + &.status-cancelled { + background: var(--color-danger-light, #fee2e2); + color: var(--color-danger, #ef4444); + border: 1px solid var(--color-danger, #ef4444); + } + + &.status-discontinued { + background: var(--color-gray-100, #f3f4f6); + color: var(--color-gray-700, #374151); + border: 1px solid var(--color-gray-300, #d1d5db); + } +} + +.prescription-number { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-secondary, #4b5563); + margin: var(--space-xs, 0.25rem) 0; + font-family: 'Monaco', 'Courier New', monospace; +} + +.prescription-details { + display: flex; + flex-direction: column; + gap: var(--space-sm, 0.5rem); +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-sm, 0.5rem); + padding: var(--space-sm, 0.5rem) 0; + width: 100%; + box-sizing: border-box; + + &:not(:last-child) { + border-bottom: 1px solid var(--color-gray-100, #f3f4f6); + } + + &.e-prescription-sent { + display: flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + color: var(--color-success, #10b981); + font-weight: var(--font-weight-medium, 500); + font-size: var(--font-size-sm, 0.875rem); + padding: var(--space-sm, 0.5rem); + background: var(--color-success-light, #d1fae5); + border-radius: var(--radius-sm, 0.25rem); + margin-top: var(--space-xs, 0.25rem); + flex-wrap: wrap; + + svg { + width: 16px; + height: 16px; + flex-shrink: 0; + } + } + + @media (max-width: 480px) { + flex-direction: column; + align-items: flex-start; + gap: var(--space-xs, 0.25rem); + } +} + +.detail-label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-medium, 500); + color: var(--text-secondary, #4b5563); + flex-shrink: 0; + white-space: nowrap; +} + +.detail-value { + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-primary, #111827); + text-align: right; + word-break: break-word; + overflow-wrap: break-word; + flex: 1; + min-width: 0; + + &.instructions-text { + text-align: left; + font-style: italic; + color: var(--text-secondary, #4b5563); + white-space: normal; + } + + @media (max-width: 480px) { + text-align: left; + width: 100%; + } +} + +// ============================================================================ +// Prescription Table +// ============================================================================ + +.prescription-table-container { + overflow-x: auto; + overflow-y: visible; + width: 100%; + -webkit-overflow-scrolling: touch; + position: relative; + + &::-webkit-scrollbar { + height: 8px; + } + + &::-webkit-scrollbar-track { + background: var(--color-gray-100, #f3f4f6); + border-radius: var(--radius-sm, 0.25rem); + } + + &::-webkit-scrollbar-thumb { + background: var(--color-gray-400, #9ca3af); + border-radius: var(--radius-sm, 0.25rem); + + &:hover { + background: var(--color-gray-500, #6b7280); + } + } +} + +.prescription-table { + width: 100%; + min-width: 1200px; + border-collapse: collapse; + font-size: var(--font-size-sm, 0.875rem); + table-layout: auto; + + thead { + background: linear-gradient(135deg, var(--color-gray-50, #f9fafb) 0%, var(--bg-primary, #ffffff) 100%); + border-bottom: 2px solid var(--color-gray-300, #d1d5db); + } + + th { + padding: var(--space-md, 1rem); + text-align: left; + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + white-space: nowrap; + + &.sortable { + cursor: pointer; + user-select: none; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + background: var(--color-gray-100, #f3f4f6); + } + } + + .sort-indicator { + margin-left: var(--space-xs, 0.25rem); + color: var(--color-primary, #2563eb); + } + } + + td { + padding: var(--space-md, 1rem); + border-bottom: 1px solid var(--color-gray-200, #e5e7eb); + color: var(--text-primary, #111827); + } + + tbody tr { + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + &:hover { + background: var(--color-gray-50, #f9fafb); + } + + &.selected { + background: linear-gradient(135deg, rgba(37, 99, 235, 0.05) 0%, rgba(255, 255, 255, 1) 100%); + } + } + + .checkbox-column { + width: 40px; + text-align: center; + + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--color-primary, #2563eb); + } + } + + .prescription-number-cell { + font-family: 'Monaco', 'Courier New', monospace; + font-weight: var(--font-weight-semibold, 600); + color: var(--color-primary, #2563eb); + } + + .e-prescription-badge { + display: inline-flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + color: var(--color-success, #10b981); + font-size: var(--font-size-xs, 0.75rem); + font-weight: var(--font-weight-medium, 500); + + svg { + width: 14px; + height: 14px; + } + } + + .not-sent { + color: var(--text-tertiary, #6b7280); + } + + .actions-column { + width: 120px; + } + + @media (max-width: 768px) { + font-size: var(--font-size-xs, 0.75rem); + + th, td { + padding: var(--space-sm, 0.5rem); + } + } +} + +.table-actions { + display: flex; + align-items: center; + gap: var(--space-xs, 0.25rem); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + background: var(--color-gray-100, #f3f4f6); + border-radius: var(--radius-sm, 0.25rem); + color: var(--text-secondary, #4b5563); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--color-gray-200, #e5e7eb); + color: var(--text-primary, #111827); + transform: scale(1.1); + } + + &.delete-button:hover { + background: var(--color-danger-light, #fee2e2); + color: var(--color-danger, #ef4444); + } +} + +// ============================================================================ +// Dropdown +// ============================================================================ + +.dropdown { + position: relative; + z-index: 100; + + .dropdown-menu { + position: absolute; + top: calc(100% + var(--space-xs, 0.25rem)); + right: 0; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--color-gray-300, #d1d5db); + border-radius: var(--radius-md, 0.5rem); + box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + min-width: 180px; + max-width: 250px; + z-index: 1000; + display: none; + overflow: visible; + white-space: nowrap; + + // Ensure dropdown is visible even if parent has overflow hidden + @media (min-width: 769px) { + position: fixed; + transform: translateX(calc(-100% + 32px)); + } + + button { + display: block; + width: 100%; + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border: none; + background: transparent; + text-align: left; + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-primary, #111827); + cursor: pointer; + transition: all var(--transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + background: var(--color-gray-100, #f3f4f6); + } + } + } + + &:hover .dropdown-menu, + .dropdown-menu:hover { + display: block; + } + + // Fix for table cells + .prescription-table & { + position: static; + + .dropdown-menu { + position: absolute; + right: 0; + top: calc(100% + var(--space-xs, 0.25rem)); + } + } +} + +// ============================================================================ +// Empty State +// ============================================================================ + +.empty-state-card { + animation: fadeIn 0.4s ease-out; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-3xl, 4rem) var(--space-xl, 2rem); + text-align: center; + + .empty-icon-wrapper { + margin-bottom: var(--space-xl, 2rem); + padding: var(--space-lg, 1.5rem); + background: linear-gradient(135deg, var(--color-gray-50, #f9fafb) 0%, rgba(255, 255, 255, 1) 100%); + border-radius: var(--radius-full, 9999px); + display: flex; + align-items: center; + justify-content: center; + } + + .empty-icon { + width: 80px; + height: 80px; + color: var(--color-gray-400, #9ca3af); + opacity: 0.6; + animation: float 3s ease-in-out infinite; + } + + .empty-content { + max-width: 500px; + + h3 { + font-size: var(--font-size-2xl, 1.5rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #111827); + margin: 0 0 var(--space-sm, 0.5rem) 0; + } + + p { + font-size: var(--font-size-base, 1rem); + color: var(--text-secondary, #4b5563); + margin: 0 0 var(--space-lg, 1.5rem) 0; + line-height: var(--line-height-normal, 1.5); + } + } + + .empty-actions { + margin-top: var(--space-lg, 1.5rem); + } + + @media (max-width: 768px) { + padding: var(--space-2xl, 3rem) var(--space-lg, 1.5rem); + + .empty-icon-wrapper { + padding: var(--space-md, 1rem); + } + + .empty-icon { + width: 60px; + height: 60px; + } + + .empty-content h3 { + font-size: var(--font-size-xl, 1.25rem); + } + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +// ============================================================================ +// Debug Toggle +// ============================================================================ + +.debug-toggle { + margin-bottom: var(--space-md, 1rem); +} + +.debug-pre { + background: var(--color-gray-100, #f3f4f6); + padding: var(--space-md, 1rem); + border-radius: var(--radius-md, 0.5rem); + font-size: var(--font-size-xs, 0.75rem); + font-family: 'Monaco', 'Courier New', monospace; + overflow-x: auto; + margin-bottom: var(--space-lg, 1.5rem); + border: 1px solid var(--color-gray-300, #d1d5db); +} + +// ============================================================================ +// Responsive Enhancements +// ============================================================================ + +@media (max-width: 640px) { + .prescription-card { + border-radius: var(--radius-lg, 0.75rem); + } + + .prescription-header { + flex-direction: column; + gap: var(--space-sm, 0.5rem); + + > div { + width: 100%; + } + } + + .prescription-header-right { + width: 100%; + flex-direction: row; + justify-content: flex-start; + flex-wrap: wrap; + } + + .detail-row { + flex-direction: column; + align-items: flex-start; + gap: var(--space-xs, 0.25rem); + + .detail-value { + text-align: left; + width: 100%; + } + } + + .table-actions { + flex-wrap: wrap; + } + + .prescription-table { + min-width: 800px; + } +} + +// Ensure all containers allow overflow +.prescription-list-header { + width: 100%; + overflow: visible; + + h3 { + word-wrap: break-word; + overflow-wrap: break-word; + } +} + +// ============================================================================ +// Print Styles +// ============================================================================ + +@media print { + .prescriptions-section { + padding: 0; + } + + .section-header, + .prescription-controls, + .bulk-actions-bar, + .header-actions, + .prescription-actions, + .form-actions, + .icon-button, + .dropdown { + display: none; + } + + .prescription-card, + .card { + break-inside: avoid; + box-shadow: none; + border: 1px solid var(--color-gray-300, #d1d5db); + margin-bottom: var(--space-md, 1rem); + + &:hover { + transform: none; + box-shadow: none; + } + + &::before { + display: none; + } + } + + .prescription-table { + font-size: var(--font-size-xs, 0.75rem); + + th, td { + padding: var(--space-xs, 0.25rem); + } + } +} + diff --git a/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.ts b/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.ts new file mode 100644 index 0000000..9ceb226 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/prescriptions/prescriptions.component.ts @@ -0,0 +1,779 @@ +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { PrescriptionService, Prescription, PrescriptionRequest } from '../../../../services/prescription.service'; +import { ModalService } from '../../../../services/modal.service'; +import { LoggerService } from '../../../../services/logger.service'; + +@Component({ + selector: 'app-prescriptions', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './prescriptions.component.html', + styleUrl: './prescriptions.component.scss' +}) +export class PrescriptionsComponent implements OnInit, OnChanges { + @Input() doctorId: string | null = null; + @Input() selectedPatientId: string | null = null; + @Input() prescriptions: Prescription[] = []; + @Input() patients: any[] = []; + @Output() prescriptionChanged = new EventEmitter(); + @Output() patientSelected = new EventEmitter(); + @Output() safetyCheckRequested = new EventEmitter(); + + // Prescription Management State + allPrescriptions: Prescription[] = []; + filteredPrescriptions: Prescription[] = []; + showAllRecords = false; + selectedPrescription: Prescription | null = null; + showUpdatePrescription = false; + showCreatePrescription = false; + showPrescriptionAnalytics = false; + showPrescriptionDebug = false; + + // Filters and Controls + prescriptionSearchTerm: string = ''; + prescriptionStatusFilter: string = 'all'; + prescriptionDateFilter: string = 'all'; + prescriptionDateFrom: string = ''; + prescriptionDateTo: string = ''; + prescriptionSortBy: string = 'createdAt'; + prescriptionSortOrder: 'asc' | 'desc' = 'desc'; + prescriptionViewMode: 'card' | 'table' = 'card'; + selectedPrescriptions: string[] = []; + + // Form Data + newPrescription: PrescriptionRequest = { + patientId: '', + doctorId: '', + medicationName: '', + dosage: '', + frequency: '', + quantity: 30, + refills: 0, + startDate: new Date().toISOString().split('T')[0] + }; + + error: string | null = null; + loading = false; + + constructor( + private prescriptionService: PrescriptionService, + private modalService: ModalService, + private logger: LoggerService + ) {} + + ngOnInit() { + if (this.doctorId) { + this.newPrescription.doctorId = this.doctorId; + this.loadAllPrescriptions(); + } + if (this.selectedPatientId) { + this.loadPatientPrescriptions(); + } + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['doctorId'] && this.doctorId) { + this.newPrescription.doctorId = this.doctorId; + if (!this.allPrescriptions.length) { + this.loadAllPrescriptions(); + } + } + if (changes['selectedPatientId']) { + if (this.selectedPatientId) { + this.loadPatientPrescriptions(); + } else { + this.showAllRecords = true; + this.applyPrescriptionFilters(); + } + } + if (changes['prescriptions']) { + this.applyPrescriptionFilters(); + } + } + + async loadAllPrescriptions() { + if (!this.doctorId) return; + try { + this.loading = true; + this.allPrescriptions = await this.prescriptionService.getPrescriptionsByDoctorId(this.doctorId); + this.applyPrescriptionFilters(); + } catch (e: any) { + this.logger.error('Error loading all prescriptions:', e); + this.error = e?.response?.data?.error || 'Failed to load prescriptions'; + this.allPrescriptions = []; + } finally { + this.loading = false; + } + } + + async loadPatientPrescriptions() { + if (!this.selectedPatientId) return; + try { + this.loading = true; + this.prescriptions = await this.prescriptionService.getPrescriptionsByPatientId(this.selectedPatientId); + this.applyPrescriptionFilters(); + } catch (e: any) { + this.logger.error('Error loading patient prescriptions:', e); + this.error = e?.response?.data?.error || 'Failed to load prescriptions'; + this.prescriptions = []; + } finally { + this.loading = false; + } + } + + async refreshPrescriptions() { + if (this.showAllRecords) { + await this.loadAllPrescriptions(); + } else if (this.selectedPatientId) { + await this.loadPatientPrescriptions(); + } + this.prescriptionChanged.emit(); + } + + async openCreatePrescription() { + this.showCreatePrescription = !this.showCreatePrescription; + if (this.showCreatePrescription) { + this.newPrescription = { + patientId: this.selectedPatientId || '', + doctorId: this.doctorId || '', + medicationName: '', + dosage: '', + frequency: '', + quantity: 30, + refills: 0, + startDate: new Date().toISOString().split('T')[0] + }; + } + } + + async createPrescription() { + // Validation + const missing: string[] = []; + if (!this.newPrescription.patientId) missing.push('patient'); + if (!this.newPrescription.doctorId) missing.push('doctor'); + if (!this.newPrescription.medicationName) missing.push('medication'); + if (!this.newPrescription.dosage) missing.push('dosage'); + if (!this.newPrescription.frequency) missing.push('frequency'); + if ((this.newPrescription.quantity as any) == null || (this.newPrescription.quantity as any) <= 0) missing.push('quantity'); + if (!(this.newPrescription.startDate as any)) missing.push('start date'); + + if (missing.length > 0) { + this.error = `Please fill in all required fields (${missing.join(', ')})`; + // Auto-hide error after 5 seconds + setTimeout(() => { + if (this.error?.includes('required fields')) { + this.error = null; + } + }, 5000); + return; + } + + try { + this.loading = true; + this.error = null; + await this.prescriptionService.createPrescription(this.newPrescription); + this.showCreatePrescription = false; + this.newPrescription = { + patientId: '', + doctorId: this.doctorId || '', + medicationName: '', + dosage: '', + frequency: '', + quantity: 30, + refills: 0, + startDate: new Date().toISOString().split('T')[0] + }; + await this.refreshPrescriptions(); + + // Request parent component to refresh safety alerts + this.safetyCheckRequested.emit(); + + // Show success feedback + await this.modalService.alert( + 'Prescription created successfully! Safety checks are being performed...', + 'success', + 'Success' + ); + } catch (e: any) { + this.logger.error('Error creating prescription:', e); + const errorMessage = e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + 'Failed to create prescription'; + this.error = errorMessage; + // Auto-hide error after 5 seconds + setTimeout(() => { + this.error = null; + }, 5000); + } finally { + this.loading = false; + } + } + + editPrescription(prescription: Prescription) { + this.selectedPrescription = prescription; + this.newPrescription = { + patientId: prescription.patientId, + doctorId: prescription.doctorId, + medicationName: prescription.medicationName, + dosage: prescription.dosage, + frequency: prescription.frequency, + quantity: prescription.quantity, + refills: prescription.refills || 0, + instructions: prescription.instructions, + startDate: prescription.startDate, + endDate: prescription.endDate, + pharmacyName: prescription.pharmacyName, + pharmacyAddress: prescription.pharmacyAddress, + pharmacyPhone: prescription.pharmacyPhone + }; + this.showUpdatePrescription = true; + (this.newPrescription as any).prescriptionId = prescription.id; + } + + async updatePrescription() { + const prescriptionId = (this.newPrescription as any).prescriptionId; + if (!prescriptionId) { + this.error = 'Prescription ID is missing. Please try again.'; + return; + } + + // Validation + const missing: string[] = []; + if (!this.newPrescription.patientId) missing.push('patient'); + if (!this.newPrescription.doctorId) missing.push('doctor'); + if (!this.newPrescription.medicationName?.trim()) missing.push('medication name'); + if (!this.newPrescription.dosage?.trim()) missing.push('dosage'); + if (!this.newPrescription.frequency?.trim()) missing.push('frequency'); + if (this.newPrescription.quantity == null || this.newPrescription.quantity <= 0) missing.push('quantity'); + if (!this.newPrescription.startDate) missing.push('start date'); + + if (missing.length > 0) { + this.error = `Please fill in all required fields (${missing.join(', ')})`; + // Auto-hide error after 5 seconds + setTimeout(() => { + if (this.error?.includes('required fields')) { + this.error = null; + } + }, 5000); + return; + } + + try { + this.loading = true; + this.error = null; + + // Create a clean request object without the prescriptionId property + const updateRequest: PrescriptionRequest = { + patientId: this.newPrescription.patientId, + doctorId: this.newPrescription.doctorId, + medicationName: this.newPrescription.medicationName.trim(), + dosage: this.newPrescription.dosage.trim(), + frequency: this.newPrescription.frequency.trim(), + quantity: this.newPrescription.quantity, + refills: this.newPrescription.refills || 0, + instructions: this.newPrescription.instructions?.trim() || undefined, + startDate: this.newPrescription.startDate, + endDate: this.newPrescription.endDate || undefined, + pharmacyName: this.newPrescription.pharmacyName?.trim() || undefined, + pharmacyAddress: this.newPrescription.pharmacyAddress?.trim() || undefined, + pharmacyPhone: this.newPrescription.pharmacyPhone?.trim() || undefined, + appointmentId: this.newPrescription.appointmentId + }; + + await this.prescriptionService.updatePrescription(prescriptionId, updateRequest); + this.showUpdatePrescription = false; + this.selectedPrescription = null; + delete (this.newPrescription as any).prescriptionId; + await this.refreshPrescriptions(); + + // Request parent component to refresh safety alerts + this.safetyCheckRequested.emit(); + + // Show success feedback + await this.modalService.alert( + 'Prescription updated successfully! Safety checks are being performed...', + 'success', + 'Success' + ); + } catch (e: any) { + this.logger.error('Error updating prescription:', e); + const errorMessage = e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + 'Failed to update prescription. Please check your input and try again.'; + this.error = errorMessage; + // Auto-hide error after 5 seconds + setTimeout(() => { + this.error = null; + }, 5000); + } finally { + this.loading = false; + } + } + + async deletePrescription(prescriptionId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this prescription? This action cannot be undone.', + 'Delete Prescription', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + try { + this.loading = true; + this.error = null; + await this.prescriptionService.deletePrescription(prescriptionId); + this.selectedPrescriptions = this.selectedPrescriptions.filter(id => id !== prescriptionId); + await this.refreshPrescriptions(); + // Show success feedback + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to delete prescription'; + // Auto-hide error after 5 seconds + setTimeout(() => { + this.error = null; + }, 5000); + } finally { + this.loading = false; + } + } + + async updatePrescriptionStatus(prescriptionId: string, status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED') { + if (!prescriptionId) { + this.error = 'Prescription ID is missing. Please try again.'; + return; + } + + try { + this.loading = true; + this.error = null; + await this.prescriptionService.updatePrescriptionStatus(prescriptionId, status); + await this.refreshPrescriptions(); + } catch (e: any) { + this.logger.error('Error updating prescription status:', e); + const errorMessage = e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + `Failed to update prescription status to ${status}. Please try again.`; + this.error = errorMessage; + // Auto-hide error after 5 seconds + setTimeout(() => { + this.error = null; + }, 5000); + } finally { + this.loading = false; + } + } + + async markEPrescriptionSent(prescriptionId: string) { + try { + this.loading = true; + await this.prescriptionService.markEPrescriptionSent(prescriptionId); + await this.refreshPrescriptions(); + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to mark e-prescription as sent'; + } finally { + this.loading = false; + } + } + + async bulkUpdatePrescriptionStatus(status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED') { + if (this.selectedPrescriptions.length === 0) { + this.error = 'Please select at least one prescription'; + // Auto-hide error after 5 seconds + setTimeout(() => { + if (this.error?.includes('Please select')) { + this.error = null; + } + }, 5000); + return; + } + + const confirmed = await this.modalService.confirm( + `Are you sure you want to update ${this.selectedPrescriptions.length} prescription(s) to ${status}?`, + 'Update Prescription Status', + 'Update', + 'Cancel' + ); + if (!confirmed) { + return; + } + + try { + this.loading = true; + this.error = null; + + // Update prescriptions one by one to handle partial failures + const results = await Promise.allSettled( + this.selectedPrescriptions.map(id => { + if (!id || id.trim() === '') { + throw new Error('Invalid prescription ID'); + } + return this.prescriptionService.updatePrescriptionStatus(id, status); + }) + ); + + // Check for failures + const failures = results.filter(result => result.status === 'rejected'); + const successes = results.filter(result => result.status === 'fulfilled'); + + if (failures.length > 0) { + // Log failures for debugging + failures.forEach((failure, index) => { + if (failure.status === 'rejected') { + this.logger.error(`Failed to update prescription ${this.selectedPrescriptions[index]}:`, failure.reason); + } + }); + + const failureReasons = failures.map((failure, index) => { + if (failure.status === 'rejected') { + const error = failure.reason; + const errorMsg = error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + 'Unknown error'; + return errorMsg; + } + return 'Unknown error'; + }).join('; '); + + const totalFailures = failures.length; + const totalSuccess = successes.length; + + if (totalSuccess > 0) { + this.error = `Updated ${totalSuccess} prescription(s) successfully. Failed to update ${totalFailures} prescription(s). ${failureReasons}`; + } else { + this.error = `Failed to update all ${totalFailures} prescription(s). ${failureReasons}`; + } + } else { + // All succeeded + this.selectedPrescriptions = []; + // Don't show error message if all succeeded + this.error = null; + } + + // Refresh to show updated statuses + await this.refreshPrescriptions(); + + // Auto-hide error after 8 seconds (longer since it contains more info) + if (failures.length > 0) { + setTimeout(() => { + this.error = null; + }, 8000); + } + } catch (e: any) { + this.logger.error('Error bulk updating prescriptions:', e); + const errorMessage = e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + 'Failed to update prescriptions. Please try again.'; + this.error = errorMessage; + // Auto-hide error after 5 seconds + setTimeout(() => { + this.error = null; + }, 5000); + } finally { + this.loading = false; + } + } + + applyPrescriptionFilters() { + const prescriptionsToFilter = this.showAllRecords ? this.allPrescriptions : this.prescriptions; + let filtered = prescriptionsToFilter && prescriptionsToFilter.length > 0 ? [...prescriptionsToFilter] : []; + + // Search filter + if (this.prescriptionSearchTerm) { + const searchLower = this.prescriptionSearchTerm.toLowerCase(); + filtered = filtered.filter(p => + p.medicationName?.toLowerCase().includes(searchLower) || + p.prescriptionNumber?.toLowerCase().includes(searchLower) || + p.patientName?.toLowerCase().includes(searchLower) || + p.doctorName?.toLowerCase().includes(searchLower) || + p.pharmacyName?.toLowerCase().includes(searchLower) + ); + } + + // Status filter + if (this.prescriptionStatusFilter !== 'all') { + filtered = filtered.filter(p => p.status === this.prescriptionStatusFilter); + } + + // Date filter + if (this.prescriptionDateFilter !== 'all') { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + filtered = filtered.filter(p => { + const startDate = new Date(p.startDate); + + switch (this.prescriptionDateFilter) { + case 'today': + return startDate >= today; + case 'week': + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + return startDate >= weekAgo; + case 'month': + const monthAgo = new Date(today); + monthAgo.setMonth(monthAgo.getMonth() - 1); + return startDate >= monthAgo; + case 'year': + const yearAgo = new Date(today); + yearAgo.setFullYear(yearAgo.getFullYear() - 1); + return startDate >= yearAgo; + case 'custom': + if (this.prescriptionDateFrom && this.prescriptionDateTo) { + const fromDate = new Date(this.prescriptionDateFrom); + const toDate = new Date(this.prescriptionDateTo); + toDate.setHours(23, 59, 59, 999); + return startDate >= fromDate && startDate <= toDate; + } + return true; + default: + return true; + } + }); + } + + // Sorting + filtered.sort((a, b) => { + let aValue: any, bValue: any; + + switch (this.prescriptionSortBy) { + case 'medicationName': + aValue = a.medicationName || ''; + bValue = b.medicationName || ''; + break; + case 'patientName': + aValue = a.patientName || a.patientId || ''; + bValue = b.patientName || b.patientId || ''; + break; + case 'status': + aValue = a.status || ''; + bValue = b.status || ''; + break; + case 'startDate': + aValue = new Date(a.startDate).getTime(); + bValue = new Date(b.startDate).getTime(); + break; + case 'prescriptionNumber': + aValue = a.prescriptionNumber || ''; + bValue = b.prescriptionNumber || ''; + break; + case 'createdAt': + default: + aValue = new Date(a.createdAt).getTime(); + bValue = new Date(b.createdAt).getTime(); + break; + } + + if (aValue < bValue) return this.prescriptionSortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return this.prescriptionSortOrder === 'asc' ? 1 : -1; + return 0; + }); + + this.filteredPrescriptions = filtered; + } + + onPrescriptionFilterChange() { + this.applyPrescriptionFilters(); + } + + togglePrescriptionSort(column: string) { + if (this.prescriptionSortBy === column) { + this.prescriptionSortOrder = this.prescriptionSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + this.prescriptionSortBy = column; + this.prescriptionSortOrder = 'desc'; + } + this.applyPrescriptionFilters(); + } + + togglePrescriptionSelection(prescriptionId: string) { + const index = this.selectedPrescriptions.indexOf(prescriptionId); + if (index > -1) { + this.selectedPrescriptions.splice(index, 1); + } else { + this.selectedPrescriptions.push(prescriptionId); + } + } + + toggleSelectAllPrescriptions() { + const prescriptionsToSelect = this.filteredPrescriptions.length > 0 + ? this.filteredPrescriptions + : (this.showAllRecords ? this.allPrescriptions : this.prescriptions); + + if (this.selectedPrescriptions.length === prescriptionsToSelect.length) { + this.selectedPrescriptions = []; + } else { + this.selectedPrescriptions = prescriptionsToSelect.map(p => p.id); + } + } + + async toggleRecordsView() { + this.showAllRecords = !this.showAllRecords; + this.selectedPrescriptions = []; + if (this.showAllRecords) { + await this.loadAllPrescriptions(); + } else { + if (this.selectedPatientId) { + await this.loadPatientPrescriptions(); + } else { + this.applyPrescriptionFilters(); + } + } + } + + selectPatient(patientId: string) { + this.patientSelected.emit(patientId); + } + + onPatientSelectionChange(patientId: string) { + this.newPrescription.patientId = patientId; + } + + getPrescriptionStatistics() { + const prescriptions = this.showAllRecords ? this.allPrescriptions : this.prescriptions; + const sourcePrescriptions = prescriptions && prescriptions.length > 0 ? prescriptions : this.filteredPrescriptions; + const total = sourcePrescriptions.length; + const active = sourcePrescriptions.filter(p => p.status === 'ACTIVE').length; + const completed = sourcePrescriptions.filter(p => p.status === 'COMPLETED').length; + const ePrescriptionsSent = sourcePrescriptions.filter(p => p.ePrescriptionSent).length; + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const recent = sourcePrescriptions.filter(p => { + const createdDate = new Date(p.createdAt); + return createdDate >= thirtyDaysAgo; + }).length; + + return { + total, + active, + completed, + ePrescriptionsSent, + recent, + completionRate: total > 0 ? ((completed / total) * 100).toFixed(1) : '0' + }; + } + + exportPrescriptions() { + const prescriptions = this.filteredPrescriptions.length > 0 ? this.filteredPrescriptions : + (this.showAllRecords ? this.allPrescriptions : this.prescriptions); + + const headers = ['Prescription #', 'Medication', 'Patient', 'Dosage', 'Frequency', 'Quantity', 'Refills', 'Status', 'Start Date', 'End Date', 'E-Prescription Sent']; + const rows = prescriptions.map(p => [ + p.prescriptionNumber || '', + p.medicationName || '', + p.patientName || p.patientId || '', + p.dosage || '', + p.frequency || '', + p.quantity?.toString() || '', + p.refills?.toString() || '', + p.status || '', + p.startDate || '', + p.endDate || '', + p.ePrescriptionSent ? 'Yes' : 'No' + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `prescriptions_${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + window.URL.revokeObjectURL(url); + } + + printPrescription(prescription: Prescription) { + const printWindow = window.open('', '_blank'); + if (!printWindow) return; + + printWindow.document.write(` + + + + Prescription - ${prescription.prescriptionNumber} + + + +
+

Prescription

+

Prescription #: ${prescription.prescriptionNumber}

+

Date: ${this.formatDate(prescription.createdAt)}

+
+
+
+

Patient: ${prescription.patientName || prescription.patientId}

+

Doctor: ${prescription.doctorName || prescription.doctorId}

+
+
+

Medication Information

+ + + + + + + + ${prescription.endDate ? `` : ''} +
Medication${prescription.medicationName}
Dosage${prescription.dosage}
Frequency${prescription.frequency}
Quantity${prescription.quantity}
Refills${prescription.refills || 0}
Start Date${this.formatDate(prescription.startDate)}
End Date${this.formatDate(prescription.endDate)}
+
+ ${prescription.instructions ? `

Instructions: ${prescription.instructions}

` : ''} + ${prescription.pharmacyName ? ` +
+

Pharmacy Information

+

Pharmacy: ${prescription.pharmacyName}

+ ${prescription.pharmacyAddress ? `

Address: ${prescription.pharmacyAddress}

` : ''} + ${prescription.pharmacyPhone ? `

Phone: ${prescription.pharmacyPhone}

` : ''} +
+ ` : ''} + +
+ + + `); + + printWindow.document.close(); + printWindow.print(); + } + + formatDate(dateString: string): string { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString(); + } + + get prescriptionDebug() { + return { + doctorId: this.doctorId, + selectedPatientId: this.selectedPatientId, + showAllRecords: this.showAllRecords, + prescriptions: this.prescriptions?.length || 0, + allPrescriptions: this.allPrescriptions?.length || 0, + filteredPrescriptions: this.filteredPrescriptions?.length || 0, + newPrescription: this.newPrescription, + }; + } +} diff --git a/frontend/src/app/pages/doctor/components/profile/profile.component.html b/frontend/src/app/pages/doctor/components/profile/profile.component.html new file mode 100644 index 0000000..99d817a --- /dev/null +++ b/frontend/src/app/pages/doctor/components/profile/profile.component.html @@ -0,0 +1,573 @@ +
+
+
+

My Profile

+

View and manage your professional information

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

Dr. {{ currentUser.firstName }} {{ currentUser.lastName }}

+

{{ currentUser.email }}

+
+
+
+
+ Phone Number + {{ currentUser.phoneNumber || doctorProfile?.phoneNumber || 'N/A' }} +
+
+ Specialization + {{ doctorProfile.specialization || 'Not specified' }} +
+
+ Years of Experience + {{ doctorProfile.yearsOfExperience || 'Not specified' }} +
+
+ Default Appointment Duration + {{ doctorProfile.defaultDurationMinutes || 30 }} minutes +
+
+ Account Status + + + {{ currentUser.isActive ? 'Active' : 'Inactive' }} + + +
+
+
+ + +
+
+

+ + + + + Edit Profile +

+

Update your professional and contact information

+
+
+ +
+
+

+ + + + + Basic Information +

+

Your personal contact details

+
+
+
+ + + 2-50 characters, letters only +
+
+ + + 2-50 characters, letters only +
+
+ + + 10-15 digits, can include country code with + +
+
+
+ + +
+
+

+ + + + + Professional Information +

+

Your medical credentials and expertise

+
+
+
+ + + 2-100 characters, required for verification +
+
+ + + 2-100 characters +
+
+ + + 0-50 years +
+
+ + + +
+
+ +
+ $ + +
+ Must be greater than 0 +
+
+ + + Default duration for patient appointments (15-120 minutes, in 15-minute increments). Default: 30 minutes. You can change it anytime (e.g., from 60 to 30). +
+
+
+ + +
+
+

+ + + + + Enterprise Information +

+

Additional professional details

+
+
+ +
+
Address
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
Education
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
Certifications
+
+
+ + +
+
+ + {{ cert }} + + +
+
+
+ + +
+
Languages Spoken
+
+
+ + +
+
+ + {{ lang }} + + +
+
+
+ + +
+
Hospital Affiliations
+
+
+ + +
+
+ + {{ hospital }} + + +
+
+
+ + +
+
Insurance Accepted
+
+
+ + +
+
+ + {{ insurance }} + + +
+
+
+ + +
+
Professional Memberships
+
+
+ + +
+
+ + {{ membership }} + + +
+
+
+
+
+ +
+ + +
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/profile/profile.component.scss b/frontend/src/app/pages/doctor/components/profile/profile.component.scss new file mode 100644 index 0000000..29c665e --- /dev/null +++ b/frontend/src/app/pages/doctor/components/profile/profile.component.scss @@ -0,0 +1,948 @@ +// Enterprise-Grade Profile Component Styling +// Color Palette +$primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$primary-blue: #0066cc; +$primary-blue-light: #e6f2ff; +$primary-blue-dark: #0052a3; +$accent-teal: #0099a1; +$accent-purple: #8b5cf6; +$text-dark: #1a1a1a; +$text-medium: #4a4a4a; +$text-light: #6b6b6b; +$text-lighter: #9ca3af; +$border-color: #e8eaed; +$border-focus: #0066cc; +$background-light: #f8f9fa; +$background-page: #f5f7fa; +$background-elevated: #ffffff; +$white: #ffffff; +$success-green: #10b981; +$success-green-light: #d1fae5; +$success-green-dark: #059669; +$error-red: #ef4444; +$error-red-light: #fee2e2; +$error-red-dark: #dc2626; +$warning-orange: #f59e0b; +$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$border-radius-sm: 8px; +$border-radius-md: 12px; +$border-radius-lg: 16px; +$border-radius-xl: 20px; +$transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); +$transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); +$transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + +.profile-section { + padding: 32px; + max-width: 1200px; + margin: 0 auto; + animation: fadeIn 0.4s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.profile-header-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 32px; + gap: 24px; + flex-wrap: wrap; +} + +.profile-header-content { + flex: 1; + min-width: 200px; +} + +.section-title { + font-size: 32px; + font-weight: 700; + color: $text-dark; + margin: 0 0 8px 0; + letter-spacing: -0.5px; + background: $primary-gradient; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.2; +} + +.section-description { + font-size: 16px; + color: $text-medium; + margin: 0; + letter-spacing: 0.1px; + line-height: 1.5; +} + +.edit-profile-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $primary-gradient; + color: $white; + border: none; + border-radius: $border-radius-md; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all $transition-base; + box-shadow: $shadow-md; + position: relative; + overflow: hidden; + white-space: nowrap; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, transparent 100%); + opacity: 0; + transition: opacity $transition-base; + } + + svg { + width: 20px; + height: 20px; + stroke-width: 2.5; + position: relative; + z-index: 1; + transition: transform $transition-base; + } + + span { + position: relative; + z-index: 1; + } + + &:hover { + transform: translateY(-2px); + box-shadow: $shadow-lg; + + &::before { + opacity: 1; + } + + svg { + transform: rotate(5deg) scale(1.1); + } + } + + &:active { + transform: translateY(0); + box-shadow: $shadow-md; + } +} + +.profile-card { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%); + backdrop-filter: blur(10px); + border-radius: $border-radius-lg; + box-shadow: $shadow-xl; + border: 1px solid rgba(0, 0, 0, 0.06); + padding: 32px; + animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: $primary-gradient; + opacity: 0.8; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.profile-header { + display: flex; + align-items: center; + gap: 24px; + padding-bottom: 32px; + margin-bottom: 32px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + flex-wrap: wrap; +} + +.profile-avatar-wrapper { + position: relative; + flex-shrink: 0; +} + +.profile-avatar { + width: 120px; + height: 120px; + border-radius: 50%; + background: $primary-gradient; + display: flex; + align-items: center; + justify-content: center; + border: 4px solid rgba(255, 255, 255, 0.9); + box-shadow: $shadow-xl, 0 0 0 4px rgba(102, 126, 234, 0.1); + overflow: hidden; + position: relative; + transition: all $transition-base; + + &::before { + content: ''; + position: absolute; + inset: -2px; + border-radius: 50%; + background: $primary-gradient; + opacity: 0; + transition: opacity $transition-base; + z-index: -1; + filter: blur(12px); + } + + &:hover { + transform: scale(1.05); + box-shadow: $shadow-2xl, 0 0 0 4px rgba(102, 126, 234, 0.15); + + &::before { + opacity: 0.4; + } + } + + .avatar-image-large { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + display: block; + } + + svg { + width: 60px; + height: 60px; + color: $white; + stroke-width: 2; + opacity: 0.9; + } +} + +.avatar-upload-btn { + position: absolute; + bottom: 0; + right: 0; + width: 40px; + height: 40px; + border-radius: 50%; + background: $primary-gradient; + color: $white; + border: 3px solid $white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: $shadow-md; + transition: all $transition-base; + z-index: 10; + + svg { + width: 18px; + height: 18px; + stroke-width: 2.5; + transition: transform $transition-base; + } + + &:hover { + transform: scale(1.1) rotate(15deg); + box-shadow: $shadow-lg; + } + + &:active { + transform: scale(1.05) rotate(10deg); + } +} + +.hidden-input { + display: none; +} + +.profile-name { + flex: 1; + min-width: 200px; + + h3 { + font-size: 28px; + font-weight: 700; + color: $text-dark; + margin: 0 0 8px 0; + letter-spacing: -0.3px; + line-height: 1.2; + } + + p { + font-size: 16px; + color: $text-medium; + margin: 0 0 12px 0; + letter-spacing: 0.1px; + } +} + +.profile-verified { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: linear-gradient(135deg, $success-green-light 0%, rgba(209, 250, 229, 0.5) 100%); + color: $success-green; + border-radius: $border-radius-sm; + font-size: 13px; + font-weight: 600; + width: fit-content; + margin-top: 8px; + box-shadow: $shadow-sm; + + svg { + width: 16px; + height: 16px; + stroke-width: 2.5; + flex-shrink: 0; + } + + span { + letter-spacing: 0.2px; + } +} + +.profile-details { + display: flex; + flex-direction: column; + gap: 20px; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: rgba(255, 255, 255, 0.6); + border-radius: $border-radius-md; + border: 1px solid rgba(0, 0, 0, 0.06); + transition: all $transition-base; + gap: 16px; + flex-wrap: wrap; + + &:hover { + background: rgba(255, 255, 255, 0.9); + box-shadow: $shadow-sm; + transform: translateX(4px); + } +} + +.detail-label { + font-size: 14px; + font-weight: 600; + color: $text-medium; + text-transform: uppercase; + letter-spacing: 0.5px; + min-width: 200px; + flex-shrink: 0; +} + +.detail-value { + font-size: 15px; + font-weight: 500; + color: $text-dark; + text-align: right; + flex: 1; + min-width: 150px; +} + +.status-badge { + display: inline-flex; + align-items: center; + padding: 6px 14px; + border-radius: $border-radius-sm; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: $shadow-sm; + + &.status-confirmed { + background: linear-gradient(135deg, $success-green 0%, $success-green-dark 100%); + color: $white; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); + } + + &.status-cancelled { + background: linear-gradient(135deg, $error-red 0%, $error-red-dark 100%); + color: $white; + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); + } +} + +// Edit Profile Form +.edit-profile-form { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%); + backdrop-filter: blur(10px); + border-radius: $border-radius-lg; + box-shadow: $shadow-xl; + border: 1px solid rgba(0, 0, 0, 0.06); + padding: 40px; + animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: $primary-gradient; + opacity: 0.8; + } +} + +.form-header { + margin-bottom: 32px; + padding-bottom: 24px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.form-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 28px; + font-weight: 700; + color: $text-dark; + margin: 0 0 8px 0; + letter-spacing: -0.3px; + background: $primary-gradient; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + + svg { + width: 28px; + height: 28px; + stroke-width: 2.5; + color: $primary-blue; + } +} + +.form-subtitle { + font-size: 15px; + color: $text-medium; + margin: 0; + letter-spacing: 0.1px; +} + +.profile-form { + display: flex; + flex-direction: column; + gap: 32px; +} + +.form-section { + background: rgba(255, 255, 255, 0.6); + border-radius: $border-radius-lg; + padding: 28px; + border: 1px solid rgba(0, 0, 0, 0.06); + transition: all $transition-base; + + &:hover { + background: rgba(255, 255, 255, 0.8); + box-shadow: $shadow-sm; + } +} + +.section-header { + margin-bottom: 24px; +} + +.section-subtitle { + display: flex; + align-items: center; + gap: 10px; + font-size: 20px; + font-weight: 700; + color: $text-dark; + margin: 0 0 6px 0; + letter-spacing: -0.2px; + + svg { + width: 22px; + height: 22px; + color: $primary-blue; + stroke-width: 2.5; + } +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; + + &.form-group-full { + grid-column: 1 / -1; + } +} + +.form-label { + font-size: 14px; + font-weight: 600; + color: $text-dark; + letter-spacing: 0.1px; + display: flex; + align-items: center; + gap: 6px; +} + +.required-indicator { + color: $error-red; + font-weight: 700; + font-size: 16px; +} + +.form-input { + padding: 12px 16px; + border: 1.5px solid $border-color; + border-radius: $border-radius-md; + font-size: 15px; + font-weight: 400; + color: $text-dark; + background: $white; + transition: all $transition-base; + font-family: inherit; + box-shadow: $shadow-sm; + + &::placeholder { + color: $text-lighter; + font-weight: 400; + } + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1), $shadow-md; + transform: translateY(-1px); + background: $white; + } + + &:hover:not(:focus) { + border-color: rgba(0, 0, 0, 0.12); + box-shadow: $shadow-sm; + } + + &:disabled { + background: $background-light; + cursor: not-allowed; + opacity: 0.6; + } +} + +.form-textarea { + min-height: 120px; + resize: vertical; + font-family: inherit; + line-height: 1.6; +} + +.input-with-icon { + position: relative; + display: flex; + align-items: center; + + .input-icon { + position: absolute; + left: 16px; + color: $text-medium; + font-weight: 600; + font-size: 16px; + pointer-events: none; + z-index: 1; + } + + .form-input { + padding-left: 36px; + } +} + +.form-hint { + font-size: 12px; + color: $text-lighter; + font-weight: 400; + letter-spacing: 0.1px; +} + +.textarea-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 4px; +} + +.char-counter { + font-size: 12px; + font-weight: 600; + color: $text-medium; + padding: 2px 8px; + background: $background-light; + border-radius: $border-radius-sm; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 16px; + padding-top: 24px; + border-top: 1px solid rgba(0, 0, 0, 0.06); + flex-wrap: wrap; +} + +.cancel-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: $white; + color: $text-medium; + border: 1.5px solid $border-color; + border-radius: $border-radius-md; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all $transition-base; + box-shadow: $shadow-sm; + + svg { + width: 18px; + height: 18px; + stroke-width: 2.5; + transition: transform $transition-base; + } + + &:hover { + background: $background-light; + border-color: $text-light; + color: $text-dark; + transform: translateY(-2px); + box-shadow: $shadow-md; + + svg { + transform: rotate(90deg); + } + } + + &:active { + transform: translateY(0); + box-shadow: $shadow-sm; + } +} + +.submit-button { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: $primary-gradient; + color: $white; + border: none; + border-radius: $border-radius-md; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all $transition-base; + box-shadow: $shadow-md; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, transparent 100%); + opacity: 0; + transition: opacity $transition-base; + } + + svg { + width: 18px; + height: 18px; + stroke-width: 2.5; + position: relative; + z-index: 1; + transition: transform $transition-base; + } + + span { + position: relative; + z-index: 1; + } + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: $shadow-lg; + + &::before { + opacity: 1; + } + + svg { + transform: scale(1.1) translateX(2px); + } + } + + &:active:not(:disabled) { + transform: translateY(0); + box-shadow: $shadow-md; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: $shadow-sm; + + &:hover { + transform: none; + box-shadow: $shadow-sm; + } + } +} + +// Responsive Design +@media (max-width: 1024px) { + .profile-section { + padding: 24px; + } + + .profile-card, + .edit-profile-form { + padding: 24px; + } + + .form-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .profile-section { + padding: 20px; + } + + .profile-header-section { + flex-direction: column; + align-items: stretch; + } + + .profile-header { + flex-direction: column; + align-items: center; + text-align: center; + } + + .detail-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .detail-label { + min-width: auto; + } + + .detail-value { + text-align: left; + width: 100%; + } + + .form-actions { + flex-direction: column-reverse; + width: 100%; + } + + .cancel-btn, + .submit-button { + width: 100%; + justify-content: center; + } +} + +@media (max-width: 480px) { + .section-title { + font-size: 24px; + } + + .profile-card, + .edit-profile-form { + padding: 20px; + } + + .form-section { + padding: 20px; + } + + .profile-avatar { + width: 100px; + height: 100px; + } +} + +// Enhanced focus states for accessibility +button:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: 2px solid $primary-blue; + outline-offset: 2px; + border-radius: $border-radius-sm; +} + +// Smooth transitions +* { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +// Performance optimizations +.profile-section, +.profile-card, +.edit-profile-form { + will-change: transform; + transform: translateZ(0); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +// Enterprise Fields Styles +.subsection-title { + font-size: 1.125rem; + font-weight: 600; + color: $text-dark; + margin: 1.5rem 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 2px solid rgba(102, 126, 234, 0.2); + + &:first-child { + margin-top: 0; + } +} + +.array-input-container { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; + + .array-input { + display: flex; + gap: 0.5rem; + align-items: center; + + input { + flex: 1; + } + + .add-btn { + padding: 0.75rem; + background: $primary-blue; + color: $white; + border: none; + border-radius: $border-radius-sm; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all $transition-base; + flex-shrink: 0; + min-width: 44px; + + &:hover { + background: $primary-blue-dark; + transform: scale(1.05); + } + + svg { + width: 20px; + height: 20px; + } + } + } + + .tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; + + .tag { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: $primary-blue-light; + color: $primary-blue-dark; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 500; + + .tag-remove { + background: transparent; + border: none; + color: $primary-blue-dark; + cursor: pointer; + padding: 0; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all $transition-base; + + &:hover { + background: rgba(0, 102, 204, 0.1); + transform: scale(1.1); + } + + svg { + width: 14px; + height: 14px; + } + } + } + } +} + diff --git a/frontend/src/app/pages/doctor/components/profile/profile.component.ts b/frontend/src/app/pages/doctor/components/profile/profile.component.ts new file mode 100644 index 0000000..f1a5c35 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/profile/profile.component.ts @@ -0,0 +1,207 @@ +import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { UserService, UserInfo, DoctorProfile, DoctorUpdateRequest, UserUpdateRequest } from '../../../../services/user.service'; + +@Component({ + selector: 'app-profile', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './profile.component.html', + styleUrl: './profile.component.scss' +}) +export class ProfileComponent implements OnChanges { + @Input() currentUser: UserInfo | null = null; + @Input() doctorProfile: DoctorProfile | null = null; + @Input() showEditProfile = false; + @Output() editProfileClick = new EventEmitter(); + @Output() updateProfile = new EventEmitter<{userData: UserUpdateRequest, doctorData: DoctorUpdateRequest}>(); + @Output() fileSelected = new EventEmitter(); + + constructor(public userService: UserService) {} + + editUserData: UserUpdateRequest = { + firstName: '', + lastName: '', + phoneNumber: '' + }; + + editDoctorData: DoctorUpdateRequest = { + medicalLicenseNumber: '', + specialization: '', + yearsOfExperience: undefined, + biography: '', + consultationFee: undefined, + defaultDurationMinutes: undefined, + // Enterprise fields + streetAddress: '', + city: '', + state: '', + zipCode: '', + country: '', + educationDegree: '', + educationUniversity: '', + educationGraduationYear: undefined, + certifications: [], + languagesSpoken: [], + hospitalAffiliations: [], + insuranceAccepted: [], + professionalMemberships: [] + }; + + newCertification: string = ''; + newLanguage: string = ''; + newHospitalAffiliation: string = ''; + newInsuranceAccepted: string = ''; + newProfessionalMembership: string = ''; + + ngOnChanges(changes: SimpleChanges) { + // When showEditProfile changes to true, initialize form data + if (changes['showEditProfile'] && this.showEditProfile) { + this.initializeFormData(); + } + // When currentUser or doctorProfile changes and we're in edit mode, update form data + if (this.showEditProfile && (changes['currentUser'] || changes['doctorProfile'])) { + this.initializeFormData(); + } + } + + initializeFormData() { + // Initialize form data with current values + if (this.currentUser) { + this.editUserData = { + firstName: this.currentUser.firstName || '', + lastName: this.currentUser.lastName || '', + phoneNumber: this.currentUser.phoneNumber || '' + }; + } + if (this.doctorProfile) { + this.editDoctorData = { + medicalLicenseNumber: this.doctorProfile.medicalLicenseNumber || '', + specialization: this.doctorProfile.specialization || '', + yearsOfExperience: this.doctorProfile.yearsOfExperience, + biography: this.doctorProfile.biography || '', + consultationFee: this.doctorProfile.consultationFee, + defaultDurationMinutes: this.doctorProfile.defaultDurationMinutes, + // Enterprise fields + streetAddress: this.doctorProfile.streetAddress || '', + city: this.doctorProfile.city || '', + state: this.doctorProfile.state || '', + zipCode: this.doctorProfile.zipCode || '', + country: this.doctorProfile.country || '', + educationDegree: this.doctorProfile.educationDegree || '', + educationUniversity: this.doctorProfile.educationUniversity || '', + educationGraduationYear: this.doctorProfile.educationGraduationYear, + certifications: [...(this.doctorProfile.certifications || [])], + languagesSpoken: [...(this.doctorProfile.languagesSpoken || [])], + hospitalAffiliations: [...(this.doctorProfile.hospitalAffiliations || [])], + insuranceAccepted: [...(this.doctorProfile.insuranceAccepted || [])], + professionalMemberships: [...(this.doctorProfile.professionalMemberships || [])] + }; + } + } + + onEditProfile() { + // Initialize form data with current values when editing starts + this.initializeFormData(); + this.editProfileClick.emit(); + } + + cancelEdit() { + // Reset form data to original values + this.initializeFormData(); + this.editProfileClick.emit(); // Emit to close edit mode + } + + onUpdateProfile() { + this.updateProfile.emit({ + userData: this.editUserData, + doctorData: this.editDoctorData + }); + } + + onFileSelected(event: Event) { + this.fileSelected.emit(event); + } + + // Array management methods + addCertification() { + if (this.newCertification && this.newCertification.trim()) { + if (!this.editDoctorData.certifications) { + this.editDoctorData.certifications = []; + } + this.editDoctorData.certifications.push(this.newCertification.trim()); + this.newCertification = ''; + } + } + + removeCertification(index: number) { + if (this.editDoctorData.certifications) { + this.editDoctorData.certifications.splice(index, 1); + } + } + + addLanguage() { + if (this.newLanguage && this.newLanguage.trim()) { + if (!this.editDoctorData.languagesSpoken) { + this.editDoctorData.languagesSpoken = []; + } + this.editDoctorData.languagesSpoken.push(this.newLanguage.trim()); + this.newLanguage = ''; + } + } + + removeLanguage(index: number) { + if (this.editDoctorData.languagesSpoken) { + this.editDoctorData.languagesSpoken.splice(index, 1); + } + } + + addHospitalAffiliation() { + if (this.newHospitalAffiliation && this.newHospitalAffiliation.trim()) { + if (!this.editDoctorData.hospitalAffiliations) { + this.editDoctorData.hospitalAffiliations = []; + } + this.editDoctorData.hospitalAffiliations.push(this.newHospitalAffiliation.trim()); + this.newHospitalAffiliation = ''; + } + } + + removeHospitalAffiliation(index: number) { + if (this.editDoctorData.hospitalAffiliations) { + this.editDoctorData.hospitalAffiliations.splice(index, 1); + } + } + + addInsuranceAccepted() { + if (this.newInsuranceAccepted && this.newInsuranceAccepted.trim()) { + if (!this.editDoctorData.insuranceAccepted) { + this.editDoctorData.insuranceAccepted = []; + } + this.editDoctorData.insuranceAccepted.push(this.newInsuranceAccepted.trim()); + this.newInsuranceAccepted = ''; + } + } + + removeInsuranceAccepted(index: number) { + if (this.editDoctorData.insuranceAccepted) { + this.editDoctorData.insuranceAccepted.splice(index, 1); + } + } + + addProfessionalMembership() { + if (this.newProfessionalMembership && this.newProfessionalMembership.trim()) { + if (!this.editDoctorData.professionalMemberships) { + this.editDoctorData.professionalMemberships = []; + } + this.editDoctorData.professionalMemberships.push(this.newProfessionalMembership.trim()); + this.newProfessionalMembership = ''; + } + } + + removeProfessionalMembership(index: number) { + if (this.editDoctorData.professionalMemberships) { + this.editDoctorData.professionalMemberships.splice(index, 1); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/doctor/components/security/security.component.html b/frontend/src/app/pages/doctor/components/security/security.component.html new file mode 100644 index 0000000..bbfe322 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/security/security.component.html @@ -0,0 +1,224 @@ +
+ +
+
+

Security Settings

+

Manage your account security and two-factor authentication

+
+
+ + +
+ Error: {{ error }} + +
+ + +
+
+
+
+

Two-Factor Authentication (2FA)

+

+ Two-factor authentication adds an extra layer of security to your account. + When enabled, you'll need your password and a verification code from your authenticator app to sign in. +

+
+
+ + + + + + + + {{ twoFAEnabled ? 'Enabled' : 'Disabled' }} +
+
+ +
+
+ + + + You have {{ twoFAStatus.backupCodesCount || 0 }} backup codes +
+
+
+ +
+ + +
+
+ + + + + +
+

Security Tips

+
    +
  • + + + + Use a strong, unique password for your account +
  • +
  • + + + + Enable two-factor authentication for enhanced security +
  • +
  • + + + + Keep your backup codes in a safe, secure location +
  • +
  • + + + + Never share your 2FA codes or backup codes with anyone +
  • +
  • + + + + Log out from shared or public computers +
  • +
+
+
diff --git a/frontend/src/app/pages/doctor/components/security/security.component.scss b/frontend/src/app/pages/doctor/components/security/security.component.scss new file mode 100644 index 0000000..4604caf --- /dev/null +++ b/frontend/src/app/pages/doctor/components/security/security.component.scss @@ -0,0 +1,963 @@ +// Enterprise-Grade Security Component Styling +// Color Palette +$primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$primary-blue: #0066cc; +$primary-blue-light: #e6f2ff; +$primary-blue-dark: #0052a3; +$accent-teal: #0099a1; +$accent-purple: #8b5cf6; +$text-dark: #1a1a1a; +$text-medium: #4a4a4a; +$text-light: #6b6b6b; +$text-lighter: #9ca3af; +$border-color: #e8eaed; +$border-focus: #0066cc; +$background-light: #f8f9fa; +$background-page: #f5f7fa; +$background-elevated: #ffffff; +$white: #ffffff; +$success-green: #10b981; +$success-green-light: #d1fae5; +$success-green-dark: #059669; +$error-red: #ef4444; +$error-red-light: #fee2e2; +$error-red-dark: #dc2626; +$warning-orange: #f59e0b; +$warning-orange-light: #fef3c7; +$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$border-radius-sm: 8px; +$border-radius-md: 12px; +$border-radius-lg: 16px; +$border-radius-xl: 20px; +$transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); +$transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); +$transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + +.security-section { + padding: 32px; + max-width: 1200px; + margin: 0 auto; + animation: fadeIn 0.4s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.section-header { + margin-bottom: 32px; +} + +.section-title { + font-size: 32px; + font-weight: 700; + color: $text-dark; + margin: 0 0 8px 0; + letter-spacing: -0.5px; + background: $primary-gradient; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.2; +} + +.section-description { + font-size: 16px; + color: $text-medium; + margin: 0; + letter-spacing: 0.1px; + line-height: 1.5; +} + +.error-message { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: linear-gradient(135deg, $error-red-light 0%, rgba(254, 226, 226, 0.5) 100%); + color: $error-red-dark; + border: 1.5px solid $error-red; + border-radius: $border-radius-md; + margin-bottom: 24px; + box-shadow: $shadow-md; + font-size: 14px; + font-weight: 600; + animation: slideDown 0.3s ease-out; + + strong { + font-weight: 700; + margin-right: 8px; + } + + button { + background: transparent; + border: none; + color: $error-red-dark; + font-size: 24px; + font-weight: 700; + cursor: pointer; + padding: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all $transition-base; + flex-shrink: 0; + + &:hover { + background: $error-red; + color: $white; + transform: rotate(90deg) scale(1.1); + } + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%); + backdrop-filter: blur(10px); + border-radius: $border-radius-lg; + box-shadow: $shadow-xl; + border: 1px solid rgba(0, 0, 0, 0.06); + padding: 32px; + margin-bottom: 24px; + animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: $primary-gradient; + opacity: 0.8; + } + + h3 { + font-size: 24px; + font-weight: 700; + color: $text-dark; + margin: 0 0 16px 0; + letter-spacing: -0.3px; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.security-status { + margin-bottom: 24px; +} + +.status-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + margin-bottom: 20px; + flex-wrap: wrap; + + > div:first-child { + flex: 1; + min-width: 300px; + } + + h3 { + font-size: 22px; + font-weight: 700; + color: $text-dark; + margin: 0 0 8px 0; + letter-spacing: -0.2px; + } +} + +.security-description { + font-size: 15px; + color: $text-medium; + margin: 0; + line-height: 1.6; + letter-spacing: 0.1px; +} + +.status-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border-radius: $border-radius-md; + font-size: 14px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: $shadow-md; + flex-shrink: 0; + white-space: nowrap; + + svg { + width: 18px; + height: 18px; + stroke-width: 2.5; + flex-shrink: 0; + } + + &.status-enabled { + background: linear-gradient(135deg, $success-green 0%, $success-green-dark 100%); + color: $white; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); + } + + &.status-disabled { + background: linear-gradient(135deg, $text-lighter 0%, #6b7280 100%); + color: $white; + box-shadow: 0 4px 12px rgba(156, 163, 175, 0.3); + } +} + +.status-details { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 20px; + border-top: 1px solid rgba(0, 0, 0, 0.06); +} + +.detail-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.6); + border-radius: $border-radius-md; + border: 1px solid rgba(0, 0, 0, 0.06); + transition: all $transition-base; + + &:hover { + background: rgba(255, 255, 255, 0.9); + box-shadow: $shadow-sm; + transform: translateX(4px); + } + + svg { + width: 20px; + height: 20px; + color: $success-green; + stroke-width: 2.5; + flex-shrink: 0; + } + + span { + font-size: 14px; + font-weight: 500; + color: $text-dark; + letter-spacing: 0.1px; + } +} + +.security-actions { + display: flex; + gap: 16px; + padding-top: 24px; + border-top: 1px solid rgba(0, 0, 0, 0.06); + flex-wrap: wrap; +} + +.primary-button, +.danger-button, +.secondary-button { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + border-radius: $border-radius-md; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all $transition-base; + border: none; + box-shadow: $shadow-md; + position: relative; + overflow: hidden; + white-space: nowrap; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, transparent 100%); + opacity: 0; + transition: opacity $transition-base; + } + + svg { + width: 20px; + height: 20px; + stroke-width: 2.5; + position: relative; + z-index: 1; + transition: transform $transition-base; + flex-shrink: 0; + } + + span { + position: relative; + z-index: 1; + } + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: $shadow-lg; + + &::before { + opacity: 1; + } + + svg { + transform: scale(1.1); + } + } + + &:active:not(:disabled) { + transform: translateY(0); + box-shadow: $shadow-md; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: $shadow-sm; + + &:hover { + transform: none; + box-shadow: $shadow-sm; + } + } +} + +.primary-button { + background: $primary-gradient; + color: $white; + + &:hover:not(:disabled) { + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); + } +} + +.danger-button { + background: linear-gradient(135deg, $error-red 0%, $error-red-dark 100%); + color: $white; + + &:hover:not(:disabled) { + box-shadow: 0 8px 20px rgba(239, 68, 68, 0.4); + } +} + +.secondary-button { + background: $white; + color: $text-medium; + border: 1.5px solid $border-color; + + &:hover:not(:disabled) { + background: $background-light; + border-color: $text-light; + color: $text-dark; + } + + &.copied { + background: linear-gradient(135deg, $success-green-light 0%, rgba(209, 250, 229, 0.5) 100%); + border-color: $success-green; + color: $success-green-dark; + } +} + +// Modal Styles +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease-out; + padding: 20px; +} + +.modal-content { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%); + backdrop-filter: blur(20px); + border-radius: $border-radius-lg; + box-shadow: $shadow-2xl; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; + animation: modalSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); + border: 1px solid rgba(0, 0, 0, 0.06); +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-header { + padding: 24px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%); + position: sticky; + top: 0; + z-index: 10; + + h2 { + font-size: 24px; + font-weight: 700; + color: $text-dark; + margin: 0; + letter-spacing: -0.3px; + background: $primary-gradient; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } +} + +.modal-close { + width: 32px; + height: 32px; + border-radius: $border-radius-sm; + border: none; + background: transparent; + color: $text-light; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all $transition-base; + flex-shrink: 0; + + svg { + width: 20px; + height: 20px; + stroke-width: 2.5; + transition: transform $transition-base; + } + + &:hover { + background: $background-light; + color: $text-dark; + transform: rotate(90deg); + } +} + +.modal-body { + padding: 24px; + flex: 1; + overflow-y: auto; +} + +.setup-step { + display: flex; + flex-direction: column; + gap: 24px; +} + +.setup-step h3 { + font-size: 20px; + font-weight: 700; + color: $text-dark; + margin: 0 0 8px 0; + letter-spacing: -0.2px; +} + +.step-instructions { + font-size: 15px; + color: $text-medium; + margin: 0 0 20px 0; + line-height: 1.6; + letter-spacing: 0.1px; + + &.important-notice { + padding: 16px; + background: linear-gradient(135deg, $warning-orange-light 0%, rgba(254, 243, 199, 0.5) 100%); + border: 1.5px solid $warning-orange; + border-radius: $border-radius-md; + color: darken($warning-orange, 20%); + font-weight: 500; + + strong { + font-weight: 700; + } + } +} + +.qr-code-container { + display: flex; + justify-content: center; + align-items: center; + padding: 24px; + background: $white; + border-radius: $border-radius-md; + border: 2px solid $border-color; + box-shadow: $shadow-md; + margin: 0 auto; + max-width: 300px; +} + +.qr-code-image { + width: 100%; + max-width: 250px; + height: auto; + border-radius: $border-radius-sm; +} + +.loading-placeholder { + display: flex; + justify-content: center; + align-items: center; + width: 250px; + height: 250px; + + .spinner { + width: 48px; + height: 48px; + color: $primary-blue; + animation: spin 1s linear infinite; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.secret-key-section { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid rgba(0, 0, 0, 0.06); + + h4 { + font-size: 16px; + font-weight: 600; + color: $text-dark; + margin: 0 0 12px 0; + letter-spacing: 0.1px; + } +} + +.secret-key-display { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: $background-light; + border-radius: $border-radius-md; + border: 1.5px solid $border-color; + box-shadow: $shadow-sm; + + .secret-key-text { + flex: 1; + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + font-size: 14px; + font-weight: 600; + color: $text-dark; + letter-spacing: 2px; + background: $white; + padding: 12px; + border-radius: $border-radius-sm; + border: 1px solid $border-color; + } +} + +.icon-button { + width: 44px; + height: 44px; + border-radius: $border-radius-md; + background: $white; + border: 1.5px solid $border-color; + color: $text-medium; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all $transition-base; + flex-shrink: 0; + box-shadow: $shadow-sm; + + svg { + width: 20px; + height: 20px; + stroke-width: 2.5; + transition: transform $transition-base; + } + + &:hover { + background: $primary-blue-light; + border-color: $primary-blue; + color: $primary-blue; + transform: scale(1.1); + box-shadow: $shadow-md; + + svg { + transform: scale(1.1); + } + } + + &:active { + transform: scale(1.05); + } + + &.copied { + background: $success-green-light; + border-color: $success-green; + color: $success-green-dark; + + &:hover { + background: $success-green-light; + border-color: $success-green; + color: $success-green-dark; + } + } +} + +.copied-message { + margin-top: 8px; + font-size: 13px; + font-weight: 600; + color: $success-green-dark; + text-align: center; + animation: fadeIn 0.3s ease-out; +} + +.verification-section { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid rgba(0, 0, 0, 0.06); +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-label { + font-size: 14px; + font-weight: 600; + color: $text-dark; + letter-spacing: 0.1px; +} + +.form-input { + padding: 12px 16px; + border: 1.5px solid $border-color; + border-radius: $border-radius-md; + font-size: 15px; + font-weight: 400; + color: $text-dark; + background: $white; + transition: all $transition-base; + font-family: inherit; + box-shadow: $shadow-sm; + + &::placeholder { + color: $text-lighter; + font-weight: 400; + } + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1), $shadow-md; + transform: translateY(-1px); + background: $white; + } + + &:hover:not(:focus) { + border-color: rgba(0, 0, 0, 0.12); + box-shadow: $shadow-sm; + } + + &.code-input { + font-size: 24px; + font-weight: 700; + letter-spacing: 8px; + text-align: center; + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + padding: 16px; + max-width: 200px; + margin: 0 auto; + } +} + +.form-hint { + font-size: 12px; + color: $text-lighter; + font-weight: 400; + letter-spacing: 0.1px; + text-align: center; +} + +.backup-codes-step { + text-align: center; +} + +.backup-codes-container { + margin: 24px 0; +} + +.backup-codes-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.backup-code-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: $white; + border: 1.5px solid $border-color; + border-radius: $border-radius-md; + box-shadow: $shadow-sm; + transition: all $transition-base; + + &:hover { + background: $background-light; + box-shadow: $shadow-md; + transform: translateY(-2px); + } + + .code-number { + font-size: 14px; + font-weight: 700; + color: $text-medium; + min-width: 24px; + } + + .backup-code-text { + flex: 1; + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + font-size: 14px; + font-weight: 600; + color: $text-dark; + letter-spacing: 1px; + text-align: left; + } +} + +.backup-codes-actions { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +.modal-footer { + padding: 20px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.06); + display: flex; + justify-content: flex-end; + gap: 12px; + background: $background-light; + flex-wrap: wrap; +} + +// Security Tips +.security-tips { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 16px; + + li { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 16px; + background: rgba(255, 255, 255, 0.6); + border-radius: $border-radius-md; + border: 1px solid rgba(0, 0, 0, 0.06); + transition: all $transition-base; + + &:hover { + background: rgba(255, 255, 255, 0.9); + box-shadow: $shadow-sm; + transform: translateX(4px); + } + + svg { + width: 24px; + height: 24px; + color: $success-green; + stroke-width: 2.5; + flex-shrink: 0; + margin-top: 2px; + } + + span, + &::before { + font-size: 15px; + font-weight: 400; + color: $text-dark; + line-height: 1.6; + letter-spacing: 0.1px; + } + + // Text content is directly in li, so we style it + &:not(:has(span)) { + &::before { + content: attr(data-text); + } + } + } +} + +// Responsive Design +@media (max-width: 1024px) { + .security-section { + padding: 24px; + } + + .card { + padding: 24px; + } +} + +@media (max-width: 768px) { + .security-section { + padding: 20px; + } + + .status-header { + flex-direction: column; + align-items: flex-start; + } + + .security-actions { + flex-direction: column; + width: 100%; + } + + .primary-button, + .danger-button, + .secondary-button { + width: 100%; + justify-content: center; + } + + .modal-content { + max-width: 100%; + margin: 0; + } + + .backup-codes-list { + grid-template-columns: 1fr; + } + + .backup-codes-actions { + flex-direction: column; + width: 100%; + } + + .backup-codes-actions .secondary-button { + width: 100%; + } +} + +@media (max-width: 480px) { + .section-title { + font-size: 24px; + } + + .card { + padding: 20px; + } + + .code-input { + font-size: 20px; + letter-spacing: 6px; + max-width: 180px; + } +} + +// Enhanced focus states for accessibility +button:focus-visible, +input:focus-visible { + outline: 2px solid $primary-blue; + outline-offset: 2px; + border-radius: $border-radius-sm; +} + +// Smooth transitions +* { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +// Performance optimizations +.security-section, +.card, +.modal-content { + will-change: transform; + transform: translateZ(0); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + diff --git a/frontend/src/app/pages/doctor/components/security/security.component.ts b/frontend/src/app/pages/doctor/components/security/security.component.ts new file mode 100644 index 0000000..7441aa0 --- /dev/null +++ b/frontend/src/app/pages/doctor/components/security/security.component.ts @@ -0,0 +1,186 @@ +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TwoFactorAuthService, TwoFactorStatus, TwoFactorSetupResponse } from '../../../../services/two-factor-auth.service'; +import { ModalService } from '../../../../services/modal.service'; +import { LoggerService } from '../../../../services/logger.service'; + +@Component({ + selector: 'app-security', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './security.component.html', + styleUrl: './security.component.scss' +}) +export class SecurityComponent implements OnInit { + @Input() twoFAEnabled = false; + @Input() twoFAStatus: TwoFactorStatus | null = null; + @Output() setup2FA = new EventEmitter(); + @Output() disable2FA = new EventEmitter(); + + // 2FA Modal State + show2FAModal = false; + qrCodeUrl: string = ''; + twoFASecretKey: string = ''; + twoFABackupCodes: string[] = []; + twoFACodeInput: string = ''; + error: string | null = null; + loading = false; + showBackupCodes = false; + copiedSecretKey = false; + copiedBackupCodes = false; + + constructor( + private twoFactorAuthService: TwoFactorAuthService, + private modalService: ModalService, + private logger: LoggerService + ) {} + + ngOnInit() { + // Component is initialized with inputs from parent + } + + async onSetup2FA() { + try { + this.loading = true; + this.error = null; + const setup: TwoFactorSetupResponse = await this.twoFactorAuthService.setup2FA(); + this.qrCodeUrl = setup.qrCodeUrl; + this.twoFASecretKey = setup.secretKey; + this.twoFABackupCodes = setup.backupCodes || []; + this.twoFACodeInput = ''; + this.show2FAModal = true; + this.showBackupCodes = false; + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to setup 2FA'; + this.logger.error('Error setting up 2FA:', e); + } finally { + this.loading = false; + } + } + + async verifyAndEnable2FA() { + if (!this.twoFACodeInput || this.twoFACodeInput.length !== 6) { + this.error = 'Please enter a valid 6-digit code'; + return; + } + try { + this.loading = true; + this.error = null; + await this.twoFactorAuthService.enable2FA(this.twoFACodeInput); + await this.refresh2FAStatus(); + this.show2FAModal = false; + this.showBackupCodes = true; + // Show success message + setTimeout(async () => { + await this.modalService.alert( + '2FA enabled successfully! Please save your backup codes.', + 'success', + '2FA Enabled' + ); + }, 100); + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to enable 2FA'; + this.logger.error('Error enabling 2FA:', e); + } finally { + this.loading = false; + } + } + + close2FAModal() { + this.show2FAModal = false; + this.qrCodeUrl = ''; + this.twoFASecretKey = ''; + this.twoFABackupCodes = []; + this.twoFACodeInput = ''; + this.error = null; + this.showBackupCodes = false; + this.copiedSecretKey = false; + this.copiedBackupCodes = false; + } + + async onDisable2FA() { + const code = await this.modalService.prompt( + 'Enter your 2FA code or backup code to disable two-factor authentication:', + 'Disable 2FA', + '2FA Code', + 'Enter 2FA code or backup code', + 'text', + 'Disable', + 'Cancel' + ); + if (!code) return; + try { + this.loading = true; + this.error = null; + await this.twoFactorAuthService.disable2FA(code); + await this.refresh2FAStatus(); + this.disable2FA.emit(code); + await this.modalService.alert( + '2FA disabled successfully', + 'success', + '2FA Disabled' + ); + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to disable 2FA'; + await this.modalService.alert( + this.error || 'Failed to disable 2FA', + 'error', + '2FA Error' + ); + } finally { + this.loading = false; + } + } + + async refresh2FAStatus() { + try { + const status = await this.twoFactorAuthService.get2FAStatus(); + this.twoFAEnabled = status.enabled; + this.twoFAStatus = status; + this.setup2FA.emit(); // Notify parent to refresh + } catch (e: any) { + this.logger.error('Error refreshing 2FA status:', e); + } + } + + copySecretKey() { + if (this.twoFASecretKey) { + navigator.clipboard.writeText(this.twoFASecretKey).then(() => { + this.copiedSecretKey = true; + setTimeout(() => { + this.copiedSecretKey = false; + }, 2000); + }); + } + } + + copyBackupCodes() { + if (this.twoFABackupCodes.length > 0) { + navigator.clipboard.writeText(this.twoFABackupCodes.join('\n')).then(() => { + this.copiedBackupCodes = true; + setTimeout(() => { + this.copiedBackupCodes = false; + }, 2000); + }); + } + } + + downloadBackupCodes() { + const content = this.twoFABackupCodes.join('\n'); + const blob = new Blob([content], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = '2fa-backup-codes.txt'; + link.click(); + window.URL.revokeObjectURL(url); + } + + onCodeInputChange() { + // Auto-verify if 6 digits entered + if (this.twoFACodeInput.length === 6 && /^\d{6}$/.test(this.twoFACodeInput)) { + // Optional: auto-verify on 6 digits + } + } +} diff --git a/frontend/src/app/pages/doctor/doctor.component.html b/frontend/src/app/pages/doctor/doctor.component.html new file mode 100644 index 0000000..b61ebfa --- /dev/null +++ b/frontend/src/app/pages/doctor/doctor.component.html @@ -0,0 +1,987 @@ +
+ +
+
+
+
+ + + + +
+

Doctor Dashboard

+

Welcome, {{ currentUser?.firstName }} {{ currentUser?.lastName }}

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

Notifications

+
+ + +
+
+
+
+

No notifications

+
+
+
+ + + + + + + + + +
+
+
{{ notification.title }}
+
{{ notification.message }}
+
{{ getNotificationTime(notification.timestamp) }}
+
+ +
+
+
+
+ + +
+ + + +
+
+

Blocked Users

+ +
+ +
+
+

Loading blocked users...

+
+
+
+
+ +
+ {{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }} +
+
+ + +
+
+ + + + +

No blocked users

+ You haven't blocked anyone yet +
+
+
+
+ + +
+
+

Messages

+
+ + +
+
+ +
+ +
+
Search Results ({{ chatSearchResults.length }})
+
+
+ +
+ {{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }} +
+
+
+
+
{{ user.firstName }} {{ user.lastName }}
+
{{ user.specialization }}
+
+
+
+ + +
+
Conversations ({{ filteredConversations.length }})
+
+

{{ chatSearchQuery ? 'No conversations found' : 'No conversations yet' }}

+

{{ chatSearchQuery ? 'Try a different search term' : 'Start a conversation to begin messaging' }}

+
+ +
+

No users found

+

Try searching with a different name

+
+
+
+ +
+ {{ conversation.otherUserName.charAt(0) }} +
+
+
+
+
{{ conversation.otherUserName }}
+
{{ conversation.lastMessage?.content || 'No messages yet' }}
+
+
+
+ {{ getConversationTime(conversation.lastMessage!.createdAt) }} +
+
+ {{ conversation.unreadCount }} +
+
+
+
+
+
+
+ + +
+ + + +
+
+

Blocked Users

+ +
+ +
+
+

Loading blocked users...

+
+
+
+
+ +
+ {{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }} +
+
+ + +
+
+ + + + +

No blocked users

+ You haven't blocked anyone yet +
+
+
+
+
+ + + + +
+ + + +
+
+
+ +
+ {{ currentUser?.firstName?.charAt(0) }}{{ currentUser?.lastName?.charAt(0) }} +
+
+
+
Dr. {{ currentUser?.firstName }} {{ currentUser?.lastName }}
+
{{ currentUser?.email }}
+
+
+
+
+ + +
+ +
+
+
+
+
+
+ + +
+ +
+
+ + + + +
+

Loading dashboard...

+
+ + +
+
+ + + + +
+

Error

+

{{ error }}

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

Dashboard Overview

+
+
+
+ + + +
+
+

Total Appointments

+

{{ getTotalAppointments() }}

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

Upcoming

+

{{ getUpcomingCount() }}

+
+
+ +
+
+ + + +
+
+

Completed

+

{{ getCompletedCount() }}

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

Patients

+

{{ patients.length }}

+
+
+
+
+ + +
+

Quick Actions

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

Patient Safety Alerts

+
+ + +
+
+ + +
+

Create New Clinical Alert

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

+ Clinical Alerts + {{ unacknowledgedAlerts.length }} +

+ +
+

No unacknowledged clinical alerts

+
+ +
+
+
+ + {{ alert.severity }} + + {{ alert.alertType.replace('_', ' ') }} +
+ {{ getRelativeTime(alert.createdAt) }} +
+
+

{{ alert.title }}

+

{{ alert.description }}

+
+ + Patient: {{ alert.patientName }} + + + Medication: {{ alert.medicationName }} + +
+
+
+ + +
+
+
+ + +
+

+ Critical Lab Results + {{ unacknowledgedCriticalResults.length }} +

+ +
+

No unacknowledged critical lab results

+
+ +
+
+
+ + {{ result.criticalityLevel }} + + {{ result.testName }} +
+ {{ getRelativeTime(result.createdAt) }} +
+
+

Critical Lab Result

+
+
+ Result Value: {{ result.resultValue }} +
+
+ Reference Range: {{ result.referenceRange }} +
+
+ Clinical Significance: {{ result.clinicalSignificance }} +
+
+ Patient: {{ result.patientName }} +
+
+
+
+ +
+
+
+
+
+ + +
+ + +
+
+
+
+
+ + + + + + +
+
+
+ + + + Messages + {{ chatUnreadCount }} +
+ + +
+ + + +
+ + +
+
+ +
+
+ +
diff --git a/frontend/src/app/pages/doctor/doctor.component.scss b/frontend/src/app/pages/doctor/doctor.component.scss new file mode 100644 index 0000000..22e6c09 --- /dev/null +++ b/frontend/src/app/pages/doctor/doctor.component.scss @@ -0,0 +1,3251 @@ +// ============================================================================ +// Enterprise Doctor Dashboard SCSS +// Modern, Professional, and Responsive Styling +// ============================================================================ + +// ============================================================================ +// CSS Custom Properties (Design Tokens) +// ============================================================================ + +// Define CSS variables globally for the component +:host, +app-doctor { + display: block; + width: 100%; +} + +:root { + // Color Palette - Medical Professional Theme + --color-primary: #2563eb; + --color-primary-dark: #1e40af; + --color-primary-light: #3b82f6; + --color-secondary: #10b981; + --color-secondary-dark: #059669; + --color-accent: #8b5cf6; + + // Semantic Colors + --color-success: #10b981; + --color-success-light: #d1fae5; + --color-info: #3b82f6; + --color-info-light: #dbeafe; + --color-warning: #f59e0b; + --color-warning-light: #fef3c7; + --color-danger: #ef4444; + --color-danger-light: #fee2e2; + + // Neutral Colors + --color-white: #ffffff; + --color-gray-50: #f9fafb; + --color-gray-100: #f3f4f6; + --color-gray-200: #e5e7eb; + --color-gray-300: #d1d5db; + --color-gray-400: #9ca3af; + --color-gray-500: #6b7280; + --color-gray-600: #4b5563; + --color-gray-700: #374151; + --color-gray-800: #1f2937; + --color-gray-900: #111827; + + // Background Colors + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f3f4f6; + --bg-overlay: rgba(0, 0, 0, 0.5); + --bg-glass: rgba(255, 255, 255, 0.8); + --bg-glass-dark: rgba(255, 255, 255, 0.95); + + // Text Colors + --text-primary: #111827; + --text-secondary: #4b5563; + --text-tertiary: #6b7280; + --text-inverse: #ffffff; + --text-disabled: #9ca3af; + + // Spacing Scale (8px base) + --space-xs: 0.25rem; // 4px + --space-sm: 0.5rem; // 8px + --space-md: 1rem; // 16px + --space-lg: 1.5rem; // 24px + --space-xl: 2rem; // 32px + --space-2xl: 3rem; // 48px + --space-3xl: 4rem; // 64px + + // Typography + --font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-family-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + + --font-size-xs: 0.75rem; // 12px + --font-size-sm: 0.875rem; // 14px + --font-size-base: 1rem; // 16px + --font-size-lg: 1.125rem; // 18px + --font-size-xl: 1.25rem; // 20px + --font-size-2xl: 1.5rem; // 24px + --font-size-3xl: 1.875rem; // 30px + --font-size-4xl: 2.25rem; // 36px + + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.25; + --line-height-normal: 1.5; + --line-height-relaxed: 1.75; + + // Border Radius + --radius-sm: 0.375rem; // 6px + --radius-md: 0.5rem; // 8px + --radius-lg: 0.75rem; // 12px + --radius-xl: 1rem; // 16px + --radius-2xl: 1.5rem; // 24px + --radius-full: 9999px; + + // Shadows - Elevation System + --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + --shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06); + + // Transitions + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + + // Z-Index Scale + --z-base: 1; + --z-dropdown: 1000; + --z-sticky: 1020; + --z-fixed: 1030; + --z-modal-backdrop: 1040; + --z-modal: 1050; + --z-popover: 1060; + --z-tooltip: 1070; + + // Layout + --header-height: 80px; + --sidebar-width: 280px; + --max-width: 1400px; + + // Breakpoints (for media queries) + --breakpoint-sm: 640px; + --breakpoint-md: 768px; + --breakpoint-lg: 1024px; + --breakpoint-xl: 1280px; + --breakpoint-2xl: 1536px; +} + +// ============================================================================ +// Base Styles & Reset +// Scoped to component to avoid conflicts with ViewEncapsulation.None +// ============================================================================ + +app-doctor, +:host { + display: block; + width: 100%; +} + +.dashboard-wrapper { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); + font-family: var(--font-family-base); + color: var(--text-primary); + line-height: var(--line-height-normal); +} + +// ============================================================================ +// Header Styles +// ============================================================================ + +.dashboard-header { + position: sticky; + top: 0; + z-index: var(--z-fixed); + background: var(--bg-glass-dark); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--color-gray-200); + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); + flex-shrink: 0; + + .header-content { + max-width: 100%; + margin: 0; + padding: var(--space-md) var(--space-xl) var(--space-md) 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); + } + + .header-left { + display: flex; + align-items: center; + gap: var(--space-lg); + margin-left: 0; + padding-left: 0; + } + + .logo-section { + display: flex; + align-items: center; + gap: var(--space-md); + margin-left: 0; + padding-left: 0; + } + + .medical-icon { + width: 48px; + height: 48px; + color: var(--color-primary); + transition: transform var(--transition-base); + + &:hover { + transform: scale(1.05); + } + } + + .header-text { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + + .dashboard-title { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .dashboard-subtitle { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: 0; + } + + .header-right { + display: flex; + align-items: center; + gap: var(--space-md); + } +} + +// ============================================================================ +// Chat Menu (Facebook-style) +// ============================================================================ + +.chat-menu-container { + position: relative; + z-index: var(--z-dropdown); +} + +.chat-menu-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: none; + background: var(--bg-primary); + border-radius: var(--radius-lg); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); + + svg { + width: 20px; + height: 20px; + } + + &:hover { + background: var(--color-gray-100); + color: var(--color-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + &.active { + background: var(--color-primary); + color: var(--text-inverse); + + svg { + color: var(--text-inverse); + } + } + + &.has-unread { + animation: pulse-chat 2s infinite; + } +} + +.blocked-users-container { + position: relative; + z-index: var(--z-dropdown); +} + +.blocked-users-button-header { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: none; + background: var(--bg-primary); + border-radius: var(--radius-lg); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); + + svg { + width: 20px; + height: 20px; + } + + &:hover { + background: var(--color-danger-light); + color: var(--color-danger); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + &.active { + background: linear-gradient(135deg, var(--color-danger) 0%, #dc2626 100%); + color: var(--text-inverse); + + svg { + color: var(--text-inverse); + } + + &:hover { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + } + } +} + +@keyframes pulse-chat { + 0%, 100% { + box-shadow: var(--shadow-sm); + } + 50% { + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.2); + } +} + +.chat-menu-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 18px; + height: 18px; + padding: 0 5px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--color-danger) 0%, #dc2626 100%); + color: var(--text-inverse); + border-radius: var(--radius-full); + font-size: 10px; + font-weight: var(--font-weight-bold); + border: 2px solid var(--bg-primary); + box-shadow: var(--shadow-sm); + animation: bounce-badge 1s infinite; +} + +@keyframes bounce-badge { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +.chat-menu-dropdown { + position: absolute; + top: calc(100% + var(--space-sm)); + right: 0; + width: 380px; + max-height: 500px; + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-2xl); + border: 1px solid var(--color-gray-200); + overflow: hidden; + z-index: var(--z-dropdown); + animation: slideDown 0.2s ease-out; + display: flex; + flex-direction: column; +} + +.chat-menu-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-lg); + border-bottom: 1px solid var(--color-gray-200); + background: linear-gradient(135deg, rgba(37, 99, 235, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%); + flex-shrink: 0; + + h3 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + } + + .chat-menu-header-actions { + display: flex; + align-items: center; + gap: 8px; + } + +} + +.chat-menu-close { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: var(--radius-lg); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--color-gray-100); + color: var(--text-primary); + } +} + +.chat-menu-blocked-users { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: var(--radius-lg); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--color-danger-light); + color: var(--color-danger); + } +} + +// Blocked Users Panel Dropdown +.blocked-users-panel-dropdown { + position: absolute; + top: calc(100% + var(--space-sm)); + right: 0; + width: 380px; + max-height: 500px; + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-2xl); + border: 1px solid var(--color-gray-200); + overflow: hidden; + z-index: var(--z-dropdown); + animation: slideDown 0.2s ease-out; + display: flex; + flex-direction: column; +} + +.blocked-users-header-dropdown { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-lg); + border-bottom: 1px solid var(--color-gray-200); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.05) 0%, rgba(220, 38, 38, 0.05) 100%); + flex-shrink: 0; + + h3 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + } + + .close-blocked-users { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: var(--radius-lg); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--color-gray-100); + color: var(--text-primary); + } + } +} + +.blocked-users-content-dropdown { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: var(--space-md); +} + +.blocked-loading { + padding: var(--space-2xl) var(--space-lg); + text-align: center; + color: var(--text-secondary); + font-weight: var(--font-weight-medium); +} + +.blocked-users-list-dropdown { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.blocked-user-item-dropdown { + display: flex; + align-items: center; + padding: var(--space-md); + background: var(--bg-primary); + border-radius: var(--radius-lg); + transition: all var(--transition-base); + gap: var(--space-md); + border: 1.5px solid transparent; + box-shadow: var(--shadow-sm); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(239, 68, 68, 0.05) 0%, rgba(220, 38, 38, 0.05) 100%); + opacity: 0; + transition: opacity var(--transition-base); + } + + &:hover { + background: var(--color-danger-light); + border-color: rgba(239, 68, 68, 0.3); + transform: translateX(3px) scale(1.01); + box-shadow: var(--shadow-md); + + &::before { + opacity: 1; + } + } + + &:active { + transform: translateX(2px) scale(0.98); + box-shadow: var(--shadow-sm); + } +} + +.blocked-user-avatar-dropdown { + position: relative; + flex-shrink: 0; + + .avatar-circle-dropdown, + .avatar-image { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; + object-fit: cover; + } + + .avatar-circle-dropdown { + background: linear-gradient(135deg, var(--color-danger) 0%, #dc2626 100%); + color: var(--text-inverse); + display: flex; + align-items: center; + justify-content: center; + font-weight: var(--font-weight-bold); + font-size: var(--font-size-lg); + } +} + +.blocked-user-info-dropdown { + flex: 1; + min-width: 0; + + .blocked-user-name-dropdown { + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-base); + color: var(--text-primary); + margin-bottom: var(--space-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .blocked-user-details-dropdown { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: var(--font-size-sm); + color: var(--text-secondary); + flex-wrap: wrap; + + span { + white-space: nowrap; + } + } +} + +.unblock-button-dropdown { + background: linear-gradient(135deg, var(--color-success) 0%, var(--color-secondary-dark) 100%); + border: none; + color: var(--text-inverse); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-lg); + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-xs); + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-sm); + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); + white-space: nowrap; + flex-shrink: 0; + pointer-events: auto; + position: relative; + z-index: 10; + + svg { + width: 16px; + height: 16px; + stroke-width: 2.5; + transition: transform var(--transition-base); + } + + &:hover { + background: linear-gradient(135deg, var(--color-secondary-dark) 0%, #047857 100%); + transform: translateY(-2px) scale(1.05); + box-shadow: var(--shadow-md); + + svg { + transform: rotate(90deg); + } + } + + &:active { + transform: translateY(0) scale(1); + box-shadow: var(--shadow-sm); + } +} + +.no-blocked-users-dropdown { + padding: var(--space-2xl) var(--space-lg); + text-align: center; + color: var(--text-secondary); + + svg { + margin-bottom: var(--space-md); + color: var(--text-tertiary); + } + + p { + margin: 0 0 var(--space-sm) 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + } + + span { + font-size: var(--font-size-sm); + color: var(--text-tertiary); + } +} + +.chat-menu-search { + padding: var(--space-md); + border-bottom: 1px solid var(--color-gray-200); + background: var(--bg-primary); + flex-shrink: 0; +} + +.search-input-container { + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: var(--space-md); + width: 18px; + height: 18px; + color: var(--text-secondary); + pointer-events: none; + z-index: 1; +} + +.search-input { + width: 100%; + padding: var(--space-sm) var(--space-md) var(--space-sm) 40px; + border: 1px solid var(--color-gray-300); + border-radius: var(--radius-lg); + background: var(--bg-primary); + color: var(--text-primary); + font-size: var(--font-size-sm); + transition: all var(--transition-base); + + &::placeholder { + color: var(--text-secondary); + } + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } +} + +.search-clear { + position: absolute; + right: var(--space-sm); + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: var(--radius-full); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); + + svg { + width: 14px; + height: 14px; + } + + &:hover { + background: var(--color-gray-100); + color: var(--text-primary); + } +} + +.search-results-section { + padding: var(--space-sm) 0; + border-bottom: 1px solid var(--color-gray-200); +} + +.search-results-header, +.conversations-header { + padding: var(--space-sm) var(--space-lg); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.conversations-section { + padding: var(--space-sm) 0; +} + +.conversation-list-dropdown { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 200px; + max-height: 400px; + display: flex; + flex-direction: column; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--color-gray-300); + border-radius: var(--radius-full); + + &:hover { + background: var(--color-gray-400); + } + } +} + +.no-conversations { + padding: var(--space-2xl); + text-align: center; + color: var(--text-secondary); + + p { + margin: 0 0 var(--space-sm) 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--text-primary); + } + + .subtext { + font-size: var(--font-size-sm); + color: var(--text-secondary); + font-weight: var(--font-weight-normal); + } +} + +.conversation-item-dropdown { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); + cursor: pointer; + transition: all var(--transition-base); + border-bottom: 1px solid var(--color-gray-100); + + &:hover { + background: var(--color-gray-50); + } + + &.active { + background: linear-gradient(90deg, rgba(37, 99, 235, 0.1) 0%, rgba(37, 99, 235, 0.05) 100%); + border-left: 3px solid var(--color-primary); + } +} + +.conversation-avatar-dropdown { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + overflow: hidden; + position: relative; + flex-shrink: 0; + border: 2px solid var(--color-gray-200); +} + +.conversation-avatar-dropdown .avatar-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar-circle-dropdown { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + color: var(--text-inverse); + font-weight: var(--font-weight-bold); + font-size: var(--font-size-lg); + text-transform: uppercase; +} + +.online-indicator-dropdown { + position: absolute; + bottom: 2px; + right: 2px; + width: 12px; + height: 12px; + border-radius: var(--radius-full); + border: 2px solid var(--bg-primary); + background: var(--color-gray-400); + + &.online { + background: var(--success-green); + box-shadow: 0 0 0 2px var(--bg-primary), 0 0 4px rgba(16, 185, 129, 0.5); + } + + &.busy { + background: var(--warning-orange); + box-shadow: 0 0 0 2px var(--bg-primary), 0 0 4px rgba(245, 158, 11, 0.5); + } + + &.offline { + background: var(--color-gray-400); + } +} + +.conversation-info-dropdown { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.conversation-name-dropdown { + font-weight: var(--font-weight-bold); + font-size: var(--font-size-base); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-preview-dropdown { + font-size: var(--font-size-sm); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-meta-dropdown { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-xs); + flex-shrink: 0; +} + +.conversation-time-dropdown { + font-size: var(--font-size-xs); + color: var(--text-secondary); + white-space: nowrap; +} + +.conversation-unread-dropdown { + min-width: 20px; + height: 20px; + padding: 0 6px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + color: var(--text-inverse); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + box-shadow: var(--shadow-sm); +} + +// ============================================================================ +// Profile Dropdown (Facebook-style) +// ============================================================================ + +.profile-menu-container { + position: relative; + z-index: var(--z-dropdown); +} + +.profile-menu-button { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs); + border: none; + background: transparent; + border-radius: var(--radius-full); + cursor: pointer; + transition: all var(--transition-base); + + &:hover { + background: var(--color-gray-100); + } + + &.active { + background: var(--color-gray-100); + } +} + +.profile-avatar-small { + width: 36px; + height: 36px; + border-radius: var(--radius-full); + overflow: hidden; + position: relative; + flex-shrink: 0; + border: 2px solid var(--color-gray-200); + transition: border-color var(--transition-base); + + .profile-menu-button:hover & { + border-color: var(--color-primary-light); + } +} + +.avatar-image-small { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar-circle-small { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + color: var(--text-inverse); + font-weight: var(--font-weight-bold); + font-size: var(--font-size-sm); + text-transform: uppercase; +} + +.chevron-icon { + width: 16px; + height: 16px; + color: var(--text-secondary); + transition: transform var(--transition-base); + + .profile-menu-button.active & { + transform: rotate(180deg); + } +} + +.profile-dropdown { + position: absolute; + top: calc(100% + var(--space-sm)); + right: 0; + width: 320px; + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + border: 1px solid var(--color-gray-200); + overflow: hidden; + z-index: var(--z-dropdown); + animation: slideDown 0.2s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.profile-dropdown-header { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-lg); + background: linear-gradient(135deg, rgba(37, 99, 235, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%); + border-bottom: 1px solid var(--color-gray-200); +} + +.profile-avatar-medium { + width: 60px; + height: 60px; + border-radius: var(--radius-full); + overflow: hidden; + position: relative; + flex-shrink: 0; + border: 3px solid var(--color-primary-light); +} + +.avatar-image-medium { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar-circle-medium { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + color: var(--text-inverse); + font-weight: var(--font-weight-bold); + font-size: var(--font-size-xl); + text-transform: uppercase; +} + +.profile-dropdown-info { + display: flex; + flex-direction: column; + gap: var(--space-xs); + flex: 1; + min-width: 0; +} + +.profile-dropdown-name { + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-dropdown-email { + font-size: var(--font-size-sm); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-dropdown-divider { + height: 1px; + background: var(--color-gray-200); + margin: var(--space-xs) 0; +} + +.profile-dropdown-menu { + display: flex; + flex-direction: column; + padding: var(--space-xs); +} + +.profile-menu-item { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md); + border: none; + background: transparent; + border-radius: var(--radius-lg); + color: var(--text-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--transition-base); + text-align: left; + width: 100%; + + svg { + width: 20px; + height: 20px; + color: var(--text-secondary); + flex-shrink: 0; + transition: color var(--transition-base); + } + + &:hover { + background: var(--color-gray-100); + color: var(--color-primary-dark); + + svg { + color: var(--color-primary); + } + } + + &.logout-item { + color: var(--color-danger); + + &:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--color-danger); + + svg { + color: var(--color-danger); + } + } + } +} + +// ============================================================================ +// Facebook-style Chat Widget +// ============================================================================ + +.chat-widget-toggle { + position: fixed; + bottom: 20px; + right: 20px; + width: 56px; + height: 56px; + border-radius: var(--radius-full); + border: none; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + color: var(--text-inverse); + cursor: pointer; + box-shadow: var(--shadow-xl); + display: flex; + align-items: center; + justify-content: center; + z-index: 10002; + transition: all var(--transition-base); + + svg { + width: 24px; + height: 24px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } + + &:hover { + transform: scale(1.1); + box-shadow: 0 8px 24px rgba(37, 99, 235, 0.4); + } + + &:active { + transform: scale(0.95); + } + + &.has-unread { + animation: pulse 2s infinite; + } +} + +@keyframes pulse { + 0%, 100% { + box-shadow: 0 4px 16px rgba(37, 99, 235, 0.3); + } + 50% { + box-shadow: 0 4px 24px rgba(37, 99, 235, 0.6); + } +} + +.chat-toggle-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 20px; + height: 20px; + padding: 0 6px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--color-danger) 0%, #dc2626 100%); + color: var(--text-inverse); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + border: 2px solid var(--bg-primary); + box-shadow: var(--shadow-sm); + animation: bounce 1s infinite; +} + +@keyframes bounce { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +.chat-widget-container { + position: fixed; + bottom: 90px; + right: 20px; + width: 420px; + height: 650px; + max-height: calc(100vh - 120px); + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-2xl); + border: 1px solid var(--color-gray-200); + display: flex; + flex-direction: column; + z-index: 10002; + overflow: hidden; + animation: slideUp 0.3s ease-out; + pointer-events: auto; + + &.hidden { + display: none !important; + visibility: hidden; + pointer-events: none; + } + + @media (max-height: 800px) { + height: calc(100vh - 140px); + max-height: calc(100vh - 140px); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-widget-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md) var(--space-lg); + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + color: var(--text-inverse); + flex-shrink: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + pointer-events: auto; + position: relative; + z-index: 2; + gap: var(--space-md); +} + +.chat-widget-title { + display: flex; + align-items: center; + gap: var(--space-md); + font-weight: var(--font-weight-bold); + font-size: var(--font-size-base); + cursor: pointer; + flex: 1; + min-width: 0; + + svg { + width: 20px; + height: 20px; + flex-shrink: 0; + } +} + + +.chat-widget-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: var(--text-inverse); + color: var(--color-primary-dark); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + margin-left: var(--space-xs); +} + +.chat-widget-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; + position: relative; + z-index: 10; + visibility: visible; + opacity: 1; +} + +.chat-action-btn { + width: 40px; + height: 40px; + border: none; + background: rgba(255, 255, 255, 0.25); + border: 1.5px solid rgba(255, 255, 255, 0.3); + border-radius: var(--radius-lg); + color: var(--text-inverse); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); + flex-shrink: 0; + position: relative; + z-index: 10; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + + svg { + width: 20px; + height: 20px; + stroke: var(--text-inverse); + stroke-width: 2.5; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } + + &:hover:not(.disabled):not(:disabled) { + background: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.08); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + } + + &:active:not(.disabled):not(:disabled) { + transform: scale(0.95); + } + + &.disabled, + &:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + background: rgba(255, 255, 255, 0.15); + } + + &.delete-btn { + background: rgba(239, 68, 68, 0.25); + border-color: rgba(239, 68, 68, 0.4); + + &:hover:not(.disabled):not(:disabled) { + background: rgba(239, 68, 68, 0.4); + border-color: rgba(239, 68, 68, 0.6); + } + } + + &.call-btn { + &:hover:not(.disabled):not(:disabled) { + background: rgba(34, 197, 94, 0.3); + border-color: rgba(34, 197, 94, 0.5); + } + } + + &.video-call-btn { + &:hover:not(.disabled):not(:disabled) { + background: rgba(59, 130, 246, 0.3); + border-color: rgba(59, 130, 246, 0.5); + } + } +} + +.chat-widget-close { + width: 32px; + height: 32px; + border: none; + background: rgba(255, 255, 255, 0.2); + border-radius: var(--radius-full); + color: var(--text-inverse); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); + flex-shrink: 0; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: rgba(255, 255, 255, 0.3); + transform: scale(1.1); + } +} + +.chat-widget-content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + height: 100%; + pointer-events: auto; + position: relative; + z-index: 1; + + app-chat { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; + pointer-events: auto; + + .chat-container { + height: 100% !important; + min-height: 0 !important; + max-height: 100% !important; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 0; + box-shadow: none; + border: none; + } + + .chat-sidebar { + height: 100%; + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .chat-main { + height: 100%; + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .conversation-list { + flex: 1; + min-height: 0; + overflow-y: auto; + } + + .messages-container { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + } + } +} + +// ============================================================================ +// Notification Styles +// ============================================================================ + +.notification-container { + position: relative; +} + +.notification-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: none; + background: var(--bg-primary); + border-radius: var(--radius-lg); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); + + svg { + width: 20px; + height: 20px; + } + + &:hover { + background: var(--color-gray-100); + color: var(--color-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + &:active { + transform: translateY(0); + } + + &.has-notifications { + color: var(--color-primary); + + &::before { + content: ''; + position: absolute; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + background: var(--color-danger); + border-radius: var(--radius-full); + border: 2px solid var(--bg-primary); + animation: pulse 2s infinite; + } + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.notification-badge { + position: absolute; + top: -4px; + right: -4px; + background: var(--color-danger); + color: var(--text-inverse); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + padding: 2px 6px; + border-radius: var(--radius-full); + min-width: 18px; + text-align: center; + line-height: 1.2; + border: 2px solid var(--bg-primary); + animation: bounce-in 0.3s ease-out; +} + +@keyframes bounce-in { + 0% { + transform: scale(0); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} + +.notification-dropdown { + position: absolute; + top: calc(100% + var(--space-sm)); + right: 0; + width: 400px; + max-width: 90vw; + background: var(--bg-glass-dark); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-2xl); + border: 1px solid var(--color-gray-200); + overflow: hidden; + animation: slide-down 0.2s ease-out; + z-index: var(--z-dropdown); +} + +@keyframes slide-down { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.notification-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-lg); + border-bottom: 1px solid var(--color-gray-200); + background: var(--bg-primary); + + h3 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + margin: 0; + color: var(--text-primary); + } + + .header-actions { + display: flex; + gap: var(--space-sm); + } +} + +.mark-all-read, +.delete-all { + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-sm); + border: none; + background: transparent; + color: var(--color-primary); + cursor: pointer; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + + &:hover { + background: var(--color-gray-100); + } +} + +.delete-all { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + color: var(--color-danger); + + svg { + width: 16px; + height: 16px; + } +} + +.notification-list { + max-height: 400px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: var(--bg-secondary); + } + + &::-webkit-scrollbar-thumb { + background: var(--color-gray-300); + border-radius: var(--radius-full); + + &:hover { + background: var(--color-gray-400); + } + } +} + +.no-notifications { + padding: var(--space-2xl); + text-align: center; + color: var(--text-tertiary); + + p { + margin: 0; + } +} + +.notification-item { + display: flex; + gap: var(--space-md); + padding: var(--space-lg); + border-bottom: 1px solid var(--color-gray-200); + cursor: pointer; + transition: all var(--transition-fast); + background: var(--bg-primary); + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--bg-secondary); + } + + &.unread { + background: var(--color-info-light); + border-left: 4px solid var(--color-primary); + + .notification-title { + font-weight: var(--font-weight-semibold); + } + } +} + +.notification-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border-radius: var(--radius-lg); + color: var(--color-primary); + + svg { + width: 20px; + height: 20px; + } +} + +.notification-content { + flex: 1; + min-width: 0; +} + +.notification-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--text-primary); + margin: 0 0 var(--space-xs) 0; +} + +.notification-message { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: 0 0 var(--space-xs) 0; + line-height: var(--line-height-relaxed); +} + +.notification-time { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + margin: 0; +} + +.notification-delete { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + opacity: 0; + + .notification-item:hover & { + opacity: 1; + } + + &:hover { + background: var(--color-danger-light); + color: var(--color-danger); + } + + svg { + width: 16px; + height: 16px; + } +} + +// ============================================================================ +// Button Styles +// ============================================================================ + +.refresh-button, +.logout-button { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-gray-300); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); + + svg { + width: 18px; + height: 18px; + } + + &:hover:not(:disabled) { + background: var(--color-gray-50); + border-color: var(--color-gray-400); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.logout-button { + color: var(--color-danger); + border-color: var(--color-danger-light); + background: var(--color-danger-light); + + &:hover:not(:disabled) { + background: var(--color-danger); + color: var(--text-inverse); + border-color: var(--color-danger); + } +} + +// ============================================================================ +// Main Content Area +// ============================================================================ + +.dashboard-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +// ============================================================================ +// Loading & Error States +// ============================================================================ + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + min-height: 400px; + gap: var(--space-lg); + height: 100%; + overflow: hidden; + + .spinner-wrapper { + width: 64px; + height: 64px; + } + + .spinner { + width: 100%; + height: 100%; + color: var(--color-primary); + animation: spin 1s linear infinite; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.loading-text { + font-size: var(--font-size-lg); + color: var(--text-secondary); + margin: 0; +} + +.error-container { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 400px; + padding: var(--space-xl); + height: 100%; + overflow: hidden; +} + +.error-card { + display: flex; + gap: var(--space-lg); + padding: var(--space-xl); + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + border-left: 4px solid var(--color-danger); + max-width: 600px; + width: 100%; +} + +.error-icon { + flex-shrink: 0; + width: 48px; + height: 48px; + color: var(--color-danger); +} + +.error-content { + flex: 1; + + h3 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin: 0 0 var(--space-sm) 0; + } + + p { + font-size: var(--font-size-base); + color: var(--text-secondary); + margin: 0 0 var(--space-md) 0; + } +} + +.retry-button { + padding: var(--space-sm) var(--space-lg); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + background: var(--color-primary); + color: var(--text-inverse); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-base); + + &:hover { + background: var(--color-primary-dark); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } +} + +// ============================================================================ +// Dashboard Layout - Sidebar and Content +// ============================================================================ + +.dashboard-layout { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; + height: 100%; +} + +// ============================================================================ +// Sidebar Navigation +// ============================================================================ + +.sidebar-navigation { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + max-width: var(--sidebar-width); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 251, 252, 0.98) 100%); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-right: 1px solid var(--color-gray-200); + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: var(--z-sticky); + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + opacity: 0.8; + z-index: 1; + } +} + +.tabs-container { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-md); + overflow-y: auto; + overflow-x: hidden; + flex: 1; + min-height: 0; + padding-top: calc(var(--space-md) + 4px); + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--color-gray-300); + border-radius: var(--radius-full); + + &:hover { + background: var(--color-gray-400); + } + } +} + +.tab-button { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-base); + white-space: nowrap; + position: relative; + text-align: left; + width: 100%; + min-height: 48px; + visibility: visible; + opacity: 1; + text-decoration: none; + outline: none; + border-left: 3px solid transparent; + + &:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--radius-md); + } + + svg { + width: 22px; + height: 22px; + flex-shrink: 0; + color: currentColor; + transition: transform var(--transition-fast); + stroke-width: 2.5; + } + + &:hover:not(.active) { + background: var(--color-gray-100); + color: var(--color-primary-dark); + border-left-color: var(--color-primary-light); + + svg { + transform: scale(1.1); + color: var(--color-primary); + } + } + + &:active:not(.active) { + transform: scale(0.98); + } + + &.active { + background: linear-gradient(90deg, rgba(37, 99, 235, 0.1) 0%, rgba(37, 99, 235, 0.05) 100%); + color: var(--color-primary-dark); + border-left-color: var(--color-primary); + font-weight: var(--font-weight-bold); + box-shadow: var(--shadow-sm); + + svg { + color: var(--color-primary); + filter: drop-shadow(0 1px 2px rgba(37, 99, 235, 0.2)); + } + + .tab-badge { + background: var(--color-primary); + color: var(--text-inverse); + box-shadow: var(--shadow-sm); + } + + &::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 60%; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); + border-radius: 0 3px 3px 0; + z-index: 1; + } + } +} + +.tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 8px; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + background: linear-gradient(135deg, var(--color-danger) 0%, #dc2626 100%); + color: var(--text-inverse); + border-radius: var(--radius-full); + margin-left: auto; + box-shadow: var(--shadow-sm); + border: 2px solid var(--bg-primary); + transition: all var(--transition-fast); + animation: pulse-badge 2s infinite; + flex-shrink: 0; +} + +@keyframes pulse-badge { + 0%, 100% { + transform: scale(1); + box-shadow: var(--shadow-sm); + } + 50% { + transform: scale(1.05); + box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2); + } +} + +// ============================================================================ +// Content Area +// ============================================================================ + +.content-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; + background: var(--bg-secondary); +} + +// ============================================================================ +// Tab Content +// ============================================================================ + +.tab-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: var(--space-xl); + animation: fade-in 0.3s ease-out; + min-height: 0; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--color-gray-300); + border-radius: var(--radius-full); + + &:hover { + background: var(--color-gray-400); + } + } +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.tab-panel { + min-height: 400px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +// ============================================================================ +// Section Styles +// ============================================================================ + +.section-title { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0 0 var(--space-xl) 0; + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-xl); + flex-wrap: wrap; + gap: var(--space-md); + + h2 { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0; + } +} + +// ============================================================================ +// Stats Section +// ============================================================================ + +.stats-section { + margin-bottom: var(--space-2xl); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--space-lg); + margin-bottom: var(--space-xl); +} + +.stat-card { + display: flex; + align-items: center; + gap: var(--space-lg); + padding: var(--space-xl); + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-md); + transition: all var(--transition-base); + border: 1px solid var(--color-gray-200); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: var(--color-primary); + transition: width var(--transition-base); + } + + &:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-xl); + + &::before { + width: 100%; + opacity: 0.05; + } + } + + &.stat-card-primary { + .stat-icon { + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); + color: var(--text-inverse); + } + + &::before { + background: var(--color-primary); + } + } + + &.stat-card-success { + .stat-icon { + background: linear-gradient(135deg, var(--color-success) 0%, #34d399 100%); + color: var(--text-inverse); + } + + &::before { + background: var(--color-success); + } + } + + &.stat-card-info { + .stat-icon { + background: linear-gradient(135deg, var(--color-info) 0%, #60a5fa 100%); + color: var(--text-inverse); + } + + &::before { + background: var(--color-info); + } + } + + &.stat-card-warning { + .stat-icon { + background: linear-gradient(135deg, var(--color-warning) 0%, #fbbf24 100%); + color: var(--text-inverse); + } + + &::before { + background: var(--color-warning); + } + } +} + +.stat-icon { + flex-shrink: 0; + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-md); + + svg { + width: 32px; + height: 32px; + } +} + +.stat-content { + flex: 1; + min-width: 0; +} + +.stat-label { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: 0 0 var(--space-xs) 0; + font-weight: var(--font-weight-medium); +} + +.stat-value { + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0; + line-height: var(--line-height-tight); +} + +// ============================================================================ +// Quick Actions +// ============================================================================ + +.quick-actions-section { + margin-bottom: var(--space-2xl); +} + +.actions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-lg); +} + +.action-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-md); + padding: var(--space-2xl); + background: var(--bg-primary); + border: 2px solid var(--color-gray-200); + border-radius: var(--radius-xl); + cursor: pointer; + transition: all var(--transition-base); + text-align: center; + + svg { + width: 48px; + height: 48px; + color: var(--color-primary); + transition: transform var(--transition-base); + } + + h3 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin: 0; + } + + p { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: 0; + } + + &:hover { + border-color: var(--color-primary); + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--color-primary-light) 0.5%); + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + + svg { + transform: scale(1.1); + color: var(--color-primary-dark); + } + } + + &:active { + transform: translateY(-2px); + } +} + +// ============================================================================ +// Info & Error Messages +// ============================================================================ + +.info-message { + display: flex; + gap: var(--space-md); + padding: var(--space-lg); + background: var(--color-info-light); + border: 1px solid var(--color-info); + border-radius: var(--radius-lg); + margin-bottom: var(--space-lg); + + svg { + flex-shrink: 0; + width: 24px; + height: 24px; + color: var(--color-info); + } + + div { + flex: 1; + font-size: var(--font-size-sm); + color: var(--text-primary); + line-height: var(--line-height-relaxed); + } +} + +.error-message { + padding: var(--space-lg); + background: var(--color-danger-light); + color: var(--color-danger); + border: 1px solid var(--color-danger); + border-radius: var(--radius-lg); + margin-bottom: var(--space-lg); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + + button { + background: transparent; + border: none; + color: var(--color-danger); + cursor: pointer; + font-size: var(--font-size-xl); + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + + &:hover { + background: var(--color-danger); + color: var(--text-inverse); + } + } +} + +// ============================================================================ +// Responsive Design +// ============================================================================ + +@media (max-width: 1024px) { + .stats-grid { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } +} + +@media (max-width: 1024px) { + .sidebar-navigation { + width: 240px; + min-width: 240px; + max-width: 240px; + } + + .tab-button { + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-sm); + + svg { + width: 20px; + height: 20px; + } + } + + .tab-content { + padding: var(--space-lg); + } +} + +@media (max-width: 768px) { + .dashboard-header .header-content { + flex-direction: column; + align-items: flex-start; + padding: var(--space-md); + } + + .header-right { + width: 100%; + justify-content: space-between; + } + + .dashboard-layout { + flex-direction: column; + } + + .sidebar-navigation { + width: 100%; + min-width: 100%; + max-width: 100%; + border-right: none; + border-bottom: 1px solid var(--color-gray-200); + max-height: 200px; + overflow-y: auto; + overflow-x: hidden; + + &::before { + display: none; + } + } + + .tabs-container { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + padding: var(--space-sm) var(--space-md); + gap: var(--space-xs); + flex-wrap: nowrap; + + &::-webkit-scrollbar { + height: 4px; + width: auto; + } + + &::-webkit-scrollbar-thumb { + background: var(--color-gray-300); + } + } + + .tab-button { + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-xs); + min-width: auto; + width: auto; + flex-shrink: 0; + border-left: none; + border-bottom: 3px solid transparent; + + &.active { + border-left: none; + border-bottom-color: var(--color-primary); + background: var(--bg-primary); + + &::before { + display: none; + } + } + + svg { + width: 18px; + height: 18px; + } + } + + .content-area { + flex: 1; + min-height: 0; + } + + .tab-content { + padding: var(--space-md); + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .actions-grid { + grid-template-columns: 1fr; + } + + .notification-dropdown { + width: calc(100vw - 32px); + right: auto; + left: 0; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 640px) { + :root { + --header-height: 60px; + --sidebar-width: 100%; + } + + .dashboard-title { + font-size: var(--font-size-xl); + } + + .section-title { + font-size: var(--font-size-xl); + } + + .sidebar-navigation { + max-height: 180px; + } + + .tab-button { + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-xs); + gap: var(--space-sm); + + svg { + width: 16px; + height: 16px; + } + } + + .tab-content { + padding: var(--space-md); + } + + .chat-menu-dropdown { + width: calc(100vw - 40px); + right: 16px; + max-width: 380px; + max-height: calc(100vh - 120px); + } + + .chat-widget-container { + width: calc(100vw - 40px); + right: 20px; + left: 20px; + height: calc(100vh - 140px); + max-height: calc(100vh - 140px); + bottom: 80px; + + .chat-widget-content app-chat { + .chat-container { + height: 100% !important; + flex-direction: column !important; + } + + .chat-sidebar { + width: 100% !important; + max-width: 100% !important; + } + + .chat-main { + display: none; + } + } + } + + .chat-widget-toggle { + bottom: 16px; + right: 16px; + width: 52px; + height: 52px; + + svg { + width: 22px; + height: 22px; + } + } + + .profile-dropdown { + width: calc(100vw - 32px); + right: 16px; + max-width: 320px; + } +} + +// ============================================================================ +// Patient Safety Alerts Styles +// ============================================================================ + +.safety-alerts-container { + padding: var(--space-xl); + background: var(--bg-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-md); + border: 1px solid var(--color-gray-200); +} + +.safety-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-xl); + padding-bottom: var(--space-lg); + border-bottom: 2px solid var(--color-gray-200); + + h2 { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0; + } +} + +.header-actions { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.btn-create { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-lg); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-primary); + background: var(--color-primary); + color: var(--text-inverse); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-base); + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--color-primary-dark); + border-color: var(--color-primary-dark); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + &.active { + background: var(--color-danger); + border-color: var(--color-danger); + + &:hover { + background: var(--color-danger); + opacity: 0.9; + } + } +} + +.refresh-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--color-gray-300); + background: var(--bg-primary); + border-radius: var(--radius-lg); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-base); + + &:hover { + background: var(--color-gray-100); + border-color: var(--color-primary); + color: var(--color-primary); + transform: rotate(180deg); + } +} + +.alerts-section { + margin-bottom: var(--space-2xl); + + &:last-child { + margin-bottom: 0; + } + + h3 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0 0 var(--space-lg) 0; + display: flex; + align-items: center; + gap: var(--space-md); + } +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 8px; + background: linear-gradient(135deg, var(--color-danger) 0%, #dc2626 100%); + color: var(--text-inverse); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + box-shadow: var(--shadow-sm); +} + +.no-alerts { + padding: var(--space-2xl); + text-align: center; + background: var(--bg-secondary); + border-radius: var(--radius-lg); + border: 2px dashed var(--color-gray-300); + + p { + margin: 0; + color: var(--text-secondary); + font-size: var(--font-size-base); + } +} + +.alert-card { + background: var(--bg-primary); + border-radius: var(--radius-lg); + border: 1px solid var(--color-gray-200); + box-shadow: var(--shadow-sm); + margin-bottom: var(--space-md); + overflow: hidden; + transition: all var(--transition-base); + + &:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); + } + + &.severity-info { + border-left: 4px solid var(--color-info); + + .severity-badge.severity-info { + background: var(--color-info); + color: var(--text-inverse); + } + } + + &.severity-warning { + border-left: 4px solid var(--color-warning); + + .severity-badge.severity-warning { + background: var(--color-warning); + color: var(--text-inverse); + } + } + + &.severity-critical { + border-left: 4px solid var(--color-danger); + + .severity-badge.severity-critical { + background: var(--color-danger); + color: var(--text-inverse); + } + } + + &.critical-result { + border-left: 4px solid var(--color-danger); + background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, var(--bg-primary) 4px); + } +} + +.alert-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md) var(--space-lg); + background: var(--bg-secondary); + border-bottom: 1px solid var(--color-gray-200); +} + +.alert-type { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.severity-badge { + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-md); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.alert-type-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-primary); +} + +.alert-time { + font-size: var(--font-size-xs); + color: var(--text-secondary); +} + +.alert-body { + padding: var(--space-lg); +} + +.alert-title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0 0 var(--space-sm) 0; +} + +.alert-description { + font-size: var(--font-size-base); + color: var(--text-secondary); + margin: 0 0 var(--space-md) 0; + line-height: var(--line-height-relaxed); +} + +.alert-meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-lg); + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--color-gray-200); +} + +.patient-name, +.medication { + font-size: var(--font-size-sm); + color: var(--text-secondary); + + strong { + color: var(--text-primary); + font-weight: var(--font-weight-semibold); + } +} + +.lab-result-details { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: var(--space-md); +} + +.detail-row { + font-size: var(--font-size-sm); + color: var(--text-secondary); + + strong { + color: var(--text-primary); + font-weight: var(--font-weight-semibold); + margin-right: var(--space-xs); + } +} + +.alert-actions { + display: flex; + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); + background: var(--bg-secondary); + border-top: 1px solid var(--color-gray-200); +} + +.btn-acknowledge, +.btn-resolve { + padding: var(--space-sm) var(--space-lg); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-base); +} + +.btn-acknowledge { + background: var(--color-primary); + color: var(--text-inverse); + + &:hover { + background: var(--color-primary-dark); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } +} + +.btn-resolve { + background: var(--color-success); + color: var(--text-inverse); + + &:hover { + background: var(--color-secondary-dark); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } +} + +// ============================================================================ +// Create Alert Form Styles +// ============================================================================ + +.create-alert-section { + background: var(--bg-secondary); + border: 2px solid var(--color-gray-200); + border-radius: var(--radius-xl); + padding: var(--space-xl); + margin-bottom: var(--space-2xl); + animation: slideDown 0.3s ease-out; + + h3 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--text-primary); + margin: 0 0 var(--space-lg) 0; + } +} + +.alert-form { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-lg); + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-sm); + + label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + + .required { + color: var(--color-danger); + margin-left: var(--space-xs); + } + } + + input, + select, + textarea { + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--color-gray-300); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + font-size: var(--font-size-base); + font-family: var(--font-family-base); + transition: all var(--transition-base); + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + } + + &::placeholder { + color: var(--text-secondary); + } + } + + textarea { + resize: vertical; + min-height: 100px; + } + + select { + cursor: pointer; + } +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-md); + margin-top: var(--space-md); + padding-top: var(--space-lg); + border-top: 1px solid var(--color-gray-200); +} + +.btn-cancel, +.btn-submit { + padding: var(--space-sm) var(--space-xl); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-base); +} + +.btn-cancel { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--color-gray-300); + + &:hover { + background: var(--color-gray-100); + border-color: var(--color-gray-400); + } +} + +.btn-submit { + background: var(--color-primary); + color: var(--text-inverse); + + &:hover { + background: var(--color-primary-dark); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + &:active { + transform: translateY(0); + } +} + +// ============================================================================ +// Utility Classes +// ============================================================================ + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.mt-0 { margin-top: 0; } +.mt-sm { margin-top: var(--space-sm); } +.mt-md { margin-top: var(--space-md); } +.mt-lg { margin-top: var(--space-lg); } + +.mb-0 { margin-bottom: 0; } +.mb-sm { margin-bottom: var(--space-sm); } +.mb-md { margin-bottom: var(--space-md); } +.mb-lg { margin-bottom: var(--space-lg); } diff --git a/frontend/src/app/pages/doctor/doctor.component.ts b/frontend/src/app/pages/doctor/doctor.component.ts new file mode 100644 index 0000000..8cdd0f5 --- /dev/null +++ b/frontend/src/app/pages/doctor/doctor.component.ts @@ -0,0 +1,2627 @@ +import { Component, OnInit, OnDestroy, HostListener, ViewChild, AfterViewInit, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { AppointmentService, Appointment } from '../../services/appointment.service'; +import { UserService, UserInfo, DoctorProfile, DoctorUpdateRequest, UserUpdateRequest } from '../../services/user.service'; +import { AdminService } from '../../services/admin.service'; +import { AvailabilityService, AvailabilityRequest, AvailabilityResponse, BulkAvailabilityRequest } from '../../services/availability.service'; +import { ChatComponent } from '../../components/chat/chat.component'; +import { ChatService, ChatUser } from '../../services/chat.service'; +import { NotificationService, Notification } from '../../services/notification.service'; +import { CallComponent } from '../../components/call/call.component'; +import { MedicalRecordService, MedicalRecord, MedicalRecordRequest, VitalSigns, VitalSignsRequest, LabResultRequest } from '../../services/medical-record.service'; +import { PrescriptionService, Prescription, PrescriptionRequest } from '../../services/prescription.service'; +import { TwoFactorAuthService } from '../../services/two-factor-auth.service'; +import { PatientSafetyService, ClinicalAlert, CriticalResult } from '../../services/patient-safety.service'; +import { LoggerService } from '../../services/logger.service'; +import { AppointmentsComponent } from './components/appointments/appointments.component'; +import { CreateAppointmentComponent } from './components/create-appointment/create-appointment.component'; +import { AvailabilityComponent } from './components/availability/availability.component'; +import { PatientsComponent } from './components/patients/patients.component'; +import { ProfileComponent } from './components/profile/profile.component'; +import { EhrComponent } from './components/ehr/ehr.component'; +import { PrescriptionsComponent } from './components/prescriptions/prescriptions.component'; +import { SecurityComponent } from './components/security/security.component'; +import { ModalService } from '../../services/modal.service'; + +@Component({ + selector: 'app-doctor', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ChatComponent, + CallComponent, + AppointmentsComponent, + CreateAppointmentComponent, + AvailabilityComponent, + PatientsComponent, + ProfileComponent, + EhrComponent, + PrescriptionsComponent, + SecurityComponent + ], + templateUrl: './doctor.component.html', + styleUrl: './doctor.component.scss', + encapsulation: ViewEncapsulation.None +}) +export class DoctorComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild(ChatComponent) chatComponent?: ChatComponent; + appointments: Appointment[] = []; + loading = false; + error: string | null = null; + currentUser: UserInfo | null = null; + doctorId: string | null = null; + activeTab: string = 'overview'; + showCreateForm = false; + + newAppointment = { + patientId: '', + scheduledDate: '', + scheduledTime: '', + durationInMinutes: 30 + }; + patients: any[] = []; + + // Availability + availability: AvailabilityResponse[] = []; + showAvailabilityForm = false; + showBulkAvailabilityForm = false; + dayFilter: string = 'all'; + newAvailability: AvailabilityRequest = { + doctorId: '', + dayOfWeek: 'MONDAY', + startTime: '09:00', + endTime: '17:00' + }; + bulkAvailability: BulkAvailabilityRequest = { + doctorId: '', + availabilities: [] + }; + + // Profile editing + showEditProfile = false; + doctorProfile: DoctorProfile | null = null; + editUserData: UserUpdateRequest = { + firstName: '', + lastName: '', + phoneNumber: '' + }; + editDoctorData: DoctorUpdateRequest = { + medicalLicenseNumber: '', + specialization: '', + yearsOfExperience: undefined, + biography: '', + consultationFee: undefined, + defaultDurationMinutes: undefined + }; + + chatUnreadCount: number = 0; + notifications: Notification[] = []; + notificationCount: number = 0; + showNotifications: boolean = false; + showProfileMenu: boolean = false; + showChatWidget: boolean = false; + showChatMenu: boolean = false; + chatSearchQuery: string = ''; + chatSearchResults: ChatUser[] = []; + filteredConversations: any[] = []; + showChatSearchResults: boolean = false; + + // Blocked users functionality + showBlockedUsers: boolean = false; + blockedUsers: ChatUser[] = []; + isLoadingBlockedUsers: boolean = false; + + // New features data + selectedPatientId: string | null = null; + medicalRecords: MedicalRecord[] = []; + allMedicalRecords: MedicalRecord[] = []; // All records created by this doctor + vitalSigns: VitalSigns[] = []; + latestVitalSigns: VitalSigns | null = null; + labResults: any[] = []; + prescriptions: Prescription[] = []; + allPrescriptions: Prescription[] = []; // All prescriptions created by this doctor + filteredPrescriptions: Prescription[] = []; + twoFAEnabled = false; + twoFAStatus: any = null; + show2FAModal = false; + qrCodeUrl: string = ''; + twoFASecretKey: string = ''; + twoFABackupCodes: string[] = []; + twoFACodeInput: string = ''; + showAllRecords = false; // Toggle to show all records vs patient-specific + + // Patient Safety + unacknowledgedAlerts: ClinicalAlert[] = []; + unacknowledgedCriticalResults: CriticalResult[] = []; + unacknowledgedAlertsCount: number = 0; + selectedPrescription: Prescription | null = null; + private alertsRefreshInterval?: any; // Interval for auto-refreshing alerts + showCreateAlertForm = false; + newAlert = { + patientId: '', + alertType: 'DRUG_INTERACTION' as 'DRUG_INTERACTION' | 'ALLERGY' | 'CONTRAINDICATION' | 'OVERDOSE_RISK' | 'DUPLICATE_THERAPY' | 'DOSE_ADJUSTMENT' | 'LAB_RESULT_ALERT' | 'VITAL_SIGN_ALERT' | 'COMPLIANCE_ALERT' | 'OTHER', + severity: 'WARNING' as 'INFO' | 'WARNING' | 'CRITICAL', + title: '', + description: '', + medicationName: '', + relatedPrescriptionId: null as string | null + }; + showUpdatePrescription = false; + selectedLabResult: any = null; + showUpdateLabResult = false; + + // Prescription Management Enterprise Features + prescriptionSearchTerm: string = ''; + prescriptionStatusFilter: string = 'all'; // 'all', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'DISCONTINUED' + prescriptionDateFilter: string = 'all'; // 'all', 'today', 'week', 'month', 'year', 'custom' + prescriptionDateFrom: string = ''; + prescriptionDateTo: string = ''; + prescriptionSortBy: string = 'createdAt'; // 'createdAt', 'medicationName', 'patientName', 'status', 'startDate' + prescriptionSortOrder: 'asc' | 'desc' = 'desc'; + prescriptionViewMode: 'card' | 'table' = 'card'; + selectedPrescriptions: string[] = []; + showPrescriptionAnalytics = false; + + // Forms + showCreateMedicalRecord = false; + showCreatePrescription = false; + showPrescriptionDebug = false; + async openCreatePrescription() { + this.showCreatePrescription = !this.showCreatePrescription; + if (this.showCreatePrescription) { + // Ensure patients are loaded + if (!this.patients || this.patients.length === 0) { + this.logger.debug('[OpenCreatePrescription] Loading patients...'); + await this.loadPatients(true); // Load for dropdowns (includes all patients) + } + this.logger.debug('[OpenCreatePrescription] Patients loaded:', this.patients?.length || 0); + + // Pre-fill patient and doctor in the form if available + if (this.selectedPatientId) { + this.newPrescription.patientId = this.selectedPatientId; + this.logger.debug('[OpenCreatePrescription] Pre-filled patientId from selectedPatientId:', this.selectedPatientId); + } else if (this.patients && this.patients.length === 1) { + // If only one patient, auto-select it + this.newPrescription.patientId = this.patients[0].id; + this.selectedPatientId = this.patients[0].id; + this.logger.debug('[OpenCreatePrescription] Auto-selected single patient:', this.patients[0].id); + } + + if (!this.newPrescription.doctorId && this.doctorId) { + this.newPrescription.doctorId = this.doctorId; + this.logger.debug('[OpenCreatePrescription] Pre-filled doctorId:', this.doctorId); + } + if (!(this.newPrescription as any).startDate) { + (this.newPrescription as any).startDate = new Date().toISOString().split('T')[0]; + } + if ((this.newPrescription as any).quantity == null) { + (this.newPrescription as any).quantity = 30; + } + if ((this.newPrescription as any).refills == null) { + (this.newPrescription as any).refills = 0; + } + + this.logger.debug('[OpenCreatePrescription] Final newPrescription state:', this.newPrescription); + } + } + + get prescriptionDebug() { + return { + selectedPatientId: this.selectedPatientId, + doctorIdContext: this.doctorId, + newPrescription: this.newPrescription, + patientsLoaded: Array.isArray(this.patients) ? this.patients.map(p => p.id) : [] + }; + } + + onPatientSelectionChange(patientId: string) { + this.selectedPatientId = patientId; + // If selected ID is not one of known patient entity IDs, try resolving from userId + const exists = Array.isArray(this.patients) && this.patients.some(p => p.id === patientId); + if (!exists && patientId) { + this.userService.getPatientIdByUserId(patientId).then(resolved => { + if (resolved) { + this.selectedPatientId = resolved; + this.newPrescription.patientId = resolved; + this.newLabResult.patientId = resolved as any; + } + }).catch(() => {}); + } else { + this.newPrescription.patientId = patientId; + this.newLabResult.patientId = patientId as any; + } + } + showCreateVitalSigns = false; + showCreateLabResult = false; + + newMedicalRecord: MedicalRecordRequest = { + patientId: '', + doctorId: '', + recordType: 'NOTE', + title: '', + content: '', + diagnosisCode: '' + }; + + newPrescription: PrescriptionRequest = { + patientId: '', + doctorId: '', + medicationName: '', + dosage: '', + frequency: '', + quantity: 30, + refills: 0, + startDate: new Date().toISOString().split('T')[0] // Today's date + }; + + newVitalSigns: VitalSignsRequest = { + patientId: '', + temperature: undefined, + bloodPressureSystolic: undefined, + bloodPressureDiastolic: undefined, + heartRate: undefined, + respiratoryRate: undefined, + oxygenSaturation: undefined, + weight: undefined, + height: undefined, + notes: '' + }; + + newLabResult: LabResultRequest = { + patientId: undefined as any, + doctorId: '', + testName: '', + resultValue: '', + status: 'PENDING', + orderedDate: new Date().toISOString().split('T')[0] // Today's date + }; + + constructor( + private appointmentService: AppointmentService, + public userService: UserService, + private auth: AuthService, + private admin: AdminService, + private router: Router, + private availabilityService: AvailabilityService, + private chatService: ChatService, + private notificationService: NotificationService, + private medicalRecordService: MedicalRecordService, + private prescriptionService: PrescriptionService, + private twoFactorAuthService: TwoFactorAuthService, + private patientSafetyService: PatientSafetyService, + private modalService: ModalService, + private logger: LoggerService + ) {} + + async ngOnInit() { + await this.loadUserAndAppointments(); + + // Connect to chat service and initialize notifications + await this.chatService.connect(); + await this.notificationService.refreshUserId(); + + // Load conversations immediately so notification service can detect unread messages + await this.chatService.getConversations(); + + this.chatService.conversations$.subscribe(conversations => { + this.chatUnreadCount = conversations.reduce((sum, c) => sum + c.unreadCount, 0); + }); + + // Subscribe to notifications - Enterprise notification management + // Business Logic: + // 1. Show only message and missed-call notifications (relevant to user) + // 2. Hide message notifications from active conversation (user is already viewing that chat) + // 3. Always show missed-call notifications (high priority, requires attention) + // 4. Show all other message notifications (from users not currently chatting) + this.notificationService.notifications$.subscribe(notifications => { + // Get all notifications first + const allNotifications = notifications; + + // Filter to only show message and missed-call notifications + const relevantNotifications = allNotifications.filter(n => + n.type === 'message' || n.type === 'missed-call' + ); + + // Apply enterprise filtering: exclude notifications from active conversation + // This prevents duplicate notifications when user is actively viewing a chat + this.notifications = relevantNotifications.filter(n => { + // Missed calls are always shown (high priority) + if (n.type === 'missed-call') { + return true; + } + + // Message notifications: hide if from active conversation + if (n.type === 'message') { + // Use enterprise method to check if notification is from active conversation + return !this.notificationService.isNotificationFromActiveConversation(n); + } + + return false; + }); + + // Calculate unread count (only for displayed notifications) + this.notificationCount = this.notifications.filter(n => !n.read).length; + + this.logger.debug('DoctorComponent: Notification management updated', { + totalNotifications: allNotifications.length, + relevantNotifications: relevantNotifications.length, + displayedNotifications: this.notifications.length, + unreadCount: this.notificationCount, + activeConversation: this.notificationService.getActiveConversationUserId() + }); + }); + + // Load safety alerts + await this.loadSafetyAlerts(); + + // Auto-open Safety Alerts tab if there are unacknowledged alerts + if (this.unacknowledgedAlertsCount > 0) { + this.activeTab = 'safety'; + } + + // Set up automatic refresh of alerts every 30 seconds + this.startAlertsAutoRefresh(); + + // Force check for unread messages after a short delay to ensure notifications are created + setTimeout(async () => { + await this.notificationService.checkForUnreadMessages(); + }, 3000); + } + + ngOnDestroy() { + // Clean up the alerts refresh interval + if (this.alertsRefreshInterval) { + clearInterval(this.alertsRefreshInterval); + } + } + + // Start automatic refresh of alerts every 30 seconds + startAlertsAutoRefresh() { + // Clear any existing interval + if (this.alertsRefreshInterval) { + clearInterval(this.alertsRefreshInterval); + } + + // Refresh alerts every 30 seconds + this.alertsRefreshInterval = setInterval(async () => { + const previousCount = this.unacknowledgedAlertsCount; + await this.loadSafetyAlerts(); + + // If new alerts appeared and doctor is not on safety tab, show notification + if (this.unacknowledgedAlertsCount > previousCount && this.activeTab !== 'safety') { + this.logger.debug(`New alerts detected! Total: ${this.unacknowledgedAlertsCount}`); + // Optional: You can add a toast notification here + } + }, 30000); // 30 seconds + } + + ngAfterViewInit(): void { + // ViewChild is available after view init + // Ensure chat component is properly initialized + if (this.chatComponent) { + this.logger.debug('ChatComponent initialized:', this.chatComponent); + } else { + this.logger.warn('ChatComponent not available after view init'); + } + } + + async loadUserAndAppointments() { + this.loading = true; + this.error = null; + try { + this.currentUser = await this.userService.getCurrentUser(); + if (!this.currentUser) { + throw new Error('Unable to load user information'); + } + + // Check if user is actually a doctor + if (this.currentUser.role !== 'DOCTOR') { + this.auth.logout(); + this.router.navigateByUrl('/login'); + return; + } + + // Load doctor profile first to get doctorId (most reliable source) + // Then load appointments and availability using the correct doctor ID + // Note: Backend needs doctor table ID, not user ID + await this.loadDoctorProfile(); + + // Load other data in parallel after doctor profile is loaded + await Promise.all([ + this.loadAppointments(), // Will use doctorProfile.id or getDoctorIdByUserId() + this.load2FAStatus(), + this.loadAllDoctorRecords(), + this.loadPatients(true) // Load all patients for dropdowns + ]); + + // Load availability after doctorProfile is loaded (so we have doctorId) + await this.loadAvailability(); + // Extract patients from appointments (this respects soft-delete flags) + // This ensures the Patients tab only shows patients with visible appointments + this.logger.debug('[DoctorComponent] Extracting patients from appointments, appointments count:', this.appointments.length); + this.extractPatientsFromAppointments(); + this.logger.debug('[DoctorComponent] Extracted patients count:', this.patients.length); + + // Set doctorId for availability form + if (this.doctorId) { + this.newAvailability.doctorId = this.doctorId; + } + } catch (e: any) { + if (e?.response?.status === 401) { + await this.auth.logout(); + this.router.navigateByUrl('/login'); + return; + } + this.error = e?.response?.data?.message || 'Failed to load dashboard data'; + } finally { + this.loading = false; + } + } + + async loadAppointments() { + try { + if (!this.currentUser) return; + + // Priority: 1. Use doctorProfile.id (most reliable) + // 2. Try getDoctorIdByUserId() + // 3. Fail gracefully if no doctor ID found + + let doctorId: string | null = null; + + // First priority: Use doctorProfile.id if available + if (this.doctorProfile?.id) { + doctorId = this.doctorProfile.id; + } else { + // Second priority: Try to get doctor ID from user service + try { + doctorId = await this.userService.getDoctorIdByUserId(this.currentUser!.id); + } catch (e: any) { + this.logger.warn('Could not get doctor ID from user service:', e); + } + } + + if (doctorId) { + this.doctorId = doctorId; + try { + this.appointments = await this.appointmentService.getAppointmentsByDoctorId(doctorId); + this.logger.debug('[DoctorComponent] loadAppointments - loaded appointments:', this.appointments.length); + // Extract patients immediately after loading appointments + this.extractPatientsFromAppointments(); + } catch (e: any) { + this.logger.error('Failed to load appointments with doctor ID:', e); + this.appointments = []; + if (e?.response?.status === 403) { + this.error = 'Access denied: Unable to load appointments. Please refresh the page.'; + } else { + this.error = 'Unable to load appointments. Please try again later.'; + } + } + } else { + // No doctor ID found - cannot load appointments + this.logger.warn('No doctor ID available. Cannot load appointments. Doctor profile may not be loaded yet.'); + this.appointments = []; + // Don't set error here - doctor profile might still be loading + // The error will be set if doctor profile fails to load + } + } catch (e: any) { + this.logger.error('Error loading appointments:', e); + this.appointments = []; + if (!this.error) { + this.error = 'Failed to load appointments. Please refresh the page.'; + } + } + } + + extractPatientsFromAppointments() { + // Extract unique patients from appointments as fallback + // This respects soft-delete: only appointments with deletedByDoctor=false are shown + const patientMap = new Map(); + this.logger.debug('[DoctorComponent] extractPatientsFromAppointments - appointments:', this.appointments.length); + + this.appointments.forEach((apt, index) => { + // Only include appointments that are NOT deleted by doctor + // (The backend already filters these, but double-check for safety) + const patientKey = `${apt.patientFirstName}-${apt.patientLastName}`; + const patientId = (apt as any).patientId; + this.logger.debug(`[DoctorComponent] Appointment ${index}:`, { + patientId, + firstName: apt.patientFirstName, + lastName: apt.patientLastName, + hasPatientId: !!patientId + }); + + if (patientId && !patientMap.has(patientId)) { + patientMap.set(patientId, { + id: patientId, // Use patient ID as the key for uniqueness + firstName: apt.patientFirstName, + lastName: apt.patientLastName, + email: '', // We don't have email from appointments + displayName: `${apt.patientFirstName} ${apt.patientLastName}` + }); + } + }); + + // Create a new array to trigger change detection + this.patients = Array.from(patientMap.values()); + this.logger.debug('[DoctorComponent] extractPatientsFromAppointments - extracted patients:', this.patients.length, this.patients); + } + + async loadPatients(forDropdowns: boolean = false) { + try { + // Always extract patients from appointments first (respects soft-delete) + this.extractPatientsFromAppointments(); + + // Only load all patients if needed for dropdowns (e.g., creating prescriptions) + // For the Patients tab, we only want patients with appointments + if (forDropdowns && this.patients.length === 0) { + // For dropdowns, we might need all patients even if no appointments yet + // But we should still merge with appointment-based patients + const allPatients = await this.userService.getAllPatients(); + // Merge but don't replace - keep appointment-based patients first + const existingIds = new Set(this.patients.map(p => p.id)); + const newPatients = allPatients.filter(p => !existingIds.has(p.id)); + this.patients = [...this.patients, ...newPatients]; + } + } catch (e: any) { + this.logger.error('Error loading patients:', e); + // Fallback: extract from appointments + this.extractPatientsFromAppointments(); + } + } + + async loadAvailability() { + // Priority: 1. Use doctorProfile.id (most reliable) + // 2. Use existing doctorId if already set + // 3. Try getDoctorIdByUserId() + // 4. Fail gracefully if no doctor ID found + + // First priority: Use doctorProfile.id if available + if (!this.doctorId && this.doctorProfile?.id) { + this.doctorId = this.doctorProfile.id; + } + + // Second priority: If still no doctorId, try to get it from user service + if (!this.doctorId && this.currentUser?.id) { + try { + const doctorId = await this.userService.getDoctorIdByUserId(this.currentUser.id); + if (doctorId) { + this.doctorId = doctorId; + } + } catch (e) { + this.logger.warn('Could not get doctor ID from user service:', e); + } + } + + // If still no doctorId, cannot load availability + if (!this.doctorId) { + this.logger.warn('No doctorId available, cannot load availability. Doctor profile may not be loaded yet.'); + this.availability = []; + return; + } + + try { + if (this.dayFilter !== 'all') { + this.availability = await this.availabilityService.getAvailabilityByDay(this.doctorId, this.dayFilter); + } else { + // Always load all availability slots (both active and inactive) + this.availability = await this.availabilityService.getDoctorAvailability(this.doctorId); + } + } catch (e: any) { + this.logger.error('Error loading availability:', e); + this.availability = []; + } + } + + async createAvailability() { + if (!this.newAvailability.doctorId || !this.newAvailability.startTime || !this.newAvailability.endTime) { + this.error = 'Please fill in all required fields'; + return; + } + + // Set doctorId from current user if not set + if (!this.newAvailability.doctorId && this.doctorId) { + this.newAvailability.doctorId = this.doctorId; + } + + // Clear previous error + this.error = null; + + try { + await this.availabilityService.createAvailability(this.newAvailability); + this.showAvailabilityForm = false; + this.newAvailability = { + doctorId: this.doctorId || '', + dayOfWeek: 'MONDAY', + startTime: '09:00', + endTime: '17:00' + }; + this.error = null; + await this.loadAvailability(); + } catch (e: any) { + this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to create availability slot'; + this.logger.error('Error creating availability:', e); + } + } + + async deleteAvailability(availabilityId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this availability slot?', + 'Delete Availability Slot', + 'Delete', + 'Cancel' + ); + if (!confirmed) return; + + try { + await this.availabilityService.deleteAvailability(availabilityId); + await this.loadAvailability(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to delete availability slot'; + } + } + + async deleteAllAvailability() { + if (!this.doctorId) { + this.error = 'Doctor ID not available'; + return; + } + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete ALL availability slots? This action cannot be undone.', + 'Delete All Availability Slots', + 'Delete All', + 'Cancel' + ); + if (!confirmed) return; + + try { + await this.availabilityService.deleteAllDoctorAvailability(this.doctorId); + await this.loadAvailability(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to delete all availability slots'; + } + } + + async createBulkAvailability() { + if (!this.bulkAvailability.doctorId || !this.bulkAvailability.availabilities.length) { + this.error = 'Please add at least one availability slot'; + return; + } + + if (!this.doctorId) { + this.error = 'Doctor ID not available'; + return; + } + + this.bulkAvailability.doctorId = this.doctorId; + + // Clear previous error + this.error = null; + + try { + await this.availabilityService.createBulkAvailability(this.bulkAvailability); + this.showBulkAvailabilityForm = false; + this.bulkAvailability = { + doctorId: this.doctorId || '', + availabilities: [] + }; + this.error = null; + await this.loadAvailability(); + } catch (e: any) { + this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to create bulk availability'; + this.logger.error('Error creating bulk availability:', e); + } + } + + addBulkAvailabilitySlot() { + this.bulkAvailability.availabilities.push({ + dayOfWeek: 'MONDAY', + startTime: '09:00', + endTime: '17:00' + }); + } + + removeBulkAvailabilitySlot(index: number) { + this.bulkAvailability.availabilities.splice(index, 1); + } + + + onDayFilterChange() { + this.loadAvailability(); + } + + getTotalAppointments(): number { + return this.appointments.length; + } + + getUpcomingCount(): number { + return this.getUpcomingAppointments().length; + } + + getCompletedCount(): number { + return this.appointments.filter(apt => apt.status === 'COMPLETED').length; + } + + getTodayDate(): string { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + shouldShowManualPatientIdInput(): boolean { + if (!this.newAppointment.patientId) { + return true; + } + return !this.patients.some(p => p.id === this.newAppointment.patientId); + } + + async refresh() { + await this.loadUserAndAppointments(); + } + + async createAppointment() { + if (!this.newAppointment.patientId || !this.newAppointment.scheduledDate || !this.newAppointment.scheduledTime) { + this.error = 'Please fill in all required fields'; + return; + } + + if (!this.doctorId) { + this.error = 'Doctor ID not available. Please refresh the page.'; + return; + } + + try { + await this.appointmentService.createAppointment({ + patientId: this.newAppointment.patientId, + doctorId: this.doctorId, + scheduledDate: this.newAppointment.scheduledDate, + scheduledTime: this.newAppointment.scheduledTime, + durationMinutes: this.newAppointment.durationInMinutes || 30 + }); + this.showCreateForm = false; + this.newAppointment = { + patientId: '', + scheduledDate: '', + scheduledTime: '', + durationInMinutes: 30 + }; + await this.refresh(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to create appointment'; + } + } + + async cancelAppointment(appointment: Appointment) { + // Note: Appointment DTO doesn't include ID from backend + // Backend needs to add ID to AppointmentResponseDto or use composite key + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + const confirmed = await this.modalService.confirm( + 'Are you sure you want to cancel this appointment?', + 'Cancel Appointment', + 'Cancel Appointment', + 'Keep Appointment' + ); + if (!confirmed) return; + + try { + await this.appointmentService.cancelAppointment(appointment.id); + await this.refresh(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to cancel appointment'; + } + } + + async confirmAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + try { + await this.appointmentService.confirmAppointment(appointment.id); + await this.refresh(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to confirm appointment'; + } + } + + async completeAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + try { + await this.appointmentService.completeAppointment(appointment.id); + await this.refresh(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to complete appointment'; + } + } + + setTab(tab: string) { + this.activeTab = tab; + // When switching to prescriptions tab, ensure data is loaded and filtered + if (tab === 'prescriptions') { + if (this.showAllRecords) { + if (this.allPrescriptions.length === 0) { + this.loadAllDoctorRecords(); + } else { + this.applyPrescriptionFilters(); + } + } else if (this.selectedPatientId) { + if (this.prescriptions.length === 0) { + this.loadPatientPrescriptions(this.selectedPatientId); + } else { + this.applyPrescriptionFilters(); + } + } else { + // If no patient selected, load all records + this.showAllRecords = true; + this.loadAllDoctorRecords(); + } + } + } + + async logout() { + // Update online status and disconnect WebSocket before removing token + try { + await this.chatService.disconnect(); + } catch (error) { + // Ignore errors during logout + } + await this.auth.logout(); + this.router.navigateByUrl('/login'); + } + + formatDate(dateString: string): string { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString(); + } + + formatTime(timeString: string): string { + if (!timeString) return 'N/A'; + return timeString.substring(0, 5); // Format HH:MM + } + + // New feature loading methods + async load2FAStatus() { + try { + this.twoFAStatus = await this.twoFactorAuthService.get2FAStatus(); + this.twoFAEnabled = this.twoFAStatus?.enabled || false; + } catch (e: any) { + this.logger.error('Error loading 2FA status:', e); + this.twoFAEnabled = false; + } + } + + async setup2FA() { + try { + const setup = await this.twoFactorAuthService.setup2FA(); + this.qrCodeUrl = setup.qrCodeUrl; + this.twoFASecretKey = setup.secretKey; + this.twoFABackupCodes = setup.backupCodes || []; + this.twoFACodeInput = ''; + this.show2FAModal = true; + } catch (e: any) { + await this.modalService.alert( + e?.response?.data?.error || 'Failed to setup 2FA', + 'error', + '2FA Setup Error' + ); + } + } + + async verifyAndEnable2FA() { + if (!this.twoFACodeInput || this.twoFACodeInput.length !== 6) { + await this.modalService.alert( + 'Please enter a valid 6-digit code', + 'warning', + 'Invalid Code' + ); + return; + } + try { + await this.twoFactorAuthService.enable2FA(this.twoFACodeInput); + await this.load2FAStatus(); + this.show2FAModal = false; + await this.modalService.alert( + '2FA enabled successfully! Please save your backup codes: ' + this.twoFABackupCodes.join(', '), + 'success', + '2FA Enabled' + ); + } catch (e: any) { + await this.modalService.alert( + e?.response?.data?.error || 'Failed to enable 2FA', + 'error', + '2FA Error' + ); + } + } + + close2FAModal() { + this.show2FAModal = false; + this.qrCodeUrl = ''; + this.twoFASecretKey = ''; + this.twoFABackupCodes = []; + this.twoFACodeInput = ''; + } + + async disable2FA(code?: string) { + let disableCode: string | null | undefined = code; + if (!disableCode) { + disableCode = await this.modalService.prompt( + 'Enter your 2FA code or backup code to disable two-factor authentication:', + 'Disable 2FA', + '2FA Code', + 'Enter 2FA code or backup code', + 'text', + 'Disable', + 'Cancel' + ); + } + if (!disableCode) return; + try { + await this.twoFactorAuthService.disable2FA(disableCode); + await this.load2FAStatus(); + await this.modalService.alert( + '2FA disabled successfully', + 'success', + '2FA Disabled' + ); + } catch (e: any) { + await this.modalService.alert( + e?.response?.data?.error || 'Failed to disable 2FA', + 'error', + '2FA Error' + ); + } + } + + async createMedicalRecord() { + if (!this.newMedicalRecord.patientId || !this.newMedicalRecord.title || !this.newMedicalRecord.content) { + this.error = 'Please fill in all required fields'; + return; + } + // Ensure doctorId is set from context + if (!this.newMedicalRecord.doctorId) { + this.newMedicalRecord.doctorId = this.doctorId || ''; + } + try { + const recordId = (this.newMedicalRecord as any).recordId; + if (recordId) { + // Update existing record + await this.medicalRecordService.updateMedicalRecord(recordId, this.newMedicalRecord); + } else { + // Create new record + await this.medicalRecordService.createMedicalRecord(this.newMedicalRecord); + } + this.showCreateMedicalRecord = false; + this.newMedicalRecord = { + patientId: '', + doctorId: this.doctorId || '', + recordType: 'NOTE', + title: '', + content: '', + diagnosisCode: '' + }; + // Clear record ID + delete (this.newMedicalRecord as any).recordId; + // Refresh all relevant data + await Promise.all([ + this.selectedPatientId ? this.loadPatientMedicalRecords(this.selectedPatientId) : Promise.resolve(), + this.loadAllDoctorRecords() + ]); + } catch (e: any) { + this.logger.error('Error creating/updating medical record:', e); + const recordId = (this.newMedicalRecord as any).recordId; + const errorMessage = e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + (recordId ? 'Failed to update medical record' : 'Failed to create medical record'); + this.error = errorMessage; + // Auto-hide error after 5 seconds + setTimeout(() => { + this.error = null; + }, 5000); + } + } + + async createPrescription() { + this.logger.debug('[CreatePrescription] initial state:', { + selectedPatientId: this.selectedPatientId, + doctorIdContext: this.doctorId, + newPrescription: this.newPrescription + }); + // Ensure patientId is set from current selection + if (!this.newPrescription.patientId) { + if (this.selectedPatientId) { + this.newPrescription.patientId = this.selectedPatientId; + } else if (Array.isArray(this.patients) && this.patients.length === 1 && this.patients[0]?.id) { + // Fallback: if only one patient is loaded in context, preselect it + this.newPrescription.patientId = this.patients[0].id; + this.selectedPatientId = this.patients[0].id; + } + } + // Ensure doctorId is set from profile/context + if (!this.newPrescription.doctorId) { + this.newPrescription.doctorId = this.doctorId || ''; + } + + // Apply sensible defaults required by backend + if (this.newPrescription.quantity == null) { + (this.newPrescription as any).quantity = 30; + } + if (this.newPrescription.refills == null) { + (this.newPrescription as any).refills = 0; + } + if (!this.newPrescription.startDate) { + (this.newPrescription as any).startDate = new Date().toISOString().split('T')[0]; + } + + // Normalize inputs + this.newPrescription.medicationName = (this.newPrescription.medicationName || '').trim(); + this.newPrescription.dosage = (this.newPrescription.dosage || '').trim(); + this.newPrescription.frequency = (this.newPrescription.frequency || '').trim(); + + // Collect missing fields for precise error + const missing: string[] = []; + if (!this.newPrescription.patientId) missing.push('patient'); + if (!this.newPrescription.doctorId) missing.push('doctor'); + if (!this.newPrescription.medicationName) missing.push('medication'); + if (!this.newPrescription.dosage) missing.push('dosage'); + if (!this.newPrescription.frequency) missing.push('frequency'); + if ((this.newPrescription as any).quantity == null || (this.newPrescription as any).quantity <= 0) missing.push('quantity'); + if (!(this.newPrescription as any).startDate) missing.push('start date'); + + if (missing.length > 0) { + this.error = `Please fill in all required fields (${missing.join(', ')})`; + this.logger.warn('[CreatePrescription] missing fields:', missing, 'state:', { + selectedPatientId: this.selectedPatientId, + doctorIdContext: this.doctorId, + newPrescription: this.newPrescription + }); + return; + } + try { + await this.prescriptionService.createPrescription(this.newPrescription); + this.showCreatePrescription = false; + this.newPrescription = { + patientId: '', + doctorId: this.doctorId || '', + medicationName: '', + dosage: '', + frequency: '', + quantity: 30, + refills: 0, + startDate: new Date().toISOString().split('T')[0] + }; + + // Refresh all relevant data including safety alerts + const previousAlertCount = this.unacknowledgedAlertsCount; + await Promise.all([ + this.selectedPatientId ? this.loadPatientPrescriptions(this.selectedPatientId) : Promise.resolve(), + this.loadAllDoctorRecords(), + this.loadSafetyAlerts() // Refresh safety alerts after prescription creation + ]); + + // Check if new safety alerts were detected + if (this.unacknowledgedAlertsCount > previousAlertCount) { + const newAlertsCount = this.unacknowledgedAlertsCount - previousAlertCount; + await this.modalService.alert( + `${newAlertsCount} new safety alert${newAlertsCount > 1 ? 's' : ''} detected. Please review in the Safety Alerts tab.`, + 'warning', + 'Safety Alert Detected' + ); + // Auto-open Safety Alerts tab if not already open + if (this.activeTab !== 'safety') { + this.activeTab = 'safety'; + } + } else { + await this.modalService.alert( + 'Prescription created successfully!', + 'success', + 'Success' + ); + } + } catch (e: any) { + this.logger.error('Error creating prescription:', e); + const errorMessage = e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + 'Failed to create prescription'; + this.error = errorMessage; + // Auto-hide error after 5 seconds + setTimeout(() => { + this.error = null; + }, 5000); + } + } + + async createVitalSigns() { + if (!this.newVitalSigns.patientId) { + this.error = 'Please select a patient'; + return; + } + try { + await this.medicalRecordService.createVitalSigns(this.newVitalSigns); + this.showCreateVitalSigns = false; + this.newVitalSigns = { + patientId: '', + temperature: undefined, + bloodPressureSystolic: undefined, + bloodPressureDiastolic: undefined, + heartRate: undefined, + respiratoryRate: undefined, + oxygenSaturation: undefined, + weight: undefined, + height: undefined, + notes: '' + }; + // Refresh vital signs if patient is selected + if (this.selectedPatientId) { + await this.loadPatientVitalSigns(this.selectedPatientId); + } + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to create vital signs'; + } + } + + async deleteVitalSigns(vitalSignsId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this vital signs record?', + 'Delete Vital Signs', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + try { + await this.medicalRecordService.deleteVitalSigns(vitalSignsId); + if (this.selectedPatientId) { + await this.loadPatientVitalSigns(this.selectedPatientId); + } + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to delete vital signs'; + } + } + + async createLabResult() { + // Auto-fill patientId from context if missing + if (!this.newLabResult.patientId) { + if (this.selectedPatientId) { + this.newLabResult.patientId = this.selectedPatientId; + } else if (Array.isArray(this.patients) && this.patients.length === 1) { + this.newLabResult.patientId = this.patients[0].id; + } + } + + // If patientId is not in known patient IDs, try to resolve from userId + if (this.newLabResult.patientId && Array.isArray(this.patients) && !this.patients.some(p => p.id === this.newLabResult.patientId)) { + try { + const resolved = await this.userService.getPatientIdByUserId(this.newLabResult.patientId as any); + if (resolved) { + this.newLabResult.patientId = resolved as any; + } + } catch {} + } + + const missing: string[] = []; + if (!this.newLabResult.patientId) missing.push('patient'); + if (!this.newLabResult.testName) missing.push('test name'); + if (!this.newLabResult.resultValue) missing.push('result value'); + + if (missing.length) { + this.error = `Please fill in all required fields: ${missing.join(', ')}`; + return; + } + try { + const labResultId = (this.newLabResult as any).labResultId; + if (labResultId) { + // Update existing lab result + await this.medicalRecordService.updateLabResult(labResultId, this.newLabResult); + } else { + // Create new lab result + await this.medicalRecordService.createLabResult(this.newLabResult); + } + this.showCreateLabResult = false; + this.newLabResult = { + patientId: undefined as any, + doctorId: this.doctorId || '', + testName: '', + resultValue: '', + status: 'PENDING', + orderedDate: new Date().toISOString().split('T')[0] + }; + // Clear lab result ID + delete (this.newLabResult as any).labResultId; + if (this.selectedPatientId) { + await this.loadPatientLabResults(this.selectedPatientId); + } + } catch (e: any) { + this.logger.error('[CreateLabResult] Error:', e); + this.logger.error('[CreateLabResult] Response:', e?.response); + this.logger.error('[CreateLabResult] Response Data:', e?.response?.data); + this.logger.error('[CreateLabResult] Request payload:', this.newLabResult); + // Extract detailed error message + let errorMsg = 'Failed to create lab result'; + if (e?.response?.data) { + if (typeof e.response.data === 'string') { + errorMsg = e.response.data; + } else if (e.response.data.error) { + errorMsg = e.response.data.error; + } else if (e.response.data.message) { + errorMsg = e.response.data.message; + } else if (e.response.data.errors) { + errorMsg = JSON.stringify(e.response.data.errors); + } + } else if (e?.message) { + errorMsg = e.message; + } + this.error = errorMsg; + } + } + + async deleteLabResult(labResultId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this lab result?', + 'Delete Lab Result', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + try { + await this.medicalRecordService.deleteLabResult(labResultId); + if (this.selectedPatientId) { + await this.loadPatientLabResults(this.selectedPatientId); + } + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to delete lab result'; + } + } + + editLabResult(labResult: any) { + // Pre-fill form with lab result data + this.newLabResult = { + patientId: labResult.patientId, + doctorId: labResult.doctorId, + testName: labResult.testName, + resultValue: labResult.resultValue, + referenceRange: labResult.referenceRange, + unit: labResult.unit, + status: labResult.status, + orderedDate: labResult.orderedDate, + resultDate: labResult.resultDate, + notes: labResult.notes + }; + this.showCreateLabResult = true; + // Store lab result ID for update + (this.newLabResult as any).labResultId = labResult.id; + } + + async loadPatientMedicalRecords(patientId: string) { + try { + this.medicalRecords = await this.medicalRecordService.getMedicalRecordsByPatientId(patientId); + } catch (e: any) { + this.logger.error('Error loading medical records:', e); + this.medicalRecords = []; + } + } + + async loadPatientPrescriptions(patientId: string) { + try { + this.prescriptions = await this.prescriptionService.getPrescriptionsByPatientId(patientId); + this.applyPrescriptionFilters(); + } catch (e: any) { + this.logger.error('Error loading prescriptions:', e); + this.prescriptions = []; + this.filteredPrescriptions = []; + } + } + + async loadPatientLabResults(patientId: string) { + try { + this.labResults = await this.medicalRecordService.getLabResultsByPatientId(patientId); + } catch (e: any) { + this.logger.error('Error loading lab results:', e); + this.labResults = []; + } + } + + selectPatient(patientId: string) { + this.selectedPatientId = patientId; + this.newMedicalRecord.patientId = patientId; + this.newPrescription.patientId = patientId; + this.newVitalSigns.patientId = patientId; + this.newLabResult.patientId = patientId; + Promise.all([ + this.loadPatientMedicalRecords(patientId), + this.loadPatientPrescriptions(patientId), + this.loadPatientLabResults(patientId), + this.loadPatientVitalSigns(patientId) + ]); + } + + async loadPatientVitalSigns(patientId: string) { + try { + [this.vitalSigns, this.latestVitalSigns] = await Promise.all([ + this.medicalRecordService.getVitalSignsByPatientId(patientId), + this.medicalRecordService.getLatestVitalSignsByPatientId(patientId) + ]); + } catch (e: any) { + this.logger.error('Error loading vital signs:', e); + this.vitalSigns = []; + this.latestVitalSigns = null; + } + } + + async loadAllDoctorRecords() { + if (!this.doctorId) return; + try { + [this.allMedicalRecords, this.allPrescriptions] = await Promise.all([ + this.medicalRecordService.getMedicalRecordsByDoctorId(this.doctorId), + this.prescriptionService.getPrescriptionsByDoctorId(this.doctorId) + ]); + this.applyPrescriptionFilters(); + } catch (e: any) { + this.logger.error('Error loading all doctor records:', e); + this.allMedicalRecords = []; + this.allPrescriptions = []; + this.filteredPrescriptions = []; + } + } + + // Enterprise Prescription Management Methods + applyPrescriptionFilters() { + const prescriptionsToFilter = this.showAllRecords ? this.allPrescriptions : this.prescriptions; + // Initialize with all prescriptions if empty array + let filtered = prescriptionsToFilter && prescriptionsToFilter.length > 0 ? [...prescriptionsToFilter] : []; + + // Search filter + if (this.prescriptionSearchTerm) { + const searchLower = this.prescriptionSearchTerm.toLowerCase(); + filtered = filtered.filter(p => + p.medicationName?.toLowerCase().includes(searchLower) || + p.prescriptionNumber?.toLowerCase().includes(searchLower) || + p.patientName?.toLowerCase().includes(searchLower) || + p.doctorName?.toLowerCase().includes(searchLower) || + p.pharmacyName?.toLowerCase().includes(searchLower) + ); + } + + // Status filter + if (this.prescriptionStatusFilter !== 'all') { + filtered = filtered.filter(p => p.status === this.prescriptionStatusFilter); + } + + // Date filter + if (this.prescriptionDateFilter !== 'all') { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + filtered = filtered.filter(p => { + const startDate = new Date(p.startDate); + + switch (this.prescriptionDateFilter) { + case 'today': + return startDate >= today; + case 'week': + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + return startDate >= weekAgo; + case 'month': + const monthAgo = new Date(today); + monthAgo.setMonth(monthAgo.getMonth() - 1); + return startDate >= monthAgo; + case 'year': + const yearAgo = new Date(today); + yearAgo.setFullYear(yearAgo.getFullYear() - 1); + return startDate >= yearAgo; + case 'custom': + if (this.prescriptionDateFrom && this.prescriptionDateTo) { + const fromDate = new Date(this.prescriptionDateFrom); + const toDate = new Date(this.prescriptionDateTo); + toDate.setHours(23, 59, 59, 999); + return startDate >= fromDate && startDate <= toDate; + } + return true; + default: + return true; + } + }); + } + + // Sorting + filtered.sort((a, b) => { + let aValue: any, bValue: any; + + switch (this.prescriptionSortBy) { + case 'medicationName': + aValue = a.medicationName || ''; + bValue = b.medicationName || ''; + break; + case 'patientName': + aValue = a.patientName || a.patientId || ''; + bValue = b.patientName || b.patientId || ''; + break; + case 'status': + aValue = a.status || ''; + bValue = b.status || ''; + break; + case 'startDate': + aValue = new Date(a.startDate).getTime(); + bValue = new Date(b.startDate).getTime(); + break; + case 'createdAt': + default: + aValue = new Date(a.createdAt).getTime(); + bValue = new Date(b.createdAt).getTime(); + break; + } + + if (aValue < bValue) return this.prescriptionSortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return this.prescriptionSortOrder === 'asc' ? 1 : -1; + return 0; + }); + + this.filteredPrescriptions = filtered; + } + + onPrescriptionFilterChange() { + this.applyPrescriptionFilters(); + } + + togglePrescriptionSort(column: string) { + if (this.prescriptionSortBy === column) { + this.prescriptionSortOrder = this.prescriptionSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + this.prescriptionSortBy = column; + this.prescriptionSortOrder = 'desc'; + } + this.applyPrescriptionFilters(); + } + + togglePrescriptionSelection(prescriptionId: string) { + const index = this.selectedPrescriptions.indexOf(prescriptionId); + if (index > -1) { + this.selectedPrescriptions.splice(index, 1); + } else { + this.selectedPrescriptions.push(prescriptionId); + } + } + + toggleSelectAllPrescriptions() { + const prescriptionsToSelect = this.filteredPrescriptions.length > 0 + ? this.filteredPrescriptions + : (this.showAllRecords ? this.allPrescriptions : this.prescriptions); + + if (this.selectedPrescriptions.length === prescriptionsToSelect.length) { + this.selectedPrescriptions = []; + } else { + this.selectedPrescriptions = prescriptionsToSelect.map(p => p.id); + } + } + + async bulkUpdatePrescriptionStatus(status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED') { + if (this.selectedPrescriptions.length === 0) { + this.error = 'Please select at least one prescription'; + return; + } + + const confirmed = await this.modalService.confirm( + `Are you sure you want to update ${this.selectedPrescriptions.length} prescription(s) to ${status}?`, + 'Update Prescription Status', + 'Update', + 'Cancel' + ); + if (!confirmed) { + return; + } + + try { + await Promise.all( + this.selectedPrescriptions.map(id => + this.prescriptionService.updatePrescriptionStatus(id, status) + ) + ); + this.selectedPrescriptions = []; + await this.refreshPrescriptions(); + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to update prescriptions'; + } + } + + async refreshPrescriptions() { + if (this.showAllRecords) { + await this.loadAllDoctorRecords(); + } else if (this.selectedPatientId) { + await this.loadPatientPrescriptions(this.selectedPatientId); + this.applyPrescriptionFilters(); + } + } + + getPrescriptionStatistics() { + // Use the source data, not filtered, for statistics + const prescriptions = this.showAllRecords ? this.allPrescriptions : this.prescriptions; + // Fallback to filtered if source is empty + const sourcePrescriptions = prescriptions && prescriptions.length > 0 ? prescriptions : this.filteredPrescriptions; + const total = sourcePrescriptions.length; + const active = sourcePrescriptions.filter(p => p.status === 'ACTIVE').length; + const completed = sourcePrescriptions.filter(p => p.status === 'COMPLETED').length; + const cancelled = sourcePrescriptions.filter(p => p.status === 'CANCELLED').length; + const discontinued = sourcePrescriptions.filter(p => p.status === 'DISCONTINUED').length; + const ePrescriptionsSent = sourcePrescriptions.filter(p => p.ePrescriptionSent).length; + + // Calculate recent activity (last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const recent = sourcePrescriptions.filter(p => { + const createdDate = new Date(p.createdAt); + return createdDate >= thirtyDaysAgo; + }).length; + + return { + total, + active, + completed, + cancelled, + discontinued, + ePrescriptionsSent, + recent, + completionRate: total > 0 ? ((completed / total) * 100).toFixed(1) : '0' + }; + } + + exportPrescriptions() { + const prescriptions = this.filteredPrescriptions.length > 0 ? this.filteredPrescriptions : + (this.showAllRecords ? this.allPrescriptions : this.prescriptions); + + // Create CSV content + const headers = ['Prescription #', 'Medication', 'Patient', 'Dosage', 'Frequency', 'Quantity', 'Refills', 'Status', 'Start Date', 'End Date', 'E-Prescription Sent']; + const rows = prescriptions.map(p => [ + p.prescriptionNumber || '', + p.medicationName || '', + p.patientName || p.patientId || '', + p.dosage || '', + p.frequency || '', + p.quantity?.toString() || '', + p.refills?.toString() || '', + p.status || '', + p.startDate || '', + p.endDate || '', + p.ePrescriptionSent ? 'Yes' : 'No' + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n'); + + // Download CSV + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `prescriptions_${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + window.URL.revokeObjectURL(url); + } + + printPrescription(prescription: Prescription) { + const printWindow = window.open('', '_blank'); + if (!printWindow) return; + + printWindow.document.write(` + + + + Prescription - ${prescription.prescriptionNumber} + + + +
+

Prescription

+

Prescription #: ${prescription.prescriptionNumber}

+

Date: ${new Date(prescription.createdAt).toLocaleDateString()}

+
+
+
+

Patient: ${prescription.patientName || prescription.patientId}

+

Doctor: ${prescription.doctorName || prescription.doctorId}

+
+
+

Medication Information

+ + + + + + + + ${prescription.endDate ? `` : ''} +
Medication${prescription.medicationName}
Dosage${prescription.dosage}
Frequency${prescription.frequency}
Quantity${prescription.quantity}
Refills${prescription.refills || 0}
Start Date${new Date(prescription.startDate).toLocaleDateString()}
End Date${new Date(prescription.endDate).toLocaleDateString()}
+
+ ${prescription.instructions ? `

Instructions: ${prescription.instructions}

` : ''} + ${prescription.pharmacyName ? ` +
+

Pharmacy Information

+

Pharmacy: ${prescription.pharmacyName}

+ ${prescription.pharmacyAddress ? `

Address: ${prescription.pharmacyAddress}

` : ''} + ${prescription.pharmacyPhone ? `

Phone: ${prescription.pharmacyPhone}

` : ''} +
+ ` : ''} + +
+ + + `); + + printWindow.document.close(); + printWindow.print(); + } + + async toggleRecordsView() { + this.showAllRecords = !this.showAllRecords; + this.selectedPrescriptions = []; + if (this.showAllRecords) { + await this.loadAllDoctorRecords(); + } else { + if (this.selectedPatientId) { + await this.loadPatientPrescriptions(this.selectedPatientId); + } else { + // If no patient selected, just clear filters + this.applyPrescriptionFilters(); + } + } + } + + async deleteMedicalRecord(recordId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this medical record? This action cannot be undone.', + 'Delete Medical Record', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + try { + await this.medicalRecordService.deleteMedicalRecord(recordId); + // Refresh data + if (this.showAllRecords) { + await this.loadAllDoctorRecords(); + } else if (this.selectedPatientId) { + await this.loadPatientMedicalRecords(this.selectedPatientId); + } + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to delete medical record'; + } + } + + async deletePrescription(prescriptionId: string) { + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this prescription? This action cannot be undone.', + 'Delete Prescription', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + try { + await this.prescriptionService.deletePrescription(prescriptionId); + // Refresh data + if (this.showAllRecords) { + await this.loadAllDoctorRecords(); + } else if (this.selectedPatientId) { + await this.loadPatientPrescriptions(this.selectedPatientId); + } + this.selectedPrescriptions = []; + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to delete prescription'; + } + } + + editMedicalRecord(record: MedicalRecord) { + // Pre-fill form with record data + this.newMedicalRecord = { + patientId: record.patientId, + doctorId: record.doctorId, + appointmentId: record.appointmentId, + recordType: record.recordType, + title: record.title, + content: record.content, + diagnosisCode: record.diagnosisCode + }; + this.showCreateMedicalRecord = true; + // Store record ID for update + (this.newMedicalRecord as any).recordId = record.id; + } + + async updatePrescriptionStatus(prescriptionId: string, status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED') { + try { + await this.prescriptionService.updatePrescriptionStatus(prescriptionId, status); + // Refresh data + if (this.showAllRecords) { + await this.loadAllDoctorRecords(); + } else if (this.selectedPatientId) { + await this.loadPatientPrescriptions(this.selectedPatientId); + } + this.selectedPrescriptions = []; + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to update prescription status'; + } + } + + async markEPrescriptionSent(prescriptionId: string) { + try { + await this.prescriptionService.markEPrescriptionSent(prescriptionId); + // Refresh data + if (this.showAllRecords) { + await this.loadAllDoctorRecords(); + } else if (this.selectedPatientId) { + await this.loadPatientPrescriptions(this.selectedPatientId); + } + this.selectedPrescriptions = []; + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to mark e-prescription as sent'; + } + } + + editPrescription(prescription: Prescription) { + this.selectedPrescription = prescription; + this.newPrescription = { + patientId: prescription.patientId, + doctorId: prescription.doctorId, + appointmentId: prescription.appointmentId, + medicationName: prescription.medicationName, + medicationCode: prescription.medicationCode, + dosage: prescription.dosage, + frequency: prescription.frequency, + quantity: prescription.quantity, + refills: prescription.refills, + instructions: prescription.instructions, + startDate: prescription.startDate, + endDate: prescription.endDate, + pharmacyName: prescription.pharmacyName, + pharmacyAddress: prescription.pharmacyAddress, + pharmacyPhone: prescription.pharmacyPhone + }; + this.showUpdatePrescription = true; + (this.newPrescription as any).prescriptionId = prescription.id; + } + + async updatePrescription() { + if (!this.newPrescription.patientId || !this.newPrescription.medicationName || !this.newPrescription.dosage || !this.newPrescription.frequency) { + this.error = 'Please fill in all required fields'; + return; + } + try { + const prescriptionId = (this.newPrescription as any).prescriptionId; + if (!prescriptionId) { + this.error = 'Prescription ID not found'; + return; + } + await this.prescriptionService.updatePrescription(prescriptionId, this.newPrescription); + this.showUpdatePrescription = false; + this.selectedPrescription = null; + this.newPrescription = { + patientId: '', + doctorId: this.doctorId || '', + medicationName: '', + dosage: '', + frequency: '', + quantity: 30, + refills: 0, + startDate: new Date().toISOString().split('T')[0] + }; + delete (this.newPrescription as any).prescriptionId; + // Refresh data + await Promise.all([ + this.selectedPatientId ? this.loadPatientPrescriptions(this.selectedPatientId) : Promise.resolve(), + this.loadAllDoctorRecords() + ]); + this.selectedPrescriptions = []; + } catch (e: any) { + this.error = e?.response?.data?.error || 'Failed to update prescription'; + } + } + + get isEditingMedicalRecord(): boolean { + return !!(this.newMedicalRecord as any).recordId; + } + + get isEditingLabResult(): boolean { + return !!(this.newLabResult as any).labResultId; + } + + getStatusClass(status: string): string { + const statusLower = status?.toLowerCase() || ''; + if (statusLower === 'completed') return 'status-completed'; + if (statusLower === 'confirmed') return 'status-confirmed'; + if (statusLower === 'cancelled') return 'status-cancelled'; + return 'status-scheduled'; + } + + getUpcomingAppointments(): Appointment[] { + const now = new Date(); + return this.appointments + .filter(apt => { + const aptDate = new Date(`${apt.scheduledDate}T${apt.scheduledTime}`); + return aptDate >= now && apt.status !== 'CANCELLED' && apt.status !== 'COMPLETED'; + }) + .sort((a, b) => { + const dateA = new Date(`${a.scheduledDate}T${a.scheduledTime}`); + const dateB = new Date(`${b.scheduledDate}T${b.scheduledTime}`); + return dateA.getTime() - dateB.getTime(); + }); + } + + getPastAppointments(): Appointment[] { + const now = new Date(); + return this.appointments + .filter(apt => { + const aptDate = new Date(`${apt.scheduledDate}T${apt.scheduledTime}`); + return aptDate < now || apt.status === 'CANCELLED' || apt.status === 'COMPLETED'; + }) + .sort((a, b) => { + const dateA = new Date(`${a.scheduledDate}T${a.scheduledTime}`); + const dateB = new Date(`${b.scheduledDate}T${b.scheduledTime}`); + return dateB.getTime() - dateA.getTime(); + }); + } + + async loadDoctorProfile() { + try { + this.doctorProfile = await this.userService.getDoctorProfile(); + // Set doctorId from profile (this is the most reliable source) + if (this.doctorProfile?.id) { + this.doctorId = this.doctorProfile.id; + + // If doctorId was set, reload appointments and availability with correct ID + // This ensures appointments and availability use the correct doctor ID + if (this.currentUser) { + // Reload appointments if they failed to load before + if (!this.appointments || this.appointments.length === 0) { + this.loadAppointments(); + } + // Reload availability if it failed to load before + if (!this.availability || this.availability.length === 0) { + this.loadAvailability(); + } + } + } else { + this.logger.error('Doctor profile loaded but does not contain ID'); + this.doctorProfile = null; + this.error = 'Unable to load doctor profile. Please refresh the page or contact support.'; + } + } catch (e: any) { + this.logger.error('Failed to load doctor profile:', e); + this.doctorProfile = null; + this.error = 'Unable to load doctor profile. Please refresh the page or contact support.'; + } + } + + async updateDoctorProfile(event?: {userData: UserUpdateRequest, doctorData: DoctorUpdateRequest}) { + try { + const userData = event?.userData || this.editUserData; + const doctorData = event?.doctorData || this.editDoctorData; + + // Update user profile if fields are provided + const hasUserChanges = userData.firstName || userData.lastName || userData.phoneNumber; + if (hasUserChanges) { + await this.userService.updateUserProfile(userData); + } + + // Update doctor profile + await this.userService.updateDoctorProfile(doctorData); + await this.loadDoctorProfile(); + await this.loadUserAndAppointments(); + this.showEditProfile = false; + this.editUserData = { + firstName: '', + lastName: '', + phoneNumber: '' + }; + this.editDoctorData = { + medicalLicenseNumber: '', + specialization: '', + yearsOfExperience: undefined, + biography: '', + consultationFee: undefined, + defaultDurationMinutes: undefined + }; + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to update doctor profile'; + } + } + + editProfile() { + // If already in edit mode, toggle it off (for cancel) + if (this.showEditProfile) { + this.showEditProfile = false; + return; + } + + // Ensure currentUser is loaded + if (!this.currentUser) { + this.logger.error('Current user not loaded'); + return; + } + // Ensure doctorProfile is loaded + if (!this.doctorProfile) { + this.logger.error('Doctor profile not loaded'); + return; + } + this.editUserData = { + firstName: this.currentUser.firstName || '', + lastName: this.currentUser.lastName || '', + phoneNumber: this.currentUser.phoneNumber || '' + }; + this.editDoctorData = { + medicalLicenseNumber: this.doctorProfile.medicalLicenseNumber || '', + specialization: this.doctorProfile.specialization || '', + yearsOfExperience: this.doctorProfile.yearsOfExperience, + biography: this.doctorProfile.biography || '', + consultationFee: this.doctorProfile.consultationFee, + defaultDurationMinutes: this.doctorProfile.defaultDurationMinutes + }; + this.showEditProfile = true; + } + + toggleNotifications(): void { + this.showNotifications = !this.showNotifications; + + // When opening notifications dropdown, mark all visible notifications as read + // This removes the indicator but keeps the notifications + if (this.showNotifications) { + // Mark all unread notifications as read when dropdown is opened + const unreadNotifications = this.notifications.filter(n => !n.read); + unreadNotifications.forEach(notification => { + this.notificationService.markAsRead(notification.id); + }); + } + } + + toggleProfileMenu(): void { + this.showProfileMenu = !this.showProfileMenu; + if (this.showProfileMenu) { + this.showNotifications = false; + } + } + + toggleChatWidget(): void { + this.showChatWidget = !this.showChatWidget; + if (this.showChatWidget) { + this.showChatMenu = false; + } + } + + + toggleChatMenu(): void { + this.showChatMenu = !this.showChatMenu; + if (this.showChatMenu) { + this.showNotifications = false; + this.showProfileMenu = false; + // Refresh conversations when opening the menu + if (this.chatComponent) { + this.chatComponent.refreshConversations(); + } + } else { + // Clear search when closing + this.clearChatSearch(); + } + } + + async openChatConversation(otherUserId: string): Promise { + if (this.chatComponent) { + // Find the conversation + const conversation = this.chatComponent.conversations.find(c => c.otherUserId === otherUserId); + if (conversation) { + // Select the conversation in chat component + await this.chatComponent.selectConversation(conversation); + // Close the menu and open the chat widget + this.showChatMenu = false; + this.showChatWidget = true; + } + } + } + + async openChatWithUser(userId: string): Promise { + this.logger.debug('[DoctorComponent] openChatWithUser called with userId:', userId); + if (this.chatComponent) { + // Always ensure chat widget is visible + this.showChatWidget = true; + this.showChatMenu = false; + + // Try to find existing conversation + const existingConversation = this.chatComponent.conversations.find(c => c.otherUserId === userId); + if (existingConversation) { + this.logger.debug('[DoctorComponent] Found existing conversation, selecting it'); + await this.chatComponent.selectConversation(existingConversation); + } else { + // Start a new conversation + this.logger.debug('[DoctorComponent] No existing conversation, creating new one'); + await this.chatComponent.openConversation(userId); + } + // Clear search + this.clearChatSearch(); + this.logger.debug('[DoctorComponent] Chat widget should now be visible, showChatWidget:', this.showChatWidget); + } else { + this.logger.error('[DoctorComponent] chatComponent is not available'); + } + } + + toggleBlockedUsers(event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.showBlockedUsers = !this.showBlockedUsers; + if (this.showBlockedUsers) { + this.loadBlockedUsers(); + // Close chat menu when opening blocked users + this.showChatMenu = false; + } + } + + async loadBlockedUsers(): Promise { + this.isLoadingBlockedUsers = true; + try { + this.blockedUsers = await this.chatService.getBlockedUsersWithDetails(); + } catch (error) { + this.logger.error('Error loading blocked users:', error); + this.blockedUsers = []; + } finally { + this.isLoadingBlockedUsers = false; + } + } + + handleUnblockClick(user: ChatUser, event: Event): void { + this.logger.debug('=== handleUnblockClick CALLED ===', user, event); + alert('Button clicked! User: ' + user.firstName + ' ' + user.lastName); + event.stopPropagation(); + event.preventDefault(); + this.unblockUser(user); + } + + async unblockUser(user: ChatUser): Promise { + this.logger.debug('=== unblockUser METHOD CALLED ===', user); + try { + this.logger.debug('Unblocking user:', user.userId, user); + this.logger.debug('User ID type:', typeof user.userId); + this.logger.debug('User ID value:', user.userId); + + // Ensure userId is a string and trim any whitespace + const userId = String(user.userId).trim(); + if (!userId || userId === 'undefined' || userId === 'null') { + throw new Error('Invalid user ID: ' + userId); + } + + this.logger.debug('Formatted user ID:', userId); + this.logger.debug('Calling chatService.unblockUser with:', userId); + + await this.chatService.unblockUser(userId); + + // Remove from blocked users list + this.blockedUsers = this.blockedUsers.filter(u => u.userId !== user.userId); + + // Refresh conversations to show unblocked user + if (this.chatComponent) { + await this.chatComponent.refreshConversations(); + } + + // Show success message + await this.modalService.alert( + `${user.firstName} ${user.lastName} has been unblocked successfully.`, + 'success', + 'User Unblocked' + ); + } catch (error: any) { + this.logger.error('Error unblocking user:', error); + this.logger.error('Error details:', { + message: error?.message, + response: error?.response?.data, + status: error?.response?.status, + userId: user.userId + }); + + let errorMessage = 'Failed to unblock user. Please try again.'; + if (error?.response?.data?.message) { + errorMessage = error.response.data.message; + } else if (error?.response?.data?.error) { + errorMessage = error.response.data.error; + } else if (error?.message) { + errorMessage = error.message; + } + + this.logger.error('Final error message:', errorMessage); + + await this.modalService.alert( + errorMessage, + 'error', + 'Unblock Error' + ); + } + } + + private searchTimeout: any = null; + + async onChatSearch(event: Event): Promise { + const input = event.target as HTMLInputElement; + const query = input.value.trim(); + this.chatSearchQuery = query; + + // Clear previous timeout + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + + if (!query) { + this.chatSearchResults = []; + this.filteredConversations = []; + return; + } + + const queryLower = query.toLowerCase(); + + // Filter existing conversations + if (this.chatComponent?.conversations && this.chatComponent.conversations.length > 0) { + this.filteredConversations = this.chatComponent.conversations.filter(conv => + conv.otherUserName.toLowerCase().includes(queryLower) + ); + } else { + this.filteredConversations = []; + } + + // Debounce API search - only search if query is at least 2 characters + if (query.length < 2) { + this.chatSearchResults = []; + return; + } + + // Debounce the API call to avoid too many requests + this.searchTimeout = setTimeout(async () => { + try { + if (this.currentUser?.role === 'DOCTOR') { + const users = await this.chatService.searchPatients(query); + this.logger.debug('Search patients results:', users); + // Filter out users we already have conversations with + const existingUserIds = this.chatComponent?.conversations?.map(c => c.otherUserId) || []; + this.chatSearchResults = users.filter(user => + !existingUserIds.includes(user.userId) + ); + this.logger.debug('Filtered search results:', this.chatSearchResults); + } else if (this.currentUser?.role === 'PATIENT') { + const users = await this.chatService.searchDoctors(query); + this.logger.debug('Search doctors results:', users); + const existingUserIds = this.chatComponent?.conversations?.map(c => c.otherUserId) || []; + this.chatSearchResults = users.filter(user => + !existingUserIds.includes(user.userId) + ); + this.logger.debug('Filtered search results:', this.chatSearchResults); + } + } catch (error) { + this.logger.error('Error searching users:', error); + this.chatSearchResults = []; + } + }, 300); // Wait 300ms after user stops typing + } + + clearChatSearch(): void { + this.chatSearchQuery = ''; + this.chatSearchResults = []; + this.filteredConversations = []; + this.showChatSearchResults = false; + } + + getConversationTime(timestamp: string | Date): string { + if (!timestamp) return ''; + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Now'; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + + onImageError(event: Event): void { + const img = event.target as HTMLImageElement; + img.style.display = 'none'; + const avatarCircle = img.nextElementSibling as HTMLElement; + if (avatarCircle && (avatarCircle.classList.contains('avatar-circle-small') || + avatarCircle.classList.contains('avatar-circle-medium'))) { + avatarCircle.style.display = 'flex'; + } + } + + onChatImageError(event: Event): void { + const img = event.target as HTMLImageElement; + img.style.display = 'none'; + const avatarCircle = img.nextElementSibling as HTMLElement; + if (avatarCircle && avatarCircle.classList.contains('avatar-circle')) { + avatarCircle.style.display = 'flex'; + } + } + + getStatusText(status: string | undefined): string { + if (!status) return 'Offline'; + switch (status) { + case 'ONLINE': return 'Online'; + case 'BUSY': return 'Busy'; + case 'OFFLINE': return 'Offline'; + default: return status; + } + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target as HTMLElement; + + // Don't close if clicking on unblock button or its children + if (target.closest('.unblock-button-dropdown')) { + return; + } + + if (!target.closest('.notification-container')) { + this.showNotifications = false; + } + if (!target.closest('.profile-menu-container')) { + this.showProfileMenu = false; + } + if (!target.closest('.chat-menu-container')) { + this.showChatMenu = false; + } + if (!target.closest('.blocked-users-container')) { + this.showBlockedUsers = false; + } + } + + markNotificationAsRead(notificationId: string): void { + this.notificationService.markAsRead(notificationId); + } + + markAllNotificationsAsRead(): void { + this.notificationService.markAllAsRead(); + } + + deleteNotification(notificationId: string, event?: Event): void { + if (event) { + event.stopPropagation(); // Prevent triggering navigation when clicking delete + } + this.notificationService.deleteNotification(notificationId); + } + + deleteAllNotifications(): void { + this.notificationService.deleteAllNotifications(); + } + + async handleNotificationClick(notification: Notification): Promise { + // Mark as read when clicked + if (!notification.read) { + this.notificationService.markAsRead(notification.id); + } + + // Navigate to messages tab + this.activeTab = 'chat'; + + // Close notification dropdown + this.showNotifications = false; + + // If notification has actionUrl with userId, try to open that conversation + if (notification.actionUrl) { + const urlParams = new URLSearchParams(notification.actionUrl.split('?')[1]); + const userId = urlParams.get('userId'); + if (userId) { + // Small delay to ensure tab switch happens first, then open conversation + setTimeout(async () => { + // Try to get chat component reference (it might not be available if tab isn't active) + let attempts = 0; + const maxAttempts = 10; + const checkChatComponent = setInterval(async () => { + attempts++; + if (this.chatComponent) { + clearInterval(checkChatComponent); + await this.chatComponent.openConversation(userId); + } else if (attempts >= maxAttempts) { + clearInterval(checkChatComponent); + this.logger.warn('ChatComponent not available after switching to chat tab'); + } + }, 100); + + // Stop checking after 2 seconds + setTimeout(() => clearInterval(checkChatComponent), 2000); + }, 300); + } + } + } + + getNotificationTime(timestamp: Date): string { + const now = new Date(); + const diffMs = now.getTime() - timestamp.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; + + return timestamp.toLocaleDateString(); + } + + async onFileSelected(event: Event): Promise { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + try { + const avatarUrl = await this.userService.uploadAvatar(file); + // Refresh current user to get updated avatar + this.currentUser = await this.userService.getCurrentUser(); + // Reload doctor profile if available + if (this.currentUser) { + this.doctorProfile = await this.userService.getDoctorProfile(); + } + } catch (error: any) { + this.logger.error('Error uploading avatar:', error); + await this.modalService.alert( + error?.response?.data?.error || 'Failed to upload avatar. Please try again.', + 'error', + 'Upload Error' + ); + } + } + } + + // Patient Safety Methods + async loadSafetyAlerts() { + if (!this.doctorId) return; + try { + this.unacknowledgedCriticalResults = await this.patientSafetyService.getUnacknowledgedCriticalResultsByDoctorId(this.doctorId); + this.unacknowledgedAlerts = await this.patientSafetyService.getAllUnacknowledgedAlerts(); + const previousCount = this.unacknowledgedAlertsCount; + this.unacknowledgedAlertsCount = this.unacknowledgedAlerts.length + this.unacknowledgedCriticalResults.length; + + // Auto-open Safety Alerts tab if alerts appeared for the first time + if (this.unacknowledgedAlertsCount > 0 && previousCount === 0 && this.activeTab !== 'safety') { + this.activeTab = 'safety'; + } + } catch (error: any) { + this.logger.error('Error loading safety alerts:', error); + } + } + + async acknowledgeClinicalAlert(alertId: string) { + try { + await this.patientSafetyService.acknowledgeAlert(alertId); + await this.loadSafetyAlerts(); + } catch (error: any) { + this.logger.error('Error acknowledging alert:', error); + await this.modalService.alert( + 'Failed to acknowledge alert', + 'error', + 'Error' + ); + } + } + + async resolveClinicalAlert(alertId: string) { + try { + await this.patientSafetyService.resolveAlert(alertId); + await this.loadSafetyAlerts(); + } catch (error: any) { + this.logger.error('Error resolving alert:', error); + await this.modalService.alert( + 'Failed to resolve alert', + 'error', + 'Error' + ); + } + } + + async acknowledgeCriticalResult(resultId: string) { + try { + await this.patientSafetyService.acknowledgeCriticalResult(resultId, {}); + await this.loadSafetyAlerts(); + } catch (error: any) { + this.logger.error('Error acknowledging critical result:', error); + await this.modalService.alert( + 'Failed to acknowledge critical result', + 'error', + 'Error' + ); + } + } + + async createClinicalAlert() { + if (!this.newAlert.patientId || !this.newAlert.title || !this.newAlert.description) { + await this.modalService.alert( + 'Please fill in all required fields (Patient, Title, Description)', + 'warning', + 'Validation Error' + ); + return; + } + + try { + await this.patientSafetyService.createAlert({ + patientId: this.newAlert.patientId, + alertType: this.newAlert.alertType, + severity: this.newAlert.severity, + title: this.newAlert.title, + description: this.newAlert.description, + medicationName: this.newAlert.medicationName || undefined, + relatedPrescriptionId: this.newAlert.relatedPrescriptionId || undefined + }); + + // Reset form + this.newAlert = { + patientId: '', + alertType: 'DRUG_INTERACTION', + severity: 'WARNING', + title: '', + description: '', + medicationName: '', + relatedPrescriptionId: null + }; + this.showCreateAlertForm = false; + + // Refresh alerts + await this.loadSafetyAlerts(); + + await this.modalService.alert( + 'Alert created successfully!', + 'success', + 'Success' + ); + } catch (error: any) { + this.logger.error('Error creating alert:', error); + await this.modalService.alert( + error?.response?.data?.message || 'Failed to create alert', + 'error', + 'Error' + ); + } + } + + toggleCreateAlertForm() { + this.showCreateAlertForm = !this.showCreateAlertForm; + if (this.showCreateAlertForm && !this.newAlert.patientId && this.patients.length > 0) { + // Pre-select first patient if available + this.newAlert.patientId = this.patients[0].id; + } + } + + getRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(); + } + + async handleSafetyCheckRequest() { + // Refresh safety alerts when requested from child component (e.g., after prescription creation) + const previousAlertCount = this.unacknowledgedAlertsCount; + await this.loadSafetyAlerts(); + + // Check if new safety alerts were detected + if (this.unacknowledgedAlertsCount > previousAlertCount) { + const newAlertsCount = this.unacknowledgedAlertsCount - previousAlertCount; + await this.modalService.alert( + `${newAlertsCount} new safety alert${newAlertsCount > 1 ? 's' : ''} detected. Please review in the Safety Alerts tab.`, + 'warning', + 'Safety Alert Detected' + ); + // Auto-open Safety Alerts tab if not already open + if (this.activeTab !== 'safety') { + this.activeTab = 'safety'; + } + } + } + + async handlePatientRemoved() { + this.logger.debug('[handlePatientRemoved] Starting refresh...'); + // Refresh appointments FIRST (they're filtered by deletedByDoctor), + // then extract patients from the updated appointments + await this.loadAppointments(); + this.logger.debug('[handlePatientRemoved] Appointments loaded:', this.appointments.length); + // Now extract patients from appointments (will exclude deleted ones) + // This ensures only patients with visible (non-deleted) appointments are shown + const beforeCount = this.patients.length; + this.extractPatientsFromAppointments(); + this.logger.debug('[handlePatientRemoved] Patients before:', beforeCount, 'after:', this.patients.length); + this.logger.debug('[handlePatientRemoved] Patient IDs:', this.patients.map(p => p.id)); + // Don't load all patients here - we want only those with appointments + // The removed patient will be filtered out automatically + } + + async handleCreateAppointmentRequest(patient: any) { + this.logger.debug('[DoctorComponent] handleCreateAppointmentRequest - patient:', patient); + // Select the patient and open create appointment form + this.selectedPatientId = patient.id; + this.newAppointment.patientId = patient.id; + + // Ensure the patient is in the patients array for the dropdown + // Check if patient exists in current patients list + const patientExists = this.patients.some(p => p.id === patient.id); + if (!patientExists) { + // Add patient to the list if not already there + this.patients = [...this.patients, { + id: patient.id, + userId: patient.userId, + firstName: patient.firstName || '', + lastName: patient.lastName || '', + displayName: `${patient.firstName || ''} ${patient.lastName || ''}`.trim(), + email: patient.email || '', + phoneNumber: patient.phoneNumber || '' + }]; + this.logger.debug('[DoctorComponent] Added patient to list:', patient.id); + } + + this.showCreateForm = true; + // Switch to create appointment tab to show the form + this.activeTab = 'create'; + this.logger.debug('[DoctorComponent] handleCreateAppointmentRequest - patient ID:', patient.id, 'activeTab:', this.activeTab); + } + + async handleStartChatRequest(data: any) { + try { + this.logger.debug('[DoctorComponent] handleStartChatRequest called with data:', data); + // Open chat with the patient + if (this.chatComponent) { + // Use openChatWithUser which will create a new conversation if it doesn't exist + await this.openChatWithUser(data.userId); + this.logger.debug('[DoctorComponent] Chat opened successfully with userId:', data.userId); + } else { + this.logger.error('[DoctorComponent] ChatComponent not available'); + await this.modalService.alert( + 'Chat feature is not available at the moment', + 'warning', + 'Chat Unavailable' + ); + } + } catch (error: any) { + this.logger.error('[DoctorComponent] Error starting chat:', error); + await this.modalService.alert( + error?.message || 'Failed to start chat', + 'error', + 'Error' + ); + } + } +} + diff --git a/frontend/src/app/pages/forgot-password/forgot-password.component.html b/frontend/src/app/pages/forgot-password/forgot-password.component.html new file mode 100644 index 0000000..f0f1346 --- /dev/null +++ b/frontend/src/app/pages/forgot-password/forgot-password.component.html @@ -0,0 +1,82 @@ + + diff --git a/frontend/src/app/pages/forgot-password/forgot-password.component.scss b/frontend/src/app/pages/forgot-password/forgot-password.component.scss new file mode 100644 index 0000000..740a045 --- /dev/null +++ b/frontend/src/app/pages/forgot-password/forgot-password.component.scss @@ -0,0 +1,856 @@ +// Enterprise-Grade Color Palette +$primary-blue: #0066cc; +$primary-blue-dark: #0052a3; +$primary-blue-light: #e6f2ff; +$primary-blue-lighter: #f0f7ff; +$accent-teal: #0099a1; +$accent-teal-light: #00c4ce; +$text-dark: #0f172a; +$text-medium: #475569; +$text-light: #64748b; +$text-lighter: #94a3b8; +$border-color: #e2e8f0; +$border-color-light: #f1f5f9; +$border-focus: #0066cc; +$error-red: #dc2626; +$error-red-dark: #b91c1c; +$error-bg: #fef2f2; +$error-border: #fecaca; +$success-green: #10b981; +$success-green-dark: #059669; +$success-bg: #d1fae5; +$warning-orange: #f59e0b; +$info-blue: #3b82f6; +$white: #ffffff; +$background-light: #f8fafc; +$background-elevated: #ffffff; +$gradient-primary: linear-gradient(135deg, #0066cc 0%, #0099a1 100%); +$gradient-primary-hover: linear-gradient(135deg, #0052a3 0%, #007a82 100%); +$gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$gradient-bg-alt: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + +// Enterprise Shadow System +$shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +$shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); +$shadow-blue: 0 10px 25px -5px rgba(0, 102, 204, 0.25); +$shadow-blue-lg: 0 20px 40px -10px rgba(0, 102, 204, 0.3); + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + +.login-wrapper { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: $gradient-bg; + background-attachment: fixed; + padding: 2rem; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.15) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.12) 0%, transparent 50%), + radial-gradient(circle at 50% 20%, rgba(102, 126, 234, 0.3) 0%, transparent 60%); + pointer-events: none; + animation: backgroundShift 20s ease-in-out infinite alternate; + } + + &::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, transparent 70%); + animation: rotateBackground 30s linear infinite; + pointer-events: none; + } + + @keyframes backgroundShift { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0.8; + transform: translateY(-20px); + } + } + + @keyframes rotateBackground { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + @media (max-width: 768px) { + padding: 1rem; + } +} + +.login-container { + background: rgba($white, 0.98); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 24px; + box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.2) inset; + width: 100%; + max-width: 460px; + padding: 3.5rem; + position: relative; + z-index: 1; + animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: $gradient-primary; + z-index: 2; + } + + @media (max-width: 768px) { + padding: 2.5rem 2rem; + border-radius: 20px; + max-width: 100%; + } + + @media (max-width: 480px) { + padding: 2rem 1.5rem; + border-radius: 16px; + } + + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +} + +.login-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.logo-container { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + background: $gradient-primary; + border-radius: 18px; + margin-bottom: 1.75rem; + box-shadow: $shadow-lg, 0 0 0 4px rgba(0, 102, 204, 0.1); + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 20px; + background: $gradient-primary; + opacity: 0; + filter: blur(12px); + transition: opacity 0.3s ease; + z-index: -1; + } + + &:hover { + transform: translateY(-2px) scale(1.05); + box-shadow: $shadow-xl, 0 0 0 4px rgba(0, 102, 204, 0.15); + + &::after { + opacity: 0.4; + } + } + + .medical-icon { + width: 40px; + height: 40px; + color: $white; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); + transition: transform 0.3s ease; + } + + &:hover .medical-icon { + transform: scale(1.1); + } +} + +.login-title { + font-size: 2rem; + font-weight: 800; + background: $gradient-primary; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 0.75rem 0; + letter-spacing: -0.75px; + font-family: $font-family; + line-height: 1.2; + + @media (max-width: 768px) { + font-size: 1.75rem; + } +} + +.login-subtitle { + font-size: 1rem; + color: $text-medium; + margin: 0; + font-weight: 500; + line-height: 1.5; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-label { + font-size: 0.875rem; + font-weight: 600; + color: $text-dark; + letter-spacing: 0.2px; + font-family: $font-family; +} + +.input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.input-icon { + position: absolute; + left: 1.125rem; + width: 20px; + height: 20px; + color: $text-light; + pointer-events: none; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; +} + +.form-input { + width: 100%; + padding: 1rem 3rem 1rem 3rem; + font-size: 0.9375rem; + font-family: $font-family; + color: $text-dark; + background: $background-elevated; + border: 2px solid $border-color; + border-radius: 12px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + box-shadow: $shadow-inner; + + &::placeholder { + color: $text-lighter; + font-weight: 400; + } + + &:hover:not(:disabled):not(:focus) { + border-color: $border-color-light; + background: $white; + box-shadow: $shadow-sm; + } + + &:focus { + border-color: $border-focus; + background: $white; + box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md; + transform: translateY(-1px); + + ~ .input-icon { + color: $border-focus; + transform: scale(1.1); + } + } + + &:disabled { + background: $background-light; + cursor: not-allowed; + opacity: 0.6; + border-color: $border-color; + } + + &.input-error { + border-color: $error-red; + background: $error-bg; + + ~ .input-icon { + color: $error-red; + } + + &:focus { + box-shadow: 0 0 0 4px rgba($error-red, 0.1), $shadow-md; + background: $white; + } + } + + &.code-input { + text-align: center; + font-size: 1.75rem; + font-weight: 700; + letter-spacing: 0.75rem; + padding-right: 0.75rem; + padding-left: 0.75rem; + font-variant-numeric: tabular-nums; + background: $primary-blue-lighter; + border-color: $primary-blue-light; + } +} + +.password-toggle { + position: absolute; + right: 0.75rem; + background: transparent; + border: none; + cursor: pointer; + padding: 0.625rem; + display: flex; + align-items: center; + justify-content: center; + color: $text-light; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 2; + border-radius: 8px; + + svg { + width: 20px; + height: 20px; + transition: transform 0.2s ease; + } + + &:hover { + color: $text-dark; + background: $background-light; + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + background: $primary-blue-lighter; + } +} + +.field-error { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: $error-red; + margin-top: 0.375rem; + font-weight: 500; + padding-left: 0.25rem; + animation: slideDown 0.2s ease-out; + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} + +.form-hint { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8125rem; + color: $text-medium; + margin: 0.5rem 0 0 0; + line-height: 1.5; + + svg { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-top: 0.125rem; + color: $text-light; + } +} + +.label-icon { + width: 18px; + height: 18px; + margin-right: 0.5rem; + vertical-align: middle; + color: $primary-blue; +} + +.form-options { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.5rem; + margin-bottom: 1rem; +} + +.remember-me { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: $text-medium; + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: $primary-blue; + } + + span { + font-weight: 500; + } + + &:hover { + color: $text-dark; + } +} + +.forgot-password-link { + font-size: 0.875rem; + color: $primary-blue; + text-decoration: none; + font-weight: 600; + transition: color 0.2s ease; + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + border-radius: 4px; + } +} + +.link-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + color: $primary-blue; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + padding: 0.5rem 0; + margin-top: 0.5rem; + transition: color 0.2s ease; + font-family: $font-family; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + border-radius: 4px; + } +} + +.info-message { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, $primary-blue-lighter 0%, lighten($primary-blue-lighter, 2%) 100%); + border: 2px solid $primary-blue-light; + border-left: 4px solid $primary-blue; + border-radius: 12px; + color: $primary-blue-dark; + font-size: 0.875rem; + font-weight: 600; + animation: slideInInfo 0.4s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: $shadow-sm; + margin-bottom: 1rem; + + svg { + width: 20px; + height: 20px; + flex-shrink: 0; + color: $primary-blue; + } + + @keyframes slideInInfo { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +} + +.error-message { + display: flex; + align-items: flex-start; + gap: 0.875rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, $error-bg 0%, lighten($error-bg, 2%) 100%); + border: 2px solid $error-border; + border-left: 4px solid $error-red; + border-radius: 12px; + color: $error-red-dark; + font-size: 0.875rem; + font-weight: 600; + animation: slideInError 0.4s cubic-bezier(0.16, 1, 0.3, 1), shake 0.5s ease-out 0.4s; + box-shadow: $shadow-sm; + margin-top: 0.5rem; + + .error-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 0.125rem; + filter: drop-shadow(0 1px 2px rgba(220, 38, 38, 0.2)); + } + + @keyframes slideInError { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-6px); } + 75% { transform: translateX(6px); } + } +} + +.submit-button { + width: 100%; + padding: 1.125rem 2rem; + font-size: 1rem; + font-weight: 700; + font-family: $font-family; + color: $white; + background: $gradient-primary; + border: none; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: $shadow-md, 0 0 0 1px rgba(255, 255, 255, 0.1) inset; + margin-top: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + position: relative; + overflow: hidden; + text-transform: none; + letter-spacing: 0.3px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent); + transition: left 0.6s ease; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + padding: 2px; + background: $gradient-primary; + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 0.3s ease; + } + + &:hover:not(:disabled) { + transform: translateY(-3px) scale(1.01); + box-shadow: $shadow-blue-lg, 0 0 0 1px rgba(255, 255, 255, 0.15) inset; + background: $gradient-primary-hover; + + &::before { + left: 100%; + } + + &::after { + opacity: 0.3; + } + } + + &:active:not(:disabled) { + transform: translateY(-1px) scale(0.99); + box-shadow: $shadow-md; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + background: $text-lighter; + box-shadow: none; + } + + &.loading { + pointer-events: none; + } + + .loading-content { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .spinner { + width: 20px; + height: 20px; + animation: spin 0.8s linear infinite; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +} + +.login-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid $border-color; + text-align: center; +} + +.footer-text { + font-size: 0.8125rem; + color: $text-light; + margin: 0 0 1.5rem 0; + font-family: $font-family; +} + +.registration-links { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.register-link { + display: flex; + align-items: center; + justify-content: center; + gap: 0.625rem; + padding: 0.875rem 1.25rem; + font-size: 0.875rem; + font-weight: 600; + font-family: $font-family; + color: $primary-blue; + text-decoration: none; + border: 2px solid $primary-blue; + border-radius: 10px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + background: transparent; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: $primary-blue-light; + opacity: 0; + transition: opacity 0.25s ease; + z-index: -1; + } + + svg { + width: 18px; + height: 18px; + transition: transform 0.25s ease; + } + + &:hover { + background: $primary-blue-light; + border-color: $primary-blue-dark; + color: $primary-blue-dark; + transform: translateY(-2px); + box-shadow: $shadow-md; + + &::before { + opacity: 1; + } + + svg { + transform: translateX(2px); + } + } + + &:active { + transform: translateY(0); + box-shadow: $shadow-sm; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + } +} + +// Focus visible for accessibility +.form-input:focus-visible, +.submit-button:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; +} + +// Dark mode support (optional, if needed) +@media (prefers-color-scheme: dark) { + .login-container { + background: #1e1e1e; + } + + .login-title { + color: $white; + } + + .login-subtitle { + color: rgba($white, 0.7); + } + + .form-label { + color: rgba($white, 0.9); + } + + .form-input { + background: #2a2a2a; + border-color: #404040; + color: $white; + + &:focus { + border-color: $border-focus; + } + } + + .footer-text { + color: rgba($white, 0.5); + } +} + +.success-message { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); + border: 1px solid #10b981; + border-radius: 12px; + margin-bottom: 1.5rem; + color: #059669; + font-size: 0.875rem; + line-height: 1.5; + + .success-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + color: #10b981; + } +} + +.back-to-login-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: $primary-blue; + text-decoration: none; + font-size: 0.875rem; + font-weight: 600; + margin-top: 1rem; + transition: color 0.2s ease; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } +} + diff --git a/frontend/src/app/pages/forgot-password/forgot-password.component.ts b/frontend/src/app/pages/forgot-password/forgot-password.component.ts new file mode 100644 index 0000000..857788b --- /dev/null +++ b/frontend/src/app/pages/forgot-password/forgot-password.component.ts @@ -0,0 +1,64 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-forgot-password', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule], + templateUrl: './forgot-password.component.html', + styleUrl: './forgot-password.component.scss' +}) +export class ForgotPasswordComponent { + email = ''; + error: string | null = null; + success: string | null = null; + loading = false; + emailError = ''; + + constructor( + private auth: AuthService, + private router: Router + ) {} + + validateEmail() { + this.emailError = ''; + if (!this.email) { + return; + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(this.email)) { + this.emailError = 'Please enter a valid email address'; + } + } + + async submit() { + this.error = null; + this.success = null; + this.emailError = ''; + + if (!this.email) { + this.emailError = 'Email is required'; + return; + } + + this.validateEmail(); + if (this.emailError) { + return; + } + + this.loading = true; + try { + const result = await this.auth.forgotPassword(this.email); + this.success = result.message || 'If an account with that email exists, a password reset link has been sent.'; + this.email = ''; + } catch (e: any) { + this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to send password reset email. Please try again.'; + } finally { + this.loading = false; + } + } +} + diff --git a/frontend/src/app/pages/login/login.component.html b/frontend/src/app/pages/login/login.component.html new file mode 100644 index 0000000..67bda11 --- /dev/null +++ b/frontend/src/app/pages/login/login.component.html @@ -0,0 +1,188 @@ + diff --git a/frontend/src/app/pages/login/login.component.scss b/frontend/src/app/pages/login/login.component.scss new file mode 100644 index 0000000..540cc72 --- /dev/null +++ b/frontend/src/app/pages/login/login.component.scss @@ -0,0 +1,813 @@ +// Enterprise-Grade Color Palette +$primary-blue: #0066cc; +$primary-blue-dark: #0052a3; +$primary-blue-light: #e6f2ff; +$primary-blue-lighter: #f0f7ff; +$accent-teal: #0099a1; +$accent-teal-light: #00c4ce; +$text-dark: #0f172a; +$text-medium: #475569; +$text-light: #64748b; +$text-lighter: #94a3b8; +$border-color: #e2e8f0; +$border-color-light: #f1f5f9; +$border-focus: #0066cc; +$error-red: #dc2626; +$error-red-dark: #b91c1c; +$error-bg: #fef2f2; +$error-border: #fecaca; +$success-green: #10b981; +$success-green-dark: #059669; +$success-bg: #d1fae5; +$warning-orange: #f59e0b; +$info-blue: #3b82f6; +$white: #ffffff; +$background-light: #f8fafc; +$background-elevated: #ffffff; +$gradient-primary: linear-gradient(135deg, #0066cc 0%, #0099a1 100%); +$gradient-primary-hover: linear-gradient(135deg, #0052a3 0%, #007a82 100%); +$gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$gradient-bg-alt: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + +// Enterprise Shadow System +$shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +$shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); +$shadow-blue: 0 10px 25px -5px rgba(0, 102, 204, 0.25); +$shadow-blue-lg: 0 20px 40px -10px rgba(0, 102, 204, 0.3); + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + +.login-wrapper { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: $gradient-bg; + background-attachment: fixed; + padding: 2rem; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.15) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.12) 0%, transparent 50%), + radial-gradient(circle at 50% 20%, rgba(102, 126, 234, 0.3) 0%, transparent 60%); + pointer-events: none; + animation: backgroundShift 20s ease-in-out infinite alternate; + } + + &::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, transparent 70%); + animation: rotateBackground 30s linear infinite; + pointer-events: none; + } + + @keyframes backgroundShift { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0.8; + transform: translateY(-20px); + } + } + + @keyframes rotateBackground { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + @media (max-width: 768px) { + padding: 1rem; + } +} + +.login-container { + background: rgba($white, 0.98); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 24px; + box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.2) inset; + width: 100%; + max-width: 460px; + padding: 3.5rem; + position: relative; + z-index: 1; + animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: $gradient-primary; + z-index: 2; + } + + @media (max-width: 768px) { + padding: 2.5rem 2rem; + border-radius: 20px; + max-width: 100%; + } + + @media (max-width: 480px) { + padding: 2rem 1.5rem; + border-radius: 16px; + } + + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +} + +.login-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.logo-container { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + background: $gradient-primary; + border-radius: 18px; + margin-bottom: 1.75rem; + box-shadow: $shadow-lg, 0 0 0 4px rgba(0, 102, 204, 0.1); + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 20px; + background: $gradient-primary; + opacity: 0; + filter: blur(12px); + transition: opacity 0.3s ease; + z-index: -1; + } + + &:hover { + transform: translateY(-2px) scale(1.05); + box-shadow: $shadow-xl, 0 0 0 4px rgba(0, 102, 204, 0.15); + + &::after { + opacity: 0.4; + } + } + + .medical-icon { + width: 40px; + height: 40px; + color: $white; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); + transition: transform 0.3s ease; + } + + &:hover .medical-icon { + transform: scale(1.1); + } +} + +.login-title { + font-size: 2rem; + font-weight: 800; + background: $gradient-primary; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 0.75rem 0; + letter-spacing: -0.75px; + font-family: $font-family; + line-height: 1.2; + + @media (max-width: 768px) { + font-size: 1.75rem; + } +} + +.login-subtitle { + font-size: 1rem; + color: $text-medium; + margin: 0; + font-weight: 500; + line-height: 1.5; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-label { + font-size: 0.875rem; + font-weight: 600; + color: $text-dark; + letter-spacing: 0.2px; + font-family: $font-family; +} + +.input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.input-icon { + position: absolute; + left: 1.125rem; + width: 20px; + height: 20px; + color: $text-light; + pointer-events: none; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; +} + +.form-input { + width: 100%; + padding: 1rem 3rem 1rem 3rem; + font-size: 0.9375rem; + font-family: $font-family; + color: $text-dark; + background: $background-elevated; + border: 2px solid $border-color; + border-radius: 12px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + box-shadow: $shadow-inner; + + &::placeholder { + color: $text-lighter; + font-weight: 400; + } + + &:hover:not(:disabled):not(:focus) { + border-color: $border-color-light; + background: $white; + box-shadow: $shadow-sm; + } + + &:focus { + border-color: $border-focus; + background: $white; + box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md; + transform: translateY(-1px); + + ~ .input-icon { + color: $border-focus; + transform: scale(1.1); + } + } + + &:disabled { + background: $background-light; + cursor: not-allowed; + opacity: 0.6; + border-color: $border-color; + } + + &.input-error { + border-color: $error-red; + background: $error-bg; + + ~ .input-icon { + color: $error-red; + } + + &:focus { + box-shadow: 0 0 0 4px rgba($error-red, 0.1), $shadow-md; + background: $white; + } + } + + &.code-input { + text-align: center; + font-size: 1.75rem; + font-weight: 700; + letter-spacing: 0.75rem; + padding-right: 0.75rem; + padding-left: 0.75rem; + font-variant-numeric: tabular-nums; + background: $primary-blue-lighter; + border-color: $primary-blue-light; + } +} + +.password-toggle { + position: absolute; + right: 0.75rem; + background: transparent; + border: none; + cursor: pointer; + padding: 0.625rem; + display: flex; + align-items: center; + justify-content: center; + color: $text-light; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 2; + border-radius: 8px; + + svg { + width: 20px; + height: 20px; + transition: transform 0.2s ease; + } + + &:hover { + color: $text-dark; + background: $background-light; + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + background: $primary-blue-lighter; + } +} + +.field-error { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: $error-red; + margin-top: 0.375rem; + font-weight: 500; + padding-left: 0.25rem; + animation: slideDown 0.2s ease-out; + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} + +.form-hint { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8125rem; + color: $text-medium; + margin: 0.5rem 0 0 0; + line-height: 1.5; + + svg { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-top: 0.125rem; + color: $text-light; + } +} + +.label-icon { + width: 18px; + height: 18px; + margin-right: 0.5rem; + vertical-align: middle; + color: $primary-blue; +} + +.form-options { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.5rem; + margin-bottom: 1rem; +} + +.remember-me { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: $text-medium; + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: $primary-blue; + } + + span { + font-weight: 500; + } + + &:hover { + color: $text-dark; + } +} + +.forgot-password-link { + font-size: 0.875rem; + color: $primary-blue; + text-decoration: none; + font-weight: 600; + transition: color 0.2s ease; + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + border-radius: 4px; + } +} + +.link-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + color: $primary-blue; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + padding: 0.5rem 0; + margin-top: 0.5rem; + transition: color 0.2s ease; + font-family: $font-family; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + border-radius: 4px; + } +} + +.info-message { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, $primary-blue-lighter 0%, lighten($primary-blue-lighter, 2%) 100%); + border: 2px solid $primary-blue-light; + border-left: 4px solid $primary-blue; + border-radius: 12px; + color: $primary-blue-dark; + font-size: 0.875rem; + font-weight: 600; + animation: slideInInfo 0.4s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: $shadow-sm; + margin-bottom: 1rem; + + svg { + width: 20px; + height: 20px; + flex-shrink: 0; + color: $primary-blue; + } + + @keyframes slideInInfo { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +} + +.error-message { + display: flex; + align-items: flex-start; + gap: 0.875rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, $error-bg 0%, lighten($error-bg, 2%) 100%); + border: 2px solid $error-border; + border-left: 4px solid $error-red; + border-radius: 12px; + color: $error-red-dark; + font-size: 0.875rem; + font-weight: 600; + animation: slideInError 0.4s cubic-bezier(0.16, 1, 0.3, 1), shake 0.5s ease-out 0.4s; + box-shadow: $shadow-sm; + margin-top: 0.5rem; + + .error-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 0.125rem; + filter: drop-shadow(0 1px 2px rgba(220, 38, 38, 0.2)); + } + + @keyframes slideInError { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-6px); } + 75% { transform: translateX(6px); } + } +} + +.submit-button { + width: 100%; + padding: 1.125rem 2rem; + font-size: 1rem; + font-weight: 700; + font-family: $font-family; + color: $white; + background: $gradient-primary; + border: none; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: $shadow-md, 0 0 0 1px rgba(255, 255, 255, 0.1) inset; + margin-top: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + position: relative; + overflow: hidden; + text-transform: none; + letter-spacing: 0.3px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent); + transition: left 0.6s ease; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + padding: 2px; + background: $gradient-primary; + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 0.3s ease; + } + + &:hover:not(:disabled) { + transform: translateY(-3px) scale(1.01); + box-shadow: $shadow-blue-lg, 0 0 0 1px rgba(255, 255, 255, 0.15) inset; + background: $gradient-primary-hover; + + &::before { + left: 100%; + } + + &::after { + opacity: 0.3; + } + } + + &:active:not(:disabled) { + transform: translateY(-1px) scale(0.99); + box-shadow: $shadow-md; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + background: $text-lighter; + box-shadow: none; + } + + &.loading { + pointer-events: none; + } + + .loading-content { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .spinner { + width: 20px; + height: 20px; + animation: spin 0.8s linear infinite; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +} + +.login-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid $border-color; + text-align: center; +} + +.footer-text { + font-size: 0.8125rem; + color: $text-light; + margin: 0 0 1.5rem 0; + font-family: $font-family; +} + +.registration-links { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.register-link { + display: flex; + align-items: center; + justify-content: center; + gap: 0.625rem; + padding: 0.875rem 1.25rem; + font-size: 0.875rem; + font-weight: 600; + font-family: $font-family; + color: $primary-blue; + text-decoration: none; + border: 2px solid $primary-blue; + border-radius: 10px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + background: transparent; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: $primary-blue-light; + opacity: 0; + transition: opacity 0.25s ease; + z-index: -1; + } + + svg { + width: 18px; + height: 18px; + transition: transform 0.25s ease; + } + + &:hover { + background: $primary-blue-light; + border-color: $primary-blue-dark; + color: $primary-blue-dark; + transform: translateY(-2px); + box-shadow: $shadow-md; + + &::before { + opacity: 1; + } + + svg { + transform: translateX(2px); + } + } + + &:active { + transform: translateY(0); + box-shadow: $shadow-sm; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + } +} + +// Focus visible for accessibility +.form-input:focus-visible, +.submit-button:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; +} + +// Dark mode support (optional, if needed) +@media (prefers-color-scheme: dark) { + .login-container { + background: #1e1e1e; + } + + .login-title { + color: $white; + } + + .login-subtitle { + color: rgba($white, 0.7); + } + + .form-label { + color: rgba($white, 0.9); + } + + .form-input { + background: #2a2a2a; + border-color: #404040; + color: $white; + + &:focus { + border-color: $border-focus; + } + } + + .footer-text { + color: rgba($white, 0.5); + } +} + diff --git a/frontend/src/app/pages/login/login.component.spec.ts b/frontend/src/app/pages/login/login.component.spec.ts new file mode 100644 index 0000000..18f3685 --- /dev/null +++ b/frontend/src/app/pages/login/login.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoginComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/login/login.component.ts b/frontend/src/app/pages/login/login.component.ts new file mode 100644 index 0000000..6db4142 --- /dev/null +++ b/frontend/src/app/pages/login/login.component.ts @@ -0,0 +1,188 @@ +import { Component, ChangeDetectorRef } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { UserService } from '../../services/user.service'; +import { LoggerService } from '../../services/logger.service'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule], + templateUrl: './login.component.html', + styleUrl: './login.component.scss' +}) +export class LoginComponent { + email = ''; + password = ''; + code = ''; // 2FA code + error: string | null = null; + loading = false; + requires2FA = false; + showPassword = false; + rememberMe = false; + emailError = ''; + passwordError = ''; + + constructor( + private auth: AuthService, + private userService: UserService, + private router: Router, + private cdr: ChangeDetectorRef, + private logger: LoggerService + ) {} + + validateEmail() { + this.emailError = ''; + if (!this.email) { + return; + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(this.email)) { + this.emailError = 'Please enter a valid email address'; + } + } + + validatePassword() { + this.passwordError = ''; + if (!this.password) { + return; + } + if (this.password.length < 3) { + this.passwordError = 'Password is too short'; + } + } + + async submit() { + this.error = null; + this.emailError = ''; + this.passwordError = ''; + + // Validation + if (!this.email) { + this.emailError = 'Email is required'; + return; + } + this.validateEmail(); + if (this.emailError) { + return; + } + + if (!this.requires2FA && !this.password) { + this.passwordError = 'Password is required'; + return; + } + if (!this.requires2FA) { + this.validatePassword(); + if (this.passwordError) { + return; + } + } + + if (this.requires2FA && !this.code) { + this.error = 'Please enter your 2FA code'; + return; + } + + this.loading = true; + try { + const result = await this.auth.login(this.email, this.password, this.code || undefined); + + if (result.requires2FA) { + this.logger.debug('Result indicates 2FA required, setting requires2FA to true'); + this.requires2FA = true; + this.error = null; + // Clear the code when switching to 2FA mode + this.code = ''; + // Force change detection to update the view + this.cdr.detectChanges(); + // Use requestAnimationFrame to ensure DOM is updated + requestAnimationFrame(() => { + const codeInput = document.getElementById('code'); + if (codeInput) { + codeInput.focus(); + } + }); + } else if (result.token) { + // Get user info to determine role-based redirect + const user = await this.userService.getCurrentUser(); + if (user) { + if (user.role === 'ADMIN') { + this.router.navigateByUrl('/admin'); + } else if (user.role === 'DOCTOR') { + this.router.navigateByUrl('/doctor'); + } else if (user.role === 'PATIENT') { + this.router.navigateByUrl('/patient'); + } else { + this.router.navigateByUrl('/admin'); + } + } else { + this.router.navigateByUrl('/admin'); + } + } + } catch (e: any) { + const errorMessage = (e?.response?.data?.message || e?.response?.data?.error || '').toLowerCase(); + const statusCode = e?.response?.status; + + this.logger.debug('Login component error:', { + status: statusCode, + message: e?.response?.data?.message || e?.response?.data?.error, + requires2FA: this.requires2FA, + fullError: e?.response?.data + }); + + // If we're in 2FA mode and got an error + if (this.requires2FA) { + // If it's an invalid 2FA code error, keep requires2FA true + if (errorMessage.includes('invalid 2fa') || errorMessage.includes('2fa code')) { + this.error = 'Invalid 2FA code. Please try again.'; + this.code = ''; // Clear the code input + // Keep requires2FA = true + } else { + // Some other error - might be wrong credentials, reset 2FA + this.error = e?.response?.data?.message || e?.response?.data?.error || 'Login failed. Please try again.'; + this.requires2FA = false; + this.code = ''; + this.password = ''; // Clear password on error + } + } else { + // Not in 2FA mode - check if 2FA is required + // Check for "2FA code required" message or 400 status with 2FA in message + const is2FARequired = errorMessage.includes('2fa code required') || + (statusCode === 400 && errorMessage.includes('2fa') && !errorMessage.includes('invalid')); + + if (is2FARequired) { + this.logger.debug('Setting requires2FA to true'); + this.requires2FA = true; + this.error = null; + // Don't clear password - user needs it for the next step + // Force change detection to update the view + this.cdr.detectChanges(); + // Use requestAnimationFrame to ensure DOM is updated + requestAnimationFrame(() => { + const codeInput = document.getElementById('code'); + if (codeInput) { + codeInput.focus(); + } + }); + } else { + // Regular login error + this.error = e?.response?.data?.message || e?.response?.data?.error || 'Login failed. Please check your credentials.'; + } + } + } finally { + this.loading = false; + } + } + + togglePasswordVisibility() { + this.showPassword = !this.showPassword; + } + + reset2FA() { + this.requires2FA = false; + this.code = ''; + this.error = null; + } +} diff --git a/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.html b/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.html new file mode 100644 index 0000000..2cd185b --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.html @@ -0,0 +1,189 @@ +
+
+
+

My Appointments

+

+ Slots auto-confirm when available. Otherwise, your care team approves the request. +

+
+ +
+ +
+
+
+
+

Request Appointment

+

Select a doctor, choose a time, and we'll handle the rest.

+
+ +
+
+
+ + +
+
+
+ + +
+
+ +
+ +
+ Loading enterprise availability... +
+
+

No available slots for this date. Please select a different date.

+
+
+ +
+ +
+ Selected Time: {{ newAppointment.scheduledTime }} +
+
+ +
+ + +
+
+
+
+ +
+

Upcoming

+
+
+
+
+

Dr. {{ apt.doctorFirstName }} {{ apt.doctorLastName }}

+

+ + + + {{ formatDateFn(apt.scheduledDate) }} at {{ formatTimeFn(apt.scheduledTime) }} +

+

+ + + + + {{ apt.durationInMinutes }} minutes +

+
+ + {{ apt.status }} + +
+
+ + +
+
+ + + + Action unavailable: Appointment ID missing. Contact support for assistance. +
+
+
+
+ +
+

Past

+
+
+
+
+

Dr. {{ apt.doctorFirstName }} {{ apt.doctorLastName }}

+

+ + + + {{ formatDateFn(apt.scheduledDate) }} at {{ formatTimeFn(apt.scheduledTime) }} +

+

+ + + + + {{ apt.durationInMinutes }} minutes +

+
+ + {{ apt.status }} + +
+
+ +
+
+
+
+ +
+ + + +

No Appointments

+

You don't have any appointments scheduled yet.

+
+
+ diff --git a/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.scss b/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.scss new file mode 100644 index 0000000..fbfbc5d --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.scss @@ -0,0 +1,450 @@ +$surface: #f7f9fc; +$surface-card: #ffffff; +$surface-accent: #eef2ff; +$border: #d7deea; +$primary: #1e4dd8; +$primary-strong: #1239a2; +$danger: #da1b4d; +$text: #0f172a; +$muted: #5c6474; +$success: #0f8b5f; + +@mixin elevated-card { + background: $surface-card; + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.06); + box-shadow: + 0 20px 45px rgba(15, 23, 42, 0.08), + 0 2px 6px rgba(15, 23, 42, 0.04); +} + +@mixin focus-ring { + outline: none; + box-shadow: 0 0 0 3px rgba(30, 77, 216, 0.25); +} + +:host { + display: block; +} + +.appointments-section { + margin: 1.5rem auto; + max-width: 1400px; + padding: 2.5rem; + position: relative; + background: linear-gradient(180deg, #fdfdff 0%, #f1f4fb 100%); + border-radius: 32px; + border: 1px solid rgba(15, 23, 42, 0.04); + box-shadow: + 0 30px 80px rgba(15, 23, 42, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.6); + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 1.5rem; + border-radius: 26px; + border: 1px solid rgba(30, 77, 216, 0.08); + pointer-events: none; + } + + @media (max-width: 768px) { + padding: 1.75rem; + } +} + +.section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + + h2, + p { + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + } + + h2 { + margin: 0; + font-size: 1.5rem; + color: $text; + letter-spacing: -0.01em; + } + + .info-text { + margin: 0.35rem 0 0; + color: $muted; + } + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + } +} + +.request-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: none; + padding: 0.85rem 1.5rem; + border-radius: 999px; + background: linear-gradient(120deg, $primary, $primary-strong); + color: #fff; + font-weight: 600; + cursor: pointer; + text-transform: capitalize; + letter-spacing: 0.02em; + box-shadow: 0 18px 35px rgba(30, 77, 216, 0.3); + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 25px 45px rgba(18, 57, 162, 0.35); + } + + &:focus-visible { + @include focus-ring; + } +} + +.request-form-container { + margin-bottom: 2rem; +} + +.create-form-container { + @include elevated-card; + padding: 1.75rem; + background: linear-gradient(160deg, rgba(30, 77, 216, 0.08), rgba(255, 255, 255, 0)) $surface-card; + border: 1px solid rgba(30, 77, 216, 0.1); +} + +.form-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; + + h3 { + margin: 0; + font-size: 1.25rem; + color: $text; + } + + p { + margin: 0.25rem 0 0; + color: $muted; + } +} + +.icon-btn { + border: none; + background: rgba(15, 23, 42, 0.07); + border-radius: 12px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; + + &:hover { + background: rgba(15, 23, 42, 0.12); + transform: translateY(-1px); + } + + &:focus-visible { + @include focus-ring; + } +} + +.create-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.form-label { + font-weight: 600; + color: $text; + display: flex; + align-items: center; + gap: 0.2rem; +} + +.required-indicator { + color: $danger; +} + +.form-input, +.form-select { + border: 1px solid $border; + border-radius: 14px; + padding: 0.85rem; + font-size: 1rem; + background: $surface-card; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus { + border-color: $primary; + @include focus-ring; + } +} + +.loading-slots, +.no-slots, +.selected-time-info { + padding: 0.85rem; + border-radius: 14px; + background: rgba(30, 77, 216, 0.07); + color: $text; + border: 1px solid rgba(30, 77, 216, 0.1); +} + +.time-slots-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.time-slot-btn { + border: 1px solid $border; + border-radius: 10px; + padding: 0.55rem 1rem; + background: $surface-card; + cursor: pointer; + transition: all 0.2s ease; + + &.selected { + border-color: $primary; + background: linear-gradient(120deg, rgba(30, 77, 216, 0.12), rgba(255, 255, 255, 0)); + color: $primary; + box-shadow: 0 10px 18px rgba(30, 77, 216, 0.15); + } + + &:hover:not(.selected) { + border-color: rgba(30, 77, 216, 0.4); + } + + &:focus-visible { + @include focus-ring; + } +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.cancel-btn, +.submit-button { + border-radius: 12px; + padding: 0.75rem 1.5rem; + border: none; + font-weight: 600; + cursor: pointer; + font-family: 'Inter', 'Segoe UI', system-ui, sans-serif; +} + +.cancel-btn { + background: rgba(15, 23, 42, 0.08); + color: $text; + border: 1px solid rgba(15, 23, 42, 0.08); + + &:hover { + background: rgba(15, 23, 42, 0.12); + } +} + +.submit-button { + background: linear-gradient(120deg, $primary, $primary-strong); + color: #fff; + box-shadow: 0 20px 30px rgba(30, 77, 216, 0.3); + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 25px 40px rgba(18, 57, 162, 0.35); + } + + &:focus-visible { + @include focus-ring; + } +} + +.appointment-group { + margin-bottom: 2rem; +} + +.group-title { + margin: 0 0 1rem; + font-size: 1.1rem; + color: $text; +} + +.appointments-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.25rem; +} + +.appointment-card { + @include elevated-card; + padding: 1.5rem; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid transparent; + background: linear-gradient(120deg, rgba(30, 77, 216, 0.15), rgba(255, 255, 255, 0)) border-box; + -webkit-mask: + linear-gradient(#fff 0 0) padding-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: destination-out; + mask: + linear-gradient(#fff 0 0) padding-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + pointer-events: none; + } +} + +.appointment-header { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.doctor-name { + margin: 0; + font-size: 1.05rem; + color: $text; +} + +.appointment-date, +.appointment-duration { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0.25rem 0; + color: $muted; + font-size: 0.9rem; +} + +.status-badge { + align-self: flex-start; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + border: 1px solid transparent; + + &.status-completed { + background: rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.25); + color: $success; + } + &.status-confirmed { + background: rgba(37, 99, 235, 0.12); + border-color: rgba(37, 99, 235, 0.25); + color: #1d4ed8; + } + &.status-cancelled { + background: rgba(234, 72, 72, 0.12); + border-color: rgba(234, 72, 72, 0.25); + color: #b91c1c; + } + &.status-scheduled { + background: rgba(234, 179, 8, 0.14); + border-color: rgba(234, 179, 8, 0.3); + color: #92400e; + } +} + +.appointment-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.action-btn { + border: 1px solid $border; + border-radius: 14px; + padding: 0.6rem 1.1rem; + background: $surface-card; + cursor: pointer; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); + } + + &:focus-visible { + @include focus-ring; + } + + &.action-btn-cancel { + color: #b45309; + border-color: rgba(234, 179, 8, 0.5); + } + + &.action-btn-delete { + color: $danger; + border-color: rgba(218, 27, 77, 0.4); + } +} + +.appointment-warning { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem; + border-radius: 14px; + background: rgba(251, 191, 36, 0.15); + color: #92400e; + border: 1px solid rgba(251, 191, 36, 0.35); +} + +.empty-state { + text-align: center; + padding: 2rem; + border-radius: 16px; + border: 2px dashed rgba(148, 163, 184, 0.5); + color: $muted; + background: $surface-accent; + + .empty-icon { + width: 48px; + height: 48px; + margin-bottom: 0.5rem; + color: rgba(148, 163, 184, 0.8); + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.ts b/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.ts new file mode 100644 index 0000000..73458f2 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-appointments-panel/patient-appointments-panel.component.ts @@ -0,0 +1,78 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Appointment } from '../../../../services/appointment.service'; + +interface AppointmentDraft { + doctorId: string; + scheduledDate: string; + scheduledTime: string; +} + +@Component({ + selector: 'app-patient-appointments-panel', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './patient-appointments-panel.component.html', + styleUrls: ['./patient-appointments-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientAppointmentsPanelComponent { + @Input() appointments: Appointment[] = []; + @Input() doctors: any[] = []; + @Input() newAppointment: AppointmentDraft = { doctorId: '', scheduledDate: '', scheduledTime: '' }; + @Input() showRequestForm = false; + @Input() availableSlots: string[] = []; + @Input() loadingSlots = false; + @Input() selectedDate = ''; + @Input() upcomingAppointments: Appointment[] = []; + @Input() pastAppointments: Appointment[] = []; + @Input() minDate = ''; + @Input() formatDateFn: (value: string) => string = (value: string) => value; + @Input() formatTimeFn: (value: string) => string = (value: string) => value; + + @Output() toggleRequestForm = new EventEmitter(); + @Output() requestAppointment = new EventEmitter(); + @Output() doctorChange = new EventEmitter(); + @Output() dateChange = new EventEmitter(); + @Output() selectSlot = new EventEmitter(); + @Output() cancelAppointment = new EventEmitter(); + @Output() deleteAppointment = new EventEmitter(); + + onToggleRequestForm(open?: boolean): void { + this.toggleRequestForm.emit(open ?? !this.showRequestForm); + } + + onSubmitRequest(): void { + this.requestAppointment.emit(); + } + + onDoctorChange(): void { + this.doctorChange.emit(); + } + + onDateChange(): void { + this.dateChange.emit(); + } + + onSelectSlot(slot: string): void { + this.selectSlot.emit(slot); + } + + onCancelAppointment(appointment: Appointment): void { + this.cancelAppointment.emit(appointment); + } + + onDeleteAppointment(appointment: Appointment): void { + this.deleteAppointment.emit(appointment); + } + + statusClass(status: string): string { + const statusLower = status?.toLowerCase() || ''; + if (statusLower === 'completed') return 'status-completed'; + if (statusLower === 'confirmed') return 'status-confirmed'; + if (statusLower === 'cancelled') return 'status-cancelled'; + return 'status-scheduled'; + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.html b/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.html new file mode 100644 index 0000000..043f15d --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.html @@ -0,0 +1,446 @@ +
+
+
+
+ + + + +
+

Patient Dashboard

+

+ Welcome, {{ currentUser?.firstName }} {{ currentUser?.lastName }} +

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

Notifications

+

Message and missed-call alerts curated for you

+
+
+ + +
+
+ +
+
+

No notifications

+
+ +
+
+ + + + + + + +
+
+
{{ notification.title }}
+
{{ notification.message }}
+
{{ getNotificationTime(notification.timestamp) }}
+
+ +
+
+
+
+ + +
+ + + +
+
+

Blocked Users

+ +
+ +
+
+

Loading blocked users...

+
+
+
+
+ +
+ {{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }} +
+
+ + +
+
+ + + + +

No blocked users

+ You haven't blocked anyone yet +
+
+
+
+ + +
+
+

Messages

+
+ + +
+
+ + + +
+
+
Search Results ({{ chatSearchResults.length }})
+
+
+ +
+ {{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }} +
+
+
+
+
{{ user.firstName }} {{ user.lastName }}
+
{{ user.specialization }}
+
+
+
+ +
+
+ Conversations ({{ filteredConversations.length }}) +
+
+

{{ chatSearchQuery ? 'No conversations found' : 'No conversations yet' }}

+

+ {{ chatSearchQuery ? 'Try a different search term' : 'Start a conversation to begin messaging' }} +

+
+
+

No users found

+

Try searching with a different name

+
+ +
+
+ +
+ {{ conversation.otherUserName.charAt(0) }} +
+
+
+
+
{{ conversation.otherUserName }}
+
+ {{ conversation.lastMessage?.content || 'No messages yet' }} +
+
+
+
+ {{ getConversationTime(conversation.lastMessage!.createdAt) }} +
+
+ {{ conversation.unreadCount }} +
+
+
+
+
+
+
+ + +
+ + +
+
+

Blocked Users

+ +
+ +
+
+

Loading blocked users...

+
+
+
+
+ +
+ {{ user.firstName.charAt(0) }}{{ user.lastName.charAt(0) }} +
+
+ + +
+
+ + + + +

No blocked users

+ You haven't blocked anyone yet +
+
+
+
+
+ + + +
+
+
+ diff --git a/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.scss b/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.scss new file mode 100644 index 0000000..53f7756 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.scss @@ -0,0 +1,862 @@ +$primary-blue: #1b64f2; +$primary-blue-dark: #154ecc; +$text-dark: #0f172a; +$text-muted: #65738b; +$border-color: #e2e8f0; +$surface: #ffffff; +$danger: #e53935; +$bg-highlight: #f1f5ff; +$shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + +:host { + display: block; + background: linear-gradient(135deg, #f4f7fb 0%, #ffffff 60%); + border-bottom: 1px solid rgba(148, 163, 184, 0.3); + box-shadow: $shadow; +} + +.dashboard-header { + background: transparent; +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; + + @media (max-width: 1024px) { + flex-direction: column; + align-items: flex-start; + padding: 1.25rem; + } +} + +.logo-section { + display: flex; + align-items: center; + gap: 1rem; + + .medical-icon { + width: 48px; + height: 48px; + color: $primary-blue; + } + + .dashboard-title { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + color: $text-dark; + } + + .dashboard-subtitle { + margin: 0.125rem 0 0; + color: $text-muted; + font-size: 0.95rem; + } +} + +.header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.notification-container { + position: relative; +} + +.notification-button { + width: 46px; + height: 46px; + border-radius: 12px; + border: 1px solid $border-color; + background: $surface; + color: $text-dark; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + cursor: pointer; + + &:hover { + border-color: $primary-blue; + color: $primary-blue; + box-shadow: 0 10px 20px rgba(27, 100, 242, 0.15); + } + + &.has-notifications { + border-color: $primary-blue; + color: $primary-blue; + } + + svg { + width: 22px; + height: 22px; + } +} + +.notification-badge { + position: absolute; + top: -6px; + right: -4px; + width: 20px; + height: 20px; + border-radius: 999px; + background: $danger; + color: #fff; + font-size: 0.75rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +.notification-dropdown { + position: absolute; + right: 0; + top: calc(100% + 12px); + width: 360px; + border-radius: 18px; + background: #ffffff; + border: 1px solid rgba(148, 163, 184, 0.25); + box-shadow: 0 25px 60px rgba(15, 23, 42, 0.12); + padding: 1rem; + z-index: 20; + + @media (max-width: 480px) { + width: 300px; + right: -40px; + } +} + +.notification-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 1rem; + + h3 { + margin: 0; + font-size: 1.1rem; + color: $text-dark; + } + + .subtitle { + margin: 0.2rem 0 0; + color: $text-muted; + font-size: 0.85rem; + } +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.5rem; + + button { + background: transparent; + border: none; + color: $primary-blue; + font-weight: 600; + cursor: pointer; + padding: 0.15rem 0.25rem; + + &:hover { + text-decoration: underline; + } + } + + .delete-all { + width: 36px; + height: 36px; + border-radius: 10px; + border: 1px solid $border-color; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + border-color: $danger; + color: $danger; + } + + svg { + width: 18px; + height: 18px; + } + } +} + +.notification-list { + max-height: 380px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.notification-item { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 12px; + border: 1px solid $border-color; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: $primary-blue; + background: $bg-highlight; + } + + &.unread { + border-color: $primary-blue; + box-shadow: 0 10px 30px rgba(27, 100, 242, 0.08); + } +} + +.notification-icon { + width: 36px; + height: 36px; + border-radius: 10px; + background: rgba(27, 100, 242, 0.1); + display: flex; + align-items: center; + justify-content: center; + color: $primary-blue; + + svg { + width: 20px; + height: 20px; + } +} + +.notification-content { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .notification-title { + font-weight: 600; + color: $text-dark; + } + + .notification-message { + font-size: 0.85rem; + color: $text-muted; + } + + .notification-time { + font-size: 0.78rem; + color: rgba(15, 23, 42, 0.6); + } +} + +.notification-delete { + width: 32px; + height: 32px; + border-radius: 10px; + border: 1px solid transparent; + background: rgba(226, 232, 240, 0.6); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: $danger; + color: $danger; + background: rgba(229, 57, 53, 0.1); + } + + svg { + width: 16px; + height: 16px; + } +} + +.no-notifications { + text-align: center; + color: $text-muted; + padding: 2rem 1rem; + font-size: 0.95rem; +} + +.refresh-button, +.logout-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1.25rem; + border-radius: 12px; + border: 1px solid $border-color; + background: $surface; + color: $text-dark; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + svg { + width: 18px; + height: 18px; + } + + &:hover:not(:disabled) { + border-color: $primary-blue; + color: $primary-blue; + box-shadow: 0 10px 20px rgba(27, 100, 242, 0.1); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.logout-button { + border-color: rgba(229, 57, 53, 0.4); + color: $danger; + + &:hover:not(:disabled) { + box-shadow: 0 10px 20px rgba(229, 57, 53, 0.15); + } +} + +// ============================================================================ +// Chat Menu + Blocked Users Styles +// ============================================================================ + +.chat-menu-container { + position: relative; + z-index: 20; +} + +.chat-menu-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + border-radius: 12px; + border: 1px solid $border-color; + background: $surface; + color: $text-muted; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: var(--shadow-sm, 0 10px 20px rgba(15, 23, 42, 0.08)); + + svg { + width: 22px; + height: 22px; + } + + &:hover { + border-color: $primary-blue; + color: $primary-blue; + transform: translateY(-2px); + } + + &.active { + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + color: #fff; + border-color: transparent; + box-shadow: 0 10px 25px rgba(37, 99, 235, 0.3); + } + + &.has-unread { + animation: pulse-chat 2s infinite; + } +} + +@keyframes pulse-chat { + 0%, 100% { + box-shadow: 0 10px 25px rgba(37, 99, 235, 0.15); + } + 50% { + box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.15); + } +} + +.chat-menu-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + border: 2px solid #fff; +} + +.chat-menu-dropdown { + position: absolute; + top: calc(100% + 12px); + right: 0; + width: 380px; + max-height: 520px; + background: #ffffff; + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 22px; + box-shadow: 0 40px 80px rgba(15, 23, 42, 0.2); + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideDown 0.2s ease-out; + z-index: 25; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-menu-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); + background: linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%); + + h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + color: $text-dark; + } +} + +.chat-menu-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.chat-menu-close, +.chat-menu-blocked-users { + width: 36px; + height: 36px; + border-radius: 12px; + border: none; + background: rgba(15, 23, 42, 0.05); + color: $text-muted; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(15, 23, 42, 0.1); + color: $text-dark; + } +} + +.chat-menu-blocked-users { + &:hover { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + } +} + +.chat-menu-search { + padding: 1rem 1.5rem 0.5rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +.search-input-container { + position: relative; + display: flex; + align-items: center; + background: #f7f8fc; + border-radius: 14px; + padding: 0 0.75rem; + border: 1px solid rgba(148, 163, 184, 0.4); + + .search-icon { + width: 18px; + height: 18px; + color: $text-muted; + } + + .search-input { + flex: 1; + border: none; + background: transparent; + padding: 0.75rem; + outline: none; + font-size: 0.95rem; + color: $text-dark; + } + + .search-clear { + width: 32px; + height: 32px; + border-radius: 10px; + border: none; + background: rgba(148, 163, 184, 0.2); + color: $text-muted; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + background: rgba(148, 163, 184, 0.35); + color: $text-dark; + } + } +} + +.conversation-list-dropdown { + flex: 1; + overflow-y: auto; + padding: 1rem 1.5rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.search-results-section { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 16px; + padding: 1rem; + background: rgba(37, 99, 235, 0.04); + + .search-results-header { + font-weight: 600; + color: $text-dark; + margin-bottom: 0.75rem; + } +} + +.conversations-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.conversation-item-dropdown { + display: flex; + align-items: center; + padding: 0.75rem; + border-radius: 14px; + border: 1px solid transparent; + transition: all 0.2s ease; + cursor: pointer; + gap: 0.75rem; + + &:hover { + background: rgba(37, 99, 235, 0.08); + border-color: rgba(37, 99, 235, 0.2); + } + + &.active { + border-color: var(--color-primary, #2563eb); + background: rgba(37, 99, 235, 0.12); + box-shadow: 0 10px 25px rgba(37, 99, 235, 0.15); + } +} + +.conversation-avatar-dropdown { + position: relative; + flex-shrink: 0; + + .avatar-image, + .avatar-circle-dropdown { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.15); + } + + .avatar-circle-dropdown { + background: linear-gradient(135deg, var(--color-primary, #2563eb), var(--color-accent, #8b5cf6)); + color: #fff; + display: none; + align-items: center; + justify-content: center; + font-weight: 700; + } + + .online-indicator-dropdown { + position: absolute; + bottom: 2px; + right: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid #fff; + + &.online { + background: #22c55e; + } + &.offline { + background: #94a3b8; + } + &.busy { + background: #f97316; + } + } +} + +.conversation-info-dropdown { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + + .conversation-name-dropdown { + font-weight: 600; + color: $text-dark; + } + + .conversation-preview-dropdown { + font-size: 0.85rem; + color: $text-muted; + } +} + +.conversation-meta-dropdown { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + font-size: 0.8rem; + color: $text-muted; + + .conversation-unread-dropdown { + min-width: 20px; + height: 20px; + border-radius: 999px; + background: $primary-blue; + color: #fff; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + } +} + +.no-conversations { + text-align: center; + padding: 1.5rem 1rem; + color: $text-muted; + + .subtext { + margin-top: 0.25rem; + font-size: 0.85rem; + } +} + +.blocked-users-container { + position: relative; + z-index: 20; +} + +.blocked-users-button-header { + width: 46px; + height: 46px; + border-radius: 12px; + border: 1px solid $border-color; + background: $surface; + color: $text-muted; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: $danger; + color: $danger; + transform: translateY(-2px); + } + + &.active { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: #fff; + border-color: transparent; + } +} + +.blocked-users-panel-dropdown { + position: absolute; + top: calc(100% + 12px); + right: 0; + width: 360px; + max-height: 520px; + background: #ffffff; + border-radius: 22px; + border: 1px solid rgba(148, 163, 184, 0.25); + box-shadow: 0 40px 80px rgba(15, 23, 42, 0.2); + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideDown 0.2s ease-out; +} + +.blocked-users-header-dropdown { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.05) 100%); + + h3 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: $text-dark; + } + + .close-blocked-users { + width: 36px; + height: 36px; + border-radius: 12px; + border: none; + background: rgba(15, 23, 42, 0.05); + color: $text-muted; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(15, 23, 42, 0.1); + color: $text-dark; + } + } +} + +.blocked-users-content-dropdown { + flex: 1; + overflow-y: auto; + padding: 1rem 1.25rem 1.5rem; +} + +.blocked-loading { + padding: 2rem 1rem; + text-align: center; + color: $text-muted; + font-weight: 600; +} + +.blocked-users-list-dropdown { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.blocked-user-item-dropdown { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: #fff; + box-shadow: 0 15px 30px rgba(15, 23, 42, 0.08); + position: relative; + overflow: hidden; + transition: all 0.2s ease; + + &:hover { + border-color: rgba(239, 68, 68, 0.4); + transform: translateY(-1px); + } +} + +.blocked-user-avatar-dropdown { + position: relative; + + .avatar-image, + .avatar-circle-dropdown { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + } + + .avatar-circle-dropdown { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: #fff; + display: none; + align-items: center; + justify-content: center; + font-weight: 700; + } +} + +.blocked-user-info-dropdown { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.2rem; + + .blocked-user-name-dropdown { + font-weight: 600; + color: $text-dark; + } + + .blocked-user-details-dropdown { + font-size: 0.85rem; + color: $text-muted; + } +} + +.unblock-button-dropdown { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.75rem; + border-radius: 10px; + border: none; + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(239, 68, 68, 0.25); + } +} + +.no-blocked-users-dropdown { + text-align: center; + padding: 2rem 1rem; + color: $text-muted; + + span { + display: block; + font-size: 0.85rem; + margin-top: 0.25rem; + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.ts b/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.ts new file mode 100644 index 0000000..a3e091e --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-dashboard-header/patient-dashboard-header.component.ts @@ -0,0 +1,164 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Notification } from '../../../../services/notification.service'; +import { UserInfo, UserService } from '../../../../services/user.service'; +import { ChatUser, Conversation } from '../../../../services/chat.service'; + +@Component({ + selector: 'app-patient-dashboard-header', + standalone: true, + imports: [CommonModule], + templateUrl: './patient-dashboard-header.component.html', + styleUrls: ['./patient-dashboard-header.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientDashboardHeaderComponent { + @Input() currentUser: UserInfo | null = null; + @Input() notificationCount = 0; + @Input() notifications: Notification[] = []; + @Input() showNotifications = false; + @Input() loading = false; + @Input() chatUnreadCount = 0; + @Input() showChatMenu = false; + @Input() showBlockedUsers = false; + @Input() blockedUsers: ChatUser[] = []; + @Input() isLoadingBlockedUsers = false; + @Input() chatSearchQuery = ''; + @Input() chatSearchResults: ChatUser[] = []; + @Input() filteredConversations: Conversation[] = []; + @Input() conversations: Conversation[] = []; + @Input() activeConversationId: string | null = null; + + @Output() refresh = new EventEmitter(); + @Output() logout = new EventEmitter(); + @Output() toggleNotifications = new EventEmitter(); + @Output() markAllNotificationsAsRead = new EventEmitter(); + @Output() deleteAllNotifications = new EventEmitter(); + @Output() deleteNotification = new EventEmitter(); + @Output() notificationClick = new EventEmitter(); + @Output() toggleChatMenu = new EventEmitter(); + @Output() toggleBlockedUsers = new EventEmitter(); + @Output() chatSearch = new EventEmitter(); + @Output() clearChatSearch = new EventEmitter(); + @Output() openChatConversation = new EventEmitter(); + @Output() openChatWithUser = new EventEmitter(); + @Output() unblockUser = new EventEmitter(); + + constructor(public userService: UserService) {} + + onToggleNotifications(): void { + this.toggleNotifications.emit(); + } + + onRefresh(): void { + this.refresh.emit(); + } + + onLogout(): void { + this.logout.emit(); + } + + onMarkAllRead(event: MouseEvent): void { + event.stopPropagation(); + this.markAllNotificationsAsRead.emit(); + } + + onDeleteAll(event: MouseEvent): void { + event.stopPropagation(); + this.deleteAllNotifications.emit(); + } + + onDeleteNotification(notificationId: string, event: MouseEvent): void { + event.stopPropagation(); + this.deleteNotification.emit(notificationId); + } + + onNotificationClick(notification: Notification): void { + this.notificationClick.emit(notification); + } + + onToggleChatMenu(): void { + this.toggleChatMenu.emit(); + } + + onToggleBlockedUsers(event?: MouseEvent): void { + event?.stopPropagation(); + this.toggleBlockedUsers.emit(); + } + + onChatSearch(event: Event): void { + const input = event.target as HTMLInputElement; + this.chatSearch.emit(input.value); + } + + onClearChatSearch(event?: MouseEvent): void { + event?.stopPropagation(); + this.clearChatSearch.emit(); + } + + onOpenChatConversation(userId: string): void { + this.openChatConversation.emit(userId); + } + + onOpenChatWithUser(userId: string): void { + this.openChatWithUser.emit(userId); + } + + onUnblockUser(user: ChatUser, event: Event): void { + event.stopPropagation(); + event.preventDefault(); + this.unblockUser.emit(user); + } + + getNotificationTime(timestamp: Date): string { + const now = new Date(); + const diffMs = now.getTime() - new Date(timestamp).getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; + return new Date(timestamp).toLocaleDateString(); + } + + getConversationTime(timestamp?: string | Date): string { + if (!timestamp) return ''; + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Now'; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + + getStatusText(status: string | undefined): string { + if (!status) return 'Offline'; + switch (status) { + case 'ONLINE': + return 'Online'; + case 'BUSY': + return 'Busy'; + case 'OFFLINE': + return 'Offline'; + default: + return status; + } + } + + onChatImageError(event: Event): void { + const img = event.target as HTMLImageElement; + img.style.display = 'none'; + const avatarCircle = img.nextElementSibling as HTMLElement; + if (avatarCircle) { + avatarCircle.style.display = 'flex'; + } + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.html b/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.html new file mode 100644 index 0000000..74a8227 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.html @@ -0,0 +1,103 @@ + + diff --git a/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.scss b/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.scss new file mode 100644 index 0000000..d62bf58 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.scss @@ -0,0 +1,87 @@ +$primary-blue: #1b64f2; +$muted: #6b7280; +$border: #e5e7eb; +$surface: #fff; +$shadow: 0 15px 40px rgba(15, 23, 42, 0.08); + +:host { + display: block; +} + +.dashboard-tabs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; + padding: 1.25rem 2rem 0; + max-width: 1400px; + margin: 0 auto; + + @media (max-width: 768px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 1rem; + } +} + +.tab-button { + border: 1px solid $border; + border-radius: 16px; + background: $surface; + padding: 0.8rem 1rem; + font-weight: 600; + font-size: 0.95rem; + color: $muted; + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05); + + .icon { + display: inline-flex; + padding: 0.3rem; + border-radius: 10px; + background: rgba(27, 100, 242, 0.08); + color: $primary-blue; + + svg { + width: 18px; + height: 18px; + } + } + + &:hover { + border-color: rgba(27, 100, 242, 0.5); + color: $primary-blue; + box-shadow: $shadow; + } + + &.active { + border-color: transparent; + background: linear-gradient(135deg, #1b64f2, #6a8bff); + color: #fff; + box-shadow: $shadow; + + .icon { + background: rgba(255, 255, 255, 0.2); + color: #fff; + } + } +} + +.tab-badge { + position: absolute; + top: -8px; + right: -6px; + min-width: 26px; + padding: 0.1rem 0.35rem; + border-radius: 999px; + font-size: 0.75rem; + background: #f97316; + color: #fff; + text-align: center; + font-weight: 700; +} + diff --git a/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.ts b/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.ts new file mode 100644 index 0000000..992001d --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-dashboard-tabs/patient-dashboard-tabs.component.ts @@ -0,0 +1,27 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-patient-dashboard-tabs', + standalone: true, + imports: [CommonModule], + templateUrl: './patient-dashboard-tabs.component.html', + styleUrls: ['./patient-dashboard-tabs.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientDashboardTabsComponent { + @Input() activeTab: string = 'overview'; + @Input() totalAppointments = 0; + @Input() doctorsCount = 0; + @Input() prescriptionsCount = 0; + + @Output() tabChange = new EventEmitter(); + + selectTab(tab: string): void { + if (this.activeTab === tab) { + return; + } + this.tabChange.emit(tab); + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.html b/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.html new file mode 100644 index 0000000..e38bd17 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.html @@ -0,0 +1,213 @@ +
+
+
+

Available Doctors

+

Enterprise-verified providers who have collaborated with you

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

Dr. {{ doctor.firstName }} {{ doctor.lastName }}

+

{{ doctor.specialization }}

+
+ + + + + + Verified + +
+
+

+ + + + {{ doctor.yearsOfExperience }} years of experience +

+

+ + + + ${{ doctor.consultationFee || 'N/A' }} consultation fee +

+

{{ doctor.biography }}

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

No Doctors Available

+

There are no verified doctors in the system yet.

+
+ + +
+ diff --git a/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.scss b/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.scss new file mode 100644 index 0000000..973c77c --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.scss @@ -0,0 +1,277 @@ +$surface: #ffffff; +$border: #e2e8f0; +$primary: #1b64f2; +$muted: #6b7280; +$text: #0f172a; +$danger: #e11d48; + +:host { + display: block; +} + +.doctors-section { + margin: 1.5rem auto; + max-width: 1400px; + background: $surface; + border-radius: 24px; + padding: 2rem; + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 25px 55px rgba(15, 23, 42, 0.12); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h2 { + margin: 0; + font-size: 1.4rem; + color: $text; + } + + p { + margin: 0.2rem 0 0; + color: $muted; + } +} + +.doctors-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; +} + +.doctor-card { + border: 1px solid $border; + border-radius: 20px; + background: linear-gradient(160deg, rgba(247, 250, 255, 0.9), #fff); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 15px 30px rgba(15, 23, 42, 0.08); + + &:hover { + border-color: rgba($primary, 0.4); + transform: translateY(-3px); + } +} + +.doctor-header { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.doctor-avatar { + width: 56px; + height: 56px; + border-radius: 16px; + background: rgba($primary, 0.08); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + .avatar-image { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.doctor-info { + flex: 1; +} + +.doctor-name { + margin: 0; + font-size: 1.05rem; + color: $text; +} + +.doctor-specialization { + margin: 0; + color: $muted; + font-size: 0.9rem; +} + +.verified-badge { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.35rem 0.65rem; + border-radius: 999px; + background: rgba(16, 185, 129, 0.15); + color: #059669; + font-size: 0.8rem; + font-weight: 600; +} + +.doctor-details { + color: $muted; + font-size: 0.9rem; + + p { + margin: 0.35rem 0; + display: flex; + align-items: center; + gap: 0.35rem; + } +} + +.doctor-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.view-profile-btn, +.btn-remove { + border-radius: 12px; + padding: 0.6rem 1rem; + border: none; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; +} + +.view-profile-btn { + background: rgba($primary, 0.12); + color: $primary; +} + +.btn-remove { + background: rgba($danger, 0.08); + color: $danger; +} + +.empty-state { + text-align: center; + padding: 2rem; + border-radius: 18px; + border: 2px dashed rgba(148, 163, 184, 0.6); + color: $muted; + + .empty-icon { + width: 48px; + height: 48px; + margin-bottom: 0.5rem; + } +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.65); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1000; +} + +.modal-content { + background: #fff; + border-radius: 24px; + max-width: 900px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + padding: 1.5rem; + box-shadow: 0 40px 80px rgba(15, 23, 42, 0.25); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + + h2 { + margin: 0; + font-size: 1.3rem; + } +} + +.modal-close { + border: none; + background: rgba(15, 23, 42, 0.08); + border-radius: 12px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.profile-full-view { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.profile-section { + border: 1px solid $border; + border-radius: 16px; + padding: 1rem; + + h3 { + margin: 0 0 0.75rem; + color: $text; + } +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.5rem; + border-radius: 10px; + background: rgba(15, 23, 42, 0.03); +} + +.info-label { + font-size: 0.85rem; + color: $muted; +} + +.info-value { + font-weight: 600; + color: $text; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.tag { + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: rgba($primary, 0.1); + color: $primary; + font-weight: 600; + font-size: 0.9rem; +} + +.biography-text { + margin: 0; + color: $muted; + line-height: 1.5; +} + diff --git a/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.ts b/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.ts new file mode 100644 index 0000000..66dc30a --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-doctors-panel/patient-doctors-panel.component.ts @@ -0,0 +1,38 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { UserService } from '../../../../services/user.service'; + +@Component({ + selector: 'app-patient-doctors-panel', + standalone: true, + imports: [CommonModule], + templateUrl: './patient-doctors-panel.component.html', + styleUrls: ['./patient-doctors-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientDoctorsPanelComponent { + @Input() doctors: any[] = []; + @Input() loading = false; + @Input() showDoctorProfileModal = false; + @Input() selectedDoctorProfile: any = null; + + @Output() viewDoctorProfile = new EventEmitter(); + @Output() removeDoctorFromHistory = new EventEmitter(); + @Output() closeDoctorProfileModal = new EventEmitter(); + + constructor(public userService: UserService) {} + + onViewDoctor(doctorId: string): void { + this.viewDoctorProfile.emit(doctorId); + } + + onRemoveDoctor(doctor: any, event: Event): void { + event.stopPropagation(); + this.removeDoctorFromHistory.emit(doctor); + } + + onCloseModal(): void { + this.closeDoctorProfileModal.emit(); + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.html b/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.html new file mode 100644 index 0000000..230cd9e --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.html @@ -0,0 +1,58 @@ +
+
+

Electronic Health Records

+

Clinical data synchronized from your care team

+
+ +
+

Latest Vital Signs

+
+
+ + {{ latestVitalSigns.temperature }}°C +
+
+ + {{ latestVitalSigns.bloodPressureSystolic }}/{{ latestVitalSigns.bloodPressureDiastolic }} mmHg +
+
+ + {{ latestVitalSigns.heartRate }} bpm +
+
+ + {{ latestVitalSigns.bmi | number: '1.1-1' }} +
+
+

Recorded: {{ formatDateFn(latestVitalSigns.recordedAt) }}

+
+ +
+

Medical Records

+
No medical records found
+
+
+ {{ record.recordType }} + {{ formatDateFn(record.createdAt) }} +
+

{{ record.title }}

+

{{ record.content }}

+
ICD-10: {{ record.diagnosisCode }}
+
+
+ +
+

Lab Results

+
No lab results found
+
+
+ {{ lab.testName }} + {{ lab.status }} +
+
{{ lab.resultValue }} {{ lab.unit }}
+
Normal: {{ lab.referenceRange }}
+

Ordered: {{ formatDateFn(lab.orderedDate) }}

+
+
+
+ diff --git a/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.scss b/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.scss new file mode 100644 index 0000000..956c168 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.scss @@ -0,0 +1,150 @@ +$surface: #ffffff; +$border: #e2e8f0; +$primary: #1b64f2; +$muted: #6b7280; +$text: #0f172a; + +:host { + display: block; +} + +.ehr-section { + margin: 1.5rem auto; + max-width: 1400px; + background: $surface; + border-radius: 24px; + padding: 2rem; + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12); + + @media (max-width: 768px) { + padding: 1.5rem; + } +} + +.section-header { + margin-bottom: 1.5rem; + + h2 { + margin: 0; + color: $text; + } + + p { + margin: 0.25rem 0 0; + color: $muted; + } +} + +.card { + border: 1px solid $border; + border-radius: 20px; + padding: 1.5rem; + background: linear-gradient(170deg, rgba(248, 250, 255, 0.7), #fff); + margin-bottom: 1.25rem; + + h3 { + margin: 0 0 1rem; + color: $text; + } +} + +.vital-signs-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +.vital-item { + border-radius: 16px; + padding: 1rem; + background: rgba(27, 100, 242, 0.06); + display: flex; + flex-direction: column; + gap: 0.35rem; + + label { + color: $muted; + font-size: 0.9rem; + } + + span { + font-size: 1.2rem; + font-weight: 700; + color: $primary; + } +} + +.timestamp { + margin-top: 0.75rem; + color: $muted; + font-size: 0.9rem; +} + +.record-item, +.lab-item { + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 16px; + padding: 1rem; + margin-bottom: 0.75rem; + background: #fff; +} + +.record-header, +.lab-header { + display: flex; + justify-content: space-between; + font-size: 0.9rem; + color: $muted; + margin-bottom: 0.5rem; +} + +.record-type { + font-weight: 600; + color: $primary; +} + +.diagnosis-code { + margin-top: 0.5rem; + font-weight: 600; + color: #b45309; +} + +.lab-status { + padding: 0.2rem 0.65rem; + border-radius: 999px; + font-weight: 600; + + &.status-completed { + background: rgba(16, 185, 129, 0.15); + color: #059669; + } + &.status-pending { + background: rgba(234, 179, 8, 0.15); + color: #b45309; + } + &.status-flagged { + background: rgba(248, 113, 113, 0.15); + color: #dc2626; + } +} + +.lab-result { + font-size: 1.2rem; + font-weight: 700; + color: $text; +} + +.lab-reference { + color: $muted; + font-size: 0.9rem; +} + +.empty-state { + padding: 1rem; + border-radius: 14px; + background: rgba(15, 23, 42, 0.05); + color: $muted; + text-align: center; +} + diff --git a/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.ts b/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.ts new file mode 100644 index 0000000..e4551c1 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-ehr-panel/patient-ehr-panel.component.ts @@ -0,0 +1,19 @@ +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MedicalRecord, VitalSigns, LabResult } from '../../../../services/medical-record.service'; + +@Component({ + selector: 'app-patient-ehr-panel', + standalone: true, + imports: [CommonModule], + templateUrl: './patient-ehr-panel.component.html', + styleUrls: ['./patient-ehr-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientEhrPanelComponent { + @Input() latestVitalSigns: VitalSigns | null = null; + @Input() medicalRecords: MedicalRecord[] = []; + @Input() labResults: LabResult[] = []; + @Input() formatDateFn: (value: string) => string = (value: string) => value; +} + diff --git a/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.html b/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.html new file mode 100644 index 0000000..cb1718c --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.html @@ -0,0 +1,103 @@ +
+
+
+

Appointment Intelligence

+

Enterprise-level visibility into your care journey

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

Total Appointments

+

{{ totalAppointments }}

+
+
+
+
+ + + +
+
+

Upcoming

+

{{ upcomingCount }}

+
+
+
+
+ + + +
+
+

Completed

+

{{ completedCount }}

+
+
+
+
+ + + +
+
+

Cancelled

+

{{ cancelledCount }}

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

View Appointments

+

Manage confirmed, pending, and past visits

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

Find Doctors

+

Browse enterprise verified care teams

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

My Profile

+

Keep personal and clinical data current

+
+
+
+
+ diff --git a/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.scss b/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.scss new file mode 100644 index 0000000..267a7fb --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.scss @@ -0,0 +1,185 @@ +@use 'sass:color'; + +$surface: #ffffff; +$bg: #f7f9fc; +$border: #e5e7eb; +$primary: #1b64f2; +$success: #10b981; +$info: #0ea5e9; +$warning: #f97316; +$text: #0f172a; +$muted: #6b7280; + +:host { + display: block; +} + +.overview-panel { + background: $surface; + border-radius: 24px; + padding: 2rem; + margin: 1.5rem auto; + max-width: 1400px; + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 25px 70px rgba(15, 23, 42, 0.12); + + @media (max-width: 768px) { + padding: 1.5rem; + border-radius: 18px; + } +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + + h2 { + margin: 0; + font-size: 1.5rem; + color: $text; + } + + p { + margin: 0.25rem 0 0; + color: $muted; + } + + .cta { + border: none; + border-radius: 999px; + padding: 0.75rem 1.25rem; + background: rgba(16, 185, 129, 0.12); + color: $success; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + transition: all 0.2s ease; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: rgba(16, 185, 129, 0.2); + } + } + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + } +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + border-radius: 18px; + padding: 1.25rem; + background: $bg; + display: flex; + align-items: center; + gap: 1rem; + border: 1px solid rgba(226, 232, 240, 0.9); + + .stat-icon { + width: 52px; + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.05); + } + + .stat-content { + p { + margin: 0; + color: $muted; + } + + h3 { + margin: 0.2rem 0 0; + font-size: 1.75rem; + color: $text; + } + } + + &.primary .stat-icon { + background: rgba($primary, 0.12); + color: $primary; + } + + &.success .stat-icon { + background: rgba($success, 0.12); + color: $success; + } + + &.info .stat-icon { + background: rgba($info, 0.12); + color: $info; + } + + &.warning .stat-icon { + background: rgba($warning, 0.12); + color: $warning; + } +} + +.quick-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; +} + +.action-card { + border-radius: 18px; + border: 1px solid $border; + padding: 1rem 1.25rem; + display: flex; + gap: 0.75rem; + align-items: center; + cursor: pointer; + transition: all 0.2s ease; + background: $surface; + + &:hover { + border-color: rgba($primary, 0.4); + box-shadow: 0 20px 35px rgba(15, 23, 42, 0.1); + transform: translateY(-3px); + } + + .action-icon { + width: 44px; + height: 44px; + border-radius: 14px; + background: rgba($primary, 0.1); + color: $primary; + display: flex; + align-items: center; + justify-content: center; + } + + h4 { + margin: 0; + font-size: 1rem; + color: $text; + } + + p { + margin: 0.15rem 0 0; + color: $muted; + font-size: 0.9rem; + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.ts b/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.ts new file mode 100644 index 0000000..5955536 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-overview-panel/patient-overview-panel.component.ts @@ -0,0 +1,24 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-patient-overview-panel', + standalone: true, + imports: [CommonModule], + templateUrl: './patient-overview-panel.component.html', + styleUrls: ['./patient-overview-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientOverviewPanelComponent { + @Input() totalAppointments = 0; + @Input() upcomingCount = 0; + @Input() completedCount = 0; + @Input() cancelledCount = 0; + + @Output() quickNav = new EventEmitter(); + + goTo(tab: string): void { + this.quickNav.emit(tab); + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.html b/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.html new file mode 100644 index 0000000..7a888c7 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.html @@ -0,0 +1,31 @@ +
+
+

Prescriptions

+

Medication orders managed by your clinical team

+
+ +
No prescriptions found
+ +
+
+
+

{{ prescription.medicationName }}

+

#{{ prescription.prescriptionNumber }}

+
+ + {{ prescription.status }} + +
+
+

Dosage: {{ prescription.dosage }}

+

Frequency: {{ prescription.frequency }}

+

Quantity: {{ prescription.quantity }}

+

Refills: {{ prescription.refills }}

+

Instructions: {{ prescription.instructions }}

+

Start Date: {{ formatDateFn(prescription.startDate) }}

+

End Date: {{ formatDateFn(prescription.endDate) }}

+

Pharmacy: {{ prescription.pharmacyName }}

+
+
+
+ diff --git a/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.scss b/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.scss new file mode 100644 index 0000000..a33c05c --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.scss @@ -0,0 +1,108 @@ +$surface: #ffffff; +$border: #e2e8f0; +$primary: #1b64f2; +$muted: #6b7280; +$text: #0f172a; + +:host { + display: block; +} + +.prescriptions-section { + margin: 1.5rem auto; + max-width: 1400px; + background: $surface; + border-radius: 24px; + padding: 2rem; + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 15px 45px rgba(15, 23, 42, 0.1); +} + +.section-header { + margin-bottom: 1.5rem; + + h2 { + margin: 0; + color: $text; + } + + p { + margin: 0.25rem 0 0; + color: $muted; + } +} + +.empty-state { + text-align: center; + padding: 1.5rem; + border-radius: 16px; + background: rgba(15, 23, 42, 0.05); + color: $muted; +} + +.prescription-card { + border: 1px solid $border; + border-radius: 18px; + padding: 1.25rem; + margin-bottom: 1rem; + background: linear-gradient(160deg, rgba(247, 250, 255, 0.6), #fff); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); +} + +.prescription-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + + h3 { + margin: 0; + color: $text; + } + + .prescription-number { + margin: 0.25rem 0 0; + color: $muted; + font-size: 0.9rem; + } +} + +.prescription-status { + padding: 0.3rem 0.8rem; + border-radius: 999px; + font-weight: 600; + + &.status-active { + background: rgba(16, 185, 129, 0.15); + color: #059669; + } + &.status-expired { + background: rgba(248, 113, 113, 0.2); + color: #dc2626; + } + &.status-pending { + background: rgba(234, 179, 8, 0.2); + color: #b45309; + } +} + +.prescription-details { + margin-top: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; + + p { + margin: 0; + color: $text; + font-size: 0.95rem; + + strong { + color: $muted; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.ts b/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.ts new file mode 100644 index 0000000..74613a4 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-prescriptions-panel/patient-prescriptions-panel.component.ts @@ -0,0 +1,17 @@ +import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Prescription } from '../../../../services/prescription.service'; + +@Component({ + selector: 'app-patient-prescriptions-panel', + standalone: true, + imports: [CommonModule], + templateUrl: './patient-prescriptions-panel.component.html', + styleUrls: ['./patient-prescriptions-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientPrescriptionsPanelComponent { + @Input() prescriptions: Prescription[] = []; + @Input() formatDateFn: (value: string) => string = (value: string) => value; +} + diff --git a/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.html b/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.html new file mode 100644 index 0000000..b5316e5 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.html @@ -0,0 +1,404 @@ +
+
+
+

My Profile

+

View and manage your personal and medical information

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

{{ currentUser?.firstName }} {{ currentUser?.lastName }}

+

{{ currentUser?.email }}

+
+
+
+
+ Phone Number + {{ currentUser?.phoneNumber || patientProfile?.emergencyContactPhone || 'N/A' }} +
+
+ Blood Type + {{ patientInfo.bloodType || 'Not specified' }} +
+
+ Allergies + + + {{ patientInfo.allergies.join(', ') }} + + None + +
+
+ Emergency Contact + + + {{ patientInfo.emergencyContactName }} + - {{ patientInfo.emergencyContactPhone }} + + Not specified + +
+
+ Account Status + + + {{ currentUser?.isActive ? 'Active' : 'Inactive' }} + + +
+
+
+ +
+
+

+ + + + + Edit Profile +

+

Update your personal and medical information

+
+ +
+
+
+

+ + + + + Basic Information +

+

Your personal contact details

+
+
+
+ + + 2-50 characters, letters only +
+
+ + + 2-50 characters, letters only +
+
+ + + 10-15 digits, can include country code with + +
+
+
+ +
+
+

+ + + + Medical Information +

+

Important health details for emergency situations

+
+ +
+
+ + + Required for medical emergencies +
+
+ + + 2-50 characters +
+
+ + + 10-15 digits, can include country code with + +
+
+ +
+
+ + +
+ Add known allergies for medical safety +
+
+ {{ allergy }} + +
+
+
+ + + + + No allergies added yet +
+
+
+
+
+ + +
+
+

+ + + + + Additional Information +

+

Complete your profile for better care

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
Insurance Information
+
+
+ + +
+
+ + +
+
+
Primary Care Physician
+
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+
+ + +
+ Add all medications you're currently taking +
+
+ {{ medication }} + +
+
+
+ + + + + No medications added yet +
+
+
+
+
+ +
+ + +
+
+
+
+ diff --git a/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.scss b/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.scss new file mode 100644 index 0000000..e05bc91 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.scss @@ -0,0 +1,372 @@ +$surface: #ffffff; +$border: #e2e8f0; +$primary: #1b64f2; +$danger: #e11d48; +$text: #0f172a; +$muted: #6b7280; + +:host { + display: block; +} + +.profile-section { + margin: 1.5rem auto; + max-width: 1400px; + background: $surface; + border-radius: 24px; + padding: 2rem; + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 25px 55px rgba(15, 23, 42, 0.12); +} + +.profile-header-section { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + } +} + +.section-title { + margin: 0; + font-size: 1.5rem; + color: $text; +} + +.section-description { + margin: 0.25rem 0 0; + color: $muted; +} + +.edit-profile-btn { + border: 1px solid rgba($primary, 0.4); + border-radius: 14px; + padding: 0.75rem 1.25rem; + background: rgba($primary, 0.08); + color: $primary; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; +} + +.profile-card { + border: 1px solid $border; + border-radius: 22px; + padding: 1.5rem; + background: linear-gradient(135deg, rgba(248, 250, 255, 0.9), #fff); + margin-bottom: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.profile-header { + display: flex; + align-items: center; + gap: 1.5rem; + + @media (max-width: 640px) { + flex-direction: column; + align-items: flex-start; + } +} + +.profile-avatar-wrapper { + position: relative; +} + +.profile-avatar { + width: 96px; + height: 96px; + border-radius: 24px; + background: rgba($primary, 0.08); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + .avatar-image-large { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.avatar-upload-btn { + position: absolute; + bottom: -6px; + right: -6px; + width: 38px; + height: 38px; + border-radius: 50%; + border: none; + background: $primary; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 10px 20px rgba(27, 100, 242, 0.2); +} + +.profile-name { + h3 { + margin: 0; + font-size: 1.4rem; + color: $text; + } + + p { + margin: 0.15rem 0 0; + color: $muted; + } +} + +.profile-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.detail-row { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem; + border-radius: 16px; + background: rgba(15, 23, 42, 0.04); +} + +.detail-label { + font-size: 0.85rem; + color: $muted; +} + +.detail-value { + font-weight: 600; + color: $text; +} + +.status-badge { + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-weight: 600; + + &.status-confirmed { + background: rgba(16, 185, 129, 0.15); + color: #059669; + } + + &.status-cancelled { + background: rgba(248, 113, 113, 0.2); + color: #dc2626; + } +} + +.edit-profile-form { + border: 1px solid $border; + border-radius: 22px; + padding: 1.5rem; + background: #fff; +} + +.form-header { + margin-bottom: 1rem; +} + +.form-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: $text; +} + +.form-subtitle { + margin: 0.25rem 0 0; + color: $muted; +} + +.profile-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-section { + border: 1px solid #edf0f7; + border-radius: 18px; + padding: 1.25rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; + + h4 { + margin: 0; + font-size: 1.1rem; + color: $text; + } + + p { + margin: 0.2rem 0 0; + color: $muted; + } + + @media (max-width: 640px) { + flex-direction: column; + } +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.3rem; + + &.form-group-full { + grid-column: 1 / -1; + } +} + +.form-label { + font-weight: 600; + color: $text; +} + +.form-input, +.form-select, +.form-textarea { + border: 1px solid $border; + border-radius: 14px; + padding: 0.8rem; + font-size: 1rem; + background: #fff; + transition: border-color 0.2s ease; + + &:focus { + border-color: $primary; + outline: none; + box-shadow: 0 0 0 3px rgba(27, 100, 242, 0.1); + } +} + +.form-textarea { + resize: vertical; +} + +.form-hint { + font-size: 0.85rem; + color: $muted; +} + +.allergies-input-container { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.allergies-input { + display: flex; + gap: 0.5rem; +} + +.add-btn { + border: none; + border-radius: 12px; + padding: 0 0.9rem; + background: rgba($primary, 0.12); + color: $primary; + cursor: pointer; +} + +.allergies-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.allergy-tag { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.08); +} + +.remove-allergy { + border: none; + background: transparent; + color: $danger; + cursor: pointer; +} + +.allergies-empty { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem; + border-radius: 12px; + background: rgba(15, 23, 42, 0.05); + color: $muted; +} + +.textarea-footer { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + color: $muted; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.cancel-btn, +.submit-button { + border-radius: 14px; + padding: 0.75rem 1.5rem; + border: none; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; +} + +.cancel-btn { + background: rgba(15, 23, 42, 0.08); + color: $text; +} + +.submit-button { + background: linear-gradient(135deg, #1b64f2, #7b5bff); + color: #fff; + box-shadow: 0 15px 30px rgba(27, 100, 242, 0.2); + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.ts b/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.ts new file mode 100644 index 0000000..ddea660 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-profile-panel/patient-profile-panel.component.ts @@ -0,0 +1,74 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { UserInfo, PatientProfile, PatientUpdateRequest } from '../../../../services/user.service'; +import { UserService } from '../../../../services/user.service'; + +@Component({ + selector: 'app-patient-profile-panel', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './patient-profile-panel.component.html', + styleUrls: ['./patient-profile-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientProfilePanelComponent { + @Input() currentUser: UserInfo | null = null; + @Input() patientInfo: any = null; + @Input() patientProfile: PatientProfile | null = null; + @Input() showEditProfile = false; + @Input() editUserData: { firstName: string; lastName: string; phoneNumber: string } = { + firstName: '', + lastName: '', + phoneNumber: '' + }; + @Input() editProfileData: PatientUpdateRequest | null = null; + @Input() newAllergy = ''; + @Input() newMedication = ''; + + @Output() editProfile = new EventEmitter(); + @Output() cancelEdit = new EventEmitter(); + @Output() saveProfile = new EventEmitter(); + @Output() addAllergy = new EventEmitter(); + @Output() removeAllergy = new EventEmitter(); + @Output() addMedication = new EventEmitter(); + @Output() removeMedication = new EventEmitter(); + @Output() avatarSelected = new EventEmitter(); + @Output() newAllergyChange = new EventEmitter(); + @Output() newMedicationChange = new EventEmitter(); + + constructor(public userService: UserService) {} + + onAvatarChange(event: Event): void { + this.avatarSelected.emit(event); + } + + onStartEdit(): void { + this.editProfile.emit(); + } + + onCancelEdit(): void { + this.cancelEdit.emit(); + } + + onSaveProfile(): void { + this.saveProfile.emit(); + } + + onAddAllergy(): void { + this.addAllergy.emit(); + } + + onRemoveAllergy(index: number): void { + this.removeAllergy.emit(index); + } + + onAddMedication(): void { + this.addMedication.emit(); + } + + onRemoveMedication(index: number): void { + this.removeMedication.emit(index); + } +} + diff --git a/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.html b/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.html new file mode 100644 index 0000000..7362863 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.html @@ -0,0 +1,75 @@ +
+
+

Two-Factor Authentication

+

Protect your account with enterprise-grade MFA

+
+ +
+
+

+ 2FA Status: + + {{ twoFAEnabled ? 'Enabled' : 'Disabled' }} + +

+

+ You have {{ twoFAStatus.backupCodesCount }} backup codes +

+
+

Two-factor authentication adds an extra layer of security to your account.

+
+ + +
+
+ + +
+ diff --git a/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.scss b/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.scss new file mode 100644 index 0000000..7ba93d8 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.scss @@ -0,0 +1,207 @@ +$surface: #ffffff; +$border: #e2e8f0; +$primary: #1b64f2; +$danger: #e11d48; +$text: #0f172a; +$muted: #6b7280; + +:host { + display: block; +} + +.security-section { + margin: 1.5rem auto; + max-width: 900px; + background: $surface; + border-radius: 24px; + padding: 2rem; + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12); +} + +.section-header { + margin-bottom: 1.25rem; + + h2 { + margin: 0; + color: $text; + } + + p { + margin: 0.25rem 0 0; + color: $muted; + } +} + +.card { + border: 1px solid $border; + border-radius: 20px; + padding: 1.5rem; + background: linear-gradient(160deg, rgba(247, 250, 255, 0.8), #fff); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); +} + +.security-status { + p { + margin: 0.2rem 0; + color: $text; + } + + .status-enabled { + color: #059669; + } + + .status-disabled { + color: $danger; + } +} + +.info-text { + margin: 1rem 0; + color: $muted; +} + +.security-actions { + display: flex; + gap: 0.75rem; +} + +.primary-button, +.danger-button, +.secondary-button { + border-radius: 14px; + padding: 0.75rem 1.5rem; + border: none; + font-weight: 600; + cursor: pointer; +} + +.primary-button { + background: linear-gradient(135deg, #1b64f2, #7b5bff); + color: #fff; + box-shadow: 0 15px 25px rgba(27, 100, 242, 0.25); +} + +.danger-button { + background: rgba($danger, 0.12); + color: $danger; +} + +.secondary-button { + background: rgba(15, 23, 42, 0.07); + color: $text; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.7); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1000; +} + +.modal-content { + background: #fff; + border-radius: 24px; + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + padding: 1.5rem; + box-shadow: 0 40px 80px rgba(15, 23, 42, 0.25); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + + h2 { + margin: 0; + } +} + +.modal-close { + border: none; + background: rgba(15, 23, 42, 0.08); + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; +} + +.modal-instructions { + margin: 1rem 0; + color: $muted; +} + +.qr-code-container { + display: flex; + justify-content: center; + margin-bottom: 1rem; + + .qr-code-image { + width: 200px; + height: 200px; + border: 1px solid $border; + border-radius: 12px; + padding: 0.75rem; + background: #fff; + } +} + +.secret-key-container { + background: rgba(27, 100, 242, 0.08); + border-radius: 14px; + padding: 0.75rem; + margin-bottom: 1rem; + + .secret-key { + display: inline-block; + font-weight: 700; + letter-spacing: 0.2rem; + } +} + +.code-input-container { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; + + .code-input { + border: 1px solid $border; + border-radius: 12px; + padding: 0.65rem; + font-size: 1.2rem; + text-align: center; + letter-spacing: 0.4rem; + } +} + +.backup-codes-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.5rem; + + code { + padding: 0.5rem; + border-radius: 10px; + background: rgba(15, 23, 42, 0.08); + text-align: center; + font-weight: 700; + } +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1rem; +} + diff --git a/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.ts b/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.ts new file mode 100644 index 0000000..5cb57e9 --- /dev/null +++ b/frontend/src/app/pages/patient/components/patient-security-panel/patient-security-panel.component.ts @@ -0,0 +1,49 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-patient-security-panel', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './patient-security-panel.component.html', + styleUrls: ['./patient-security-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PatientSecurityPanelComponent { + @Input() twoFAEnabled = false; + @Input() twoFAStatus: any = null; + @Input() show2FAModal = false; + @Input() qrCodeUrl = ''; + @Input() twoFASecretKey = ''; + @Input() twoFABackupCodes: string[] = []; + @Input() twoFACodeInput = ''; + + @Output() twoFACodeInputChange = new EventEmitter(); + @Output() setup2FA = new EventEmitter(); + @Output() disable2FA = new EventEmitter(); + @Output() close2FAModal = new EventEmitter(); + @Output() verifyAndEnable2FA = new EventEmitter(); + + onSetup(): void { + this.setup2FA.emit(); + } + + onDisable(): void { + this.disable2FA.emit(); + } + + onCloseModal(): void { + this.close2FAModal.emit(); + } + + onVerify(): void { + this.verifyAndEnable2FA.emit(); + } + + onCodeChange(value: string): void { + this.twoFACodeInput = value; + this.twoFACodeInputChange.emit(value); + } +} + diff --git a/frontend/src/app/pages/patient/patient.component.html b/frontend/src/app/pages/patient/patient.component.html new file mode 100644 index 0000000..26b022e --- /dev/null +++ b/frontend/src/app/pages/patient/patient.component.html @@ -0,0 +1,223 @@ +
+ + +
+
+
+ + + + +
+

Loading enterprise dashboard...

+
+ +
+
+ + + + +
+

We hit a snag

+

{{ error }}

+ +
+
+
+ + + + +
+ + + + + + + + + + + + + +
+
+
+ + + +
+
+
+ + + + Messages + {{ chatUnreadCount }} +
+ +
+ + + +
+ + +
+
+ +
+
+
diff --git a/frontend/src/app/pages/patient/patient.component.scss b/frontend/src/app/pages/patient/patient.component.scss new file mode 100644 index 0000000..967f789 --- /dev/null +++ b/frontend/src/app/pages/patient/patient.component.scss @@ -0,0 +1,284 @@ +$background: #eef2ff; +$surface: #ffffff; +$text-dark: #0f172a; +$text-muted: #6b7280; +$border: rgba(148, 163, 184, 0.4); + +:host { + display: block; + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + color: $text-dark; +} + +.dashboard-wrapper { + min-height: 100vh; + background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 35%, #ffffff 100%); + display: flex; + flex-direction: column; +} + +.dashboard-main { + flex: 1; + padding-bottom: 4rem; +} + +.loading-container, +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 1rem; + text-align: center; +} + +.spinner-wrapper { + margin-bottom: 1rem; +} + +.spinner { + width: 48px; + height: 48px; + color: #4f46e5; +} + +.loading-text { + color: $text-muted; +} + +.error-card { + background: $surface; + border-radius: 24px; + padding: 2rem; + border: 1px solid $border; + box-shadow: 0 25px 60px rgba(15, 23, 42, 0.12); + max-width: 520px; +} + +.error-icon { + width: 48px; + height: 48px; + margin-bottom: 1rem; + color: #ef4444; +} + +.retry-button { + margin-top: 1.5rem; + border: none; + border-radius: 14px; + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, #1b64f2, #7b5bff); + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.tab-shell { + max-width: 1400px; + margin: 1.5rem auto 0; + padding: 0 1.5rem; +} + +.tab-panel { + background: $surface; + border-radius: 24px; + padding: 1.5rem; + border: 1px solid $border; + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08); +} + +@media (max-width: 768px) { + .tab-shell { + padding: 0 1rem; + } + + .tab-panel { + border-radius: 18px; + padding: 1rem; + } +} + +// ============================================================================ +// Facebook-style Chat Widget +// ============================================================================ + +.chat-widget-container { + position: fixed; + bottom: 90px; + right: 20px; + width: 420px; + height: 650px; + max-height: calc(100vh - 120px); + background: var(--bg-primary, #ffffff); + border-radius: var(--radius-xl, 24px); + box-shadow: var(--shadow-2xl, 0 30px 60px rgba(15, 23, 42, 0.25)); + border: 1px solid var(--color-gray-200, rgba(148, 163, 184, 0.4)); + display: flex; + flex-direction: column; + z-index: 10002; + overflow: hidden; + animation: slideUp 0.3s ease-out; + + &.hidden { + display: none !important; + visibility: hidden; + pointer-events: none; + } + + @media (max-height: 800px) { + height: calc(100vh - 140px); + max-height: calc(100vh - 140px); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-widget-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + background: linear-gradient(135deg, var(--color-primary, #2563eb) 0%, var(--color-accent, #8b5cf6) 100%); + color: var(--text-inverse, #ffffff); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + gap: 1rem; +} + +.chat-widget-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + + svg { + width: 20px; + height: 20px; + } +} + +.chat-widget-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: var(--text-inverse, #ffffff); + color: var(--color-primary-dark, #1d4ed8); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; +} + +.chat-widget-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.chat-action-btn { + width: 40px; + height: 40px; + border: none; + background: rgba(255, 255, 255, 0.25); + border-radius: 14px; + color: var(--text-inverse, #ffffff); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + border: 1.5px solid rgba(255, 255, 255, 0.35); + backdrop-filter: blur(10px); + + svg { + width: 20px; + height: 20px; + stroke: currentColor; + stroke-width: 2.5; + } + + &:hover:not(.disabled):not(:disabled) { + transform: translateY(-1px) scale(1.05); + background: rgba(255, 255, 255, 0.4); + } + + &.disabled, + &:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + background: rgba(255, 255, 255, 0.15); + } + + &.delete-btn { + background: rgba(239, 68, 68, 0.25); + border-color: rgba(239, 68, 68, 0.4); + } +} + +.chat-widget-close { + width: 32px; + height: 32px; + border: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + color: var(--text-inverse, #ffffff); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + background: rgba(255, 255, 255, 0.3); + } +} + +.chat-widget-content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + + app-chat { + flex: 1; + display: flex; + flex-direction: column; + + .chat-container { + flex: 1; + display: flex; + flex-direction: column; + border: none; + box-shadow: none; + border-radius: 0; + } + + .chat-sidebar, + .chat-main { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + + .conversation-list, + .messages-container { + flex: 1; + min-height: 0; + overflow-y: auto; + } + } +} diff --git a/frontend/src/app/pages/patient/patient.component.ts b/frontend/src/app/pages/patient/patient.component.ts new file mode 100644 index 0000000..6335ddb --- /dev/null +++ b/frontend/src/app/pages/patient/patient.component.ts @@ -0,0 +1,1251 @@ +import { Component, OnInit, HostListener, ViewChild, AfterViewInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { AppointmentService, Appointment } from '../../services/appointment.service'; +import { UserService, UserInfo, PatientProfile, PatientUpdateRequest } from '../../services/user.service'; +import { ChatComponent } from '../../components/chat/chat.component'; +import { ChatService, ChatUser, Conversation } from '../../services/chat.service'; +import { NotificationService, Notification } from '../../services/notification.service'; +import { CallComponent } from '../../components/call/call.component'; +import { MedicalRecordService, MedicalRecord, VitalSigns, LabResult } from '../../services/medical-record.service'; +import { PrescriptionService, Prescription } from '../../services/prescription.service'; +import { TwoFactorAuthService } from '../../services/two-factor-auth.service'; +import { LoggerService } from '../../services/logger.service'; +import { ModalService } from '../../services/modal.service'; +import { PatientDashboardHeaderComponent } from './components/patient-dashboard-header/patient-dashboard-header.component'; +import { PatientDashboardTabsComponent } from './components/patient-dashboard-tabs/patient-dashboard-tabs.component'; +import { PatientOverviewPanelComponent } from './components/patient-overview-panel/patient-overview-panel.component'; +import { PatientAppointmentsPanelComponent } from './components/patient-appointments-panel/patient-appointments-panel.component'; +import { PatientProfilePanelComponent } from './components/patient-profile-panel/patient-profile-panel.component'; +import { PatientDoctorsPanelComponent } from './components/patient-doctors-panel/patient-doctors-panel.component'; +import { PatientEhrPanelComponent } from './components/patient-ehr-panel/patient-ehr-panel.component'; +import { PatientPrescriptionsPanelComponent } from './components/patient-prescriptions-panel/patient-prescriptions-panel.component'; +import { PatientSecurityPanelComponent } from './components/patient-security-panel/patient-security-panel.component'; + +@Component({ + selector: 'app-patient', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ChatComponent, + CallComponent, + PatientDashboardHeaderComponent, + PatientDashboardTabsComponent, + PatientOverviewPanelComponent, + PatientAppointmentsPanelComponent, + PatientProfilePanelComponent, + PatientDoctorsPanelComponent, + PatientEhrPanelComponent, + PatientPrescriptionsPanelComponent, + PatientSecurityPanelComponent + ], + templateUrl: './patient.component.html', + styleUrl: './patient.component.scss' +}) +export class PatientComponent implements OnInit, AfterViewInit { + @ViewChild(ChatComponent) chatComponent?: ChatComponent; + + appointments: Appointment[] = []; + doctors: any[] = []; + patientInfo: any = null; + patientProfile: PatientProfile | null = null; + loading = false; + error: string | null = null; + currentUser: UserInfo | null = null; + activeTab: string = 'overview'; + patientId: string | null = null; + showEditProfile = false; + editUserData: any = { + firstName: '', + lastName: '', + phoneNumber: '' + }; + editProfileData: PatientUpdateRequest = { + emergencyContactName: '', + emergencyContactPhone: '', + bloodType: '', + allergies: [], + // Enterprise fields + dateOfBirth: '', + gender: '', + streetAddress: '', + city: '', + state: '', + zipCode: '', + country: '', + insuranceProvider: '', + insurancePolicyNumber: '', + medicalHistorySummary: '', + currentMedications: [], + primaryCarePhysicianName: '', + primaryCarePhysicianPhone: '', + preferredLanguage: '', + occupation: '', + maritalStatus: '' + }; + newAllergy: string = ''; + newMedication: string = ''; + + newAppointment = { + doctorId: '', + scheduledDate: '', + scheduledTime: '' + // durationInMinutes is not needed - always defaults to 30 for patients + }; + showRequestForm = false; + availableSlots: string[] = []; + loadingSlots = false; + selectedDate: string = ''; + + chatUnreadCount: number = 0; + showChatWidget = false; + showChatMenu = false; + chatSearchQuery: string = ''; + chatSearchResults: ChatUser[] = []; + filteredConversations: Conversation[] = []; + conversations: Conversation[] = []; + showBlockedUsers = false; + blockedUsers: ChatUser[] = []; + isLoadingBlockedUsers = false; + private searchTimeout: any = null; + notifications: Notification[] = []; + notificationCount: number = 0; + showNotifications: boolean = false; + + // New features data + medicalRecords: MedicalRecord[] = []; + vitalSigns: VitalSigns[] = []; + labResults: LabResult[] = []; + prescriptions: Prescription[] = []; + latestVitalSigns: VitalSigns | null = null; + twoFAEnabled = false; + twoFAStatus: any = null; + show2FAModal = false; + qrCodeUrl: string = ''; + twoFASecretKey: string = ''; + twoFABackupCodes: string[] = []; + twoFACodeInput: string = ''; + + constructor( + private appointmentService: AppointmentService, + public userService: UserService, + private auth: AuthService, + private router: Router, + private chatService: ChatService, + private notificationService: NotificationService, + private medicalRecordService: MedicalRecordService, + private prescriptionService: PrescriptionService, + private twoFactorAuthService: TwoFactorAuthService, + private modalService: ModalService, + private logger: LoggerService + ) {} + + async ngOnInit() { + await this.loadUserAndAppointments(); + + // Connect to chat service and initialize notifications + await this.chatService.connect(); + await this.notificationService.refreshUserId(); + + // Load conversations immediately so notification service can detect unread messages + await this.chatService.getConversations(); + + this.chatService.conversations$.subscribe((conversations: Conversation[]) => { + this.conversations = conversations; + this.chatUnreadCount = conversations.reduce((sum, c) => sum + c.unreadCount, 0); + if (this.chatSearchQuery) { + const queryLower = this.chatSearchQuery.toLowerCase(); + this.filteredConversations = conversations.filter(conv => + conv.otherUserName?.toLowerCase().includes(queryLower) + ); + } + }); + + // Subscribe to notifications - Enterprise notification management + // Business Logic: + // 1. Show only message and missed-call notifications (relevant to user) + // 2. Hide message notifications from active conversation (user is already viewing that chat) + // 3. Always show missed-call notifications (high priority, requires attention) + // 4. Show all other message notifications (from users not currently chatting) + this.notificationService.notifications$.subscribe(notifications => { + // Get all notifications first + const allNotifications = notifications; + + // Filter to only show message and missed-call notifications + const relevantNotifications = allNotifications.filter(n => + n.type === 'message' || n.type === 'missed-call' + ); + + // Apply enterprise filtering: exclude notifications from active conversation + // This prevents duplicate notifications when user is actively viewing a chat + this.notifications = relevantNotifications.filter(n => { + // Missed calls are always shown (high priority) + if (n.type === 'missed-call') { + return true; + } + + // Message notifications: hide if from active conversation + if (n.type === 'message') { + // Use enterprise method to check if notification is from active conversation + return !this.notificationService.isNotificationFromActiveConversation(n); + } + + return false; + }); + + // Calculate unread count (only for displayed notifications) + this.notificationCount = this.notifications.filter(n => !n.read).length; + + this.logger.debug('PatientComponent: Notification management updated', { + totalNotifications: allNotifications.length, + relevantNotifications: relevantNotifications.length, + displayedNotifications: this.notifications.length, + unreadCount: this.notificationCount, + activeConversation: this.notificationService.getActiveConversationUserId() + }); + }); + + // Force check for unread messages after a short delay to ensure notifications are created + setTimeout(async () => { + await this.notificationService.checkForUnreadMessages(); + }, 3000); + } + + ngAfterViewInit(): void { + // ViewChild is available after view init + } + + toggleNotifications(): void { + this.showNotifications = !this.showNotifications; + + // When opening notifications dropdown, mark all visible notifications as read + // This removes the indicator but keeps the notifications + if (this.showNotifications) { + // Mark all unread notifications as read when dropdown is opened + const unreadNotifications = this.notifications.filter(n => !n.read); + unreadNotifications.forEach(notification => { + this.notificationService.markAsRead(notification.id); + }); + } + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target as HTMLElement; + + if (target.closest('.unblock-button-dropdown')) { + return; + } + + if (!target.closest('.notification-container')) { + this.showNotifications = false; + } + if (!target.closest('.chat-menu-container')) { + if (this.showChatMenu) { + this.clearChatSearch(); + } + this.showChatMenu = false; + } + if (!target.closest('.blocked-users-container')) { + this.showBlockedUsers = false; + } + } + + markNotificationAsRead(notificationId: string): void { + this.notificationService.markAsRead(notificationId); + } + + markAllNotificationsAsRead(): void { + this.notificationService.markAllAsRead(); + } + + deleteNotification(notificationId: string, event?: Event): void { + if (event) { + event.stopPropagation(); // Prevent triggering navigation when clicking delete + } + this.notificationService.deleteNotification(notificationId); + } + + deleteAllNotifications(): void { + this.notificationService.deleteAllNotifications(); + } + + async handleNotificationClick(notification: Notification): Promise { + // Mark as read when clicked + if (!notification.read) { + this.notificationService.markAsRead(notification.id); + } + + // Close notification dropdown and open chat widget + this.showNotifications = false; + this.showChatWidget = true; + this.showChatMenu = false; + + // If notification has actionUrl with userId, try to open that conversation + if (notification.actionUrl) { + const urlParams = new URLSearchParams(notification.actionUrl.split('?')[1]); + const userId = urlParams.get('userId'); + if (userId) { + // Small delay to ensure tab switch happens first, then open conversation + setTimeout(async () => { + // Try to get chat component reference (it might not be available if tab isn't active) + let attempts = 0; + const maxAttempts = 10; + const checkChatComponent = setInterval(async () => { + attempts++; + if (this.chatComponent) { + clearInterval(checkChatComponent); + await this.chatComponent.openConversation(userId); + } else if (attempts >= maxAttempts) { + clearInterval(checkChatComponent); + this.logger.warn('ChatComponent not available after switching to chat tab'); + } + }, 100); + + // Stop checking after 2 seconds + setTimeout(() => clearInterval(checkChatComponent), 2000); + }, 300); + } + } + } + + getNotificationTime(timestamp: Date): string { + const now = new Date(); + const diffMs = now.getTime() - timestamp.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; + + return timestamp.toLocaleDateString(); + } + + toggleChatWidget(): void { + this.showChatWidget = !this.showChatWidget; + if (!this.showChatWidget) { + this.showChatMenu = false; + } + } + + toggleChatMenu(): void { + this.showChatMenu = !this.showChatMenu; + if (this.showChatMenu) { + this.showBlockedUsers = false; + if (this.chatComponent) { + this.chatComponent.refreshConversations(); + } + } else { + this.clearChatSearch(); + } + } + + toggleBlockedUsers(event?: Event): void { + event?.stopPropagation(); + this.showBlockedUsers = !this.showBlockedUsers; + if (this.showBlockedUsers) { + this.loadBlockedUsers(); + this.showChatMenu = false; + } + } + + async loadBlockedUsers(): Promise { + this.isLoadingBlockedUsers = true; + try { + this.blockedUsers = await this.chatService.getBlockedUsersWithDetails(); + } catch (error) { + this.logger.error('Error loading blocked users:', error); + this.blockedUsers = []; + } finally { + this.isLoadingBlockedUsers = false; + } + } + + async unblockUser(user: ChatUser): Promise { + try { + const userId = String(user.userId).trim(); + if (!userId) { + throw new Error('Invalid user ID'); + } + await this.chatService.unblockUser(userId); + this.blockedUsers = this.blockedUsers.filter(u => u.userId !== user.userId); + if (this.chatComponent) { + await this.chatComponent.refreshConversations(); + } + await this.modalService.alert( + `${user.firstName} ${user.lastName} has been unblocked successfully.`, + 'success', + 'User Unblocked' + ); + } catch (error: any) { + this.logger.error('Error unblocking user:', error); + await this.modalService.alert( + error?.response?.data?.error || 'Failed to unblock user', + 'error', + 'Unblock Error' + ); + } + } + + async openChatConversation(otherUserId: string): Promise { + if (!this.chatComponent) { + this.logger.warn('ChatComponent not available'); + return; + } + const conversation = this.chatComponent.conversations.find(c => c.otherUserId === otherUserId); + if (conversation) { + await this.chatComponent.selectConversation(conversation); + this.showChatMenu = false; + this.showChatWidget = true; + } + } + + async openChatWithUser(userId: string): Promise { + if (!this.chatComponent) { + this.logger.warn('ChatComponent not available'); + return; + } + this.showChatWidget = true; + this.showChatMenu = false; + + const existingConversation = this.chatComponent.conversations.find(c => c.otherUserId === userId); + if (existingConversation) { + await this.chatComponent.selectConversation(existingConversation); + } else { + await this.chatComponent.openConversation(userId); + } + this.clearChatSearch(); + } + + onChatSearch(query: string): void { + const trimmedQuery = query.trim(); + this.chatSearchQuery = trimmedQuery; + + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + + if (!trimmedQuery) { + this.chatSearchResults = []; + this.filteredConversations = []; + return; + } + + const queryLower = trimmedQuery.toLowerCase(); + if (this.conversations.length > 0) { + this.filteredConversations = this.conversations.filter(conv => + conv.otherUserName?.toLowerCase().includes(queryLower) + ); + } else { + this.filteredConversations = []; + } + + if (trimmedQuery.length < 2) { + this.chatSearchResults = []; + return; + } + + this.searchTimeout = setTimeout(async () => { + try { + const existingUserIds = this.conversations.map(c => c.otherUserId); + if (this.currentUser?.role === 'DOCTOR') { + const users = await this.chatService.searchPatients(trimmedQuery); + this.chatSearchResults = users.filter(user => !existingUserIds.includes(user.userId)); + } else { + const users = await this.chatService.searchDoctors(trimmedQuery); + this.chatSearchResults = users.filter(user => !existingUserIds.includes(user.userId)); + } + } catch (error) { + this.logger.error('Error searching users:', error); + this.chatSearchResults = []; + } + }, 300); + } + + clearChatSearch(): void { + this.chatSearchQuery = ''; + this.chatSearchResults = []; + this.filteredConversations = []; + } + + getConversationTime(timestamp: string | Date): string { + if (!timestamp) return ''; + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Now'; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + + onChatImageError(event: Event): void { + const img = event.target as HTMLImageElement; + img.style.display = 'none'; + const avatarCircle = img.nextElementSibling as HTMLElement; + if (avatarCircle && avatarCircle.classList.contains('avatar-circle')) { + avatarCircle.style.display = 'flex'; + } + } + + getStatusText(status: string | undefined): string { + if (!status) return 'Offline'; + switch (status) { + case 'ONLINE': + return 'Online'; + case 'BUSY': + return 'Busy'; + case 'OFFLINE': + return 'Offline'; + default: + return status; + } + } + + async onFileSelected(event: Event): Promise { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + try { + const avatarUrl = await this.userService.uploadAvatar(file); + // Refresh current user to get updated avatar + this.currentUser = await this.userService.getCurrentUser(); + // Reload patient profile if available + if (this.currentUser) { + this.patientProfile = await this.userService.getPatientProfile(); + } + } catch (error: any) { + this.logger.error('Error uploading avatar:', error); + await this.modalService.alert( + error?.response?.data?.error || 'Failed to upload avatar. Please try again.', + 'error', + 'Upload Error' + ); + } + } + } + + async loadUserAndAppointments() { + this.loading = true; + this.error = null; + try { + this.currentUser = await this.userService.getCurrentUser(); + if (!this.currentUser) { + throw new Error('Unable to load user information'); + } + + // Load patient profile first to get the patient ID + this.patientProfile = await this.userService.getPatientProfile(); + if (this.patientProfile?.id) { + this.patientId = this.patientProfile.id; + } + + await Promise.all([ + this.loadAppointments(), + this.loadDoctors(), + this.loadPatientInfo(), + this.loadEHRData(), + this.loadPrescriptions(), + this.load2FAStatus() + ]); + } catch (e: any) { + if (e?.response?.status === 401) { + await this.auth.logout(); + this.router.navigateByUrl('/login'); + return; + } + this.error = e?.response?.data?.message || 'Failed to load dashboard data'; + } finally { + this.loading = false; + } + } + + async loadAppointments() { + try { + if (!this.patientId) { + // If patientId is not set, try to get it from profile + if (!this.patientProfile) { + this.patientProfile = await this.userService.getPatientProfile(); + } + if (this.patientProfile?.id) { + this.patientId = this.patientProfile.id; + } else { + this.logger.warn('Patient ID not available, cannot load appointments'); + this.appointments = []; + return; + } + } + + if (this.patientId) { + this.appointments = await this.appointmentService.getAppointmentsByPatientId(this.patientId); + } else { + this.appointments = []; + } + } catch (e: any) { + this.logger.error('Error loading appointments:', e); + this.appointments = []; + } + } + + async loadDoctors() { + try { + // First, extract doctors from appointments (only shows doctors with non-deleted appointments) + this.extractDoctorsFromAppointments(); + + // If we have appointments, we already have the doctors + // Otherwise, fallback to loading all doctors (for dropdowns, etc.) + if (this.doctors.length === 0) { + this.doctors = await this.userService.getAllDoctors(); + } + } catch (e: any) { + this.logger.error('Error loading doctors:', e); + // Fallback: try to extract from appointments + this.extractDoctorsFromAppointments(); + // If still empty, load all doctors + if (this.doctors.length === 0) { + this.doctors = await this.userService.getAllDoctors(); + } + } + } + + extractDoctorsFromAppointments() { + // Extract unique doctors from appointments (only those with non-deleted appointments) + const doctorMap = new Map(); + this.appointments.forEach(apt => { + const doctorKey = `${apt.doctorFirstName}-${apt.doctorLastName}`; + if (!doctorMap.has(doctorKey)) { + doctorMap.set(doctorKey, { + id: (apt as any).doctorId || '', + firstName: apt.doctorFirstName, + lastName: apt.doctorLastName, + email: '', + displayName: `Dr. ${apt.doctorFirstName} ${apt.doctorLastName}` + }); + } + }); + this.doctors = Array.from(doctorMap.values()); + } + + async loadPatientInfo() { + try { + // Use the already-loaded patientProfile instead of calling admin endpoint + // Combine patientProfile with currentUser to create patientInfo structure + if (this.patientProfile && this.currentUser) { + this.patientInfo = { + ...this.patientProfile, + user: { + id: this.currentUser.id, + email: this.currentUser.email, + firstName: this.currentUser.firstName, + lastName: this.currentUser.lastName, + phoneNumber: this.currentUser.phoneNumber, + isActive: this.currentUser.isActive + } + }; + } else { + // If patientProfile wasn't loaded yet, try to load it + if (!this.patientProfile) { + this.patientProfile = await this.userService.getPatientProfile(); + } + if (this.patientProfile && this.currentUser) { + this.patientInfo = { + ...this.patientProfile, + user: { + id: this.currentUser.id, + email: this.currentUser.email, + firstName: this.currentUser.firstName, + lastName: this.currentUser.lastName, + phoneNumber: this.currentUser.phoneNumber, + isActive: this.currentUser.isActive + } + }; + } + } + } catch (e: any) { + this.logger.error('Error loading patient info:', e); + this.patientInfo = null; + } + } + + async refresh() { + await this.loadUserAndAppointments(); + } + + async requestAppointment() { + if (!this.newAppointment.doctorId || !this.newAppointment.scheduledDate || !this.newAppointment.scheduledTime) { + this.error = 'Please fill in all required fields and select an available time slot'; + return; + } + + if (!this.currentUser || !this.patientId) { + this.error = 'Patient information not available. Please refresh the page.'; + return; + } + + try { + // Get doctor's default duration, fallback to 30 if not set + const selectedDoctor = this.doctors.find(d => d.id === this.newAppointment.doctorId); + const durationMinutes = selectedDoctor?.defaultDurationMinutes || 30; + + await this.appointmentService.createAppointment({ + patientId: this.patientId, + doctorId: this.newAppointment.doctorId, + scheduledDate: this.newAppointment.scheduledDate, + scheduledTime: this.newAppointment.scheduledTime, + durationMinutes: durationMinutes // Use doctor's default duration + }); + + this.showRequestForm = false; + this.newAppointment = { + doctorId: '', + scheduledDate: '', + scheduledTime: '' + // durationInMinutes is always 30 for patients + }; + this.availableSlots = []; + this.selectedDate = ''; + await this.refresh(); + this.error = null; + } catch (e: any) { + this.logger.error('Error creating appointment:', e); + this.logger.error('Error response:', e?.response?.data); + + // Handle validation errors + if (e?.response?.status === 400) { + const errorData = e?.response?.data; + if (typeof errorData === 'string') { + this.error = errorData; + } else if (errorData?.message) { + this.error = errorData.message; + } else if (errorData && typeof errorData === 'object') { + // Handle field-specific validation errors + const errorMessages = Object.values(errorData).filter((msg: any) => typeof msg === 'string'); + this.error = errorMessages.length > 0 + ? errorMessages.join(', ') + : 'Validation failed. Please check all fields.'; + } else { + this.error = 'Failed to request appointment. Please check all required fields.'; + } + } else { + this.error = e?.response?.data?.message || 'Failed to request appointment. Please try again.'; + } + } + } + + async loadAvailableSlots() { + if (!this.newAppointment.doctorId || !this.newAppointment.scheduledDate) { + this.availableSlots = []; + return; + } + + // Only load if date changed + if (this.selectedDate === this.newAppointment.scheduledDate) { + return; + } + + this.selectedDate = this.newAppointment.scheduledDate; + this.loadingSlots = true; + this.availableSlots = []; + + try { + // Get doctor's default duration from doctors list + const selectedDoctor = this.doctors.find(d => d.id === this.newAppointment.doctorId); + + // Only pass durationMinutes if it's explicitly set in the frontend (should not happen) + // Otherwise, let backend use doctor's defaultDurationMinutes from database + // This ensures the backend always uses the most up-to-date doctor setting + const durationMinutes = undefined; // Always let backend determine from doctor's default + + this.availableSlots = await this.appointmentService.getAvailableTimeSlots( + this.newAppointment.doctorId, + this.newAppointment.scheduledDate, + durationMinutes // Pass undefined so backend uses doctor's defaultDurationMinutes from DB + ); + } catch (e: any) { + this.logger.error('Error loading available slots:', e); + this.error = 'Failed to load available time slots'; + this.availableSlots = []; + } finally { + this.loadingSlots = false; + } + } + + onDoctorChange() { + this.availableSlots = []; + this.selectedDate = ''; + this.newAppointment.scheduledTime = ''; + if (this.newAppointment.scheduledDate) { + this.loadAvailableSlots(); + } + } + + onDateChange() { + this.newAppointment.scheduledTime = ''; + if (this.newAppointment.doctorId && this.newAppointment.scheduledDate) { + this.loadAvailableSlots(); + } + } + + selectTimeSlot(time: string) { + this.newAppointment.scheduledTime = time; + } + + async cancelAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + const confirmed = await this.modalService.confirm( + 'Are you sure you want to cancel this appointment?', + 'Cancel Appointment', + 'Cancel Appointment', + 'Keep Appointment' + ); + if (!confirmed) return; + + try { + await this.appointmentService.cancelAppointment(appointment.id); + await this.refresh(); + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to cancel appointment'; + } + } + + async deleteAppointment(appointment: Appointment) { + if (!appointment.id) { + this.error = 'Appointment ID not available. Backend needs to include ID in response.'; + return; + } + const confirmed = await this.modalService.confirm( + 'Are you sure you want to delete this appointment? This action cannot be undone.', + 'Delete Appointment', + 'Delete', + 'Cancel' + ); + if (!confirmed) { + return; + } + + try { + await this.appointmentService.deleteAppointment(appointment.id); + await this.refresh(); + this.error = null; + } catch (e: any) { + this.error = e?.response?.data?.message || e?.message || 'Failed to delete appointment'; + } + } + + // Note: Patients cannot confirm appointments - only doctors can confirm + // Appointments are auto-confirmed when created if the slot is available + // This method is kept for backward compatibility but should not be called from UI + async confirmAppointment(appointment: Appointment) { + this.error = 'Only doctors can confirm appointments. Your appointment will be automatically confirmed if the slot is available.'; + } + + setTab(tab: string) { + this.activeTab = tab; + } + + async logout() { + // Update online status and disconnect WebSocket before removing token + try { + await this.chatService.disconnect(); + } catch (error) { + // Ignore errors during logout + } + await this.auth.logout(); + this.router.navigateByUrl('/login'); + } + + formatDate(dateString: string): string { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString(); + } + + formatTime(timeString: string): string { + if (!timeString) return 'N/A'; + return timeString.substring(0, 5); + } + + getStatusClass(status: string): string { + const statusLower = status?.toLowerCase() || ''; + if (statusLower === 'completed') return 'status-completed'; + if (statusLower === 'confirmed') return 'status-confirmed'; + if (statusLower === 'cancelled') return 'status-cancelled'; + return 'status-scheduled'; + } + + getUpcomingAppointments(): Appointment[] { + const now = new Date(); + return this.appointments + .filter(apt => { + const aptDate = new Date(`${apt.scheduledDate}T${apt.scheduledTime}`); + return aptDate >= now && apt.status !== 'CANCELLED' && apt.status !== 'COMPLETED'; + }) + .sort((a, b) => { + const dateA = new Date(`${a.scheduledDate}T${a.scheduledTime}`); + const dateB = new Date(`${b.scheduledDate}T${b.scheduledTime}`); + return dateA.getTime() - dateB.getTime(); + }); + } + + getPastAppointments(): Appointment[] { + const now = new Date(); + return this.appointments + .filter(apt => { + const aptDate = new Date(`${apt.scheduledDate}T${apt.scheduledTime}`); + return aptDate < now || apt.status === 'CANCELLED' || apt.status === 'COMPLETED'; + }) + .sort((a, b) => { + const dateA = new Date(`${a.scheduledDate}T${a.scheduledTime}`); + const dateB = new Date(`${b.scheduledDate}T${b.scheduledTime}`); + return dateB.getTime() - dateA.getTime(); + }); + } + + getTotalAppointments(): number { + return this.appointments.length; + } + + getUpcomingCount(): number { + return this.getUpcomingAppointments().length; + } + + getCompletedCount(): number { + return this.appointments.filter(apt => apt.status === 'COMPLETED').length; + } + + getCancelledCount(): number { + return this.appointments.filter(apt => apt.status === 'CANCELLED').length; + } + + getTodayDate(): string { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + getVerifiedDoctors(): any[] { + return this.doctors.filter((d: any) => d.isVerified === true); + } + + selectedDoctorProfile: any = null; + showDoctorProfileModal = false; + + async viewDoctorProfile(doctorId: string) { + try { + this.selectedDoctorProfile = await this.userService.getDoctorProfileById(doctorId); + this.showDoctorProfileModal = true; + } catch (e: any) { + this.error = e?.message || 'Failed to load doctor profile'; + this.logger.error('Failed to load doctor profile:', e); + } + } + + async removeDoctorFromHistory(doctor: any, event?: Event) { + event?.stopPropagation(); // Prevent card click + + const confirmed = await this.modalService.confirm( + `Are you sure you want to remove Dr. ${doctor.firstName} ${doctor.lastName} from your history? This will hide all appointments with this doctor from your view. This action cannot be undone.`, + 'Remove Doctor from History', + 'warning' + ); + + if (!confirmed) return; + + try { + this.loading = true; + await this.appointmentService.removeDoctorFromHistory(doctor.id); + await this.modalService.alert( + 'Doctor removed from history successfully', + 'success', + 'Success' + ); + + // Refresh appointments and doctors + await Promise.all([ + this.loadAppointments(), + this.loadDoctors() + ]); + } catch (error: any) { + this.logger.error('Error removing doctor from history:', error); + await this.modalService.alert( + error?.message || 'Failed to remove doctor from history', + 'error', + 'Error' + ); + } finally { + this.loading = false; + } + } + + closeDoctorProfileModal() { + this.showDoctorProfileModal = false; + this.selectedDoctorProfile = null; + } + + async loadPatientProfile() { + try { + this.patientProfile = await this.userService.getPatientProfile(); + if (this.patientProfile?.id && !this.patientId) { + this.patientId = this.patientProfile.id; + } + } catch (e: any) { + this.logger.error('Failed to load patient profile:', e); + this.patientProfile = null; + } + } + + async updateUserProfile() { + // This method is not used anymore - updatePatientProfile handles both + return; + } + + async updatePatientProfile() { + try { + // Update user profile if fields are provided + const hasUserChanges = this.editUserData.firstName || this.editUserData.lastName || this.editUserData.phoneNumber; + if (hasUserChanges) { + await this.userService.updateUserProfile(this.editUserData); + } + + // Update patient profile + await this.userService.updatePatientProfile(this.editProfileData); + await this.loadPatientProfile(); + await this.loadUserAndAppointments(); + this.showEditProfile = false; + this.editUserData = { + firstName: '', + lastName: '', + phoneNumber: '' + }; + this.editProfileData = { + emergencyContactName: '', + emergencyContactPhone: '', + bloodType: '', + allergies: [], + // Enterprise fields - reset to empty + dateOfBirth: '', + gender: '', + streetAddress: '', + city: '', + state: '', + zipCode: '', + country: '', + insuranceProvider: '', + insurancePolicyNumber: '', + medicalHistorySummary: '', + currentMedications: [], + primaryCarePhysicianName: '', + primaryCarePhysicianPhone: '', + preferredLanguage: '', + occupation: '', + maritalStatus: '' + }; + } catch (e: any) { + this.error = e?.response?.data?.message || 'Failed to update patient profile'; + } + } + + addAllergy() { + if (this.newAllergy.trim() && this.editProfileData.allergies) { + this.editProfileData.allergies.push(this.newAllergy.trim()); + this.newAllergy = ''; + } + } + + removeAllergy(index: number) { + if (this.editProfileData.allergies) { + this.editProfileData.allergies.splice(index, 1); + } + } + + addMedication() { + if (this.newMedication && this.newMedication.trim()) { + if (!this.editProfileData.currentMedications) { + this.editProfileData.currentMedications = []; + } + this.editProfileData.currentMedications.push(this.newMedication.trim()); + this.newMedication = ''; + } + } + + removeMedication(index: number) { + if (this.editProfileData.currentMedications) { + this.editProfileData.currentMedications.splice(index, 1); + } + } + + editProfile() { + // Ensure currentUser is loaded + if (!this.currentUser) { + this.logger.error('Current user not loaded'); + return; + } + // Ensure patientProfile is loaded + if (!this.patientProfile) { + this.logger.error('Patient profile not loaded'); + return; + } + // Use patientProfile instead of patientInfo + this.editUserData = { + firstName: this.currentUser.firstName || '', + lastName: this.currentUser.lastName || '', + phoneNumber: this.currentUser.phoneNumber || '' + }; + this.editProfileData = { + emergencyContactName: this.patientProfile.emergencyContactName || '', + emergencyContactPhone: this.patientProfile.emergencyContactPhone || '', + bloodType: this.patientProfile.bloodType || '', + allergies: [...(this.patientProfile.allergies || [])], + // Enterprise fields + dateOfBirth: this.patientProfile.dateOfBirth || '', + gender: this.patientProfile.gender || '', + streetAddress: this.patientProfile.streetAddress || '', + city: this.patientProfile.city || '', + state: this.patientProfile.state || '', + zipCode: this.patientProfile.zipCode || '', + country: this.patientProfile.country || '', + insuranceProvider: this.patientProfile.insuranceProvider || '', + insurancePolicyNumber: this.patientProfile.insurancePolicyNumber || '', + medicalHistorySummary: this.patientProfile.medicalHistorySummary || '', + currentMedications: [...(this.patientProfile.currentMedications || [])], + primaryCarePhysicianName: this.patientProfile.primaryCarePhysicianName || '', + primaryCarePhysicianPhone: this.patientProfile.primaryCarePhysicianPhone || '', + preferredLanguage: this.patientProfile.preferredLanguage || '', + occupation: this.patientProfile.occupation || '', + maritalStatus: this.patientProfile.maritalStatus || '' + }; + this.showEditProfile = true; + } + + // New feature loading methods + async loadEHRData() { + if (!this.patientId) return; + try { + [this.medicalRecords, this.vitalSigns, this.labResults, this.latestVitalSigns] = await Promise.all([ + this.medicalRecordService.getMedicalRecordsByPatientId(this.patientId), + this.medicalRecordService.getVitalSignsByPatientId(this.patientId), + this.medicalRecordService.getLabResultsByPatientId(this.patientId), + this.medicalRecordService.getLatestVitalSignsByPatientId(this.patientId) + ]); + } catch (e: any) { + this.logger.error('Error loading EHR data:', e); + this.medicalRecords = []; + this.vitalSigns = []; + this.labResults = []; + } + } + + async loadPrescriptions() { + if (!this.patientId) return; + try { + this.prescriptions = await this.prescriptionService.getPrescriptionsByPatientId(this.patientId); + } catch (e: any) { + this.logger.error('Error loading prescriptions:', e); + this.prescriptions = []; + } + } + + async load2FAStatus() { + try { + this.twoFAStatus = await this.twoFactorAuthService.get2FAStatus(); + this.twoFAEnabled = this.twoFAStatus?.enabled || false; + } catch (e: any) { + this.logger.error('Error loading 2FA status:', e); + this.twoFAEnabled = false; + } + } + + async setup2FA() { + try { + const setup = await this.twoFactorAuthService.setup2FA(); + this.qrCodeUrl = setup.qrCodeUrl; + this.twoFASecretKey = setup.secretKey; + this.twoFABackupCodes = setup.backupCodes || []; + this.twoFACodeInput = ''; + this.show2FAModal = true; + } catch (e: any) { + await this.modalService.alert( + e?.response?.data?.error || 'Failed to setup 2FA', + 'error', + '2FA Setup Error' + ); + } + } + + async verifyAndEnable2FA() { + if (!this.twoFACodeInput || this.twoFACodeInput.length !== 6) { + await this.modalService.alert( + 'Please enter a valid 6-digit code', + 'warning', + 'Invalid Code' + ); + return; + } + try { + await this.twoFactorAuthService.enable2FA(this.twoFACodeInput); + await this.load2FAStatus(); + this.show2FAModal = false; + await this.modalService.alert( + '2FA enabled successfully! Please save your backup codes: ' + this.twoFABackupCodes.join(', '), + 'success', + '2FA Enabled' + ); + } catch (e: any) { + await this.modalService.alert( + e?.response?.data?.error || 'Failed to enable 2FA', + 'error', + '2FA Error' + ); + } + } + + close2FAModal() { + this.show2FAModal = false; + this.qrCodeUrl = ''; + this.twoFASecretKey = ''; + this.twoFABackupCodes = []; + this.twoFACodeInput = ''; + } + + async disable2FA() { + const code = await this.modalService.prompt( + 'Enter your 2FA code or backup code to disable two-factor authentication:', + 'Disable 2FA', + '2FA Code', + 'Enter 2FA code or backup code', + 'text', + 'Disable', + 'Cancel' + ); + if (!code) return; + try { + await this.twoFactorAuthService.disable2FA(code); + await this.load2FAStatus(); + await this.modalService.alert( + '2FA disabled successfully', + 'success', + '2FA Disabled' + ); + } catch (e: any) { + await this.modalService.alert( + e?.response?.data?.error || 'Failed to disable 2FA', + 'error', + '2FA Error' + ); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/register/doctor/doctor-register.component.html b/frontend/src/app/pages/register/doctor/doctor-register.component.html new file mode 100644 index 0000000..8f78d99 --- /dev/null +++ b/frontend/src/app/pages/register/doctor/doctor-register.component.html @@ -0,0 +1,324 @@ +
+
+
+
+ + + + +
+

Doctor Registration

+

Join our telemedicine platform

+
+ + +
+ + + +
+

Registration Successful!

+

Your account has been created. Redirecting to login...

+
+
+ + +
+
+

Personal Information

+
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + + +
+ {{ getFieldError('email') }} +
+ +
+ +
+ + + + +
+ {{ getFieldError('phoneNumber') }} +
+
+ +
+

Medical Credentials

+
+ + + {{ getFieldError('medicalLicenseNumber') }} +
+ +
+ + + {{ getFieldError('specialization') }} +
+ +
+
+ + + {{ getFieldError('yearsOfExperience') }} +
+
+ + + {{ getFieldError('consultationFee') }} +
+
+ +
+ + +
+
+ +
+

Account Security

+
+ +
+ + + + + + +
+
+
+
+
+ + {{ passwordStrengthText || 'Enter a password' }} + +
+ {{ getFieldError('password') }} +
+ +
+ +
+ + + + + + +
+ {{ getFieldError('confirmPassword') }} +
+
+ +
+ + + + + {{ error }} +
+ + +
+ + +
+
+ diff --git a/frontend/src/app/pages/register/doctor/doctor-register.component.scss b/frontend/src/app/pages/register/doctor/doctor-register.component.scss new file mode 100644 index 0000000..95784f2 --- /dev/null +++ b/frontend/src/app/pages/register/doctor/doctor-register.component.scss @@ -0,0 +1,853 @@ +// Enterprise-Grade Color Palette +$primary-blue: #0066cc; +$primary-blue-dark: #0052a3; +$primary-blue-light: #e6f2ff; +$primary-blue-lighter: #f0f7ff; +$accent-teal: #0099a1; +$accent-teal-light: #00c4ce; +$text-dark: #0f172a; +$text-medium: #475569; +$text-light: #64748b; +$text-lighter: #94a3b8; +$border-color: #e2e8f0; +$border-color-light: #f1f5f9; +$border-focus: #0066cc; +$error-red: #dc2626; +$error-red-dark: #b91c1c; +$error-bg: #fef2f2; +$error-border: #fecaca; +$success-green: #10b981; +$success-green-dark: #059669; +$success-bg: #d1fae5; +$warning-orange: #f59e0b; +$info-blue: #3b82f6; +$white: #ffffff; +$background-light: #f8fafc; +$background-elevated: #ffffff; +$gradient-primary: linear-gradient(135deg, #0066cc 0%, #0099a1 100%); +$gradient-primary-hover: linear-gradient(135deg, #0052a3 0%, #007a82 100%); +$gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$gradient-bg-alt: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + +// Enterprise Shadow System +$shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +$shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); +$shadow-blue: 0 10px 25px -5px rgba(0, 102, 204, 0.25); +$shadow-blue-lg: 0 20px 40px -10px rgba(0, 102, 204, 0.3); + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + +.register-wrapper { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: $gradient-bg; + background-attachment: fixed; + padding: 2rem; + position: relative; + overflow-y: auto; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.15) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.12) 0%, transparent 50%), + radial-gradient(circle at 50% 20%, rgba(102, 126, 234, 0.3) 0%, transparent 60%); + pointer-events: none; + animation: backgroundShift 20s ease-in-out infinite alternate; + } + + &::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, transparent 70%); + animation: rotateBackground 30s linear infinite; + pointer-events: none; + } + + @keyframes backgroundShift { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0.8; + transform: translateY(-20px); + } + } + + @keyframes rotateBackground { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + @media (max-width: 768px) { + padding: 1rem; + } +} + +.register-container { + background: rgba($white, 0.98); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 24px; + box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.2) inset; + width: 100%; + max-width: 850px; + padding: 3.5rem; + position: relative; + z-index: 1; + animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1); + margin: 2rem 0; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: $gradient-primary; + z-index: 2; + } + + @media (max-width: 768px) { + padding: 2.5rem 2rem; + border-radius: 20px; + margin: 1rem 0; + max-width: 100%; + } + + @media (max-width: 480px) { + padding: 2rem 1.5rem; + border-radius: 16px; + } + + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +} + +.register-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.logo-container { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + background: $gradient-primary; + border-radius: 18px; + margin-bottom: 1.75rem; + box-shadow: $shadow-lg, 0 0 0 4px rgba(0, 102, 204, 0.1); + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 20px; + background: $gradient-primary; + opacity: 0; + filter: blur(12px); + transition: opacity 0.3s ease; + z-index: -1; + } + + &:hover { + transform: translateY(-2px) scale(1.05); + box-shadow: $shadow-xl, 0 0 0 4px rgba(0, 102, 204, 0.15); + + &::after { + opacity: 0.4; + } + } + + .medical-icon { + width: 40px; + height: 40px; + color: $white; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); + transition: transform 0.3s ease; + } + + &:hover .medical-icon { + transform: scale(1.1); + } +} + +.register-title { + font-size: 2rem; + font-weight: 800; + background: $gradient-primary; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 0.75rem 0; + letter-spacing: -0.75px; + font-family: $font-family; + line-height: 1.2; + + @media (max-width: 768px) { + font-size: 1.75rem; + } +} + +.register-subtitle { + font-size: 1rem; + color: $text-medium; + margin: 0; + font-weight: 500; + line-height: 1.5; +} + +.register-form { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.form-section { + display: flex; + flex-direction: column; + gap: 1.75rem; + padding: 2rem 0; + border-bottom: 2px solid $border-color-light; + position: relative; + + &:first-of-type { + padding-top: 0; + } + + &:last-of-type { + border-bottom: none; + padding-bottom: 0; + } + + &::before { + content: ''; + position: absolute; + left: 0; + bottom: -2px; + width: 0; + height: 2px; + background: $gradient-primary; + transition: width 0.3s ease; + } + + &:focus-within::before, + &:hover::before { + width: 100px; + } +} + +.section-title { + font-size: 1.25rem; + font-weight: 700; + background: $gradient-primary; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 0.5rem 0; + font-family: $font-family; + display: flex; + align-items: center; + gap: 0.75rem; + letter-spacing: -0.3px; + + &::before { + content: ''; + width: 4px; + height: 1.5rem; + background: $gradient-primary; + border-radius: 2px; + display: block; + } +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-label { + font-size: 0.875rem; + font-weight: 600; + color: $text-dark; + letter-spacing: 0.2px; + font-family: $font-family; +} + +.input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.input-icon { + position: absolute; + left: 1rem; + width: 20px; + height: 20px; + color: $text-light; + pointer-events: none; + transition: color 0.2s ease; +} + +.form-input { + width: 100%; + padding: 1rem 3rem 1rem 3rem; + font-size: 0.9375rem; + font-family: $font-family; + color: $text-dark; + background: $background-elevated; + border: 2px solid $border-color; + border-radius: 12px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + box-shadow: $shadow-inner; + + &:not(.form-textarea) { + padding-left: 3rem; + } + + &::placeholder { + color: $text-lighter; + font-weight: 400; + } + + &:hover:not(:disabled):not(:focus) { + border-color: $border-color-light; + background: $white; + box-shadow: $shadow-sm; + } + + &:focus { + border-color: $border-focus; + background: $white; + box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md; + transform: translateY(-1px); + + ~ .input-icon { + color: $border-focus; + transform: scale(1.1); + } + } + + &:disabled { + background: $background-light; + cursor: not-allowed; + opacity: 0.6; + border-color: $border-color; + } +} + +.form-textarea { + padding: 1rem 1.25rem; + resize: vertical; + min-height: 120px; + font-family: $font-family; + line-height: 1.6; + border-radius: 12px; + border: 2px solid $border-color; + background: $background-elevated; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: $shadow-inner; + + &:hover:not(:focus) { + border-color: $border-color-light; + background: $white; + box-shadow: $shadow-sm; + } + + &:focus { + border-color: $border-focus; + background: $white; + box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md; + outline: none; + } +} + +select.form-input { + padding-left: 1rem; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b6b6b' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; + padding-right: 2.5rem; +} + +.input-error { + border-color: $error-red !important; + background: $error-bg !important; + + ~ .input-icon { + color: $error-red !important; + } + + &:focus { + box-shadow: 0 0 0 4px rgba($error-red, 0.1), $shadow-md !important; + background: $white !important; + } +} + +.field-error { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: $error-red; + margin-top: 0.375rem; + font-weight: 500; + padding-left: 0.25rem; + animation: slideDown 0.2s ease-out; + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} + +.password-toggle { + position: absolute; + right: 0.75rem; + background: transparent; + border: none; + cursor: pointer; + padding: 0.625rem; + display: flex; + align-items: center; + justify-content: center; + color: $text-light; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 2; + border-radius: 8px; + + svg { + width: 20px; + height: 20px; + transition: transform 0.2s ease; + } + + &:hover { + color: $text-dark; + background: $background-light; + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + background: $primary-blue-lighter; + } +} + +.password-strength { + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.strength-bar { + width: 100%; + height: 4px; + background: $background-light; + border-radius: 2px; + overflow: hidden; +} + +.strength-fill { + height: 100%; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 2px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + &.strength-0 { + width: 0%; + background: transparent; + } + &.strength-1 { + background: linear-gradient(90deg, $error-red, lighten($error-red, 10%)); + box-shadow: 0 0 8px rgba($error-red, 0.3); + } + &.strength-2 { + background: linear-gradient(90deg, $warning-orange, lighten($warning-orange, 10%)); + box-shadow: 0 0 8px rgba($warning-orange, 0.3); + } + &.strength-3 { + background: linear-gradient(90deg, $info-blue, lighten($info-blue, 10%)); + box-shadow: 0 0 8px rgba($info-blue, 0.3); + } + &.strength-4 { + background: linear-gradient(90deg, $success-green, lighten($success-green, 10%)); + box-shadow: 0 0 8px rgba($success-green, 0.3); + } + &.strength-5 { + background: linear-gradient(90deg, $success-green, $success-green-dark); + box-shadow: 0 0 12px rgba($success-green, 0.4); + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } + } +} + +.strength-text { + font-size: 0.8125rem; + font-weight: 600; + transition: color 0.3s ease; + + &.strength-0 { color: $text-lighter; } + &.strength-1 { color: $error-red-dark; } + &.strength-2 { color: darken($warning-orange, 10%); } + &.strength-3 { color: darken($info-blue, 10%); } + &.strength-4 { color: $success-green-dark; } + &.strength-5 { color: $success-green-dark; } +} + +.error-message { + display: flex; + align-items: flex-start; + gap: 0.875rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, $error-bg 0%, lighten($error-bg, 2%) 100%); + border: 2px solid $error-border; + border-left: 4px solid $error-red; + border-radius: 12px; + color: $error-red-dark; + font-size: 0.875rem; + font-weight: 600; + animation: slideInError 0.4s cubic-bezier(0.16, 1, 0.3, 1), shake 0.5s ease-out 0.4s; + box-shadow: $shadow-sm; + margin-top: 0.5rem; + + .error-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 0.125rem; + filter: drop-shadow(0 1px 2px rgba(220, 38, 38, 0.2)); + } + + @keyframes slideInError { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-6px); } + 75% { transform: translateX(6px); } + } +} + +.success-message { + display: flex; + align-items: flex-start; + gap: 1.25rem; + padding: 1.75rem 2rem; + background: linear-gradient(135deg, $success-bg 0%, lighten($success-bg, 2%) 100%); + border: 2px solid rgba($success-green, 0.3); + border-left: 4px solid $success-green; + border-radius: 16px; + margin-bottom: 2rem; + animation: slideInSuccess 0.5s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: $shadow-lg; + + .success-icon { + width: 36px; + height: 36px; + color: $success-green; + flex-shrink: 0; + margin-top: 0.125rem; + filter: drop-shadow(0 2px 4px rgba(16, 185, 129, 0.2)); + animation: checkmark 0.6s cubic-bezier(0.16, 1, 0.3, 1); + } + + .success-content { + flex: 1; + + h3 { + font-size: 1.25rem; + font-weight: 700; + color: $success-green-dark; + margin: 0 0 0.5rem 0; + font-family: $font-family; + letter-spacing: -0.3px; + } + + p { + font-size: 0.9375rem; + color: $text-dark; + margin: 0; + font-family: $font-family; + line-height: 1.6; + } + } + + @keyframes slideInSuccess { + from { + opacity: 0; + transform: translateY(-15px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes checkmark { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + opacity: 1; + } + } +} + +.submit-button { + width: 100%; + padding: 1.125rem 2rem; + font-size: 1.0625rem; + font-weight: 700; + font-family: $font-family; + color: $white; + background: $gradient-primary; + border: none; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: $shadow-md, 0 0 0 1px rgba(255, 255, 255, 0.1) inset; + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + position: relative; + overflow: hidden; + text-transform: none; + letter-spacing: 0.3px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent); + transition: left 0.6s ease; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + padding: 2px; + background: $gradient-primary; + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 0.3s ease; + } + + &:hover:not(:disabled) { + transform: translateY(-3px) scale(1.01); + box-shadow: $shadow-blue-lg, 0 0 0 1px rgba(255, 255, 255, 0.15) inset; + background: $gradient-primary-hover; + + &::before { + left: 100%; + } + + &::after { + opacity: 0.3; + } + } + + &:active:not(:disabled) { + transform: translateY(-1px) scale(0.99); + box-shadow: $shadow-md; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + background: $text-lighter; + box-shadow: none; + } + + &.loading { + pointer-events: none; + } + + .loading-content { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .spinner { + width: 20px; + height: 20px; + animation: spin 0.8s linear infinite; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +} + +.register-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid $border-color; + text-align: center; +} + +.footer-text { + font-size: 0.8125rem; + color: $text-light; + margin: 0; + font-family: $font-family; +} + +.login-link { + color: $primary-blue; + text-decoration: none; + font-weight: 600; + transition: color 0.2s ease; + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } +} + +// Focus visible for accessibility +.form-input:focus-visible, +select.form-input:focus-visible, +.submit-button:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; +} + +// Dark mode support +@media (prefers-color-scheme: dark) { + .register-container { + background: #1e1e1e; + } + + .register-title { + color: $white; + } + + .register-subtitle { + color: rgba($white, 0.7); + } + + .section-title { + color: rgba($white, 0.9); + } + + .form-label { + color: rgba($white, 0.9); + } + + .form-input { + background: #2a2a2a; + border-color: #404040; + color: $white; + + &:focus { + border-color: $border-focus; + } + } + + .footer-text { + color: rgba($white, 0.5); + } +} + diff --git a/frontend/src/app/pages/register/doctor/doctor-register.component.ts b/frontend/src/app/pages/register/doctor/doctor-register.component.ts new file mode 100644 index 0000000..b7a6d20 --- /dev/null +++ b/frontend/src/app/pages/register/doctor/doctor-register.component.ts @@ -0,0 +1,187 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { RegistrationService } from '../../../services/registration.service'; + +@Component({ + selector: 'app-doctor-register', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule], + templateUrl: './doctor-register.component.html', + styleUrl: './doctor-register.component.scss' +}) +export class DoctorRegisterComponent { + formData = { + email: '', + password: '', + confirmPassword: '', + firstName: '', + lastName: '', + phoneNumber: '', + medicalLicenseNumber: '', + specialization: '', + yearsOfExperience: 0, + biography: '', + consultationFee: 0 + }; + + loading = false; + error: string | null = null; + success = false; + showPassword = false; + showConfirmPassword = false; + passwordStrength = 0; + passwordStrengthText = ''; + validationErrors: { [key: string]: string } = {}; + + constructor( + private registrationService: RegistrationService, + private router: Router + ) {} + + checkPasswordStrength(password: string): { strength: number; text: string } { + if (!password) { + return { strength: 0, text: '' }; + } + let strength = 0; + if (password.length >= 8) strength++; + if (password.length >= 12) strength++; + if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++; + if (/[0-9]/.test(password)) strength++; + if (/[^A-Za-z0-9]/.test(password)) strength++; + + const texts = ['', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong']; + return { strength, text: texts[strength] || '' }; + } + + onPasswordChange() { + const result = this.checkPasswordStrength(this.formData.password); + this.passwordStrength = result.strength; + this.passwordStrengthText = result.text; + this.validationErrors = {}; + } + + validateField(field: string, value: any): string { + switch (field) { + case 'email': + if (!value) return 'Email is required'; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(value)) return 'Please enter a valid email address'; + return ''; + case 'password': + if (!value) return 'Password is required'; + if (value.length < 8) return 'Password must be at least 8 characters'; + if (value.length > 20) return 'Password must not exceed 20 characters'; + return ''; + case 'confirmPassword': + if (!value) return 'Please confirm your password'; + if (value !== this.formData.password) return 'Passwords do not match'; + return ''; + case 'firstName': + case 'lastName': + if (!value) return 'This field is required'; + if (value.length < 2) return 'Must be at least 2 characters'; + if (value.length > 50) return 'Must not exceed 50 characters'; + return ''; + case 'phoneNumber': + if (!value) return 'Phone number is required'; + const phoneRegex = /^\+?[1-9][0-9]\d{1,14}$/; + if (!phoneRegex.test(value)) return 'Please enter a valid phone number'; + return ''; + case 'medicalLicenseNumber': + if (!value) return 'Medical license number is required'; + return ''; + case 'specialization': + if (!value) return 'Specialization is required'; + return ''; + case 'yearsOfExperience': + if (value === null || value === undefined || value < 0) return 'Years of experience must be 0 or greater'; + if (value > 50) return 'Years of experience cannot exceed 50'; + return ''; + case 'consultationFee': + if (value === null || value === undefined || value <= 0) return 'Consultation fee must be greater than 0'; + return ''; + default: + return ''; + } + } + + validateAll(): boolean { + this.validationErrors = {}; + let isValid = true; + + const fields: (keyof typeof this.formData)[] = [ + 'email', 'password', 'confirmPassword', 'firstName', 'lastName', + 'phoneNumber', 'medicalLicenseNumber', 'specialization', + 'yearsOfExperience', 'consultationFee' + ]; + + fields.forEach(field => { + const error = this.validateField(field, this.formData[field]); + if (error) { + this.validationErrors[field] = error; + isValid = false; + } + }); + + return isValid; + } + + async submit() { + this.error = null; + this.validationErrors = {}; + + if (!this.validateAll()) { + this.error = 'Please fix the errors in the form'; + return; + } + + this.loading = true; + try { + await this.registrationService.registerDoctor({ + user: { + email: this.formData.email, + password: { + password: this.formData.password, + confirmPassword: this.formData.confirmPassword + }, + firstName: this.formData.firstName, + lastName: this.formData.lastName, + phoneNumber: this.formData.phoneNumber + }, + medicalLicenseNumber: this.formData.medicalLicenseNumber, + specialization: this.formData.specialization, + yearsOfExperience: this.formData.yearsOfExperience, + biography: this.formData.biography || undefined, + consultationFee: this.formData.consultationFee + }); + + this.success = true; + setTimeout(() => { + this.router.navigateByUrl('/login'); + }, 2000); + } catch (e: any) { + this.error = e?.response?.data?.message || e?.message || 'Registration failed. Please try again.'; + } finally { + this.loading = false; + } + } + + togglePasswordVisibility() { + this.showPassword = !this.showPassword; + } + + toggleConfirmPasswordVisibility() { + this.showConfirmPassword = !this.showConfirmPassword; + } + + getFieldError(field: string): string { + return this.validationErrors[field] || ''; + } + + hasFieldError(field: string): boolean { + return !!this.validationErrors[field]; + } +} + diff --git a/frontend/src/app/pages/register/patient/patient-register.component.html b/frontend/src/app/pages/register/patient/patient-register.component.html new file mode 100644 index 0000000..f3f3b17 --- /dev/null +++ b/frontend/src/app/pages/register/patient/patient-register.component.html @@ -0,0 +1,305 @@ +
+
+
+
+ + + + +
+

Patient Registration

+

Create your patient account

+
+ + +
+ + + +
+

Registration Successful!

+

Your account has been created. Redirecting to login...

+
+
+ + +
+
+

Personal Information

+
+
+ + + {{ getFieldError('firstName') }} +
+
+ + + {{ getFieldError('lastName') }} +
+
+ +
+ +
+ + + + + +
+ {{ getFieldError('email') }} +
+ +
+ +
+ + + + +
+ {{ getFieldError('phoneNumber') }} +
+
+ +
+

Medical Information (Optional)

+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ + + + +
+ {{ getFieldError('emergencyContactPhone') }} +
+
+ +
+

Account Security

+
+ +
+ + + + + + +
+
+
+
+
+ + {{ passwordStrengthText || 'Enter a password' }} + +
+ {{ getFieldError('password') }} +
+ +
+ +
+ + + + + + +
+ {{ getFieldError('confirmPassword') }} +
+
+ +
+ + + + + {{ error }} +
+ + +
+ + +
+
+ diff --git a/frontend/src/app/pages/register/patient/patient-register.component.scss b/frontend/src/app/pages/register/patient/patient-register.component.scss new file mode 100644 index 0000000..b36969f --- /dev/null +++ b/frontend/src/app/pages/register/patient/patient-register.component.scss @@ -0,0 +1,854 @@ +// Enterprise-Grade Color Palette +$primary-blue: #0066cc; +$primary-blue-dark: #0052a3; +$primary-blue-light: #e6f2ff; +$primary-blue-lighter: #f0f7ff; +$accent-teal: #0099a1; +$accent-teal-light: #00c4ce; +$text-dark: #0f172a; +$text-medium: #475569; +$text-light: #64748b; +$text-lighter: #94a3b8; +$border-color: #e2e8f0; +$border-color-light: #f1f5f9; +$border-focus: #0066cc; +$error-red: #dc2626; +$error-red-dark: #b91c1c; +$error-bg: #fef2f2; +$error-border: #fecaca; +$success-green: #10b981; +$success-green-dark: #059669; +$success-bg: #d1fae5; +$warning-orange: #f59e0b; +$info-blue: #3b82f6; +$white: #ffffff; +$background-light: #f8fafc; +$background-elevated: #ffffff; +$gradient-primary: linear-gradient(135deg, #0066cc 0%, #0099a1 100%); +$gradient-primary-hover: linear-gradient(135deg, #0052a3 0%, #007a82 100%); +$gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$gradient-bg-alt: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + +// Enterprise Shadow System +$shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +$shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); +$shadow-blue: 0 10px 25px -5px rgba(0, 102, 204, 0.25); +$shadow-blue-lg: 0 20px 40px -10px rgba(0, 102, 204, 0.3); + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + +.register-wrapper { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: $gradient-bg; + background-attachment: fixed; + padding: 2rem; + position: relative; + overflow-y: auto; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.15) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.12) 0%, transparent 50%), + radial-gradient(circle at 50% 20%, rgba(102, 126, 234, 0.3) 0%, transparent 60%); + pointer-events: none; + animation: backgroundShift 20s ease-in-out infinite alternate; + } + + &::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, transparent 70%); + animation: rotateBackground 30s linear infinite; + pointer-events: none; + } + + @keyframes backgroundShift { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0.8; + transform: translateY(-20px); + } + } + + @keyframes rotateBackground { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + @media (max-width: 768px) { + padding: 1rem; + } +} + +.register-container { + background: rgba($white, 0.98); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 24px; + box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.2) inset; + width: 100%; + max-width: 850px; + padding: 3.5rem; + position: relative; + z-index: 1; + animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1); + margin: 2rem 0; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: $gradient-primary; + z-index: 2; + } + + @media (max-width: 768px) { + padding: 2.5rem 2rem; + border-radius: 20px; + margin: 1rem 0; + max-width: 100%; + } + + @media (max-width: 480px) { + padding: 2rem 1.5rem; + border-radius: 16px; + } + + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +} + +.register-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.logo-container { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + background: $gradient-primary; + border-radius: 18px; + margin-bottom: 1.75rem; + box-shadow: $shadow-lg, 0 0 0 4px rgba(0, 102, 204, 0.1); + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 20px; + background: $gradient-primary; + opacity: 0; + filter: blur(12px); + transition: opacity 0.3s ease; + z-index: -1; + } + + &:hover { + transform: translateY(-2px) scale(1.05); + box-shadow: $shadow-xl, 0 0 0 4px rgba(0, 102, 204, 0.15); + + &::after { + opacity: 0.4; + } + } + + .medical-icon { + width: 40px; + height: 40px; + color: $white; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); + transition: transform 0.3s ease; + } + + &:hover .medical-icon { + transform: scale(1.1); + } +} + +.register-title { + font-size: 2rem; + font-weight: 800; + background: $gradient-primary; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 0.75rem 0; + letter-spacing: -0.75px; + font-family: $font-family; + line-height: 1.2; + + @media (max-width: 768px) { + font-size: 1.75rem; + } +} + +.register-subtitle { + font-size: 1rem; + color: $text-medium; + margin: 0; + font-weight: 500; + line-height: 1.5; +} + +.register-form { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.form-section { + display: flex; + flex-direction: column; + gap: 1.75rem; + padding: 2rem 0; + border-bottom: 2px solid $border-color-light; + position: relative; + + &:first-of-type { + padding-top: 0; + } + + &:last-of-type { + border-bottom: none; + padding-bottom: 0; + } + + &::before { + content: ''; + position: absolute; + left: 0; + bottom: -2px; + width: 0; + height: 2px; + background: $gradient-primary; + transition: width 0.3s ease; + } + + &:focus-within::before, + &:hover::before { + width: 100px; + } +} + +.section-title { + font-size: 1.25rem; + font-weight: 700; + background: $gradient-primary; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 0.5rem 0; + font-family: $font-family; + display: flex; + align-items: center; + gap: 0.75rem; + letter-spacing: -0.3px; + + &::before { + content: ''; + width: 4px; + height: 1.5rem; + background: $gradient-primary; + border-radius: 2px; + display: block; + } +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-label { + font-size: 0.875rem; + font-weight: 600; + color: $text-dark; + letter-spacing: 0.2px; + font-family: $font-family; +} + +.input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.input-icon { + position: absolute; + left: 1.125rem; + width: 20px; + height: 20px; + color: $text-light; + pointer-events: none; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; +} + +.form-input { + width: 100%; + padding: 1rem 3rem 1rem 3rem; + font-size: 0.9375rem; + font-family: $font-family; + color: $text-dark; + background: $background-elevated; + border: 2px solid $border-color; + border-radius: 12px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + box-shadow: $shadow-inner; + + &:not(.form-textarea) { + padding-left: 3rem; + } + + &::placeholder { + color: $text-lighter; + font-weight: 400; + } + + &:hover:not(:disabled):not(:focus) { + border-color: $border-color-light; + background: $white; + box-shadow: $shadow-sm; + } + + &:focus { + border-color: $border-focus; + background: $white; + box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md; + transform: translateY(-1px); + + ~ .input-icon { + color: $border-focus; + transform: scale(1.1); + } + } + + &:disabled { + background: $background-light; + cursor: not-allowed; + opacity: 0.6; + border-color: $border-color; + } +} + +.form-textarea { + padding: 1rem 1.25rem; + resize: vertical; + min-height: 120px; + font-family: $font-family; + line-height: 1.6; + border-radius: 12px; + border: 2px solid $border-color; + background: $background-elevated; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: $shadow-inner; + + &:hover:not(:focus) { + border-color: $border-color-light; + background: $white; + box-shadow: $shadow-sm; + } + + &:focus { + border-color: $border-focus; + background: $white; + box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md; + outline: none; + } +} + +select.form-input { + padding-left: 1rem; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b6b6b' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; + padding-right: 2.5rem; +} + +.input-error { + border-color: $error-red !important; + background: $error-bg !important; + + ~ .input-icon { + color: $error-red !important; + } + + &:focus { + box-shadow: 0 0 0 4px rgba($error-red, 0.1), $shadow-md !important; + background: $white !important; + } +} + +.field-error { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: $error-red; + margin-top: 0.375rem; + font-weight: 500; + padding-left: 0.25rem; + animation: slideDown 0.2s ease-out; + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} + +.password-toggle { + position: absolute; + right: 0.75rem; + background: transparent; + border: none; + cursor: pointer; + padding: 0.625rem; + display: flex; + align-items: center; + justify-content: center; + color: $text-light; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 2; + border-radius: 8px; + + svg { + width: 20px; + height: 20px; + transition: transform 0.2s ease; + } + + &:hover { + color: $text-dark; + background: $background-light; + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + background: $primary-blue-lighter; + } +} + +.password-strength { + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.strength-bar { + width: 100%; + height: 4px; + background: $background-light; + border-radius: 2px; + overflow: hidden; +} + +.strength-fill { + height: 100%; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 2px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + &.strength-0 { + width: 0%; + background: transparent; + } + &.strength-1 { + background: linear-gradient(90deg, $error-red, lighten($error-red, 10%)); + box-shadow: 0 0 8px rgba($error-red, 0.3); + } + &.strength-2 { + background: linear-gradient(90deg, $warning-orange, lighten($warning-orange, 10%)); + box-shadow: 0 0 8px rgba($warning-orange, 0.3); + } + &.strength-3 { + background: linear-gradient(90deg, $info-blue, lighten($info-blue, 10%)); + box-shadow: 0 0 8px rgba($info-blue, 0.3); + } + &.strength-4 { + background: linear-gradient(90deg, $success-green, lighten($success-green, 10%)); + box-shadow: 0 0 8px rgba($success-green, 0.3); + } + &.strength-5 { + background: linear-gradient(90deg, $success-green, $success-green-dark); + box-shadow: 0 0 12px rgba($success-green, 0.4); + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } + } +} + +.strength-text { + font-size: 0.8125rem; + font-weight: 600; + transition: color 0.3s ease; + + &.strength-0 { color: $text-lighter; } + &.strength-1 { color: $error-red-dark; } + &.strength-2 { color: darken($warning-orange, 10%); } + &.strength-3 { color: darken($info-blue, 10%); } + &.strength-4 { color: $success-green-dark; } + &.strength-5 { color: $success-green-dark; } +} + +.error-message { + display: flex; + align-items: flex-start; + gap: 0.875rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, $error-bg 0%, lighten($error-bg, 2%) 100%); + border: 2px solid $error-border; + border-left: 4px solid $error-red; + border-radius: 12px; + color: $error-red-dark; + font-size: 0.875rem; + font-weight: 600; + animation: slideInError 0.4s cubic-bezier(0.16, 1, 0.3, 1), shake 0.5s ease-out 0.4s; + box-shadow: $shadow-sm; + margin-top: 0.5rem; + + .error-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 0.125rem; + filter: drop-shadow(0 1px 2px rgba(220, 38, 38, 0.2)); + } + + @keyframes slideInError { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-6px); } + 75% { transform: translateX(6px); } + } +} + +.success-message { + display: flex; + align-items: flex-start; + gap: 1.25rem; + padding: 1.75rem 2rem; + background: linear-gradient(135deg, $success-bg 0%, lighten($success-bg, 2%) 100%); + border: 2px solid rgba($success-green, 0.3); + border-left: 4px solid $success-green; + border-radius: 16px; + margin-bottom: 2rem; + animation: slideInSuccess 0.5s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: $shadow-lg; + + .success-icon { + width: 36px; + height: 36px; + color: $success-green; + flex-shrink: 0; + margin-top: 0.125rem; + filter: drop-shadow(0 2px 4px rgba(16, 185, 129, 0.2)); + animation: checkmark 0.6s cubic-bezier(0.16, 1, 0.3, 1); + } + + .success-content { + flex: 1; + + h3 { + font-size: 1.25rem; + font-weight: 700; + color: $success-green-dark; + margin: 0 0 0.5rem 0; + font-family: $font-family; + letter-spacing: -0.3px; + } + + p { + font-size: 0.9375rem; + color: $text-dark; + margin: 0; + font-family: $font-family; + line-height: 1.6; + } + } + + @keyframes slideInSuccess { + from { + opacity: 0; + transform: translateY(-15px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes checkmark { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + opacity: 1; + } + } +} + +.submit-button { + width: 100%; + padding: 1.125rem 2rem; + font-size: 1.0625rem; + font-weight: 700; + font-family: $font-family; + color: $white; + background: $gradient-primary; + border: none; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: $shadow-md, 0 0 0 1px rgba(255, 255, 255, 0.1) inset; + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + position: relative; + overflow: hidden; + text-transform: none; + letter-spacing: 0.3px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent); + transition: left 0.6s ease; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + padding: 2px; + background: $gradient-primary; + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 0.3s ease; + } + + &:hover:not(:disabled) { + transform: translateY(-3px) scale(1.01); + box-shadow: $shadow-blue-lg, 0 0 0 1px rgba(255, 255, 255, 0.15) inset; + background: $gradient-primary-hover; + + &::before { + left: 100%; + } + + &::after { + opacity: 0.3; + } + } + + &:active:not(:disabled) { + transform: translateY(-1px) scale(0.99); + box-shadow: $shadow-md; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + background: $text-lighter; + box-shadow: none; + } + + &.loading { + pointer-events: none; + } + + .loading-content { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .spinner { + width: 20px; + height: 20px; + animation: spin 0.8s linear infinite; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +} + +.register-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid $border-color; + text-align: center; +} + +.footer-text { + font-size: 0.8125rem; + color: $text-light; + margin: 0; + font-family: $font-family; +} + +.login-link { + color: $primary-blue; + text-decoration: none; + font-weight: 600; + transition: color 0.2s ease; + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } +} + +// Focus visible for accessibility +.form-input:focus-visible, +select.form-input:focus-visible, +.submit-button:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; +} + +// Dark mode support +@media (prefers-color-scheme: dark) { + .register-container { + background: #1e1e1e; + } + + .register-title { + color: $white; + } + + .register-subtitle { + color: rgba($white, 0.7); + } + + .section-title { + color: rgba($white, 0.9); + } + + .form-label { + color: rgba($white, 0.9); + } + + .form-input { + background: #2a2a2a; + border-color: #404040; + color: $white; + + &:focus { + border-color: $border-focus; + } + } + + .footer-text { + color: rgba($white, 0.5); + } +} + diff --git a/frontend/src/app/pages/register/patient/patient-register.component.ts b/frontend/src/app/pages/register/patient/patient-register.component.ts new file mode 100644 index 0000000..4a9e6a4 --- /dev/null +++ b/frontend/src/app/pages/register/patient/patient-register.component.ts @@ -0,0 +1,191 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { RegistrationService } from '../../../services/registration.service'; + +@Component({ + selector: 'app-patient-register', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule], + templateUrl: './patient-register.component.html', + styleUrl: './patient-register.component.scss' +}) +export class PatientRegisterComponent { + formData = { + email: '', + password: '', + confirmPassword: '', + firstName: '', + lastName: '', + phoneNumber: '', + emergencyContactName: '', + emergencyContactPhone: '', + bloodType: '', + allergies: '' + }; + + loading = false; + error: string | null = null; + success = false; + showPassword = false; + showConfirmPassword = false; + passwordStrength = 0; + passwordStrengthText = ''; + validationErrors: { [key: string]: string } = {}; + + bloodTypes = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-']; + + constructor( + private registrationService: RegistrationService, + private router: Router + ) {} + + checkPasswordStrength(password: string): { strength: number; text: string } { + if (!password) { + return { strength: 0, text: '' }; + } + let strength = 0; + if (password.length >= 8) strength++; + if (password.length >= 12) strength++; + if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++; + if (/[0-9]/.test(password)) strength++; + if (/[^A-Za-z0-9]/.test(password)) strength++; + + const texts = ['', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong']; + return { strength, text: texts[strength] || '' }; + } + + onPasswordChange() { + const result = this.checkPasswordStrength(this.formData.password); + this.passwordStrength = result.strength; + this.passwordStrengthText = result.text; + this.validationErrors = {}; + } + + validateField(field: string, value: any): string { + switch (field) { + case 'email': + if (!value) return 'Email is required'; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(value)) return 'Please enter a valid email address'; + return ''; + case 'password': + if (!value) return 'Password is required'; + if (value.length < 8) return 'Password must be at least 8 characters'; + if (value.length > 20) return 'Password must not exceed 20 characters'; + return ''; + case 'confirmPassword': + if (!value) return 'Please confirm your password'; + if (value !== this.formData.password) return 'Passwords do not match'; + return ''; + case 'firstName': + case 'lastName': + if (!value) return 'This field is required'; + if (value.length < 2) return 'Must be at least 2 characters'; + if (value.length > 50) return 'Must not exceed 50 characters'; + return ''; + case 'phoneNumber': + if (!value) return 'Phone number is required'; + const phoneRegex = /^\+?[1-9][0-9]\d{1,14}$/; + if (!phoneRegex.test(value)) return 'Please enter a valid phone number'; + return ''; + case 'emergencyContactPhone': + if (value && this.formData.emergencyContactName) { + const phoneRegex = /^\+?[1-9][0-9]\d{1,14}$/; + if (!phoneRegex.test(value)) return 'Please enter a valid phone number'; + } + return ''; + default: + return ''; + } + } + + validateAll(): boolean { + this.validationErrors = {}; + let isValid = true; + + const fields: (keyof typeof this.formData)[] = [ + 'email', 'password', 'confirmPassword', 'firstName', 'lastName', 'phoneNumber' + ]; + + fields.forEach(field => { + const error = this.validateField(field, this.formData[field]); + if (error) { + this.validationErrors[field] = error; + isValid = false; + } + }); + + // Validate emergency contact phone if emergency contact name is provided + if (this.formData.emergencyContactName) { + const error = this.validateField('emergencyContactPhone', this.formData.emergencyContactPhone); + if (error) { + this.validationErrors['emergencyContactPhone'] = error; + isValid = false; + } + } + + return isValid; + } + + async submit() { + this.error = null; + this.validationErrors = {}; + + if (!this.validateAll()) { + this.error = 'Please fix the errors in the form'; + return; + } + + this.loading = true; + try { + const allergiesArray = this.formData.allergies + ? this.formData.allergies.split(',').map(a => a.trim()).filter(a => a.length > 0) + : undefined; + + await this.registrationService.registerPatient({ + user: { + email: this.formData.email, + password: { + password: this.formData.password, + confirmPassword: this.formData.confirmPassword + }, + firstName: this.formData.firstName, + lastName: this.formData.lastName, + phoneNumber: this.formData.phoneNumber + }, + emergencyContactName: this.formData.emergencyContactName || undefined, + emergencyContactPhone: this.formData.emergencyContactPhone || undefined, + bloodType: this.formData.bloodType || undefined, + allergies: allergiesArray + }); + + this.success = true; + setTimeout(() => { + this.router.navigateByUrl('/login'); + }, 2000); + } catch (e: any) { + this.error = e?.response?.data?.message || e?.message || 'Registration failed. Please try again.'; + } finally { + this.loading = false; + } + } + + togglePasswordVisibility() { + this.showPassword = !this.showPassword; + } + + toggleConfirmPasswordVisibility() { + this.showConfirmPassword = !this.showConfirmPassword; + } + + getFieldError(field: string): string { + return this.validationErrors[field] || ''; + } + + hasFieldError(field: string): boolean { + return !!this.validationErrors[field]; + } +} + diff --git a/frontend/src/app/pages/reset-password/reset-password.component.html b/frontend/src/app/pages/reset-password/reset-password.component.html new file mode 100644 index 0000000..5f7a4f5 --- /dev/null +++ b/frontend/src/app/pages/reset-password/reset-password.component.html @@ -0,0 +1,136 @@ + + diff --git a/frontend/src/app/pages/reset-password/reset-password.component.scss b/frontend/src/app/pages/reset-password/reset-password.component.scss new file mode 100644 index 0000000..740a045 --- /dev/null +++ b/frontend/src/app/pages/reset-password/reset-password.component.scss @@ -0,0 +1,856 @@ +// Enterprise-Grade Color Palette +$primary-blue: #0066cc; +$primary-blue-dark: #0052a3; +$primary-blue-light: #e6f2ff; +$primary-blue-lighter: #f0f7ff; +$accent-teal: #0099a1; +$accent-teal-light: #00c4ce; +$text-dark: #0f172a; +$text-medium: #475569; +$text-light: #64748b; +$text-lighter: #94a3b8; +$border-color: #e2e8f0; +$border-color-light: #f1f5f9; +$border-focus: #0066cc; +$error-red: #dc2626; +$error-red-dark: #b91c1c; +$error-bg: #fef2f2; +$error-border: #fecaca; +$success-green: #10b981; +$success-green-dark: #059669; +$success-bg: #d1fae5; +$warning-orange: #f59e0b; +$info-blue: #3b82f6; +$white: #ffffff; +$background-light: #f8fafc; +$background-elevated: #ffffff; +$gradient-primary: linear-gradient(135deg, #0066cc 0%, #0099a1 100%); +$gradient-primary-hover: linear-gradient(135deg, #0052a3 0%, #007a82 100%); +$gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$gradient-bg-alt: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + +// Enterprise Shadow System +$shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +$shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); +$shadow-blue: 0 10px 25px -5px rgba(0, 102, 204, 0.25); +$shadow-blue-lg: 0 20px 40px -10px rgba(0, 102, 204, 0.3); + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + +.login-wrapper { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: $gradient-bg; + background-attachment: fixed; + padding: 2rem; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.15) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.12) 0%, transparent 50%), + radial-gradient(circle at 50% 20%, rgba(102, 126, 234, 0.3) 0%, transparent 60%); + pointer-events: none; + animation: backgroundShift 20s ease-in-out infinite alternate; + } + + &::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, transparent 70%); + animation: rotateBackground 30s linear infinite; + pointer-events: none; + } + + @keyframes backgroundShift { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0.8; + transform: translateY(-20px); + } + } + + @keyframes rotateBackground { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + @media (max-width: 768px) { + padding: 1rem; + } +} + +.login-container { + background: rgba($white, 0.98); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 24px; + box-shadow: $shadow-2xl, 0 0 0 1px rgba(255, 255, 255, 0.2) inset; + width: 100%; + max-width: 460px; + padding: 3.5rem; + position: relative; + z-index: 1; + animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: $gradient-primary; + z-index: 2; + } + + @media (max-width: 768px) { + padding: 2.5rem 2rem; + border-radius: 20px; + max-width: 100%; + } + + @media (max-width: 480px) { + padding: 2rem 1.5rem; + border-radius: 16px; + } + + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +} + +.login-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.logo-container { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + background: $gradient-primary; + border-radius: 18px; + margin-bottom: 1.75rem; + box-shadow: $shadow-lg, 0 0 0 4px rgba(0, 102, 204, 0.1); + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 20px; + background: $gradient-primary; + opacity: 0; + filter: blur(12px); + transition: opacity 0.3s ease; + z-index: -1; + } + + &:hover { + transform: translateY(-2px) scale(1.05); + box-shadow: $shadow-xl, 0 0 0 4px rgba(0, 102, 204, 0.15); + + &::after { + opacity: 0.4; + } + } + + .medical-icon { + width: 40px; + height: 40px; + color: $white; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); + transition: transform 0.3s ease; + } + + &:hover .medical-icon { + transform: scale(1.1); + } +} + +.login-title { + font-size: 2rem; + font-weight: 800; + background: $gradient-primary; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 0.75rem 0; + letter-spacing: -0.75px; + font-family: $font-family; + line-height: 1.2; + + @media (max-width: 768px) { + font-size: 1.75rem; + } +} + +.login-subtitle { + font-size: 1rem; + color: $text-medium; + margin: 0; + font-weight: 500; + line-height: 1.5; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-label { + font-size: 0.875rem; + font-weight: 600; + color: $text-dark; + letter-spacing: 0.2px; + font-family: $font-family; +} + +.input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.input-icon { + position: absolute; + left: 1.125rem; + width: 20px; + height: 20px; + color: $text-light; + pointer-events: none; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; +} + +.form-input { + width: 100%; + padding: 1rem 3rem 1rem 3rem; + font-size: 0.9375rem; + font-family: $font-family; + color: $text-dark; + background: $background-elevated; + border: 2px solid $border-color; + border-radius: 12px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + box-shadow: $shadow-inner; + + &::placeholder { + color: $text-lighter; + font-weight: 400; + } + + &:hover:not(:disabled):not(:focus) { + border-color: $border-color-light; + background: $white; + box-shadow: $shadow-sm; + } + + &:focus { + border-color: $border-focus; + background: $white; + box-shadow: 0 0 0 4px rgba($border-focus, 0.1), $shadow-md; + transform: translateY(-1px); + + ~ .input-icon { + color: $border-focus; + transform: scale(1.1); + } + } + + &:disabled { + background: $background-light; + cursor: not-allowed; + opacity: 0.6; + border-color: $border-color; + } + + &.input-error { + border-color: $error-red; + background: $error-bg; + + ~ .input-icon { + color: $error-red; + } + + &:focus { + box-shadow: 0 0 0 4px rgba($error-red, 0.1), $shadow-md; + background: $white; + } + } + + &.code-input { + text-align: center; + font-size: 1.75rem; + font-weight: 700; + letter-spacing: 0.75rem; + padding-right: 0.75rem; + padding-left: 0.75rem; + font-variant-numeric: tabular-nums; + background: $primary-blue-lighter; + border-color: $primary-blue-light; + } +} + +.password-toggle { + position: absolute; + right: 0.75rem; + background: transparent; + border: none; + cursor: pointer; + padding: 0.625rem; + display: flex; + align-items: center; + justify-content: center; + color: $text-light; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 2; + border-radius: 8px; + + svg { + width: 20px; + height: 20px; + transition: transform 0.2s ease; + } + + &:hover { + color: $text-dark; + background: $background-light; + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + background: $primary-blue-lighter; + } +} + +.field-error { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: $error-red; + margin-top: 0.375rem; + font-weight: 500; + padding-left: 0.25rem; + animation: slideDown 0.2s ease-out; + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} + +.form-hint { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8125rem; + color: $text-medium; + margin: 0.5rem 0 0 0; + line-height: 1.5; + + svg { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-top: 0.125rem; + color: $text-light; + } +} + +.label-icon { + width: 18px; + height: 18px; + margin-right: 0.5rem; + vertical-align: middle; + color: $primary-blue; +} + +.form-options { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.5rem; + margin-bottom: 1rem; +} + +.remember-me { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: $text-medium; + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: $primary-blue; + } + + span { + font-weight: 500; + } + + &:hover { + color: $text-dark; + } +} + +.forgot-password-link { + font-size: 0.875rem; + color: $primary-blue; + text-decoration: none; + font-weight: 600; + transition: color 0.2s ease; + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + border-radius: 4px; + } +} + +.link-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + color: $primary-blue; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + padding: 0.5rem 0; + margin-top: 0.5rem; + transition: color 0.2s ease; + font-family: $font-family; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + border-radius: 4px; + } +} + +.info-message { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, $primary-blue-lighter 0%, lighten($primary-blue-lighter, 2%) 100%); + border: 2px solid $primary-blue-light; + border-left: 4px solid $primary-blue; + border-radius: 12px; + color: $primary-blue-dark; + font-size: 0.875rem; + font-weight: 600; + animation: slideInInfo 0.4s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: $shadow-sm; + margin-bottom: 1rem; + + svg { + width: 20px; + height: 20px; + flex-shrink: 0; + color: $primary-blue; + } + + @keyframes slideInInfo { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } +} + +.error-message { + display: flex; + align-items: flex-start; + gap: 0.875rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, $error-bg 0%, lighten($error-bg, 2%) 100%); + border: 2px solid $error-border; + border-left: 4px solid $error-red; + border-radius: 12px; + color: $error-red-dark; + font-size: 0.875rem; + font-weight: 600; + animation: slideInError 0.4s cubic-bezier(0.16, 1, 0.3, 1), shake 0.5s ease-out 0.4s; + box-shadow: $shadow-sm; + margin-top: 0.5rem; + + .error-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 0.125rem; + filter: drop-shadow(0 1px 2px rgba(220, 38, 38, 0.2)); + } + + @keyframes slideInError { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-6px); } + 75% { transform: translateX(6px); } + } +} + +.submit-button { + width: 100%; + padding: 1.125rem 2rem; + font-size: 1rem; + font-weight: 700; + font-family: $font-family; + color: $white; + background: $gradient-primary; + border: none; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: $shadow-md, 0 0 0 1px rgba(255, 255, 255, 0.1) inset; + margin-top: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + position: relative; + overflow: hidden; + text-transform: none; + letter-spacing: 0.3px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent); + transition: left 0.6s ease; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + padding: 2px; + background: $gradient-primary; + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 0.3s ease; + } + + &:hover:not(:disabled) { + transform: translateY(-3px) scale(1.01); + box-shadow: $shadow-blue-lg, 0 0 0 1px rgba(255, 255, 255, 0.15) inset; + background: $gradient-primary-hover; + + &::before { + left: 100%; + } + + &::after { + opacity: 0.3; + } + } + + &:active:not(:disabled) { + transform: translateY(-1px) scale(0.99); + box-shadow: $shadow-md; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + background: $text-lighter; + box-shadow: none; + } + + &.loading { + pointer-events: none; + } + + .loading-content { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .spinner { + width: 20px; + height: 20px; + animation: spin 0.8s linear infinite; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +} + +.login-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid $border-color; + text-align: center; +} + +.footer-text { + font-size: 0.8125rem; + color: $text-light; + margin: 0 0 1.5rem 0; + font-family: $font-family; +} + +.registration-links { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.register-link { + display: flex; + align-items: center; + justify-content: center; + gap: 0.625rem; + padding: 0.875rem 1.25rem; + font-size: 0.875rem; + font-weight: 600; + font-family: $font-family; + color: $primary-blue; + text-decoration: none; + border: 2px solid $primary-blue; + border-radius: 10px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + background: transparent; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: $primary-blue-light; + opacity: 0; + transition: opacity 0.25s ease; + z-index: -1; + } + + svg { + width: 18px; + height: 18px; + transition: transform 0.25s ease; + } + + &:hover { + background: $primary-blue-light; + border-color: $primary-blue-dark; + color: $primary-blue-dark; + transform: translateY(-2px); + box-shadow: $shadow-md; + + &::before { + opacity: 1; + } + + svg { + transform: translateX(2px); + } + } + + &:active { + transform: translateY(0); + box-shadow: $shadow-sm; + } + + &:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; + } +} + +// Focus visible for accessibility +.form-input:focus-visible, +.submit-button:focus-visible { + outline: 2px solid $border-focus; + outline-offset: 2px; +} + +// Dark mode support (optional, if needed) +@media (prefers-color-scheme: dark) { + .login-container { + background: #1e1e1e; + } + + .login-title { + color: $white; + } + + .login-subtitle { + color: rgba($white, 0.7); + } + + .form-label { + color: rgba($white, 0.9); + } + + .form-input { + background: #2a2a2a; + border-color: #404040; + color: $white; + + &:focus { + border-color: $border-focus; + } + } + + .footer-text { + color: rgba($white, 0.5); + } +} + +.success-message { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); + border: 1px solid #10b981; + border-radius: 12px; + margin-bottom: 1.5rem; + color: #059669; + font-size: 0.875rem; + line-height: 1.5; + + .success-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + color: #10b981; + } +} + +.back-to-login-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: $primary-blue; + text-decoration: none; + font-size: 0.875rem; + font-weight: 600; + margin-top: 1rem; + transition: color 0.2s ease; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + color: $primary-blue-dark; + text-decoration: underline; + } +} + diff --git a/frontend/src/app/pages/reset-password/reset-password.component.ts b/frontend/src/app/pages/reset-password/reset-password.component.ts new file mode 100644 index 0000000..b2003b8 --- /dev/null +++ b/frontend/src/app/pages/reset-password/reset-password.component.ts @@ -0,0 +1,114 @@ +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule, ActivatedRoute } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule], + templateUrl: './reset-password.component.html', + styleUrl: './reset-password.component.scss' +}) +export class ResetPasswordComponent implements OnInit { + token = ''; + newPassword = ''; + confirmPassword = ''; + error: string | null = null; + success: string | null = null; + loading = false; + passwordError = ''; + confirmPasswordError = ''; + showPassword = false; + showConfirmPassword = false; + + constructor( + private auth: AuthService, + private router: Router, + private route: ActivatedRoute + ) {} + + ngOnInit() { + this.route.queryParams.subscribe(params => { + this.token = params['token'] || ''; + if (!this.token) { + this.error = 'Invalid reset link. Please request a new password reset.'; + } + }); + } + + validatePassword() { + this.passwordError = ''; + if (!this.newPassword) { + return; + } + if (this.newPassword.length < 8) { + this.passwordError = 'Password must be at least 8 characters long'; + } + } + + validateConfirmPassword() { + this.confirmPasswordError = ''; + if (!this.confirmPassword) { + return; + } + if (this.newPassword !== this.confirmPassword) { + this.confirmPasswordError = 'Passwords do not match'; + } + } + + async submit() { + this.error = null; + this.success = null; + this.passwordError = ''; + this.confirmPasswordError = ''; + + if (!this.token) { + this.error = 'Invalid reset link. Please request a new password reset.'; + return; + } + + if (!this.newPassword) { + this.passwordError = 'Password is required'; + return; + } + + this.validatePassword(); + if (this.passwordError) { + return; + } + + if (!this.confirmPassword) { + this.confirmPasswordError = 'Please confirm your password'; + return; + } + + this.validateConfirmPassword(); + if (this.confirmPasswordError) { + return; + } + + this.loading = true; + try { + const result = await this.auth.resetPassword(this.token, this.newPassword, this.confirmPassword); + this.success = result.message || 'Password has been reset successfully.'; + setTimeout(() => { + this.router.navigate(['/login']); + }, 2000); + } catch (e: any) { + this.error = e?.response?.data?.message || e?.response?.data?.error || 'Failed to reset password. Please try again.'; + } finally { + this.loading = false; + } + } + + togglePasswordVisibility() { + this.showPassword = !this.showPassword; + } + + toggleConfirmPasswordVisibility() { + this.showConfirmPassword = !this.showConfirmPassword; + } +} + diff --git a/frontend/src/app/services/admin.service.ts b/frontend/src/app/services/admin.service.ts new file mode 100644 index 0000000..d2d8e57 --- /dev/null +++ b/frontend/src/app/services/admin.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders() { + const token = localStorage.getItem('jwtToken'); + return { Authorization: `Bearer ${token}` }; +} + +@Injectable({ providedIn: 'root' }) +export class AdminService { + async getStats() { + const res = await axios.get(`${BASE_URL}/api/v3/admin/stats`, { headers: authHeaders() }); + return res.data; + } + async getUsers() { + const res = await axios.get(`${BASE_URL}/api/v3/admin/users`, { headers: authHeaders() }); + return res.data; + } + async getDoctors() { + const res = await axios.get(`${BASE_URL}/api/v3/admin/doctors`, { headers: authHeaders() }); + return res.data; + } + async getPatients() { + const res = await axios.get(`${BASE_URL}/api/v3/admin/patients`, { headers: authHeaders() }); + return res.data; + } + async activateUser(email: string) { + const res = await axios.patch(`${BASE_URL}/api/v3/admin/users/${encodeURIComponent(email)}/activate`, {}, { headers: authHeaders() }); + return res.data; + } + async deactivateUser(email: string) { + const res = await axios.patch(`${BASE_URL}/api/v3/admin/users/${encodeURIComponent(email)}/deactivate`, {}, { headers: authHeaders() }); + return res.data; + } + async deleteUser(email: string) { + await axios.delete(`${BASE_URL}/api/v3/admin/users/${encodeURIComponent(email)}`, { headers: authHeaders() }); + } + async verifyDoctor(medicalLicenseNumber: string) { + await axios.patch(`${BASE_URL}/api/v3/admin/doctors/${encodeURIComponent(medicalLicenseNumber)}/verify`, {}, { headers: authHeaders() }); + } + async unverifyDoctor(medicalLicenseNumber: string) { + await axios.patch(`${BASE_URL}/api/v3/admin/doctors/${encodeURIComponent(medicalLicenseNumber)}/unverify`, {}, { headers: authHeaders() }); + } + async deleteAppointment(appointmentId: string) { + await axios.delete(`${BASE_URL}/api/v3/appointments/${appointmentId}`, { headers: authHeaders() }); + } + async getAllAppointments() { + const res = await axios.get(`${BASE_URL}/api/v3/admin/appointments`, { headers: authHeaders() }); + return res.data; + } + + async getUsersPaginated(page: number = 0, size: number = 20, sortBy?: string, direction: string = 'ASC') { + const params: any = { page, size, direction }; + if (sortBy) params.sortBy = sortBy; + const res = await axios.get(`${BASE_URL}/api/v3/admin/users/paginated`, { params, headers: authHeaders() }); + return res.data; + } + + async getMetrics() { + const res = await axios.get(`${BASE_URL}/api/v3/admin/metrics`, { headers: authHeaders() }); + return res.data; + } +} diff --git a/frontend/src/app/services/appointment.service.ts b/frontend/src/app/services/appointment.service.ts new file mode 100644 index 0000000..db5e1e4 --- /dev/null +++ b/frontend/src/app/services/appointment.service.ts @@ -0,0 +1,164 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; +import { LoggerService } from './logger.service'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders() { + const token = localStorage.getItem('jwtToken'); + // Return headers even if token is missing - let server handle 401 response + if (!token || token === 'null' || token === 'undefined') { + // Note: Logger service not available in function scope, using console.warn for critical auth issues + // This is acceptable as it's a critical security-related warning + return { Authorization: 'Bearer ' }; // Empty token, server will return 401 + } + + // Check if token is expired (basic check for logging only - don't remove token, let server validate) + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const expiration = payload.exp * 1000; // Convert to milliseconds + const now = Date.now(); + if (expiration < now) { + // Token expired - server will handle validation + } + } catch (e) { + // If we can't decode, still send it - let server validate + // Silent failure - server will handle validation + } + + return { Authorization: `Bearer ${token}` }; +} + +export interface Appointment { + id?: string; + patientId?: string; + patientFirstName: string; + patientLastName: string; + doctorId?: string; + doctorFirstName: string; + doctorLastName: string; + scheduledDate: string; + scheduledTime: string; + durationInMinutes: number; + status: 'SCHEDULED' | 'CANCELLED' | 'CONFIRMED' | 'COMPLETED'; +} + +export interface AppointmentRequest { + patientId: string; + doctorId: string; + scheduledDate: string; + scheduledTime: string; + durationMinutes: number; // Changed from durationInMinutes to match backend DTO +} + +@Injectable({ providedIn: 'root' }) +export class AppointmentService { + constructor(private logger: LoggerService) {} + async getAppointmentsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/appointments/patient/${patientId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view appointments for your own patients'); + } + throw error; + } + } + + async getAppointmentsByDoctorId(doctorId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/appointments/doctor/${doctorId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view your own appointments'); + } + throw error; + } + } + + async createAppointment(request: AppointmentRequest): Promise { + try { + const res = await axios.post(`${BASE_URL}/api/v3/appointments`, request, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + this.logger.error('Appointment creation error:', error, { + status: error?.response?.status, + data: error?.response?.data + }); + + // Handle 401 Unauthorized - token expired or invalid + if (error?.response?.status === 401) { + // Extract error message from response + const errorMsg = error?.response?.data?.message || + error?.response?.data?.error || + 'Authentication failed. Please refresh the page (F5) or log in again.'; + throw new Error(errorMsg); + } + throw error; + } + } + + async cancelAppointment(appointmentId: string): Promise { + const res = await axios.patch(`${BASE_URL}/api/v3/appointments/${appointmentId}/cancel`, {}, { headers: authHeaders() }); + return res.data; + } + + async confirmAppointment(appointmentId: string): Promise { + try { + const res = await axios.patch(`${BASE_URL}/api/v3/appointments/${appointmentId}/confirm`, {}, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: Only doctors can confirm appointments'); + } + throw error; + } + } + + async completeAppointment(appointmentId: string): Promise { + const res = await axios.patch(`${BASE_URL}/api/v3/appointments/${appointmentId}/complete`, {}, { headers: authHeaders() }); + return res.data; + } + + async getAvailableTimeSlots(doctorId: string, date: string, durationMinutes?: number): Promise { + const params = durationMinutes ? `?durationMinutes=${durationMinutes}` : ''; + const res = await axios.get(`${BASE_URL}/api/v3/appointments/available-slots/${doctorId}/${date}${params}`, { headers: authHeaders() }); + return res.data; + } + + async deleteAppointment(appointmentId: string): Promise { + try { + await axios.delete(`${BASE_URL}/api/v3/appointments/${appointmentId}`, { headers: authHeaders() }); + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only delete your own appointments'); + } + throw error; + } + } + + async removePatientFromHistory(patientId: string): Promise { + try { + await axios.post(`${BASE_URL}/api/v3/appointments/doctor/remove-patient/${patientId}`, {}, { headers: authHeaders() }); + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: Only doctors can remove patients from history'); + } + throw error; + } + } + + async removeDoctorFromHistory(doctorId: string): Promise { + try { + await axios.post(`${BASE_URL}/api/v3/appointments/patient/remove-doctor/${doctorId}`, {}, { headers: authHeaders() }); + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: Only patients can remove doctors from history'); + } + throw error; + } + } +} + diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts new file mode 100644 index 0000000..0ec26d5 --- /dev/null +++ b/frontend/src/app/services/auth.service.ts @@ -0,0 +1,128 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; +import { UserService } from './user.service'; +import { LoggerService } from './logger.service'; +import { BASE_URL, getBaseUrl } from '../config/api.config'; + +const TOKEN_KEY = 'jwtToken'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + constructor( + private userService: UserService, + private logger: LoggerService + ) {} + + async login(email: string, password: string, code?: string, deviceFingerprint?: string): Promise<{ token: string; requires2FA?: boolean }> { + try { + const baseUrl = getBaseUrl(); + this.logger.debug('[AuthService] Login attempt:', { baseUrl, origin: window.location.origin, protocol: window.location.protocol }); + const response = await axios.post(`${baseUrl}/api/v3/auth/login`, { + email, + password, + code: code || null, + deviceFingerprint: deviceFingerprint || null + }, { + withCredentials: true // Enable CORS credentials + }); + const token = response.data?.token ?? response.data?.jwt ?? response.data?.accessToken; + if (!token) throw new Error('No token returned'); + localStorage.setItem(TOKEN_KEY, token); + // Clear user cache on login to force refresh + this.userService.clearUserCache(); + return { token }; + } catch (error: any) { + const errorData = error?.response?.data || {}; + const errorMessage = (errorData.message || errorData.error || '').toLowerCase(); + const statusCode = error?.response?.status; + + // Log for debugging + this.logger.error('[AuthService] Login error:', error, { + status: statusCode, + message: errorData.message || errorData.error, + errorCode: error?.code, + requestUrl: error?.config?.url + }); + + // Check if 2FA is required (when no code provided) + // Backend returns 400 with "2FA code required" + if ((errorMessage.includes('2fa') && errorMessage.includes('code') && errorMessage.includes('required')) || + (errorMessage.includes('2fa') && statusCode === 400 && !code)) { + this.logger.debug('2FA required detected, returning requires2FA: true'); + return { token: '', requires2FA: true }; + } + + // If invalid 2FA code, don't reset requires2FA - let the component handle it + // Just throw the error so the component can display it + if ((errorMessage.includes('invalid') && errorMessage.includes('2fa')) || + (errorMessage.includes('2fa') && statusCode === 401 && code)) { + // Keep requires2FA state, just throw the error + throw error; + } + + throw error; + } + } + + async logout(): Promise { + const token = this.getToken(); + + // Call logout endpoint to set status to OFFLINE (if we have a token) + if (token) { + try { + await axios.post(`${getBaseUrl()}/api/v3/auth/logout`, {}, { + headers: { Authorization: `Bearer ${token}` }, + withCredentials: true + }); + } catch (error: any) { + // Ignore 401 errors (token may already be invalid) or network errors + // Still proceed with local logout cleanup + if (error?.response?.status !== 401) { + this.logger.warn('Error calling logout endpoint:', error); + } + } + } + + // Clear local storage and cache + localStorage.removeItem(TOKEN_KEY); + this.userService.clearUserCache(); + } + + getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); + } + + isAuthenticated(): boolean { + return !!this.getToken(); + } + + async forgotPassword(email: string): Promise<{ message: string }> { + try { + const baseUrl = getBaseUrl(); + const response = await axios.post(`${baseUrl}/api/v3/auth/forgot-password`, { + email + }); + return response.data; + } catch (error: any) { + this.logger.error('[AuthService] Forgot password error:', error); + throw error; + } + } + + async resetPassword(token: string, newPassword: string, confirmPassword: string): Promise<{ message: string }> { + try { + const baseUrl = getBaseUrl(); + const response = await axios.post(`${baseUrl}/api/v3/auth/reset-password`, { + token, + newPassword, + confirmPassword + }); + return response.data; + } catch (error: any) { + this.logger.error('[AuthService] Reset password error:', error); + throw error; + } + } +} diff --git a/frontend/src/app/services/availability.service.ts b/frontend/src/app/services/availability.service.ts new file mode 100644 index 0000000..1f31b20 --- /dev/null +++ b/frontend/src/app/services/availability.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders() { + const token = localStorage.getItem('jwtToken'); + return { Authorization: `Bearer ${token}` }; +} + +export interface AvailabilityRequest { + doctorId: string; + dayOfWeek: 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY'; + startTime: string; + endTime: string; +} + +export interface BulkAvailabilityRequest { + doctorId: string; + availabilities: { + dayOfWeek: 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY'; + startTime: string; + endTime: string; + }[]; +} + +export interface AvailabilityResponse { + id: string; + doctorId: string; + dayOfWeek: string; + startTime: string; + endTime: string; + isAvailable: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class AvailabilityService { + async getDoctorAvailability(doctorId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/availability/doctor/${doctorId}`, { headers: authHeaders() }); + return res.data; + } + + async createAvailability(request: AvailabilityRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/availability`, request, { headers: authHeaders() }); + return res.data; + } + + async updateAvailability(availabilityId: string, request: Partial): Promise { + const res = await axios.patch(`${BASE_URL}/api/v3/availability/${availabilityId}`, request, { headers: authHeaders() }); + return res.data; + } + + async deleteAvailability(availabilityId: string): Promise { + await axios.delete(`${BASE_URL}/api/v3/availability/${availabilityId}`, { headers: authHeaders() }); + } + + async getAvailabilityByDay(doctorId: string, dayOfWeek: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/availability/doctor/${doctorId}/day/${dayOfWeek}`, { headers: authHeaders() }); + return res.data; + } + + async getActiveAvailability(doctorId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/availability/doctor/${doctorId}/active`, { headers: authHeaders() }); + return res.data; + } + + async createBulkAvailability(request: BulkAvailabilityRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/availability/bulk`, request, { headers: authHeaders() }); + return res.data; + } + + async deleteAllDoctorAvailability(doctorId: string): Promise { + await axios.delete(`${BASE_URL}/api/v3/availability/doctor/${doctorId}`, { headers: authHeaders() }); + } +} + diff --git a/frontend/src/app/services/call.service.ts b/frontend/src/app/services/call.service.ts new file mode 100644 index 0000000..6fa634b --- /dev/null +++ b/frontend/src/app/services/call.service.ts @@ -0,0 +1,1070 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { ChatService } from './chat.service'; +import { AuthService } from './auth.service'; +import { UserService } from './user.service'; +import { NotificationService } from './notification.service'; +import { LoggerService } from './logger.service'; +import { StompSubscription } from '@stomp/stompjs'; +import axios from 'axios'; + +import { BASE_URL } from '../config/api.config'; + +export interface CallInfo { + callId: string; + senderId: string; + senderName: string; + senderAvatarUrl?: string; + receiverId: string; + receiverName: string; + receiverAvatarUrl?: string; + callType: 'video' | 'audio'; + callStatus: 'ringing' | 'accepted' | 'rejected' | 'ended' | 'cancelled'; + startedAt?: Date; + endedAt?: Date; +} + +export interface WebRtcSignal { + callId: string; + senderId: string; + receiverId: string; + signalType: 'offer' | 'answer' | 'ice-candidate'; + signalData: any; +} + +export interface TurnConfig { + server: string; + port: number; + username: string; + password: string; + realm: string; + stunUrls: string; + turnUrls: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class CallService { + private callSubject = new Subject(); + private signalSubject = new Subject(); + private currentCall: CallInfo | null = null; + private peerConnection: RTCPeerConnection | null = null; + private localStream: MediaStream | null = null; + private remoteStream: MediaStream | null = null; + private currentUserId: string | null = null; + private isInitiator: boolean = false; + private callSubscription: StompSubscription | null = null; + private signalSubscription: StompSubscription | null = null; + private isSubscribedToCalls: boolean = false; + private isSubscribedToSignals: boolean = false; + private pendingIceCandidates: RTCIceCandidateInit[] = []; + private remoteDescriptionSet: boolean = false; + private callTimeoutId: any = null; // Timer for unanswered call timeout + private callStartTime: Date | null = null; // Track when call started + private turnConfig: TurnConfig | null = null; // Cache TURN config + + public call$ = this.callSubject.asObservable(); + public signal$ = this.signalSubject.asObservable(); + + constructor( + private chatService: ChatService, + private authService: AuthService, + private userService: UserService, + private notificationService: NotificationService, + private logger: LoggerService + ) { + this.setupCallSubscription(); + this.setupSignalSubscription(); + this.initCurrentUser(); + this.fetchTurnConfig(); + } + + private async fetchTurnConfig(): Promise { + try { + const response = await axios.get(`${BASE_URL}/api/v3/turn/config`); + this.turnConfig = response.data; + this.logger.debug('TURN config loaded:', this.turnConfig); + } catch (error) { + this.logger.error('Failed to load TURN config, using fallback STUN servers:', error); + // Use fallback config + this.turnConfig = { + server: 'localhost', + port: 3478, + username: 'telemedicine', + password: 'changeme', + realm: 'localdomain', + stunUrls: 'stun:localhost:3478', + turnUrls: 'turn:localhost:3478' + }; + } + } + + private async initCurrentUser(): Promise { + try { + const user = await this.userService.getCurrentUser(); + if (user) { + this.currentUserId = user.id; + } + } catch (error) { + this.logger.error('Error getting current user for call service:', error); + } + } + + private setupCallSubscription(): void { + // Subscribe to call events via chat service WebSocket + const checkAndSubscribe = () => { + if (this.chatService.isConnected()) { + // Reset subscription flag if we were disconnected + if (!this.isSubscribedToCalls) { + this.subscribeToCalls(); + } + } else { + // Reset subscription flags when disconnected + this.isSubscribedToCalls = false; + this.callSubscription = null; + setTimeout(checkAndSubscribe, 1000); + } + }; + checkAndSubscribe(); + } + + private subscribeToCalls(): void { + // Prevent duplicate subscriptions + if (this.isSubscribedToCalls && this.callSubscription) { + this.logger.debug('Already subscribed to call events'); + return; + } + + const stompClient = (this.chatService as any).stompClient; + if (!stompClient || !this.chatService.isConnected()) { + this.logger.debug('STOMP client not ready for call subscription'); + return; + } + + // Use the same method as chat service to get queue identifier + const userEmail = this.userService.getUserEmail(); + const queueIdentifier = userEmail; + + if (!queueIdentifier) { + this.logger.error('User email not available for call subscription'); + return; + } + + try { + // Unsubscribe existing subscription if any + if (this.callSubscription) { + this.callSubscription.unsubscribe(); + } + + const subscription = stompClient.subscribe(`/user/queue/call`, (message: any) => { + try { + const callInfo: CallInfo = JSON.parse(message.body); + this.logger.debug('Received call event:', callInfo); + this.handleCallEvent(callInfo); + } catch (error) { + this.logger.error('Error parsing call notification:', error); + } + }); + + this.callSubscription = subscription; + this.isSubscribedToCalls = true; + this.logger.debug('Subscribed to call events on queue:', `/user/queue/call`); + } catch (error) { + this.logger.error('Error creating call subscription:', error); + this.isSubscribedToCalls = false; + } + } + + private setupSignalSubscription(): void { + const checkAndSubscribe = () => { + if (this.chatService.isConnected()) { + // Reset subscription flag if we were disconnected + if (!this.isSubscribedToSignals) { + this.subscribeToSignals(); + } + } else { + // Reset subscription flags when disconnected + this.isSubscribedToSignals = false; + this.signalSubscription = null; + setTimeout(checkAndSubscribe, 1000); + } + }; + checkAndSubscribe(); + } + + private subscribeToSignals(): void { + // Prevent duplicate subscriptions + if (this.isSubscribedToSignals && this.signalSubscription) { + this.logger.debug('Already subscribed to signal events'); + return; + } + + const stompClient = (this.chatService as any).stompClient; + if (!stompClient || !this.chatService.isConnected()) { + this.logger.debug('STOMP client not ready for signal subscription'); + return; + } + + // Use the same method as chat service to get queue identifier + const userEmail = this.userService.getUserEmail(); + const queueIdentifier = userEmail; + + if (!queueIdentifier) { + this.logger.error('User email not available for signal subscription'); + return; + } + + try { + // Unsubscribe existing subscription if any + if (this.signalSubscription) { + this.signalSubscription.unsubscribe(); + } + + const subscription = stompClient.subscribe(`/user/queue/webrtc-signal`, (message: any) => { + try { + const signal: WebRtcSignal = JSON.parse(message.body); + this.logger.debug('Received WebRTC signal:', signal); + this.handleWebRtcSignal(signal); + } catch (error) { + this.logger.error('Error parsing WebRTC signal:', error); + } + }); + + this.signalSubscription = subscription; + this.isSubscribedToSignals = true; + this.logger.debug('Subscribed to WebRTC signals on queue:', `/user/queue/webrtc-signal`); + } catch (error) { + this.logger.error('Error subscribing to WebRTC signals:', error); + this.isSubscribedToSignals = false; + } + } + + private handleCallEvent(callInfo: CallInfo): void { + this.logger.debug('Handling call event:', callInfo); + this.logger.debug('Current user ID:', this.currentUserId); + this.logger.debug('Call sender ID:', callInfo.senderId); + this.logger.debug('Call receiver ID:', callInfo.receiverId); + this.logger.debug('Is initiator:', this.isInitiator); + + // Replace optimistic call with real one + if (this.currentCall?.callId?.startsWith('temp-')) { + this.logger.debug('Replacing optimistic call with real call event'); + } + + // Set call start time when ringing + if (callInfo.callStatus === 'ringing' && !this.callStartTime) { + this.callStartTime = new Date(); + callInfo.startedAt = this.callStartTime; + + // Create "Call started" message in chat + this.createCallEventMessage(callInfo, 'started'); + } + + this.currentCall = callInfo; + this.callSubject.next(callInfo); + + if (callInfo.callStatus === 'ringing') { + // Call is ringing - check if we're the sender or receiver + const isSender = this.currentUserId && callInfo.senderId === this.currentUserId; + const isReceiver = this.currentUserId && callInfo.receiverId === this.currentUserId; + + this.logger.debug('Call is ringing - isSender:', isSender, 'isReceiver:', isReceiver); + + if (isSender) { + // We initiated the call - show outgoing call UI + this.logger.debug('Showing outgoing call UI'); + // Keep isInitiator = true (already set in initiateCall) + + // Start timeout for unanswered call (30 seconds) + this.startCallTimeout(); + } else if (isReceiver) { + // We're receiving the call - show incoming call UI + this.logger.debug('Showing incoming call UI'); + this.isInitiator = false; + // Don't start timeout for incoming calls (only for outgoing) + } + } else if (callInfo.callStatus === 'accepted') { + this.logger.debug('Call accepted'); + // Clear timeout since call was answered + this.clearCallTimeout(); + + if (this.isInitiator && !this.peerConnection) { + // We initiated the call and it was accepted, start WebRTC + this.logger.debug('Call accepted, starting WebRTC as initiator'); + this.startWebRtc(); + } else if (!this.isInitiator && !this.peerConnection) { + // We received and accepted a call, start WebRTC + this.logger.debug('Call accepted, starting WebRTC as receiver'); + this.startWebRtc(); + } + } else if (callInfo.callStatus === 'ended' || callInfo.callStatus === 'rejected' || callInfo.callStatus === 'cancelled') { + this.logger.debug('Call ended/rejected/cancelled'); + + // Set call end time + const endTime = new Date(); + callInfo.endedAt = endTime; + + // Create "Call ended" message in chat + this.createCallEventMessage(callInfo, callInfo.callStatus === 'rejected' ? 'rejected' : + callInfo.callStatus === 'cancelled' ? 'missed' : 'ended'); + + // Check if this was a missed call (not answered by receiver within timeout) + const isSender = this.currentUserId && callInfo.senderId === this.currentUserId; + const isReceiver = this.currentUserId && callInfo.receiverId === this.currentUserId; + + // Check for missed call (cancelled without being accepted) + // A call is missed if: + // 1. Status is 'cancelled' (not answered within timeout) + // 2. We are the receiver (didn't answer) + // 3. Call was ringing (never accepted) + if (callInfo.callStatus === 'cancelled' && isReceiver) { + // Receiver missed the call + this.createMissedCallNotification(callInfo); + } + + // Clear timeout since call ended + this.clearCallTimeout(); + this.endCall(); + } + } + + private createCallEventMessage(callInfo: CallInfo, eventType: 'started' | 'ended' | 'rejected' | 'missed'): void { + if (!this.currentUserId) return; + + const isSender = this.currentUserId === callInfo.senderId; + const otherUserId = isSender ? callInfo.receiverId : callInfo.senderId; + const otherUserName = isSender ? callInfo.receiverName : callInfo.senderName; + + let messageContent = ''; + if (eventType === 'started') { + // Only send message to the receiver (who received the call) + // The sender knows they started the call, so no need to send them a message + if (isSender) { + // Current user is the sender - don't send message (they know they started the call) + return; + } else { + // Current user is the receiver - send message to them + messageContent = `📞 ${otherUserName} started a ${callInfo.callType} call`; + } + } else if (eventType === 'ended') { + // Only send message once, from the sender's perspective + // The sender initiated the call, so they send the "call ended" message + if (isSender) { + const duration = this.callStartTime && callInfo.endedAt + ? this.formatCallDuration(callInfo.endedAt.getTime() - this.callStartTime.getTime()) + : ''; + messageContent = `📞 Call ended${duration ? ` - Duration: ${duration}` : ''}`; + } else { + // Receiver doesn't send "call ended" message + return; + } + } else if (eventType === 'rejected') { + // For rejection: only the person who didn't reject sees the message + // If current user rejected, don't send message (they know they rejected) + // If other user rejected, send message to current user + if (isSender) { + // Current user is the caller, other user rejected - show message to current user + messageContent = `📞 ${otherUserName} rejected the call`; + } else { + // Current user rejected - don't send message to chat (they know they rejected) + return; + } + } else if (eventType === 'missed') { + // Only send message to the caller (who initiated the call that was missed) + // The receiver knows they missed the call, so no need to send them a message + if (isSender) { + // Current user is the caller - send message to them + messageContent = `📞 ${otherUserName} missed the call`; + } else { + // Current user is the receiver who missed - don't send message (they know they missed) + return; + } + } + + // Send as system message to chat + if (messageContent) { + this.chatService.sendMessage(otherUserId, messageContent); + } + } + + private formatCallDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s`; + } + return `${seconds}s`; + } + + private createMissedCallNotification(callInfo: CallInfo): void { + if (!this.currentUserId) return; + + const isReceiver = this.currentUserId === callInfo.receiverId; + if (!isReceiver) return; // Only create notification for receiver + + const senderName = callInfo.senderName; + const callTypeLabel = callInfo.callType === 'video' ? 'video call' : 'call'; + + this.notificationService.showNotification({ + type: 'missed-call', + title: `Missed ${callTypeLabel}`, + message: `You missed a ${callTypeLabel} from ${senderName}`, + timestamp: new Date(), + read: false, + actionUrl: `/messages?userId=${callInfo.senderId}` + }); + } + + private startCallTimeout(): void { + // Clear any existing timeout + this.clearCallTimeout(); + + // Set timeout for 30 seconds + this.callTimeoutId = setTimeout(() => { + this.logger.debug('Call timeout: No answer after 30 seconds, hanging up...'); + if (this.currentCall && this.currentCall.callStatus === 'ringing') { + // Only hang up if call is still ringing (not answered/rejected) + // Note: Missed call notification will be created in handleCallEvent when status becomes 'cancelled' + this.cancelCall(); + } + this.clearCallTimeout(); + }, 30000); // 30 seconds + } + + private clearCallTimeout(): void { + if (this.callTimeoutId) { + clearTimeout(this.callTimeoutId); + this.callTimeoutId = null; + } + } + + private handleWebRtcSignal(signal: WebRtcSignal): void { + this.signalSubject.next(signal); + + if (!this.peerConnection) { + this.initializePeerConnection(); + } + + if (signal.signalType === 'offer') { + this.handleOffer(signal.signalData); + } else if (signal.signalType === 'answer') { + this.handleAnswer(signal.signalData); + } else if (signal.signalType === 'ice-candidate') { + this.handleIceCandidate(signal.signalData); + } + } + + async initiateCall(receiverId: string, callType: 'video' | 'audio'): Promise { + if (!this.currentUserId) { + await this.initCurrentUser(); + } + + // Ensure chat service is connected + if (!this.chatService.isConnected()) { + this.logger.debug('WebSocket not connected, attempting to connect...'); + await this.chatService.connect(); + // Wait a bit for connection to establish + await new Promise(resolve => setTimeout(resolve, 1000)); + + if (!this.chatService.isConnected()) { + throw new Error('WebSocket connection failed. Please try again.'); + } + } + + const stompClient = (this.chatService as any).stompClient; + if (!stompClient) { + this.logger.error('STOMP client not available'); + throw new Error('WebSocket client not available'); + } + + // Verify STOMP client is activated + if (!stompClient.active) { + this.logger.warn('STOMP client not active, attempting to activate...'); + stompClient.activate(); + // Wait for activation + await new Promise(resolve => setTimeout(resolve, 500)); + } + + this.logger.debug('STOMP client state check:', { + active: stompClient.active, + connected: this.chatService.isConnected(), + clientExists: !!stompClient, + isSubscribedToCalls: this.isSubscribedToCalls + }); + + // Ensure we're subscribed before initiating - wait for subscription to be confirmed + if (!this.isSubscribedToCalls) { + this.logger.debug('Setting up call subscription before initiating call...'); + this.subscribeToCalls(); + + // Wait for subscription to be established with retries + let retries = 0; + while (!this.isSubscribedToCalls && retries < 10) { + await new Promise(resolve => setTimeout(resolve, 200)); + retries++; + } + + if (!this.isSubscribedToCalls) { + this.logger.warn('Warning: Call subscription may not be ready, but proceeding with call initiation'); + } else { + this.logger.debug('Call subscription confirmed, proceeding with call initiation'); + // Small delay to ensure subscription is fully registered with broker + await new Promise(resolve => setTimeout(resolve, 300)); + } + } else { + this.logger.debug('Already subscribed to call events, ready to initiate call'); + } + + this.isInitiator = true; + + try { + const payload = { + receiverId: receiverId, // Send as string, backend will parse to UUID + callType + }; + this.logger.debug('Publishing call initiate with payload:', payload, 'STOMP client connected:', this.chatService.isConnected()); + + // Create optimistic call state immediately + const optimisticCall: CallInfo = { + callId: 'temp-' + Date.now(), + senderId: this.currentUserId || '', + senderName: 'You', + receiverId: receiverId, + receiverName: 'Connecting...', + callType: callType, + callStatus: 'ringing' + }; + + // Set temporary call state to show UI immediately + this.currentCall = optimisticCall; + // Set call start time + this.callStartTime = new Date(); + optimisticCall.startedAt = this.callStartTime; + this.callSubject.next(optimisticCall); + this.logger.debug('Set optimistic call state:', optimisticCall); + + // Create "Call started" message immediately for optimistic state + this.createCallEventMessage(optimisticCall, 'started'); + + // Start timeout for unanswered call (30 seconds) + // This handles the optimistic call state - will be cleared if real call event is received + this.startCallTimeout(); + + try { + this.logger.debug('About to publish to STOMP:', { + destination: '/app/call.initiate', + payload: payload, + clientConnected: this.chatService.isConnected(), + stompClientExists: !!stompClient + }); + + // Get auth token for headers + const token = this.authService.getToken(); + const headers: any = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // Publish with explicit headers including Authorization + const publishFrame = { + destination: '/app/call.initiate', + body: JSON.stringify(payload), + headers: headers + }; + + this.logger.debug('Publishing frame:', { + destination: publishFrame.destination, + body: publishFrame.body, + hasAuthHeader: !!headers['Authorization'] + }); + + // Verify STOMP client state + this.logger.debug('STOMP client active:', stompClient.active); + this.logger.debug('STOMP client state:', (stompClient as any).connected); + + try { + stompClient.publish(publishFrame); + this.logger.debug('Call initiation message published successfully'); + } catch (publishError) { + this.logger.error('Error during STOMP publish:', publishError); + throw publishError; + } + } catch (error) { + this.logger.error('Error publishing call initiation:', error); + throw error; + } + + // Replace optimistic state with real one when received, or clear after timeout + // Increase timeout to 15 seconds to give backend more time + const timeoutId = setTimeout(() => { + if (this.currentCall?.callId?.startsWith('temp-')) { + this.logger.warn('No call confirmation received after 15 seconds, clearing optimistic state'); + this.logger.warn('This might indicate a WebSocket delivery issue'); + this.logger.warn('Subscription status:', { + isSubscribed: this.isSubscribedToCalls, + hasSubscription: !!this.callSubscription, + connected: this.chatService.isConnected() + }); + // Don't clear if we received a real call event + if (this.currentCall?.callId?.startsWith('temp-')) { + this.currentCall = null; + this.callSubject.next(null as any); + } + } + }, 15000); + + // Clear timeout if we receive a real call event + const subscription = this.call$.subscribe((callInfo: CallInfo | null) => { + if (callInfo && !callInfo.callId?.startsWith('temp-')) { + clearTimeout(timeoutId); + subscription.unsubscribe(); + } + }); + } catch (error) { + this.logger.error('Error publishing call initiation:', error); + this.currentCall = null; + throw error; + } + } + + async answerCall(): Promise { + if (!this.currentCall) return; + + const stompClient = (this.chatService as any).stompClient; + if (!stompClient) return; + + this.isInitiator = false; + + stompClient.publish({ + destination: '/app/call.answer', + body: JSON.stringify(this.currentCall) + }); + } + + async rejectCall(): Promise { + if (!this.currentCall) return; + + // Clear timeout when rejecting call + this.clearCallTimeout(); + + const stompClient = (this.chatService as any).stompClient; + if (!stompClient) return; + + stompClient.publish({ + destination: '/app/call.reject', + body: JSON.stringify(this.currentCall) + }); + + this.currentCall = null; + } + + async cancelCall(): Promise { + if (!this.currentCall) return; + + // Clear timeout when cancelling call + this.clearCallTimeout(); + + const stompClient = (this.chatService as any).stompClient; + if (!stompClient) return; + + stompClient.publish({ + destination: '/app/call.cancel', + body: JSON.stringify(this.currentCall) + }); + + this.endCall(); + } + + async endCall(): Promise { + // Clear timeout when ending call + this.clearCallTimeout(); + + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); + this.localStream = null; + } + + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + + if (this.currentCall) { + // Set end time if not already set + if (!this.currentCall.endedAt) { + this.currentCall.endedAt = new Date(); + } + + if (this.currentCall.callStatus === 'accepted') { + const stompClient = (this.chatService as any).stompClient; + if (stompClient) { + stompClient.publish({ + destination: '/app/call.end', + body: JSON.stringify(this.currentCall) + }); + } + } + } + + // Clear call start time + this.callStartTime = null; + this.currentCall = null; + this.isInitiator = false; + this.remoteStream = null; + } + + getCurrentCall(): CallInfo | null { + return this.currentCall; + } + + getLocalStream(): MediaStream | null { + return this.localStream; + } + + getRemoteStream(): MediaStream | null { + return this.remoteStream; + } + + async toggleMute(): Promise { + if (!this.localStream) return false; + + const audioTracks = this.localStream.getAudioTracks(); + if (audioTracks.length > 0) { + const enabled = !audioTracks[0].enabled; + audioTracks.forEach(track => track.enabled = enabled); + return enabled; + } + return false; + } + + async toggleVideo(): Promise { + if (!this.localStream) return false; + + const videoTracks = this.localStream.getVideoTracks(); + if (videoTracks.length > 0) { + const enabled = !videoTracks[0].enabled; + videoTracks.forEach(track => track.enabled = enabled); + return enabled; + } + return false; + } + + private async startWebRtc(): Promise { + try { + this.logger.debug('Starting WebRTC, isInitiator:', this.isInitiator); + await this.initializePeerConnection(); + this.logger.debug('Peer connection initialized'); + + await this.getUserMedia(); + this.logger.debug('Media obtained successfully'); + + if (this.isInitiator) { + this.logger.debug('Creating offer as initiator'); + await this.createOffer(); + } else { + this.logger.debug('Waiting for offer as receiver'); + } + } catch (error: any) { + this.logger.error('Error starting WebRTC:', error); + + // If media access fails, end the call with an error status + if (this.currentCall && error.message) { + this.logger.error('WebRTC failed, ending call due to error:', error.message); + // Update call status to indicate failure + this.currentCall.callStatus = 'ended'; + this.callSubject.next(this.currentCall); + + // Clean up any partial setup + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); + this.localStream = null; + } + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection = null; + } + + // Notify user via call event with error info + const errorCallInfo = { + ...this.currentCall, + error: error.message || 'Failed to start video/audio call. Please check your permissions and try again.' + }; + this.callSubject.next(errorCallInfo as any); + } + + // Re-throw to allow calling code to handle if needed + throw error; + } + } + + private async initializePeerConnection(): Promise { + // Wait for TURN config if not yet loaded + if (!this.turnConfig) { + await this.fetchTurnConfig(); + } + + // Build ICE servers configuration + const iceServers: RTCIceServer[] = []; + + // Add Google STUN servers as fallback + iceServers.push({ urls: 'stun:stun.l.google.com:19302' }); + iceServers.push({ urls: 'stun:stun1.l.google.com:19302' }); + + // Add TURN server configuration if available + if (this.turnConfig) { + const turnServer: RTCIceServer = { + urls: [ + `turn:${this.turnConfig.server}:${this.turnConfig.port}?transport=udp`, + `turn:${this.turnConfig.server}:${this.turnConfig.port}?transport=tcp` + ], + username: this.turnConfig.username, + credential: this.turnConfig.password + }; + iceServers.push(turnServer); + this.logger.debug('Using TURN server:', this.turnConfig.server + ':' + this.turnConfig.port); + } + + const configuration: RTCConfiguration = { + iceServers: iceServers, + iceTransportPolicy: 'all' as RTCIceTransportPolicy + }; + + this.logger.debug('Peer connection ICE servers:', iceServers); + this.peerConnection = new RTCPeerConnection(configuration); + // Reset signaling state helpers + this.pendingIceCandidates = []; + this.remoteDescriptionSet = false; + + // Handle remote stream + this.peerConnection.ontrack = (event) => { + this.remoteStream = event.streams[0]; + this.signalSubject.next({ + callId: this.currentCall?.callId || '', + senderId: '', + receiverId: '', + signalType: 'ice-candidate', + signalData: { remoteStreamReady: true } + }); + }; + + // Handle ICE candidates + this.peerConnection.onicecandidate = (event) => { + if (event.candidate && this.currentCall) { + this.sendSignal('ice-candidate', event.candidate); + } + }; + + // Handle connection state changes + this.peerConnection.onconnectionstatechange = () => { + if (this.peerConnection?.connectionState === 'failed' || + this.peerConnection?.connectionState === 'disconnected') { + this.logger.debug('Peer connection failed or disconnected'); + } + }; + } + + private async getUserMedia(): Promise { + // Check if mediaDevices API is available + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + const error = new Error('Media devices API is not available. Please ensure you are using HTTPS or localhost.'); + this.logger.error('Media devices not available:', error); + throw error; + } + + // Check if we're in a secure context (required for getUserMedia) + const isSecureContext = window.isSecureContext || + location.protocol === 'https:' || + location.hostname === 'localhost' || + location.hostname === '127.0.0.1'; + + if (!isSecureContext) { + const error = new Error('Media access requires HTTPS or localhost.'); + this.logger.error('Not in secure context:', error); + throw error; + } + + const isVideoCall = this.currentCall?.callType === 'video'; + let constraints: MediaStreamConstraints = { + audio: true, + video: isVideoCall + }; + + try { + this.logger.debug('Requesting media access with constraints:', constraints); + this.localStream = await navigator.mediaDevices.getUserMedia(constraints); + this.logger.debug('Media access granted, stream obtained'); + + // Add tracks to peer connection + if (this.peerConnection && this.localStream) { + this.localStream.getTracks().forEach(track => { + if (this.peerConnection) { + this.peerConnection.addTrack(track, this.localStream!); + } + }); + this.logger.debug('Added media tracks to peer connection'); + } + } catch (error: any) { + this.logger.error('Error accessing media devices:', error); + + // For video calls, try audio-only fallback if video fails + if (isVideoCall && (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError' || + error.message?.includes('not found'))) { + this.logger.debug('Video access failed, attempting audio-only fallback...'); + try { + constraints = { audio: true, video: false }; + this.logger.debug('Requesting audio-only access:', constraints); + this.localStream = await navigator.mediaDevices.getUserMedia(constraints); + this.logger.debug('Audio-only access granted, continuing call without video'); + + // Update call type to audio if we're falling back + if (this.currentCall) { + this.logger.debug('Falling back to audio-only call'); + // Note: The call info still says video, but we'll only have audio + } + + // Add tracks to peer connection + if (this.peerConnection && this.localStream) { + this.localStream.getTracks().forEach(track => { + if (this.peerConnection) { + this.peerConnection.addTrack(track, this.localStream!); + } + }); + this.logger.debug('Added audio-only tracks to peer connection'); + } + return; // Success with audio-only + } catch (audioError: any) { + this.logger.error('Audio-only fallback also failed:', audioError); + // Continue to throw the original video error with better message + } + } + + // Provide user-friendly error messages + let errorMessage = 'Failed to access media devices.'; + if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { + errorMessage = 'Camera/microphone access was denied. Please allow access in your browser settings and try again.'; + } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError' || + error.message?.includes('not found') || error.message?.includes('not found here')) { + errorMessage = 'No camera/microphone found. Please connect a device or check your browser permissions.'; + } else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') { + errorMessage = 'Camera/microphone is being used by another application. Please close other apps using the device.'; + } else if (error.name === 'OverconstrainedError' || error.name === 'ConstraintNotSatisfiedError') { + errorMessage = 'Camera/microphone does not support the requested settings. Trying audio-only...'; + } + + this.logger.error('Detailed error info:', error, { + name: error.name, + message: error.message, + constraint: error.constraint + }); + + // Create a more informative error + const mediaError = new Error(errorMessage); + (mediaError as any).originalError = error; + throw mediaError; + } + } + + private async createOffer(): Promise { + if (!this.peerConnection) return; + + try { + const offer = await this.peerConnection.createOffer(); + await this.peerConnection.setLocalDescription(offer); + this.sendSignal('offer', offer); + } catch (error) { + this.logger.error('Error creating offer:', error); + } + } + + private async handleOffer(offer: RTCSessionDescriptionInit): Promise { + if (!this.peerConnection) return; + + try { + await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); + this.remoteDescriptionSet = true; + await this.getUserMedia(); + const answer = await this.peerConnection.createAnswer(); + await this.peerConnection.setLocalDescription(answer); + this.sendSignal('answer', answer); + // Drain any ICE candidates that arrived before remote description + if (this.pendingIceCandidates.length > 0) { + for (const candidate of this.pendingIceCandidates) { + try { + await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (e) { + this.logger.error('Error applying queued ICE candidate:', e); + } + } + this.pendingIceCandidates = []; + } + } catch (error) { + this.logger.error('Error handling offer:', error); + } + } + + private async handleAnswer(answer: RTCSessionDescriptionInit): Promise { + if (!this.peerConnection) return; + + try { + await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer)); + } catch (error) { + this.logger.error('Error handling answer:', error); + } + } + + private async handleIceCandidate(candidate: RTCIceCandidateInit): Promise { + if (!this.peerConnection) return; + + try { + // If remote description not yet set, queue the candidate + const hasRemote = !!this.peerConnection.remoteDescription; + if (!hasRemote && !this.remoteDescriptionSet) { + this.pendingIceCandidates.push(candidate); + return; + } + await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (error) { + this.logger.error('Error handling ICE candidate:', error); + } + } + + private sendSignal(signalType: 'offer' | 'answer' | 'ice-candidate', signalData: any): void { + if (!this.currentCall) return; + + if (!this.chatService.isConnected()) { + this.logger.error('WebSocket not connected for sending signal'); + return; + } + + const stompClient = (this.chatService as any).stompClient; + if (!stompClient) { + this.logger.error('STOMP client not available for sending signal'); + return; + } + + const otherUserId = this.currentCall.senderId === this.currentUserId + ? this.currentCall.receiverId + : this.currentCall.senderId; + + const signal: WebRtcSignal = { + callId: this.currentCall.callId, + senderId: this.currentUserId || '', + receiverId: otherUserId, + signalType, + signalData + }; + + try { + stompClient.publish({ + destination: '/app/call.signal', + body: JSON.stringify(signal) + }); + } catch (error) { + this.logger.error('Error sending WebRTC signal:', error); + } + } +} + diff --git a/frontend/src/app/services/chat.service.ts b/frontend/src/app/services/chat.service.ts new file mode 100644 index 0000000..86fb1d0 --- /dev/null +++ b/frontend/src/app/services/chat.service.ts @@ -0,0 +1,627 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import SockJS from 'sockjs-client'; +import { Client, IMessage, StompSubscription } from '@stomp/stompjs'; +import axios from 'axios'; +import { AuthService } from './auth.service'; +import { UserService } from './user.service'; +import { LoggerService } from './logger.service'; + +import { BASE_URL, WS_URL, getWsUrl } from '../config/api.config'; + +export interface Message { + id?: string; + senderId: string; + senderName: string; + receiverId: string; + receiverName: string; + content: string; + isRead: boolean; + createdAt: string; +} + +export interface Conversation { + otherUserId: string; + otherUserName: string; + otherUserRole: string; + isOnline: boolean; + otherUserStatus?: 'ONLINE' | 'OFFLINE' | 'BUSY'; + lastSeen?: string; + unreadCount: number; + lastMessage?: Message; + messages?: Message[]; + otherUserAvatarUrl?: string; +} + +export interface ChatUser { + userId: string; + firstName: string; + lastName: string; + specialization?: string; + isVerified?: boolean; + isOnline: boolean; + status?: 'ONLINE' | 'OFFLINE' | 'BUSY'; + avatarUrl?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class ChatService { + private stompClient: Client | null = null; + private connected = false; + private messageSubject = new Subject(); + private conversationsSubject = new BehaviorSubject([]); + private onlineStatusSubject = new Subject<{ userId: string; isOnline: boolean; status?: string; lastSeen?: string }>(); + private typingSubject = new Subject<{ userId: string; isTyping: boolean; senderName?: string }>(); + + public messages$ = this.messageSubject.asObservable(); + public conversations$ = this.conversationsSubject.asObservable(); + public onlineStatus$ = this.onlineStatusSubject.asObservable(); + public typing$ = this.typingSubject.asObservable(); + private currentUserId: string | null = null; + + constructor( + private authService: AuthService, + private userService: UserService, + private logger: LoggerService + ) {} + + async connect(): Promise { + if (this.connected || !this.authService.isAuthenticated()) { + return; + } + + const token = this.authService.getToken(); + if (!token) { + this.logger.error('No token available for WebSocket connection'); + return; + } + + // Get current user ID and status + let currentUserStatus: 'ONLINE' | 'OFFLINE' | 'BUSY' | null = null; + try { + const user = await this.userService.getCurrentUser(); + if (user) { + this.currentUserId = user.id; + currentUserStatus = (user.status as 'ONLINE' | 'OFFLINE' | 'BUSY') || null; + } + } catch (error) { + this.logger.error('Error getting current user:', error); + } + + // Use dynamic WebSocket URL to ensure it's computed at runtime + const wsUrl = getWsUrl(); + this.logger.debug('[ChatService] Connecting to WebSocket:', wsUrl); + this.stompClient = new Client({ + webSocketFactory: () => new SockJS(wsUrl) as any, + connectHeaders: { + Authorization: `Bearer ${token}` + }, + reconnectDelay: 5000, + heartbeatIncoming: 4000, + heartbeatOutgoing: 4000, + onConnect: () => { + this.logger.debug('WebSocket Connected'); + this.connected = true; + this.subscribeToMessages(); + // Backend now handles respecting OFFLINE/BUSY status automatically + // We just need to notify backend that WebSocket is connected + // Backend will check current status and only update if appropriate + setTimeout(() => { + this.updateOnlineStatus(true).catch(err => { + this.logger.warn('Failed to update online status on connect:', err); + }); + }, 500); + }, + onStompError: (frame) => { + this.logger.error('WebSocket STOMP error:', frame); + }, + onWebSocketClose: () => { + this.logger.debug('WebSocket Disconnected'); + this.connected = false; + // Don't update online status on disconnect if token still exists + // This preserves status during page refresh - it will be restored on reconnect + // Only explicit disconnect() call (logout) will set status to OFFLINE + // The backend updateOnlineStatus(false) now only sets isOnline=false without changing status + } + }); + + this.stompClient.activate(); + } + + async disconnect(): Promise { + if (this.stompClient) { + // Update online status to false before disconnecting (explicit logout) + // Only try if we have a valid token (avoid 401 on logout) + const token = this.authService.getToken(); + if (token && this.connected) { + try { + // Call updateUserStatus with OFFLINE to explicitly set status to OFFLINE on logout + await axios.put(`${BASE_URL}/api/v3/messages/status?status=OFFLINE`, {}, { + headers: { Authorization: `Bearer ${token}` } + }); + } catch (err: any) { + // Silently ignore 401 errors during logout (token may already be invalid) + // Don't log as error since this is expected behavior during logout + if (err?.response?.status !== 401) { + this.logger.warn('Could not update status to OFFLINE on disconnect:', err); + } + } + } + this.stompClient.deactivate(); + this.connected = false; + } + } + + private subscribeToMessages(): void { + if (!this.stompClient || !this.connected) return; + + const userId = this.currentUserId || this.getUserIdFromToken(this.authService.getToken()!); + + if (userId) { + // Subscribe to personal message queue - using email as identifier for user-specific queue + const userEmail = this.userService.getUserEmail(); + const queueIdentifier = userEmail || userId; + + this.stompClient.subscribe(`/user/queue/messages`, (message: IMessage) => { + const msg: Message = JSON.parse(message.body); + this.messageSubject.next(msg); + this.refreshConversations(); + }); + + // Subscribe to typing notifications + this.stompClient.subscribe(`/user/queue/typing`, (message: IMessage) => { + try { + const typingData = JSON.parse(message.body); + this.logger.debug('Typing notification received:', typingData); + this.typingSubject.next({ + userId: typingData.senderId, + isTyping: typingData.isTyping, + senderName: typingData.senderName + }); + } catch (error) { + this.logger.error('Error parsing typing notification:', error); + } + }); + } + + // Subscribe to online status updates + this.stompClient.subscribe('/topic/online-status', (message: IMessage) => { + const status = JSON.parse(message.body); + this.onlineStatusSubject.next(status); + // Refresh conversations when online status changes to update UI + this.refreshConversations(); + }); + } + + sendMessage(receiverId: string, content: string): void { + if (!this.stompClient || !this.connected) { + this.logger.error('WebSocket not connected'); + return; + } + + const message: any = { + receiverId, + content + }; + + this.stompClient.publish({ + destination: '/app/chat.send', + body: JSON.stringify(message) + }); + } + + sendTypingNotification(receiverId: string, isTyping: boolean): void { + if (!this.stompClient || !this.connected) { + this.logger.warn('WebSocket not connected, cannot send typing notification'); + return; + } + + try { + this.stompClient.publish({ + destination: '/app/chat.typing', + body: JSON.stringify({ + receiverId, + isTyping + }) + }); + } catch (error) { + this.logger.error('Error sending typing notification:', error); + } + } + + async getConversations(): Promise { + try { + const token = this.authService.getToken(); + const res = await axios.get(`${BASE_URL}/api/v3/messages/conversations`, { + headers: { Authorization: `Bearer ${token}` } + }); + const conversations = res.data; + + // Deduplicate conversations by otherUserId + const uniqueConversations = conversations.reduce((acc: Conversation[], conversation: Conversation) => { + const existingIndex = acc.findIndex(c => c.otherUserId === conversation.otherUserId); + if (existingIndex === -1) { + acc.push(conversation); + } else { + // Keep the most recent conversation + const existing = acc[existingIndex]; + if (conversation.lastMessage && (!existing.lastMessage || + new Date(conversation.lastMessage.createdAt) > new Date(existing.lastMessage.createdAt))) { + acc[existingIndex] = conversation; + } else { + // Update status if duplicate has newer status + existing.otherUserStatus = conversation.otherUserStatus || existing.otherUserStatus; + existing.isOnline = conversation.isOnline; + existing.lastSeen = conversation.lastSeen; + } + } + return acc; + }, [] as Conversation[]); + + this.conversationsSubject.next(uniqueConversations); + return uniqueConversations; + } catch (error) { + this.logger.error('Error fetching conversations:', error); + return []; + } + } + + async getConversation(otherUserId: string): Promise { + try { + const token = this.authService.getToken(); + const response = await axios.get(`${BASE_URL}/api/v3/messages/conversation/${otherUserId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + return response.data; + } catch (error) { + this.logger.error('Error fetching conversation:', error); + return []; + } + } + + async markAsRead(senderId: string): Promise { + try { + const token = this.authService.getToken(); + await axios.put(`${BASE_URL}/api/v3/messages/read/${senderId}`, {}, { + headers: { Authorization: `Bearer ${token}` } + }); + this.refreshConversations(); + } catch (error) { + this.logger.error('Error marking messages as read:', error); + } + } + + async getUnreadCount(): Promise { + try { + const token = this.authService.getToken(); + const response = await axios.get(`${BASE_URL}/api/v3/messages/unread/count`, { + headers: { Authorization: `Bearer ${token}` } + }); + return response.data; + } catch (error) { + this.logger.error('Error fetching unread count:', error); + return 0; + } + } + + async refreshConversations(): Promise { + await this.getConversations(); + } + + async updateUserStatus(status: 'ONLINE' | 'OFFLINE' | 'BUSY'): Promise { + try { + const token = this.authService.getToken(); + await axios.put(`${BASE_URL}/api/v3/messages/status?status=${status}`, {}, { + headers: { Authorization: `Bearer ${token}` } + }); + } catch (error) { + this.logger.error('Error updating user status:', error); + throw error; + } + } + + private async updateOnlineStatus(online: boolean): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + // No token available, skip update (likely during logout) + return; + } + await axios.put(`${BASE_URL}/api/v3/messages/status/online?online=${online}`, {}, { + headers: { Authorization: `Bearer ${token}` } + }); + } catch (error: any) { + // Don't log 401 errors as they're expected during logout + if (error?.response?.status !== 401) { + this.logger.error('Error updating online status:', error); + } + } + } + + // Expose a safe public method to update online status on lifecycle events + async setOnline(online: boolean): Promise { + await this.updateOnlineStatus(online); + } + + private getUserIdFromToken(token: string): string | null { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.sub || payload.userId || null; + } catch { + return null; + } + } + + isConnected(): boolean { + return this.connected; + } + + getCurrentUserId(): string | null { + return this.currentUserId; + } + + async searchDoctors(query: string = ''): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + this.logger.error('No authentication token available'); + return []; + } + const response = await axios.get(`${BASE_URL}/api/v3/messages/search/doctors`, { + params: { query }, + headers: { Authorization: `Bearer ${token}` } + }); + this.logger.debug('Search doctors response:', response.data); + return response.data || []; + } catch (error: any) { + this.logger.error('Error searching doctors:', error); + if (error.response) { + this.logger.error('Response status:', error.response.status); + this.logger.error('Response data:', error.response.data); + } + return []; + } + } + + async searchPatients(query: string = ''): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + this.logger.error('No authentication token available'); + return []; + } + const response = await axios.get(`${BASE_URL}/api/v3/messages/search/patients`, { + params: { query }, + headers: { Authorization: `Bearer ${token}` } + }); + this.logger.debug('Search patients response:', response.data); + return response.data || []; + } catch (error: any) { + this.logger.error('Error searching patients:', error); + if (error.response) { + this.logger.error('Response status:', error.response.status); + this.logger.error('Response data:', error.response.data); + } + return []; + } + } + + async deleteMessage(messageId: string): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + throw new Error('No authentication token available'); + } + await axios.delete(`${BASE_URL}/api/v3/messages/message/${messageId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + // Refresh conversations after deletion + await this.refreshConversations(); + } catch (error: any) { + this.logger.error('Error deleting message:', error); + throw error; + } + } + + async deleteConversation(otherUserId: string): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + throw new Error('No authentication token available'); + } + await axios.delete(`${BASE_URL}/api/v3/messages/conversation/${otherUserId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + // Refresh conversations after deletion + await this.refreshConversations(); + } catch (error: any) { + this.logger.error('Error deleting conversation:', error); + throw error; + } + } + + async blockUser(userId: string): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + throw new Error('No authentication token available'); + } + await axios.post(`${BASE_URL}/api/v3/messages/block/${userId}`, {}, { + headers: { Authorization: `Bearer ${token}` } + }); + // Refresh conversations after blocking + await this.refreshConversations(); + } catch (error: any) { + this.logger.error('Error blocking user:', error); + throw error; + } + } + + async unblockUser(userId: string): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + throw new Error('No authentication token available'); + } + // Ensure userId is properly formatted as a string and remove any whitespace + let formattedUserId = String(userId).trim(); + + // Validate UUID format (basic check for UUID structure) + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(formattedUserId)) { + this.logger.error('Invalid UUID format:', formattedUserId); + throw new Error(`Invalid user ID format: ${formattedUserId}`); + } + + this.logger.debug('Unblocking user with ID:', formattedUserId); + const url = `${BASE_URL}/api/v3/messages/block/${formattedUserId}`; + this.logger.debug('Unblock URL:', url); + + const response = await axios.delete(url, { + headers: { Authorization: `Bearer ${token}` } + }); + this.logger.debug('Unblock response:', response.data); + // Refresh conversations after unblocking + await this.refreshConversations(); + } catch (error: any) { + this.logger.error('Error unblocking user:', error, { + response: error?.response?.data, + status: error?.response?.status, + message: error?.message, + userId: userId + }); + + // Re-throw with more details + const errorMessage = error?.response?.data?.message || error?.response?.data?.error || error?.message || 'Failed to unblock user'; + const enhancedError = new Error(errorMessage); + (enhancedError as any).response = error?.response; + throw enhancedError; + } + } + + async isBlocked(userId: string): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + return false; + } + const response = await axios.get(`${BASE_URL}/api/v3/messages/block/status/${userId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + return response.data.blocked || false; + } catch (error: any) { + this.logger.error('Error checking block status:', error); + return false; + } + } + + async getBlockedUsers(): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + return []; + } + const response = await axios.get(`${BASE_URL}/api/v3/messages/block/list`, { + headers: { Authorization: `Bearer ${token}` } + }); + return response.data || []; + } catch (error: any) { + this.logger.error('Error getting blocked users:', error); + return []; + } + } + + async getBlockedUsersWithDetails(): Promise { + try { + const token = this.authService.getToken(); + if (!token) { + this.logger.warn('No authentication token available for getting blocked users'); + return []; + } + const response = await axios.get(`${BASE_URL}/api/v3/messages/block/list/details`, { + headers: { Authorization: `Bearer ${token}` } + }); + this.logger.debug('Blocked users with details response:', response.data); + + // Ensure userId is converted to string if it comes as UUID + const users: ChatUser[] = (response.data || []).map((user: any) => { + // Handle UUID - it might come as an object with a toString() method or as a string + let userIdStr: string; + if (user.userId) { + if (typeof user.userId === 'string') { + userIdStr = user.userId; + } else if (user.userId.toString) { + userIdStr = user.userId.toString(); + } else { + userIdStr = String(user.userId); + } + // Ensure it's a valid UUID format + userIdStr = userIdStr.trim(); + this.logger.debug('Processing blocked user:', user.firstName, user.lastName, 'userId:', userIdStr); + } else { + this.logger.warn('Blocked user missing userId:', user); + userIdStr = ''; + } + + return { + ...user, + userId: userIdStr + }; + }); + + return users; + } catch (error: any) { + this.logger.error('Error getting blocked users with details:', error); + if (error.response) { + this.logger.error('Response status:', error.response.status); + this.logger.error('Response data:', error.response.data); + } + return []; + } + } + + async startConversationWithUser(userId: string): Promise { + // Load conversations to see if one already exists + await this.refreshConversations(); + const existing = (await this.getConversations()).find(c => c.otherUserId === userId); + + if (existing) { + return existing; + } + + // Create a new conversation entry (no messages yet) + const user = await this.getUserInfo(userId); + if (!user) return null; + + return { + otherUserId: userId, + otherUserName: `${user.firstName} ${user.lastName}`, + otherUserRole: '', + isOnline: user.isOnline || false, + unreadCount: 0, + messages: [] + }; + } + + private async getUserInfo(userId: string): Promise { + // Try to get from search results based on current user's role + try { + if (this.currentUserId) { + const user = await this.userService.getCurrentUser(); + if (user?.role === 'PATIENT') { + const doctors = await this.searchDoctors(''); + return doctors.find(u => u.userId === userId) || null; + } else if (user?.role === 'DOCTOR') { + const patients = await this.searchPatients(''); + return patients.find(u => u.userId === userId) || null; + } + } + } catch (error) { + this.logger.error('Error getting user info:', error); + } + return null; + } +} + diff --git a/frontend/src/app/services/error-handler.service.ts b/frontend/src/app/services/error-handler.service.ts new file mode 100644 index 0000000..6b30fa3 --- /dev/null +++ b/frontend/src/app/services/error-handler.service.ts @@ -0,0 +1,371 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { ModalService } from './modal.service'; +import { LoggerService } from './logger.service'; +import { AuthService } from './auth.service'; +import axios, { AxiosError, AxiosResponse } from 'axios'; + +/** + * Error response interface matching backend ErrorResponseDto + */ +export interface ErrorResponse { + error: string; + message: string; + status: number; + timestamp?: string; + path?: string; + correlationId?: string; + errors?: { [key: string]: string }; // Validation errors + details?: { [key: string]: any }; // Additional error details + requires2FA?: boolean; +} + +/** + * Global error handler service for centralized error processing + */ +@Injectable({ + providedIn: 'root' +}) +export class ErrorHandlerService { + private isHandlingError = false; + + constructor( + private modalService: ModalService, + private logger: LoggerService, + private authService: AuthService, + private router: Router + ) { + this.setupAxiosInterceptor(); + } + + /** + * Setup axios interceptor to catch all HTTP errors globally + */ + private setupAxiosInterceptor(): void { + // Response interceptor + axios.interceptors.response.use( + (response: AxiosResponse) => response, + (error: AxiosError) => { + this.handleError(error); + return Promise.reject(error); + } + ); + } + + /** + * Handle error from axios request + */ + handleError(error: AxiosError | Error): void { + // Prevent infinite loops + if (this.isHandlingError) { + return; + } + + this.isHandlingError = true; + + try { + if (this.isAxiosError(error)) { + this.handleHttpError(error); + } else { + this.handleGenericError(error); + } + } catch (handlerError) { + this.logger.error('Error in error handler:', handlerError); + } finally { + this.isHandlingError = false; + } + } + + /** + * Handle HTTP errors from axios + */ + private handleHttpError(error: AxiosError): void { + const response = error.response; + const status = response?.status || 0; + const errorData: ErrorResponse = response?.data as ErrorResponse || { + error: 'Request failed', + message: error.message || 'An unexpected error occurred', + status + }; + + // Log error with correlation ID if available + const correlationId = errorData.correlationId || 'N/A'; + this.logger.error(`HTTP Error [${status}] | Correlation ID: ${correlationId}`, { + error: errorData.error, + message: errorData.message, + path: errorData.path, + url: error.config?.url, + method: error.config?.method, + correlationId + }); + + // Handle specific status codes + switch (status) { + case 401: + this.handleUnauthorized(errorData); + break; + case 403: + this.handleForbidden(errorData); + break; + case 404: + this.handleNotFound(errorData, error); + break; + case 400: + this.handleBadRequest(errorData); + break; + case 409: + this.handleConflict(errorData); + break; + case 422: + this.handleValidationError(errorData); + break; + case 500: + case 502: + case 503: + case 504: + this.handleServerError(errorData); + break; + default: + this.handleGenericHttpError(errorData); + } + } + + /** + * Handle 401 Unauthorized errors + */ + private handleUnauthorized(errorData: ErrorResponse): void { + // Don't show modal for 2FA requirement - let the component handle it + const message = errorData.message?.toLowerCase() || ''; + if (errorData.requires2FA || + message.includes('2fa') || + message.includes('two factor') || + message.includes('2-factor')) { + return; + } + + // If token is invalid, logout and redirect to login + if (this.authService.isAuthenticated()) { + this.authService.logout().then(() => { + this.router.navigate(['/login']); + }); + } + + this.showErrorModal( + 'Session Expired', + errorData.message || 'Your session has expired. Please log in again.', + 'error' + ); + } + + /** + * Handle 403 Forbidden errors + */ + private handleForbidden(errorData: ErrorResponse): void { + this.showErrorModal( + 'Access Denied', + errorData.message || 'You do not have permission to perform this action.', + 'error' + ); + } + + /** + * Handle 404 Not Found errors + */ + private handleNotFound(errorData: ErrorResponse, error?: AxiosError): void { + // Get URL from error path or config + const url = errorData.path || error?.config?.url || ''; + + // List of endpoints that may legitimately return 404 when no data exists + // These are expected 404s and should not show error modals + const expected404Endpoints = [ + '/api/v3/vital-signs/patient/', + '/api/v3/lab-results/patient/', + '/api/v3/medical-records/patient/', + '/api/v3/prescriptions/patient/', + '/api/v3/appointments/patient/', + '/latest', // Any endpoint ending with /latest + '/patient/', // Any patient-specific endpoint + ]; + + // Check if this is an expected 404 (no data exists) + const isExpected404 = expected404Endpoints.some(endpoint => url.includes(endpoint)); + + if (isExpected404) { + // Log but don't show modal for expected 404s + this.logger.debug('Expected 404 (no data found):', { + url, + message: errorData.message, + correlationId: errorData.correlationId + }); + return; // Don't show error modal for expected 404s + } + + // Show modal for unexpected 404s (actual resource not found) + this.showErrorModal( + 'Not Found', + errorData.message || 'The requested resource was not found.', + 'warning' + ); + } + + /** + * Handle 400 Bad Request errors + */ + private handleBadRequest(errorData: ErrorResponse): void { + // Don't show modal for 2FA requirement - let the component handle it + const message = errorData.message?.toLowerCase() || ''; + if (errorData.requires2FA || + message.includes('2fa') || + message.includes('two factor') || + message.includes('2-factor')) { + return; + } + + // If validation errors exist, show them + if (errorData.errors && Object.keys(errorData.errors).length > 0) { + this.handleValidationError(errorData); + return; + } + + this.showErrorModal( + 'Invalid Request', + errorData.message || 'The request is invalid. Please check your input.', + 'warning' + ); + } + + /** + * Handle 409 Conflict errors + */ + private handleConflict(errorData: ErrorResponse): void { + this.showErrorModal( + 'Conflict', + errorData.message || 'The operation conflicts with existing data.', + 'warning' + ); + } + + /** + * Handle 422 Validation errors + */ + private handleValidationError(errorData: ErrorResponse): void { + let message = errorData.message || 'Validation failed. Please check the following:'; + + if (errorData.errors && Object.keys(errorData.errors).length > 0) { + const errorList = Object.entries(errorData.errors) + .map(([field, msg]) => `• ${field}: ${msg}`) + .join('\n'); + message = `${message}\n\n${errorList}`; + } + + this.showErrorModal( + 'Validation Error', + message, + 'warning' + ); + } + + /** + * Handle 5xx Server errors + */ + private handleServerError(errorData: ErrorResponse): void { + const correlationId = errorData.correlationId || 'N/A'; + this.showErrorModal( + 'Server Error', + `${errorData.message || 'An internal server error occurred. Please try again later.'}\n\nCorrelation ID: ${correlationId}`, + 'error' + ); + } + + /** + * Handle generic HTTP errors + */ + private handleGenericHttpError(errorData: ErrorResponse): void { + this.showErrorModal( + 'Error', + errorData.message || 'An error occurred while processing your request.', + 'error' + ); + } + + /** + * Handle generic errors (non-HTTP) + */ + private handleGenericError(error: Error): void { + this.logger.error('Generic error:', error); + + // Only show modal for critical errors + if (error.message && !error.message.includes('Network Error')) { + this.showErrorModal( + 'Error', + error.message || 'An unexpected error occurred.', + 'error' + ); + } + } + + /** + * Show error modal to user + */ + private showErrorModal(title: string, message: string, type: 'error' | 'warning' | 'info' = 'error'): void { + // Use setTimeout to avoid blocking the error handling flow + setTimeout(() => { + this.modalService.alert(message, type, title).catch(err => { + this.logger.error('Error showing error modal:', err); + }); + }, 0); + } + + /** + * Check if error is an AxiosError + */ + private isAxiosError(error: any): error is AxiosError { + return error && error.isAxiosError !== undefined; + } + + /** + * Extract user-friendly error message from error + */ + getErrorMessage(error: AxiosError | Error): string { + if (this.isAxiosError(error)) { + const errorData: ErrorResponse = error.response?.data as ErrorResponse || {}; + return errorData.message || errorData.error || error.message || 'An unexpected error occurred'; + } + return error.message || 'An unexpected error occurred'; + } + + /** + * Extract validation errors from error response + */ + getValidationErrors(error: AxiosError): { [key: string]: string } | null { + if (this.isAxiosError(error)) { + const errorData: ErrorResponse = error.response?.data as ErrorResponse || {}; + return errorData.errors || null; + } + return null; + } + + /** + * Check if error requires 2FA + */ + requires2FA(error: AxiosError): boolean { + if (this.isAxiosError(error)) { + const errorData: ErrorResponse = error.response?.data as ErrorResponse || {}; + return errorData.requires2FA === true || + errorData.message?.toLowerCase().includes('2fa') === true; + } + return false; + } + + /** + * Get correlation ID from error response + */ + getCorrelationId(error: AxiosError): string | null { + if (this.isAxiosError(error)) { + const errorData: ErrorResponse = error.response?.data as ErrorResponse || {}; + return errorData.correlationId || null; + } + return null; + } +} + diff --git a/frontend/src/app/services/gdpr.service.ts b/frontend/src/app/services/gdpr.service.ts new file mode 100644 index 0000000..9046668 --- /dev/null +++ b/frontend/src/app/services/gdpr.service.ts @@ -0,0 +1,207 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; + +import { BASE_URL } from '../config/api.config'; + +export enum ConsentType { + PRIVACY_POLICY = 'PRIVACY_POLICY', + COOKIES = 'COOKIES', + MARKETING = 'MARKETING', + DATA_PROCESSING = 'DATA_PROCESSING', + THIRD_PARTY_SHARING = 'THIRD_PARTY_SHARING', + ANALYTICS = 'ANALYTICS', + ADVERTISING = 'ADVERTISING' +} + +export enum ConsentStatus { + GRANTED = 'GRANTED', + DENIED = 'DENIED', + WITHDRAWN = 'WITHDRAWN', + PENDING = 'PENDING' +} + +export enum DataSubjectRequestType { + ACCESS = 'ACCESS', + RECTIFICATION = 'RECTIFICATION', + ERASURE = 'ERASURE', + RESTRICTION = 'RESTRICTION', + PORTABILITY = 'PORTABILITY', + OBJECTION = 'OBJECTION' +} + +export interface DataSubjectConsent { + id: string; + userId: string; + consentType: ConsentType; + consentStatus: ConsentStatus; + consentVersion?: string; + consentMethod?: string; + grantedAt?: string; + withdrawnAt?: string; + expiresAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface DataSubjectRequest { + id: string; + userId: string; + requestType: DataSubjectRequestType; + requestStatus: string; + description?: string; + requestedAt: string; + completedAt?: string; + rejectedAt?: string; + rejectionReason?: string; + responseData?: any; + verificationToken?: string; + verifiedAt?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class GdprService { + private getAuthHeaders() { + const token = localStorage.getItem('jwtToken'); + return token ? { Authorization: `Bearer ${token}` } : {}; + } + + async grantConsent(consentType: ConsentType, consentVersion?: string, consentMethod: string = 'WEB_FORM'): Promise { + const response = await axios.post( + `${BASE_URL}/api/v3/gdpr/consent`, + null, + { + params: { consentType, consentVersion, consentMethod }, + headers: this.getAuthHeaders() + } + ); + return response.data; + } + + async withdrawConsent(consentType: ConsentType): Promise { + const response = await axios.post( + `${BASE_URL}/api/v3/gdpr/consent/withdraw`, + null, + { + params: { consentType }, + headers: this.getAuthHeaders() + } + ); + return response.data; + } + + async getUserConsents(): Promise { + const response = await axios.get( + `${BASE_URL}/api/v3/gdpr/consent`, + { headers: this.getAuthHeaders() } + ); + return response.data; + } + + async checkConsent(consentType: ConsentType): Promise { + try { + const response = await axios.get( + `${BASE_URL}/api/v3/gdpr/consent/check`, + { + params: { consentType }, + headers: this.getAuthHeaders() + } + ); + return response.data.hasConsent || false; + } catch (error) { + return false; + } + } + + async requestAccess(description?: string): Promise { + const response = await axios.post( + `${BASE_URL}/api/v3/gdpr/request/access`, + null, + { + params: { description }, + headers: this.getAuthHeaders() + } + ); + return response.data; + } + + async requestErasure(description?: string): Promise { + const response = await axios.post( + `${BASE_URL}/api/v3/gdpr/request/erasure`, + null, + { + params: { description }, + headers: this.getAuthHeaders() + } + ); + return response.data; + } + + async requestRectification(description?: string): Promise { + const response = await axios.post( + `${BASE_URL}/api/v3/gdpr/request/rectification`, + null, + { + params: { description }, + headers: this.getAuthHeaders() + } + ); + return response.data; + } + + async requestPortability(description?: string): Promise { + const response = await axios.post( + `${BASE_URL}/api/v3/gdpr/request/portability`, + null, + { + params: { description }, + headers: this.getAuthHeaders() + } + ); + return response.data; + } + + async getUserRequests(): Promise { + const response = await axios.get( + `${BASE_URL}/api/v3/gdpr/request`, + { headers: this.getAuthHeaders() } + ); + return response.data; + } + + async verifyRequest(token: string): Promise { + const response = await axios.post( + `${BASE_URL}/api/v3/gdpr/request/verify`, + null, + { + params: { token }, + headers: this.getAuthHeaders() + } + ); + return response.data; + } + + // Check if consent banner should be shown + shouldShowConsentBanner(): boolean { + const consentBannerShown = localStorage.getItem('gdpr_consent_banner_shown'); + return !consentBannerShown; + } + + // Mark consent banner as shown + markConsentBannerShown(): void { + localStorage.setItem('gdpr_consent_banner_shown', 'true'); + } + + // Check if user has granted all required consents + async hasRequiredConsents(): Promise { + try { + const privacyPolicy = await this.checkConsent(ConsentType.PRIVACY_POLICY); + const dataProcessing = await this.checkConsent(ConsentType.DATA_PROCESSING); + return privacyPolicy && dataProcessing; + } catch (error) { + return false; + } + } +} + diff --git a/frontend/src/app/services/hipaa-audit.service.ts b/frontend/src/app/services/hipaa-audit.service.ts new file mode 100644 index 0000000..8a581a6 --- /dev/null +++ b/frontend/src/app/services/hipaa-audit.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; +import { LoggerService } from './logger.service'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders(logger?: LoggerService) { + const token = localStorage.getItem('jwtToken'); + if (!token || token === 'null' || token === 'undefined') { + if (logger) { + logger.error('JWT token is missing or invalid'); + } + throw new Error('Authentication required. Please login again.'); + } + return { Authorization: `Bearer ${token}` }; +} + +export interface HipaaAuditLog { + id: string; + userId: string; + userName?: string; + actionType: 'VIEW' | 'CREATE' | 'UPDATE' | 'DELETE' | 'EXPORT' | 'PRINT' | 'DOWNLOAD'; + resourceType: string; + resourceId?: string; + patientId?: string; + ipAddress?: string; + userAgent?: string; + success: boolean; + failureReason?: string; + details?: any; + timestamp: string; +} + +export interface PhiAccessLog { + id: string; + userId: string; + userName?: string; + patientId: string; + patientName?: string; + accessType: 'Treatment' | 'Payment' | 'Operations' | 'Authorization'; + accessedFields: string[]; + purpose: string; + ipAddress?: string; + userAgent?: string; + timestamp: string; +} + +export interface BreachNotification { + id: string; + breachType: 'UNAUTHORIZED_ACCESS' | 'DISCLOSURE' | 'LOSS' | 'THEFT'; + description: string; + affectedPatientsCount: number; + mitigationSteps: string; + status: 'INVESTIGATING' | 'CONTAINED' | 'RESOLVED' | 'REPORTED'; + incidentDate?: string; // ISO date string (YYYY-MM-DD) + discoveryDate?: string; // ISO date string (YYYY-MM-DD) + reportedAt?: string; + createdAt: string; +} + +export interface BreachNotificationRequest { + breachType: 'UNAUTHORIZED_ACCESS' | 'DISCLOSURE' | 'LOSS' | 'THEFT'; + description: string; + affectedPatientsCount: number; + mitigationSteps: string; + incidentDate: string; // ISO date string (YYYY-MM-DD) + discoveryDate: string; // ISO date string (YYYY-MM-DD) +} + +@Injectable({ providedIn: 'root' }) +export class HipaaAuditService { + constructor(private logger: LoggerService) {} + + async getHipaaAuditLogsByPatientId(patientId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/hipaa/patient/${patientId}`, { headers: authHeaders(this.logger) }); + return res.data; + } + + async getHipaaAuditLogsByUserId(userId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/hipaa/user/${userId}`, { headers: authHeaders(this.logger) }); + return res.data; + } + + async getPhiAccessLogsByPatientId(patientId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/phi-access/patient/${patientId}`, { headers: authHeaders(this.logger) }); + return res.data; + } + + async getPhiAccessLogsByUserId(userId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/phi-access/user/${userId}`, { headers: authHeaders(this.logger) }); + return res.data; + } + + async getPhiAccessLogsByPatientIdAndDateRange(patientId: string, startDate: string, endDate: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/phi-access/patient/${patientId}/date-range`, { + params: { startDate, endDate }, + headers: authHeaders(this.logger) + }); + return res.data; + } + + async getHipaaAuditLogsByPatientIdAndDateRange(patientId: string, startDate: string, endDate: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/hipaa/patient/${patientId}/date-range`, { + params: { startDate, endDate }, + headers: authHeaders(this.logger) + }); + return res.data; + } + + async getHipaaAuditLogsByResource(resourceType: string, resourceId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/hipaa/resource/${resourceType}/${resourceId}`, { headers: authHeaders(this.logger) }); + return res.data; + } + + async getAccessCount(patientId: string, since: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/hipaa/patient/${patientId}/access-count`, { + params: { since }, + headers: authHeaders(this.logger) + }); + return res.data; + } + + // Breach Notifications (Admin only) + async getBreachNotifications(): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/breaches`, { headers: authHeaders(this.logger) }); + return res.data; + } + + async getBreachNotificationById(id: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/breaches/${id}`, { headers: authHeaders(this.logger) }); + return res.data; + } + + async createBreachNotification(request: BreachNotificationRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/audit/breaches`, request, { headers: authHeaders(this.logger) }); + return res.data; + } + + async updateBreachNotificationStatus(id: string, status: 'INVESTIGATING' | 'CONTAINED' | 'RESOLVED' | 'REPORTED'): Promise { + const res = await axios.patch(`${BASE_URL}/api/v3/audit/breaches/${id}/status?status=${status}`, {}, { headers: authHeaders(this.logger) }); + return res.data; + } + + async getBreachNotificationsByStatus(status: 'INVESTIGATING' | 'CONTAINED' | 'RESOLVED' | 'REPORTED'): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/audit/breaches/status/${status}`, { headers: authHeaders(this.logger) }); + return res.data; + } +} + diff --git a/frontend/src/app/services/logger.service.ts b/frontend/src/app/services/logger.service.ts new file mode 100644 index 0000000..b3b7678 --- /dev/null +++ b/frontend/src/app/services/logger.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; + +/** + * Centralized logging service for the application. + * In production, logs are disabled by default to prevent information leakage. + */ +@Injectable({ + providedIn: 'root' +}) +export class LoggerService { + private isProduction = false; + private isDebugEnabled = false; + + constructor() { + // Check if we're in production mode + this.isProduction = !window.location.hostname.includes('localhost') && + !window.location.hostname.includes('127.0.0.1'); + + // Enable debug logging in development or if explicitly enabled + this.isDebugEnabled = !this.isProduction || + localStorage.getItem('DEBUG_LOGGING') === 'true'; + } + + /** + * Log debug information (only in development) + */ + debug(message: string, ...args: any[]): void { + if (this.isDebugEnabled) { + console.debug(`[DEBUG] ${message}`, ...args); + } + } + + /** + * Log informational messages + */ + info(message: string, ...args: any[]): void { + if (this.isDebugEnabled) { + console.info(`[INFO] ${message}`, ...args); + } + } + + /** + * Log warnings (always logged) + */ + warn(message: string, ...args: any[]): void { + console.warn(`[WARN] ${message}`, ...args); + } + + /** + * Log errors (always logged) + */ + error(message: string, error?: any, ...args: any[]): void { + if (error) { + console.error(`[ERROR] ${message}`, error, ...args); + } else { + console.error(`[ERROR] ${message}`, ...args); + } + } + + /** + * Log in production (for critical errors only) + */ + log(message: string, ...args: any[]): void { + if (this.isDebugEnabled) { + console.log(`[LOG] ${message}`, ...args); + } + } +} + diff --git a/frontend/src/app/services/medical-record.service.ts b/frontend/src/app/services/medical-record.service.ts new file mode 100644 index 0000000..06bbc87 --- /dev/null +++ b/frontend/src/app/services/medical-record.service.ts @@ -0,0 +1,339 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; +import { LoggerService } from './logger.service'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders() { + const token = localStorage.getItem('jwtToken'); + return { Authorization: `Bearer ${token}` }; +} + +export interface MedicalRecord { + id: string; + patientId: string; + patientName?: string; + doctorId: string; + doctorName?: string; + appointmentId?: string; + recordType: 'DIAGNOSIS' | 'LAB_RESULT' | 'IMAGING' | 'VITAL_SIGNS' | 'NOTE' | 'PROCEDURE' | 'TREATMENT_PLAN' | 'OTHER'; + title: string; + content: string; + diagnosisCode?: string; + createdAt: string; +} + +export interface MedicalRecordRequest { + patientId: string; + doctorId: string; + appointmentId?: string; + recordType: 'DIAGNOSIS' | 'LAB_RESULT' | 'IMAGING' | 'VITAL_SIGNS' | 'NOTE' | 'PROCEDURE' | 'TREATMENT_PLAN' | 'OTHER'; + title: string; + content: string; + diagnosisCode?: string; +} + +export interface VitalSigns { + id: string; + patientId: string; + patientName?: string; + doctorId?: string; + doctorName?: string; + temperature?: number; + bloodPressureSystolic?: number; + bloodPressureDiastolic?: number; + heartRate?: number; + respiratoryRate?: number; + oxygenSaturation?: number; + weight?: number; // maps from backend weightKg + height?: number; // maps from backend heightCm + bmi?: number; + notes?: string; + recordedAt: string; +} + +export interface VitalSignsRequest { + patientId: string; + doctorId?: string; + appointmentId?: string; + temperature?: number; + bloodPressureSystolic?: number; + bloodPressureDiastolic?: number; + heartRate?: number; + respiratoryRate?: number; + oxygenSaturation?: number; + weight?: number; // will be sent as weightKg + height?: number; // will be sent as heightCm + notes?: string; +} + +export interface LabResult { + id: string; + patientId: string; + patientName?: string; + doctorId?: string; + doctorName?: string; + medicalRecordId?: string; + testName: string; + resultValue: string; + referenceRange?: string; + unit?: string; + status: 'NORMAL' | 'ABNORMAL' | 'CRITICAL' | 'PENDING'; + orderedDate: string; // maps from backend performedAt + resultDate?: string; // not provided by backend; reserved for UI + notes?: string; // not provided by backend; reserved for UI + resultFileUrl?: string; +} + +export interface LabResultRequest { + patientId: string; + doctorId?: string; + medicalRecordId?: string; + testName: string; + resultValue: string; + referenceRange?: string; + unit?: string; + status: 'NORMAL' | 'ABNORMAL' | 'CRITICAL' | 'PENDING'; + orderedDate: string; // will be sent as performedAt + resultDate?: string; + notes?: string; +} + +@Injectable({ providedIn: 'root' }) +export class MedicalRecordService { + constructor(private logger: LoggerService) {} + // Medical Records + async getMedicalRecordsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/medical-records/patient/${patientId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view records for your own patients'); + } + throw error; + } + } + + async getMedicalRecordsByDoctorId(doctorId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/medical-records/doctor/${doctorId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view your own records'); + } + throw error; + } + } + + async getMedicalRecordsByPatientIdAndType(patientId: string, recordType: MedicalRecord['recordType']): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/medical-records/patient/${patientId}/type/${recordType}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view records for your own patients'); + } + throw error; + } + } + + async getMedicalRecordsByAppointmentId(appointmentId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/medical-records/appointment/${appointmentId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view records for your own appointments'); + } + throw error; + } + } + + async getMedicalRecordById(id: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/medical-records/${id}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view records for your own patients'); + } + throw error; + } + } + + async createMedicalRecord(request: MedicalRecordRequest): Promise { + try { + const res = await axios.post(`${BASE_URL}/api/v3/medical-records`, request, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only create records for yourself'); + } + if (error?.response?.status === 400 && error?.response?.data?.message) { + throw new Error(error.response.data.message); + } + throw error; + } + } + + async updateMedicalRecord(id: string, request: MedicalRecordRequest): Promise { + try { + const res = await axios.put(`${BASE_URL}/api/v3/medical-records/${id}`, request, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only update records for your own patients'); + } + if (error?.response?.status === 400 && error?.response?.data?.message) { + throw new Error(error.response.data.message); + } + throw error; + } + } + + async deleteMedicalRecord(id: string): Promise { + try { + await axios.delete(`${BASE_URL}/api/v3/medical-records/${id}`, { headers: authHeaders() }); + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only delete records for your own patients'); + } + throw error; + } + } + + // Vital Signs + async getVitalSignsByPatientId(patientId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/vital-signs/patient/${patientId}`, { headers: authHeaders() }); + return (res.data || []).map((vs: any) => ({ + ...vs, + weight: vs?.weightKg ?? vs?.weight, + height: vs?.heightCm ?? vs?.height + })); + } + + async getLatestVitalSignsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/vital-signs/patient/${patientId}/latest`, { headers: authHeaders() }); + const vs = res.data; + if (!vs) return null; + return { + ...vs, + weight: vs?.weightKg ?? vs?.weight, + height: vs?.heightCm ?? vs?.height + }; + } catch (error: any) { + // Return null for 404 (not found) or 400 (no vital signs) - both are acceptable + if (error?.response?.status === 404 || error?.response?.status === 400) { + return null; + } + throw error; + } + } + + async createVitalSigns(request: VitalSignsRequest): Promise { + const payload: any = { + patientId: request.patientId, + appointmentId: request.appointmentId, + temperature: request.temperature, + bloodPressureSystolic: request.bloodPressureSystolic, + bloodPressureDiastolic: request.bloodPressureDiastolic, + heartRate: request.heartRate, + respiratoryRate: request.respiratoryRate, + oxygenSaturation: request.oxygenSaturation, + weightKg: request.weight, + heightCm: request.height, + notes: request.notes + }; + const res = await axios.post(`${BASE_URL}/api/v3/vital-signs`, payload, { headers: authHeaders() }); + const vs = res.data; + return { ...vs, weight: vs?.weightKg ?? vs?.weight, height: vs?.heightCm ?? vs?.height }; + } + + async deleteVitalSigns(id: string): Promise { + await axios.delete(`${BASE_URL}/api/v3/vital-signs/${id}`, { headers: authHeaders() }); + } + + // Lab Results + async getLabResultsByPatientId(patientId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/lab-results/patient/${patientId}`, { headers: authHeaders() }); + return (res.data || []).map((lr: any) => ({ + ...lr, + orderedDate: lr?.performedAt ?? lr?.orderedDate + })); + } + + async getLabResultById(id: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/lab-results/${id}`, { headers: authHeaders() }); + return res.data; + } + + async createLabResult(request: LabResultRequest): Promise { + // Validate required fields + if (!request.patientId || (typeof request.patientId === 'string' && request.patientId.trim() === '')) { + throw new Error('Patient ID is required'); + } + if (!request.testName || (typeof request.testName === 'string' && request.testName.trim() === '')) { + throw new Error('Test name is required'); + } + if (!request.resultValue || (typeof request.resultValue === 'string' && request.resultValue.trim() === '')) { + throw new Error('Result value is required'); + } + + // Validate UUID format for patientId + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (typeof request.patientId === 'string' && !uuidRegex.test(request.patientId)) { + throw new Error('Patient ID must be a valid UUID'); + } + + const performedAtIso = request.orderedDate ? new Date(`${request.orderedDate}T00:00:00Z`).toISOString() : undefined; + // Build payload with ONLY fields that exist in LabResultRequestDto + const payload: any = { + patientId: request.patientId, + testName: request.testName, + resultValue: request.resultValue || null, + status: (request.status?.toUpperCase() || 'PENDING') as 'PENDING' | 'NORMAL' | 'ABNORMAL' | 'CRITICAL' + }; + // Add optional fields only if they have values + if (request.medicalRecordId) payload.medicalRecordId = request.medicalRecordId; + if (request.unit) payload.unit = request.unit; + if (request.referenceRange) payload.referenceRange = request.referenceRange; + if (performedAtIso) payload.performedAt = performedAtIso; + + this.logger.debug('[MedicalRecordService] Sending lab result payload:', JSON.stringify(payload, null, 2)); + + try { + const res = await axios.post(`${BASE_URL}/api/v3/lab-results`, payload, { headers: authHeaders() }); + const lr = res.data; + return { ...lr, orderedDate: lr?.performedAt ? new Date(lr.performedAt).toISOString().split('T')[0] : lr?.orderedDate }; + } catch (error: any) { + this.logger.error('[MedicalRecordService] Lab result creation failed:', error?.response?.data || error); + throw error; + } + } + + async updateLabResult(id: string, request: LabResultRequest): Promise { + const performedAtIso = request.orderedDate ? new Date(`${request.orderedDate}T00:00:00Z`).toISOString() : undefined; + const payload: any = { + patientId: request.patientId, + testName: request.testName, + resultValue: request.resultValue, + status: request.status?.toUpperCase() || 'PENDING' + }; + // Add optional fields only if they have values + if (request.medicalRecordId) payload.medicalRecordId = request.medicalRecordId; + if (request.unit) payload.unit = request.unit; + if (request.referenceRange) payload.referenceRange = request.referenceRange; + if (performedAtIso) payload.performedAt = performedAtIso; + const res = await axios.put(`${BASE_URL}/api/v3/lab-results/${id}`, payload, { headers: authHeaders() }); + const lr = res.data; + return { ...lr, orderedDate: lr?.performedAt ?? lr?.orderedDate }; + } + + async deleteLabResult(id: string): Promise { + await axios.delete(`${BASE_URL}/api/v3/lab-results/${id}`, { headers: authHeaders() }); + } +} + diff --git a/frontend/src/app/services/modal.service.ts b/frontend/src/app/services/modal.service.ts new file mode 100644 index 0000000..6cfd280 --- /dev/null +++ b/frontend/src/app/services/modal.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@angular/core'; +import { Subject, Observable } from 'rxjs'; +import { LoggerService } from './logger.service'; + +export interface ModalConfig { + title?: string; + message: string; + type?: 'info' | 'warning' | 'error' | 'success' | 'confirm' | 'prompt'; + confirmText?: string; + cancelText?: string; + showCancel?: boolean; + inputLabel?: string; + inputPlaceholder?: string; + inputType?: string; + inputValue?: string; +} + +export interface ModalResult { + confirmed: boolean; + inputValue?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class ModalService { + private modalSubject = new Subject(); + private resultSubject = new Subject(); + + constructor(private logger: LoggerService) {} + + showModal(config: ModalConfig): Observable { + this.logger.debug('ModalService: showModal called with config:', config); + this.modalSubject.next(config); + return this.resultSubject.asObservable(); + } + + getModal(): Observable { + return this.modalSubject.asObservable(); + } + + closeModal(result: ModalResult): void { + this.modalSubject.next(null); + this.resultSubject.next(result); + } + + // Convenience methods + alert(message: string, type: 'info' | 'warning' | 'error' | 'success' = 'info', title?: string): Promise { + return new Promise((resolve) => { + const subscription = this.showModal({ + title: title || this.getDefaultTitle(type), + message, + type, + showCancel: false, + confirmText: 'OK' + }).subscribe(() => { + subscription.unsubscribe(); + resolve(); + }); + }); + } + + confirm(message: string, title: string = 'Confirm Action', confirmText: string = 'Confirm', cancelText: string = 'Cancel'): Promise { + return new Promise((resolve) => { + const subscription = this.showModal({ + title, + message, + type: 'confirm', + showCancel: true, + confirmText, + cancelText + }).subscribe((result) => { + subscription.unsubscribe(); + resolve(result.confirmed); + }); + }); + } + + prompt(message: string, title: string = 'Enter Code', inputLabel: string = 'Code', inputPlaceholder: string = 'Enter code', inputType: string = 'text', confirmText: string = 'Confirm', cancelText: string = 'Cancel'): Promise { + return new Promise((resolve) => { + this.showModal({ + title, + message, + type: 'prompt', + showCancel: true, + confirmText, + cancelText, + inputLabel, + inputPlaceholder, + inputType, + inputValue: '' + }).subscribe((result) => { + if (result.confirmed) { + resolve(result.inputValue || null); + } else { + resolve(null); + } + }); + }); + } + + private getDefaultTitle(type: string): string { + switch (type) { + case 'error': return 'Error'; + case 'warning': return 'Warning'; + case 'success': return 'Success'; + case 'info': return 'Information'; + default: return 'Notification'; + } + } +} + diff --git a/frontend/src/app/services/notification.service.ts b/frontend/src/app/services/notification.service.ts new file mode 100644 index 0000000..7d14c09 --- /dev/null +++ b/frontend/src/app/services/notification.service.ts @@ -0,0 +1,546 @@ +import { Injectable } from '@angular/core'; +import { AuthService } from './auth.service'; +import { LoggerService } from './logger.service'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { ChatService, Message, Conversation } from './chat.service'; +import { UserService } from './user.service'; + +export interface Notification { + id: string; + type: 'message' | 'system' | 'missed-call'; + title: string; + message: string; + timestamp: Date; + read: boolean; + actionUrl?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class NotificationService { + private notifications: Notification[] = []; + private notificationsSubject = new BehaviorSubject([]); + public notifications$ = this.notificationsSubject.asObservable(); + private currentUserId: string | null = null; + + private previousConversations: Conversation[] = []; + private processedMessageIds = new Set(); + private isInitialized = false; + private activeConversationUserId: string | null = null; // Track which conversation is currently open + + constructor( + private chatService: ChatService, + private userService: UserService, + private authService: AuthService, + private logger: LoggerService + ) { + // Only initialize when authenticated + if (!this.authService.isAuthenticated()) { + this.logger.debug('NotificationService: Skipping initialization - user not authenticated'); + return; + } + + // Initialize current user ID + this.initCurrentUserId(); + + // Subscribe to new WebSocket messages - only show notifications for incoming messages + this.chatService.messages$.subscribe(async (message: Message) => { + await this.handleNewMessage(message); + }); + + // Subscribe to conversation updates to catch unread messages + this.chatService.conversations$.subscribe(async (conversations: Conversation[]) => { + await this.handleConversationUpdates(conversations); + }); + + // Mark as initialized after a short delay to process existing unread messages first + setTimeout(() => { + this.isInitialized = true; + this.logger.debug('NotificationService: Initialized, will now process new messages'); + // Check for unread messages periodically (every 10 seconds) + this.startPeriodicCheck(); + + // Force check conversations again after initialization to catch any missed messages + setTimeout(async () => { + try { + if (this.authService.isAuthenticated()) { + await this.chatService.getConversations(); + } + } catch (error) { + this.logger.error('NotificationService: Error in post-init check:', error); + } + }, 1000); + }, 1500); + } + + private startPeriodicCheck(): void { + // Check for unread messages every 10 seconds + setInterval(async () => { + try { + if (!this.authService.isAuthenticated()) { + return; + } + await this.chatService.getConversations(); + } catch (error) { + this.logger.error('NotificationService: Error in periodic check:', error); + } + }, 10000); + } + + private async handleNewMessage(message: Message): Promise { + if (!this.authService.isAuthenticated()) return; + this.logger.debug('NotificationService: Received WebSocket message', message); + + // Skip call event messages (these are system messages, not user messages) + if (this.isCallEventMessage(message.content)) { + this.logger.debug('NotificationService: Skipping call event message'); + return; + } + + // Get current user ID to filter out messages we sent ourselves + if (!this.currentUserId) { + await this.initCurrentUserId(); + this.logger.debug('NotificationService: Current user ID initialized:', this.currentUserId); + } + + // Normalize IDs for comparison (convert to strings and trim) + const currentId = this.currentUserId?.toString().trim() || null; + const receiverId = message.receiverId?.toString().trim() || ''; + const senderId = message.senderId?.toString().trim() || ''; + const messageId = message.id?.toString() || ''; + const activeConversationId = this.activeConversationUserId?.toString().trim() || null; + + // Skip if we've already processed this message + if (messageId && this.processedMessageIds.has(messageId)) { + this.logger.debug('NotificationService: Message already processed, skipping'); + return; + } + + // Check if chat is open for this conversation - don't show notification if user is viewing this chat + if (activeConversationId && senderId === activeConversationId) { + this.logger.debug('NotificationService: Chat is open for this conversation, skipping notification'); + // Still mark as processed to avoid duplicate notifications + if (messageId) { + this.processedMessageIds.add(messageId); + } + return; + } + + // Debug logging + this.logger.debug('NotificationService: Checking WebSocket message', { + currentUserId: currentId, + receiverId: receiverId, + senderId: senderId, + messageId: messageId, + activeConversationId: activeConversationId, + isReceived: currentId && receiverId === currentId && senderId !== currentId, + messageContent: message.content?.substring(0, 50) + }); + + // Only show notification if message is received (not sent by current user) + if (currentId && receiverId === currentId && senderId !== currentId) { + this.logger.debug('NotificationService: ✅ Showing notification for WebSocket message from', message.senderName); + if (messageId) { + this.processedMessageIds.add(messageId); + } + this.showNotification({ + type: 'message', + title: `New message from ${message.senderName}`, + message: message.content.length > 100 ? message.content.substring(0, 100) + '...' : message.content, + timestamp: message.createdAt ? new Date(message.createdAt) : new Date(), + read: false, + actionUrl: `/messages?userId=${message.senderId}` + }); + } else { + this.logger.debug('NotificationService: ❌ Skipping WebSocket notification -', + !currentId ? 'no current user ID' : + receiverId !== currentId ? `receiver ID (${receiverId}) doesn't match current (${currentId})` : + senderId === currentId ? 'message sent by current user' : + 'unknown reason'); + } + } + + private isCallEventMessage(content: string): boolean { + if (!content) return false; + // Check if message starts with call emoji or contains call-related keywords + const callEventPatterns = [ + /^📞/, + /started a (video|audio) call/i, + /call ended/i, + /missed the call/i, + /rejected the call/i, + /call started/i + ]; + return callEventPatterns.some(pattern => pattern.test(content)); + } + + private async handleConversationUpdates(conversations: Conversation[]): Promise { + if (!this.authService.isAuthenticated()) return; + if (!this.currentUserId) { + await this.initCurrentUserId(); + } + + // On initial load, create notifications for unread messages + if (!this.isInitialized || this.previousConversations.length === 0) { + this.logger.debug('NotificationService: Initial conversation load, checking for unread messages', { + conversationsCount: conversations.length, + currentUserId: this.currentUserId + }); + + // Create notifications for existing unread messages on initial load + for (const conversation of conversations) { + this.logger.debug('NotificationService: Checking conversation', { + otherUserId: conversation.otherUserId, + otherUserName: conversation.otherUserName, + unreadCount: conversation.unreadCount, + hasLastMessage: !!conversation.lastMessage + }); + + if (conversation.unreadCount > 0 && conversation.lastMessage) { + const message = conversation.lastMessage; + const messageId = message.id?.toString() || ''; + + // Skip call event messages + if (this.isCallEventMessage(message.content || '')) { + this.logger.debug('NotificationService: Skipping call event message in conversation'); + continue; + } + + // Since conversations are fetched with user's auth token, any unread message is for this user + // Verify it's not from ourselves by checking sender + const currentId = this.currentUserId?.toString().trim() || null; + const senderId = message.senderId?.toString().trim() || ''; + + // Check if message is from ourselves (should not notify) + const isFromSelf = currentId && senderId && + currentId.toLowerCase() === senderId.toLowerCase(); + + // Check if chat is open for this conversation - don't show notification if user is viewing this chat + const activeConversationId = this.activeConversationUserId?.toString().trim() || null; + const isActiveConversation = activeConversationId && senderId === activeConversationId; + + this.logger.debug('NotificationService: Checking unread message', { + currentId, + senderId, + otherUserName: conversation.otherUserName, + unreadCount: conversation.unreadCount, + isFromSelf, + activeConversationId, + isActiveConversation, + willNotify: !isFromSelf && !isActiveConversation + }); + + // Show notification if message is not from ourselves and chat is not open for this conversation + if (!isFromSelf && !isActiveConversation) { + // Check if notification already exists for this message/conversation + const existingNotification = this.notifications.find(n => + n.actionUrl?.includes(conversation.otherUserId) + ); + + // Only create if we don't have a recent notification for this conversation + // or if the unread count increased + const shouldNotify = !existingNotification || + (existingNotification && conversation.unreadCount > 1); + + if (shouldNotify) { + this.logger.debug('NotificationService: ✅ Creating notification for unread message from', conversation.otherUserName); + this.showNotification({ + type: 'message', + title: `Unread message${conversation.unreadCount > 1 ? `s (${conversation.unreadCount})` : ''} from ${conversation.otherUserName}`, + message: message.content ? (message.content.length > 100 ? message.content.substring(0, 100) + '...' : message.content) : 'New message', + timestamp: message.createdAt ? new Date(message.createdAt) : new Date(), + read: false, + actionUrl: `/messages?userId=${conversation.otherUserId}` + }); + } else { + this.logger.debug('NotificationService: Notification already exists for this conversation'); + } + + // Mark as processed to avoid duplicate notifications + if (messageId) { + this.processedMessageIds.add(messageId); + } + } else { + this.logger.debug('NotificationService: Skipping - message is from current user'); + } + } else if (conversation.unreadCount > 0 && !conversation.lastMessage) { + // Handle case where there are unread messages but no lastMessage in the DTO + this.logger.debug('NotificationService: Found unread messages but no lastMessage, creating generic notification'); + const existingNotification = this.notifications.find(n => + n.actionUrl?.includes(conversation.otherUserId) + ); + + if (!existingNotification) { + this.showNotification({ + type: 'message', + title: `Unread message${conversation.unreadCount > 1 ? `s (${conversation.unreadCount})` : ''} from ${conversation.otherUserName}`, + message: 'You have unread messages', + timestamp: new Date(), + read: false, + actionUrl: `/messages?userId=${conversation.otherUserId}` + }); + } + } + } + + this.previousConversations = [...conversations]; + return; + } + + // Find conversations with new unread messages + for (const conversation of conversations) { + if (conversation.unreadCount > 0 && conversation.lastMessage) { + const message = conversation.lastMessage; + const messageId = message.id?.toString() || ''; + + // Skip if already processed + if (messageId && this.processedMessageIds.has(messageId)) { + continue; + } + + // Skip call event messages + if (this.isCallEventMessage(message.content || '')) { + this.logger.debug('NotificationService: Skipping call event message in conversation update'); + continue; + } + + // Check if this conversation had unread messages before + const previousConversation = this.previousConversations.find( + c => c.otherUserId === conversation.otherUserId + ); + + // Only notify if unread count increased + const previousUnreadCount = previousConversation?.unreadCount || 0; + if (conversation.unreadCount > previousUnreadCount) { + + // Verify message is for current user + const currentId = this.currentUserId?.toString().trim() || null; + const receiverId = message.receiverId?.toString().trim() || ''; + const senderId = message.senderId?.toString().trim() || ''; + const activeConversationId = this.activeConversationUserId?.toString().trim() || null; + + // Check if chat is open for this conversation - don't show notification if user is viewing this chat + if (activeConversationId && senderId === activeConversationId) { + this.logger.debug('NotificationService: Chat is open for this conversation, skipping notification'); + // Still mark as processed to avoid duplicate notifications + if (messageId) { + this.processedMessageIds.add(messageId); + } + continue; + } + + if (currentId && receiverId === currentId && senderId !== currentId) { + this.logger.debug('NotificationService: ✅ Showing notification for new unread message from', conversation.otherUserName); + if (messageId) { + this.processedMessageIds.add(messageId); + } + this.showNotification({ + type: 'message', + title: `New message from ${conversation.otherUserName}`, + message: message.content.length > 100 ? message.content.substring(0, 100) + '...' : message.content, + timestamp: message.createdAt ? new Date(message.createdAt) : new Date(), + read: false, + actionUrl: `/messages?userId=${conversation.otherUserId}` + }); + } + } + } + } + + // Update previous conversations for next comparison + this.previousConversations = [...conversations]; + } + + private async initCurrentUserId(): Promise { + try { + if (!this.authService.isAuthenticated()) { + this.currentUserId = null; + return; + } + const user = await this.userService.getCurrentUser(); + this.currentUserId = user?.id || null; + this.logger.debug('NotificationService: Initialized user ID:', this.currentUserId); + + // Also try to get from chat service if available + if (!this.currentUserId && this.chatService.getCurrentUserId) { + this.currentUserId = this.chatService.getCurrentUserId(); + this.logger.debug('NotificationService: Got user ID from chat service:', this.currentUserId); + } + } catch (error) { + this.logger.error('NotificationService: Error initializing user ID:', error); + this.currentUserId = null; + } + } + + // Public method to refresh user ID (call when user logs in or changes) + async refreshUserId(): Promise { + if (!this.authService.isAuthenticated()) { + this.currentUserId = null; + return; + } + await this.initCurrentUserId(); + } + + showNotification(notification: Omit): void { + const newNotification: Notification = { + ...notification, + id: Date.now().toString() + Math.random().toString(36).substr(2, 9), + }; + this.notifications.unshift(newNotification); + + // Keep only the last 50 notifications to prevent memory issues + if (this.notifications.length > 50) { + this.notifications = this.notifications.slice(0, 50); + } + + this.notificationsSubject.next([...this.notifications]); + this.logger.debug('NotificationService: Notification added. Total:', this.notifications.length, 'Unread:', this.getUnreadCount()); + + // Request browser notification permission and show + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(notification.title, { + body: notification.message, + icon: '/assets/icon.png', + }); + } else if ('Notification' in window && Notification.permission !== 'denied') { + Notification.requestPermission().then(permission => { + if (permission === 'granted') { + new Notification(notification.title, { + body: notification.message, + icon: '/assets/icon.png', + }); + } + }); + } + } + + markAsRead(id: string): void { + const notification = this.notifications.find(n => n.id === id); + if (notification) { + notification.read = true; + this.notificationsSubject.next([...this.notifications]); + } + } + + markAllAsRead(): void { + this.notifications.forEach(n => n.read = true); + this.notificationsSubject.next([...this.notifications]); + } + + deleteNotification(id: string): void { + this.notifications = this.notifications.filter(n => n.id !== id); + this.notificationsSubject.next([...this.notifications]); + this.logger.debug('NotificationService: Notification deleted. Remaining:', this.notifications.length); + } + + deleteAllNotifications(): void { + this.notifications = []; + this.notificationsSubject.next([]); + this.logger.debug('NotificationService: All notifications deleted'); + } + + clearAll(): void { + this.notifications = []; + this.notificationsSubject.next([]); + } + + getUnreadCount(): number { + return this.notifications.filter(n => !n.read).length; + } + + getNotifications(): Notification[] { + return [...this.notifications]; + } + + // Public method to manually check for unread messages and create notifications + async checkForUnreadMessages(): Promise { + try { + this.logger.debug('NotificationService: Manually checking for unread messages'); + if (!this.authService.isAuthenticated()) return; + await this.chatService.getConversations(); + } catch (error) { + this.logger.error('NotificationService: Error checking for unread messages:', error); + } + } + + // Set the active conversation (called when user opens a chat) + setActiveConversation(userId: string | null): void { + this.activeConversationUserId = userId ? userId.toString().trim() : null; + this.logger.debug('NotificationService: Active conversation set to:', this.activeConversationUserId); + // Re-emit notifications so components can re-filter based on active conversation + this.notificationsSubject.next([...this.notifications]); + } + + // Clear the active conversation (called when user closes a chat) + clearActiveConversation(): void { + this.activeConversationUserId = null; + this.logger.debug('NotificationService: Active conversation cleared'); + // Re-emit notifications so components can re-filter based on active conversation + this.notificationsSubject.next([...this.notifications]); + } + + // Get the active conversation user ID (for filtering notifications) + getActiveConversationUserId(): string | null { + return this.activeConversationUserId; + } + + /** + * Extract user ID from notification actionUrl + * @param actionUrl - The actionUrl from notification (format: /messages?userId=xxx) + * @returns The user ID or null if not found + */ + extractUserIdFromActionUrl(actionUrl?: string): string | null { + if (!actionUrl) return null; + + try { + const urlParts = actionUrl.split('?'); + if (urlParts.length < 2) return null; + + const urlParams = new URLSearchParams(urlParts[1]); + const userId = urlParams.get('userId'); + return userId ? userId.trim() : null; + } catch (error) { + this.logger.error('NotificationService: Error extracting userId from actionUrl:', error); + return null; + } + } + + /** + * Check if a notification is from the active conversation + * @param notification - The notification to check + * @returns true if notification is from active conversation user + */ + isNotificationFromActiveConversation(notification: Notification): boolean { + if (!this.activeConversationUserId || notification.type !== 'message') { + return false; + } + + const notificationUserId = this.extractUserIdFromActionUrl(notification.actionUrl); + return notificationUserId !== null && notificationUserId === this.activeConversationUserId; + } + + /** + * Get filtered notifications excluding those from active conversation + * Enterprise logic: When user is actively chatting with someone, + * their message notifications are hidden to reduce redundancy + * @returns Filtered notifications array + */ + getFilteredNotifications(): Notification[] { + return this.notifications.filter(n => { + // Always show missed-call notifications (high priority) + if (n.type === 'missed-call') { + return true; + } + + // For message notifications, exclude if from active conversation + if (n.type === 'message') { + return !this.isNotificationFromActiveConversation(n); + } + + // Show other notification types + return true; + }); + } +} + diff --git a/frontend/src/app/services/patient-safety.service.ts b/frontend/src/app/services/patient-safety.service.ts new file mode 100644 index 0000000..46a5048 --- /dev/null +++ b/frontend/src/app/services/patient-safety.service.ts @@ -0,0 +1,332 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders() { + const token = localStorage.getItem('jwtToken'); + return { Authorization: `Bearer ${token}` }; +} + +// Clinical Alert Interfaces +export interface ClinicalAlert { + id: string; + patientId: string; + patientName?: string; + doctorId?: string; + doctorName?: string; + alertType: 'DRUG_INTERACTION' | 'ALLERGY' | 'CONTRAINDICATION' | 'OVERDOSE_RISK' | 'DUPLICATE_THERAPY' | 'DOSE_ADJUSTMENT' | 'LAB_RESULT_ALERT' | 'VITAL_SIGN_ALERT' | 'COMPLIANCE_ALERT' | 'OTHER'; + severity: 'INFO' | 'WARNING' | 'CRITICAL'; + title: string; + description: string; + medicationName?: string; + relatedPrescriptionId?: string; + acknowledged: boolean; + acknowledgedAt?: string; + acknowledgedById?: string; + acknowledgedByName?: string; + createdAt: string; + resolvedAt?: string; + resolvedById?: string; + resolvedByName?: string; +} + +export interface ClinicalAlertRequest { + patientId: string; + alertType: 'DRUG_INTERACTION' | 'ALLERGY' | 'CONTRAINDICATION' | 'OVERDOSE_RISK' | 'DUPLICATE_THERAPY' | 'DOSE_ADJUSTMENT' | 'LAB_RESULT_ALERT' | 'VITAL_SIGN_ALERT' | 'COMPLIANCE_ALERT' | 'OTHER'; + severity: 'INFO' | 'WARNING' | 'CRITICAL'; + title: string; + description: string; + medicationName?: string; + relatedPrescriptionId?: string; +} + +// Critical Result Interfaces +export interface CriticalResult { + id: string; + labResultId: string; + patientId: string; + patientName?: string; + doctorId: string; + doctorName?: string; + criticalityLevel: 'URGENT' | 'CRITICAL' | 'CRITICAL_PANIC'; + testName: string; + resultValue?: string; + referenceRange?: string; + clinicalSignificance?: string; + acknowledgmentRequired: boolean; + acknowledged: boolean; + acknowledgedAt?: string; + acknowledgedById?: string; + acknowledgedByName?: string; + acknowledgmentMethod?: string; + followUpRequired: boolean; + followUpStatus?: string; + notifiedAt?: string; + createdAt: string; +} + +export interface CriticalResultAcknowledgmentRequest { + acknowledgmentMethod?: string; + followUpStatus?: string; +} + +// Sentinel Event Interfaces +export interface SentinelEvent { + id: string; + eventType: 'DEATH' | 'SURGICAL_ERROR' | 'MEDICATION_ERROR' | 'WRONG_PATIENT' | 'WRONG_SITE_SURGERY' | 'RAPE' | 'INFANT_ABDUCTION' | 'FALL' | 'RESTRAINT_DEATH' | 'TRANSFUSION_ERROR' | 'INFECTION_OUTBREAK' | 'OTHER'; + severity: 'SEVERE' | 'MODERATE' | 'MILD'; + patientId?: string; + patientName?: string; + doctorId?: string; + doctorName?: string; + appointmentId?: string; + description: string; + location?: string; + occurredAt: string; + reportedAt: string; + reportedById: string; + reportedByName?: string; + status: 'REPORTED' | 'UNDER_INVESTIGATION' | 'RESOLVED' | 'CLOSED'; + investigationNotes?: string; + rootCauseAnalysis?: string; + correctiveAction?: string; + resolvedAt?: string; + resolvedById?: string; + resolvedByName?: string; + createdAt: string; +} + +export interface SentinelEventRequest { + eventType: 'DEATH' | 'SURGICAL_ERROR' | 'MEDICATION_ERROR' | 'WRONG_PATIENT' | 'WRONG_SITE_SURGERY' | 'RAPE' | 'INFANT_ABDUCTION' | 'FALL' | 'RESTRAINT_DEATH' | 'TRANSFUSION_ERROR' | 'INFECTION_OUTBREAK' | 'OTHER'; + severity: 'SEVERE' | 'MODERATE' | 'MILD'; + patientId?: string; + doctorId?: string; + appointmentId?: string; + description: string; + location?: string; + occurredAt: string; +} + +export interface SentinelEventUpdateRequest { + status?: 'REPORTED' | 'UNDER_INVESTIGATION' | 'RESOLVED' | 'CLOSED'; + investigationNotes?: string; + rootCauseAnalysis?: string; + correctiveAction?: string; +} + +// Duplicate Patient Interfaces +export interface DuplicatePatientRecord { + id: string; + primaryPatientId: string; + primaryPatientName?: string; + duplicatePatientId: string; + duplicatePatientName?: string; + matchScore: number; + matchReasons: string[]; + status: 'PENDING' | 'CONFIRMED' | 'RESOLVED' | 'REJECTED'; + reviewedById?: string; + reviewedByName?: string; + reviewedAt?: string; + reviewNotes?: string; + createdAt: string; +} + +export interface DuplicatePatientReviewRequest { + status: 'PENDING' | 'CONFIRMED' | 'RESOLVED' | 'REJECTED'; + reviewNotes?: string; +} + +@Injectable({ providedIn: 'root' }) +export class PatientSafetyService { + + // Clinical Alerts + async getAlertsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/clinical-alerts/patient/${patientId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view alerts for your own patients'); + } + throw error; + } + } + + async getUnacknowledgedAlertsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/clinical-alerts/patient/${patientId}/unacknowledged`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view alerts for your own patients'); + } + throw error; + } + } + + async getAllUnacknowledgedAlerts(): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/clinical-alerts/unacknowledged`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view alerts for your own patients'); + } + throw error; + } + } + + async createAlert(request: ClinicalAlertRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/clinical-alerts`, request, { headers: authHeaders() }); + return res.data; + } + + async acknowledgeAlert(alertId: string): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/clinical-alerts/${alertId}/acknowledge`, {}, { headers: authHeaders() }); + return res.data; + } + + async resolveAlert(alertId: string): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/clinical-alerts/${alertId}/resolve`, {}, { headers: authHeaders() }); + return res.data; + } + + async checkForDrugInteractions(patientId: string): Promise { + await axios.post(`${BASE_URL}/api/v3/clinical-alerts/check-interactions/${patientId}`, {}, { headers: authHeaders() }); + } + + // Critical Results + async getCriticalResultsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/critical-results/patient/${patientId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view critical results for your own patients'); + } + throw error; + } + } + + async getUnacknowledgedCriticalResultsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/critical-results/patient/${patientId}/unacknowledged`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view critical results for your own patients'); + } + throw error; + } + } + + async getUnacknowledgedCriticalResultsByDoctorId(doctorId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/critical-results/doctor/${doctorId}/unacknowledged`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view your own critical results'); + } + throw error; + } + } + + async getAllUnacknowledgedCriticalResults(): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/critical-results/unacknowledged`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view critical results for your own patients'); + } + throw error; + } + } + + async acknowledgeCriticalResult(resultId: string, request: CriticalResultAcknowledgmentRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/critical-results/${resultId}/acknowledge`, request, { headers: authHeaders() }); + return res.data; + } + + async getCriticalResultById(id: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/critical-results/${id}`, { headers: authHeaders() }); + return res.data; + } + + // Sentinel Events + async getAllSentinelEvents(): Promise { + const res = await axios.get(`${BASE_URL}/api/sentinel-events`, { headers: authHeaders() }); + return res.data; + } + + async getSentinelEventsByStatus(status: 'REPORTED' | 'UNDER_INVESTIGATION' | 'RESOLVED' | 'CLOSED'): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/sentinel-events/status/${status}`, { headers: authHeaders() }); + return res.data; + } + + async getActiveSentinelEvents(): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/sentinel-events/active`, { headers: authHeaders() }); + return res.data; + } + + async getSentinelEventsByTimeRange(startDate: string, endDate: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/sentinel-events/time-range`, { + params: { startDate, endDate }, + headers: authHeaders() + }); + return res.data; + } + + async reportSentinelEvent(request: SentinelEventRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/sentinel-events`, request, { headers: authHeaders() }); + return res.data; + } + + async updateSentinelEvent(eventId: string, request: SentinelEventUpdateRequest): Promise { + const res = await axios.put(`${BASE_URL}/api/v3/sentinel-events/${eventId}`, request, { headers: authHeaders() }); + return res.data; + } + + async getSentinelEventById(id: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/sentinel-events/${id}`, { headers: authHeaders() }); + return res.data; + } + + // Duplicate Patients + async getAllDuplicateRecords(): Promise { + const res = await axios.get(`${BASE_URL}/api/duplicate-patients`, { headers: authHeaders() }); + return res.data; + } + + async getDuplicateRecordsByStatus(status: 'PENDING' | 'CONFIRMED' | 'RESOLVED' | 'REJECTED'): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/duplicate-patients/status/${status}`, { headers: authHeaders() }); + return res.data; + } + + async getPendingDuplicateRecords(): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/duplicate-patients/pending`, { headers: authHeaders() }); + return res.data; + } + + async getDuplicatesForPatient(patientId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/duplicate-patients/patient/${patientId}`, { headers: authHeaders() }); + return res.data; + } + + async reviewDuplicate(duplicateId: string, request: DuplicatePatientReviewRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/duplicate-patients/${duplicateId}/review`, request, { headers: authHeaders() }); + return res.data; + } + + async getDuplicateRecordById(id: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/duplicate-patients/${id}`, { headers: authHeaders() }); + return res.data; + } + + async scanForDuplicates(): Promise { + await axios.post(`${BASE_URL}/api/v3/duplicate-patients/scan`, {}, { headers: authHeaders() }); + } +} + diff --git a/frontend/src/app/services/prescription.service.ts b/frontend/src/app/services/prescription.service.ts new file mode 100644 index 0000000..9476983 --- /dev/null +++ b/frontend/src/app/services/prescription.service.ts @@ -0,0 +1,222 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders() { + const token = localStorage.getItem('jwtToken'); + return { Authorization: `Bearer ${token}` }; +} + +export interface Prescription { + id: string; + patientId: string; + patientName?: string; + doctorId: string; + doctorName?: string; + appointmentId?: string; + medicationName: string; + medicationCode?: string; + dosage: string; + frequency: string; + quantity: number; + refills: number; + instructions?: string; + startDate: string; + endDate?: string; + status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED'; + pharmacyName?: string; + pharmacyAddress?: string; + pharmacyPhone?: string; + prescriptionNumber: string; + ePrescriptionSent?: boolean; + ePrescriptionSentAt?: string; + createdAt: string; +} + +export interface PrescriptionRequest { + patientId: string; + doctorId: string; + appointmentId?: string; + medicationName: string; + medicationCode?: string; + dosage: string; + frequency: string; + quantity: number; + refills: number; + instructions?: string; + startDate: string; + endDate?: string; + pharmacyName?: string; + pharmacyAddress?: string; + pharmacyPhone?: string; +} + +export interface MedicationIntakeLog { + id: string; + prescriptionId: string; + medicationName?: string; + takenAt: string; + scheduledTime: string; + wasTaken: boolean; + notes?: string; +} + +export interface MedicationIntakeLogRequest { + prescriptionId: string; + scheduledTime: string; + wasTaken: boolean; + notes?: string; +} + +@Injectable({ providedIn: 'root' }) +export class PrescriptionService { + async getPrescriptionsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/prescriptions/patient/${patientId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view prescriptions for your own patients'); + } + throw error; + } + } + + async getPrescriptionsByDoctorId(doctorId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/prescriptions/doctor/${doctorId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view your own prescriptions'); + } + throw error; + } + } + + async getActivePrescriptionsByPatientId(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/prescriptions/patient/${patientId}/active`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view prescriptions for your own patients'); + } + throw error; + } + } + + async getPrescriptionsByPatientIdAndStatus(patientId: string, status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED'): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/prescriptions/patient/${patientId}/status/${status}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view prescriptions for your own patients'); + } + throw error; + } + } + + async getPrescriptionsByAppointmentId(appointmentId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/prescriptions/appointment/${appointmentId}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view prescriptions for your own appointments'); + } + throw error; + } + } + + async getPrescriptionById(id: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/prescriptions/${id}`, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only view prescriptions for your own patients'); + } + throw error; + } + } + + async getPrescriptionByNumber(prescriptionNumber: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/prescriptions/number/${prescriptionNumber}`, { headers: authHeaders() }); + return res.data; + } + + async createPrescription(request: PrescriptionRequest): Promise { + try { + const res = await axios.post(`${BASE_URL}/api/prescriptions`, request, { headers: authHeaders() }); + return res.data; + } catch (error: any) { + if (error?.response?.status === 403) { + throw new Error('Access denied: You can only create prescriptions for yourself'); + } + if (error?.response?.status === 400 && error?.response?.data?.message) { + throw new Error(error.response.data.message); + } + throw error; + } + } + + async updatePrescriptionStatus(id: string, status: 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISCONTINUED'): Promise { + // Backend expects status as a query parameter, not in the request body + const res = await axios.patch(`${BASE_URL}/api/v3/prescriptions/${id}/status?status=${status}`, {}, { headers: authHeaders() }); + return res.data; + } + + async markEPrescriptionSent(id: string): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/prescriptions/${id}/mark-sent`, {}, { headers: authHeaders() }); + return res.data; + } + + async updatePrescription(id: string, request: PrescriptionRequest): Promise { + const res = await axios.put(`${BASE_URL}/api/v3/prescriptions/${id}`, request, { headers: authHeaders() }); + return res.data; + } + + async deletePrescription(id: string): Promise { + await axios.delete(`${BASE_URL}/api/v3/prescriptions/${id}`, { headers: authHeaders() }); + } + + // Medication Intake Logs + async createMedicationIntakeLog(request: MedicationIntakeLogRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/medication-intake-logs`, request, { headers: authHeaders() }); + return res.data; + } + + async getMedicationIntakeLogsByPrescriptionId(prescriptionId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/medication-intake-logs/prescription/${prescriptionId}`, { headers: authHeaders() }); + return res.data; + } + + async getMedicationIntakeLogsByPatientId(patientId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/medication-intake-logs/patient/${patientId}`, { headers: authHeaders() }); + return res.data; + } + + async getMissedDoses(prescriptionId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/medication-intake-logs/prescription/${prescriptionId}/missed`, { headers: authHeaders() }); + return res.data; + } + + async getAdherenceRate(prescriptionId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/medication-intake-logs/prescription/${prescriptionId}/adherence`, { headers: authHeaders() }); + return res.data.adherenceRate || 0; + } + + async markDoseAsTaken(logId: string): Promise { + const res = await axios.patch(`${BASE_URL}/api/v3/medication-intake-logs/${logId}/mark-taken`, {}, { headers: authHeaders() }); + return res.data; + } + + async getIntakeLogById(logId: string): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/medication-intake-logs/${logId}`, { headers: authHeaders() }); + return res.data; + } +} + diff --git a/frontend/src/app/services/registration.service.ts b/frontend/src/app/services/registration.service.ts new file mode 100644 index 0000000..ef1a013 --- /dev/null +++ b/frontend/src/app/services/registration.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; + +import { BASE_URL } from '../config/api.config'; + +export interface DoctorRegistrationRequest { + user: { + email: string; + password: { + password: string; + confirmPassword: string; + }; + firstName: string; + lastName: string; + phoneNumber: string; + }; + medicalLicenseNumber: string; + specialization: string; + yearsOfExperience: number; + biography?: string; + consultationFee: number; +} + +export interface PatientRegistrationRequest { + user: { + email: string; + password: { + password: string; + confirmPassword: string; + }; + firstName: string; + lastName: string; + phoneNumber: string; + }; + emergencyContactName?: string; + emergencyContactPhone?: string; + bloodType?: string; + allergies?: string[]; +} + +export interface AdminRegistrationRequest { + email: string; + password: { + password: string; + confirmPassword: string; + }; + firstName: string; + lastName: string; + phoneNumber: string; +} + +@Injectable({ providedIn: 'root' }) +export class RegistrationService { + async registerDoctor(data: DoctorRegistrationRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/users/register/doctor`, data); + return res.data; + } + + async registerPatient(data: PatientRegistrationRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/users/register/patient`, data); + return res.data; + } + + async registerAdmin(data: AdminRegistrationRequest): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/users/register/admin`, data); + return res.data; + } +} + diff --git a/frontend/src/app/services/two-factor-auth.service.ts b/frontend/src/app/services/two-factor-auth.service.ts new file mode 100644 index 0000000..c53048e --- /dev/null +++ b/frontend/src/app/services/two-factor-auth.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders() { + const token = localStorage.getItem('jwtToken'); + return { Authorization: `Bearer ${token}` }; +} + +export interface TwoFactorSetupResponse { + userId: string; + secretKey: string; + qrCodeUrl: string; + backupCodes: string[]; + enabled: boolean; +} + +export interface TwoFactorStatus { + enabled: boolean; + hasBackupCodes?: boolean; + backupCodesCount?: number; +} + +@Injectable({ providedIn: 'root' }) +export class TwoFactorAuthService { + async setup2FA(): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/2fa/setup`, {}, { headers: authHeaders() }); + return res.data; + } + + async enable2FA(code: string): Promise { + await axios.post(`${BASE_URL}/api/v3/2fa/enable`, { code }, { headers: authHeaders() }); + } + + async disable2FA(code: string): Promise { + await axios.post(`${BASE_URL}/api/v3/2fa/disable`, { code }, { headers: authHeaders() }); + } + + async get2FAStatus(): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/2fa/status`, { headers: authHeaders() }); + return res.data; + } + + async regenerateBackupCodes(code: string): Promise { + const res = await axios.post(`${BASE_URL}/api/v3/2fa/backup-codes/regenerate`, { code }, { headers: authHeaders() }); + return res.data.backupCodes || []; + } + + async getQrCodeUrl(): Promise { + const res = await axios.get(`${BASE_URL}/api/v3/2fa/qr-code`, { headers: authHeaders() }); + return res.data.qrCodeUrl; + } +} + diff --git a/frontend/src/app/services/user.service.ts b/frontend/src/app/services/user.service.ts new file mode 100644 index 0000000..ab2304b --- /dev/null +++ b/frontend/src/app/services/user.service.ts @@ -0,0 +1,454 @@ +import { Injectable } from '@angular/core'; +import axios from 'axios'; +import { LoggerService } from './logger.service'; + +import { BASE_URL } from '../config/api.config'; + +function authHeaders() { + const token = localStorage.getItem('jwtToken'); + return { Authorization: `Bearer ${token}` }; +} + +export interface UserUpdateRequest { + firstName?: string; + lastName?: string; + phoneNumber?: string; +} + +export interface UserInfo { + id: string; + email: string; + firstName: string; + lastName: string; + phoneNumber?: string; + role: 'ADMIN' | 'DOCTOR' | 'PATIENT'; + isActive: boolean; + avatarUrl?: string; + status?: 'ONLINE' | 'OFFLINE' | 'BUSY'; + isOnline?: boolean; +} + +export interface DoctorProfile { + id: string; + firstName: string; + lastName: string; + phoneNumber: string; + email?: string; + medicalLicenseNumber?: string; + specialization: string; + yearsOfExperience: number; + biography?: string; + consultationFee?: number; + defaultDurationMinutes?: number; + isVerified?: boolean; + avatarUrl?: string; + // Enterprise fields + streetAddress?: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; + educationDegree?: string; + educationUniversity?: string; + educationGraduationYear?: number; + certifications?: string[]; + languagesSpoken?: string[]; + hospitalAffiliations?: string[]; + insuranceAccepted?: string[]; + professionalMemberships?: string[]; +} + +export interface DoctorUpdateRequest { + medicalLicenseNumber?: string; + specialization?: string; + yearsOfExperience?: number; + biography?: string; + consultationFee?: number; + defaultDurationMinutes?: number; + // Enterprise fields + streetAddress?: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; + educationDegree?: string; + educationUniversity?: string; + educationGraduationYear?: number; + certifications?: string[]; + languagesSpoken?: string[]; + hospitalAffiliations?: string[]; + insuranceAccepted?: string[]; + professionalMemberships?: string[]; +} + +export interface PatientProfile { + id: string; + userId?: string; + firstName: string; + lastName: string; + phoneNumber: string; + email?: string; + bloodType?: string; + emergencyContactName?: string; + emergencyContactPhone?: string; + allergies?: string[]; + avatarUrl?: string; + // Enterprise fields + dateOfBirth?: string; + gender?: string; + streetAddress?: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; + insuranceProvider?: string; + insurancePolicyNumber?: string; + medicalHistorySummary?: string; + currentMedications?: string[]; + primaryCarePhysicianName?: string; + primaryCarePhysicianPhone?: string; + preferredLanguage?: string; + occupation?: string; + maritalStatus?: string; +} + +export interface PatientUpdateRequest { + emergencyContactName?: string; + emergencyContactPhone?: string; + bloodType?: string; + allergies?: string[]; + // Enterprise fields + dateOfBirth?: string; + gender?: string; + streetAddress?: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; + insuranceProvider?: string; + insurancePolicyNumber?: string; + medicalHistorySummary?: string; + currentMedications?: string[]; + primaryCarePhysicianName?: string; + primaryCarePhysicianPhone?: string; + preferredLanguage?: string; + occupation?: string; + maritalStatus?: string; +} + +@Injectable({ providedIn: 'root' }) +export class UserService { + private currentUserCache: UserInfo | null = null; + private currentUserPromise: Promise | null = null; + private cacheTimestamp: number = 0; + private readonly CACHE_DURATION = 60000; // 60 seconds + + constructor(private logger: LoggerService) {} + + getUserEmail(): string | null { + const token = localStorage.getItem('jwtToken'); + if (!token) return null; + + try { + // Decode JWT token (simple base64 decode) + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.sub || payload.email || null; + } catch (e) { + return null; + } + } + + async getCurrentUser(): Promise { + // Use the proper endpoint that works for all authenticated users + return await this.getCurrentUserProfile(); + } + + async getDoctorIdByUserId(userId: string): Promise { + try { + // First, check if current user is a doctor and use the /me endpoint + const currentUser = await this.getCurrentUserProfile(); + if (currentUser?.role === 'DOCTOR' && currentUser.id === userId) { + const doctorProfile = await this.getDoctorProfile(); + if (doctorProfile?.id) { + return doctorProfile.id; + } + } + + // If not current user or not a doctor, try admin endpoint (only works for admins) + const currentUserForCheck = currentUser || await this.getCurrentUserProfile(); + if (currentUserForCheck?.role === 'ADMIN') { + const doctors = await axios.get(`${BASE_URL}/api/v3/admin/doctors`, { headers: authHeaders() }); + const doctor = doctors.data.find((d: any) => d.user?.id === userId); + return doctor?.id || null; + } + + return null; + } catch (e: any) { + // Silently handle 403 errors (expected for non-admin users) + if (e?.response?.status === 403) { + return null; + } + // Only log unexpected errors + this.logger.warn('Error getting doctor ID:', e); + return null; + } + } + + async getPatientIdByUserId(userId: string): Promise { + try { + // First, check if current user is a patient and use the /me endpoint + const currentUser = await this.getCurrentUserProfile(); + if (currentUser?.role === 'PATIENT' && currentUser.id === userId) { + const patientProfile = await this.getPatientProfile(); + if (patientProfile?.id) { + return patientProfile.id; + } + } + + // If not current user or not a patient, try admin endpoint (only works for admins) + const currentUserForCheck = currentUser || await this.getCurrentUserProfile(); + if (currentUserForCheck?.role === 'ADMIN') { + const patients = await axios.get(`${BASE_URL}/api/v3/admin/patients`, { headers: authHeaders() }); + const patient = patients.data.find((p: any) => p.user?.id === userId); + return patient?.id || null; + } + + return null; + } catch (e: any) { + // Silently handle 403 errors (expected for non-admin users) + if (e?.response?.status === 403) { + return null; + } + this.logger.warn('Error getting patient ID:', e); + return null; + } + } + + async getUserById(userId: string): Promise { + try { + // Check if current user is admin before calling admin endpoint + const currentUser = await this.getCurrentUserProfile(); + if (currentUser?.role !== 'ADMIN') { + // Non-admin users can't access this endpoint + return null; + } + + const res = await axios.get(`${BASE_URL}/api/v3/admin/users`, { headers: authHeaders() }); + const users = res.data; + return users.find((u: UserInfo) => u.id === userId) || null; + } catch (e: any) { + // Silently handle 403 errors (expected for non-admin users) + if (e?.response?.status === 403) { + return null; + } + this.logger.warn('Error getting user by ID:', e); + return null; + } + } + + async getCurrentUserProfile(): Promise { + // Check cache validity + const now = Date.now(); + if (this.currentUserCache && (now - this.cacheTimestamp) < this.CACHE_DURATION) { + return this.currentUserCache; + } + + // If there's already a pending request, return that promise instead of making a new one + if (this.currentUserPromise) { + return this.currentUserPromise; + } + + // Create a new request promise + this.currentUserPromise = (async () => { + try { + const res = await axios.get(`${BASE_URL}/api/v3/users/me`, { + headers: authHeaders(), + timeout: 10000 // 10 second timeout + }); + const user = res.data; + // Update cache + this.currentUserCache = user; + this.cacheTimestamp = Date.now(); + this.currentUserPromise = null; // Clear the promise after completion + return user; + } catch (e: any) { + this.currentUserPromise = null; // Clear the promise on error + // Only log non-aborted errors to reduce noise + if (e.code !== 'ECONNABORTED' && e.code !== 'ERR_CANCELED') { + this.logger.error('Failed to get current user profile:', e); + } + return null; + } + })(); + + return this.currentUserPromise; + } + + // Method to clear cache (useful after login/logout or profile updates) + clearUserCache(): void { + this.currentUserCache = null; + this.currentUserPromise = null; + this.cacheTimestamp = 0; + } + + async updateUserProfile(updateData: UserUpdateRequest): Promise { + const res = await axios.patch(`${BASE_URL}/api/v3/users/me`, updateData, { headers: authHeaders() }); + // Clear cache after update to force refresh + this.clearUserCache(); + this.currentUserCache = res.data; + this.cacheTimestamp = Date.now(); + return res.data; + } + + async changePassword(currentPassword: string, newPassword: string, confirmNewPassword: string): Promise { + try { + await axios.post(`${BASE_URL}/api/v3/users/me/change-password`, { + currentPassword, + newPassword, + confirmNewPassword + }, { headers: authHeaders() }); + } catch (error: any) { + if (error?.response?.status === 400 && error?.response?.data?.message) { + throw new Error(error.response.data.message); + } + if (error?.response?.status === 401) { + throw new Error('Current password is incorrect'); + } + throw error; + } + } + + async getDoctorProfile(): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/users/doctors/me`, { headers: authHeaders() }); + return res.data; + } catch (e: any) { + this.logger.error('Failed to get doctor profile:', e); + return null; + } + } + + async updateDoctorProfile(updateData: DoctorUpdateRequest): Promise { + const res = await axios.patch(`${BASE_URL}/api/v3/users/doctors/me`, updateData, { headers: authHeaders() }); + return res.data; + } + + async getPatientProfile(): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/users/patients/me`, { headers: authHeaders() }); + return res.data; + } catch (e: any) { + this.logger.error('Failed to get patient profile:', e); + return null; + } + } + + async updatePatientProfile(updateData: PatientUpdateRequest): Promise { + const res = await axios.patch(`${BASE_URL}/api/v3/users/patients/me`, updateData, { headers: authHeaders() }); + return res.data; + } + + async getAllDoctors(): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/users/doctors`, { headers: authHeaders() }); + return res.data; + } catch (e: any) { + this.logger.error('Failed to get all doctors:', e); + return []; + } + } + + async getAllPatients(): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/users/patients`, { headers: authHeaders() }); + return res.data; + } catch (e: any) { + this.logger.error('Failed to get all patients:', e); + return []; + } + } + + async getDoctorProfileById(doctorId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/users/doctors/${doctorId}`, { headers: authHeaders() }); + return res.data; + } catch (e: any) { + if (e?.response?.status === 403) { + throw new Error('Access denied: Unable to view doctor profile'); + } + if (e?.response?.status === 404) { + throw new Error('Doctor not found'); + } + throw e; + } + } + + async getPatientProfileById(patientId: string): Promise { + try { + const res = await axios.get(`${BASE_URL}/api/v3/users/patients/${patientId}`, { headers: authHeaders() }); + return res.data; + } catch (e: any) { + if (e?.response?.status === 403) { + throw new Error('Access denied: Only doctors can view patient profiles'); + } + if (e?.response?.status === 404) { + throw new Error('Patient not found'); + } + throw e; + } + } + + async uploadAvatar(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + const res = await axios.post(`${BASE_URL}/api/v3/files/avatar`, formData, { + headers: { + ...authHeaders(), + 'Content-Type': 'multipart/form-data' + } + }); + return res.data.avatarUrl; + } + + async uploadAvatarBase64(base64Image: string): Promise { + const res = await axios.post( + `${BASE_URL}/api/v3/files/avatar/base64`, + { image: base64Image }, + { headers: authHeaders() } + ); + return res.data.avatarUrl; + } + + async deleteAvatar(): Promise { + await axios.delete(`${BASE_URL}/api/v3/files/avatar`, { headers: authHeaders() }); + } + + getAvatarUrl(avatarUrl?: string): string { + if (!avatarUrl) return ''; + // If already a full URL, return as-is + if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) { + return avatarUrl; + } + // If it starts with /, prepend BASE_URL + if (avatarUrl.startsWith('/')) { + // Fix: Ensure avatar URLs use /api/v3/files/avatars/ instead of /api/files/avatars/ or /files/avatars/ + if (avatarUrl.startsWith('/api/files/avatars/')) { + // Replace /api/files/avatars/ with /api/v3/files/avatars/ + return `${BASE_URL}/api/v3/files/avatars/${avatarUrl.substring('/api/files/avatars/'.length)}`; + } else if (avatarUrl.startsWith('/files/avatars/')) { + return `${BASE_URL}/api/v3${avatarUrl}`; + } + return `${BASE_URL}${avatarUrl}`; + } + // Otherwise, assume it's a relative path and prepend BASE_URL with / + // Fix: Ensure avatar URLs use /api/v3/files/avatars/ instead of /api/files/avatars/ or /files/avatars/ + if (avatarUrl.startsWith('api/files/avatars/')) { + // Replace api/files/avatars/ with api/v1/files/avatars/ + return `${BASE_URL}/api/v3/files/avatars/${avatarUrl.substring('api/files/avatars/'.length)}`; + } else if (avatarUrl.startsWith('files/avatars/')) { + return `${BASE_URL}/api/v3/${avatarUrl}`; + } + return `${BASE_URL}/${avatarUrl}`; + } +} + diff --git a/frontend/src/app/styles/_shared.scss b/frontend/src/app/styles/_shared.scss new file mode 100644 index 0000000..634856c --- /dev/null +++ b/frontend/src/app/styles/_shared.scss @@ -0,0 +1,45 @@ +// Enterprise-Grade Color Palette +$primary-blue: #0066cc; +$primary-blue-dark: #0052a3; +$primary-blue-light: #e6f2ff; +$primary-blue-lighter: #f0f7ff; +$accent-teal: #0099a1; +$accent-teal-light: #00c4ce; +$text-dark: #0f172a; +$text-medium: #475569; +$text-light: #64748b; +$text-lighter: #94a3b8; +$border-color: #e2e8f0; +$border-color-light: #f1f5f9; +$border-focus: #0066cc; +$error-red: #dc2626; +$error-red-dark: #b91c1c; +$error-bg: #fef2f2; +$error-border: #fecaca; +$success-green: #10b981; +$success-green-dark: #059669; +$success-bg: #d1fae5; +$warning-orange: #f59e0b; +$info-blue: #3b82f6; +$white: #ffffff; +$background-light: #f8fafc; +$background-elevated: #ffffff; +$gradient-primary: linear-gradient(135deg, #0066cc 0%, #0099a1 100%); +$gradient-primary-hover: linear-gradient(135deg, #0052a3 0%, #007a82 100%); +$gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +$gradient-bg-alt: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + +// Enterprise Shadow System +$shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +$shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +$shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); +$shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); +$shadow-blue: 0 10px 25px -5px rgba(0, 102, 204, 0.25); +$shadow-blue-lg: 0 20px 40px -10px rgba(0, 102, 204, 0.3); + +// Typography +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..5af5066 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,19 @@ + + + + + Frontend + + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..11fd9f4 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,11 @@ +// Polyfill for global (required by sockjs-client) +if (typeof (window as any).global === 'undefined') { + (window as any).global = window; +} + +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 0000000..c4a4b4b --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,26 @@ +/* You can add global styles to this file, and also import other style files */ + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + background-color: #f8f9fa; +} + +button { + font-family: inherit; +} + +input { + font-family: inherit; +} diff --git a/frontend/ssl/cert.pem b/frontend/ssl/cert.pem new file mode 100644 index 0000000..acb650d --- /dev/null +++ b/frontend/ssl/cert.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFmzCCA4OgAwIBAgIUf5i3I/vZ6s7b/UevfjvufdWeRpUwDQYJKoZIhvcNAQEL +BQAwXTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5 +MRUwEwYDVQQKDAxPcmdhbml6YXRpb24xGDAWBgNVBAMMDzE5My4xOTQuMTU1LjI0 +OTAeFw0yNTExMDUxMDQ3MTlaFw0yNjExMDUxMDQ3MTlaMF0xCzAJBgNVBAYTAlVT +MQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UECgwMT3JnYW5p +emF0aW9uMRgwFgYDVQQDDA8xOTMuMTk0LjE1NS4yNDkwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQC6nhW4EJRHQAMHh0uvYDGLNzUnW4GRtItCVpYrZzYY +Py7DB66Ie0rHvD2VBT6pLbIPn8nAH2woyEHZEBycJyOPSMZhWIUtNjoJd67kWSdF +vfh8dAAnBLIC9fosfY6OyYrhe5/HPjIOOpZTIxPlM2mctR7GcWK8P69vHMAFu75b +MlZUXJ9VtnYLpQ23zLH1FfCIcRhbL2RRJfy9ni6e3R+c0oQ00qZNqFQpMT9qPasE +VhP1jG1W/ZR4FjOPWBghXfdOTyrh8uUFtFGqAqURJqJ0py2vkX+kWKMkIkrz3BXh +fsyEUJy7xn/iSag0YcSMkdIzx9VEs9blAljBn0uIPudoNZpCtKctHIHOwkXhffNv +yigcSXe5o+reeMSpNIG/jj1nu+0LWjej7QrhLb/oInyx/QvW8lXkNzsLzkFR3v0d +s0eFD4ZGKtAhKv1EVb0KJX4SfyqdvXHMpxFGaBvTdoXnfr0HqXHZgc+qJ0E4lJI4 +qcrPCrdrue/F0sUYGyt21jXAAOFq0C4+K5pOMksGoebklC8xJbjENFjpha7YO9Wo +kDHGgAOrYMN8UEGohx1GX23z+mnMrkhpWVHsMNxLr7wVLT8ze3ygos7FxYzZiLAI +n+tBW++IkUxS5wC6HHTXpXDCB5uPa7oxrFR6vbeq432FSN4+bY3pJy4kfuiCRdmk +ZwIDAQABo1MwUTAdBgNVHQ4EFgQUCPEKF3IiGiAZBHIE6dD7999iCacwHwYDVR0j +BBgwFoAUCPEKF3IiGiAZBHIE6dD7999iCacwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAgEAqcKR0y2/uM1B7ErZ/cgLHwvbNfCzUKOoVkiC3ixY3Iok +Fytvh7hioUwJXivgL7mK60U0I3NdBKPI52/HelKIPhqMaSkgrMShQir/UcCMNW5u +JpYKRMOudkYwaKtf4bcP0Eqoul58bHYoMtPo+Mp0iBIO7/qGRC5XzSfj9WMcFDvR +7BrCYNBZ5FRd5g+R6U0cuAMI0jai/o7nW+NrTmicrpRZZUa6ay1OxBNgfHFVztDc +0YM2VvQ1w+3fz8sXDcFXwxkmzF0AsTzHII/WWFIlToX1uvqFF39BciS1WCGVoLfr +FWt4aAPvcV1jwu1zeyWnFDoiuXKfaMlCmcJ7DL6QHqZv4av7AecivZffIpl2A2mC +In+8/sSNWoZtr4cmcx4w0LxgrradZr/h1oL9o4EJQONFw+r55pQTlHS6J0A1lY1L +YYQgklUrU/CchIKeQB88Wf1qUylozERhpcPM340luUX8XHuZnq9K6NDMcG2zcKzr +9psHOFkpzOdv3vha5EwYUeWGwWGqmW3IaWdYi59VKQxcyVUN/TfIE7leas7QSs6S +WmLsmMGCXIXV36hkCl1DDDKfklphKOqQW1KwRqKaiqd7Qr8RtQP1jZsrjiUuju0T +MsfLXqP+cW+yAtOMP3OStczX7+QwcS/C6eOQoAKcpMFmOjA0lN+wfuvqA1ptypk= +-----END CERTIFICATE----- diff --git a/frontend/ssl/key.pem b/frontend/ssl/key.pem new file mode 100644 index 0000000..c84c7fc --- /dev/null +++ b/frontend/ssl/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQC6nhW4EJRHQAMH +h0uvYDGLNzUnW4GRtItCVpYrZzYYPy7DB66Ie0rHvD2VBT6pLbIPn8nAH2woyEHZ +EBycJyOPSMZhWIUtNjoJd67kWSdFvfh8dAAnBLIC9fosfY6OyYrhe5/HPjIOOpZT +IxPlM2mctR7GcWK8P69vHMAFu75bMlZUXJ9VtnYLpQ23zLH1FfCIcRhbL2RRJfy9 +ni6e3R+c0oQ00qZNqFQpMT9qPasEVhP1jG1W/ZR4FjOPWBghXfdOTyrh8uUFtFGq +AqURJqJ0py2vkX+kWKMkIkrz3BXhfsyEUJy7xn/iSag0YcSMkdIzx9VEs9blAljB +n0uIPudoNZpCtKctHIHOwkXhffNvyigcSXe5o+reeMSpNIG/jj1nu+0LWjej7Qrh +Lb/oInyx/QvW8lXkNzsLzkFR3v0ds0eFD4ZGKtAhKv1EVb0KJX4SfyqdvXHMpxFG +aBvTdoXnfr0HqXHZgc+qJ0E4lJI4qcrPCrdrue/F0sUYGyt21jXAAOFq0C4+K5pO +MksGoebklC8xJbjENFjpha7YO9WokDHGgAOrYMN8UEGohx1GX23z+mnMrkhpWVHs +MNxLr7wVLT8ze3ygos7FxYzZiLAIn+tBW++IkUxS5wC6HHTXpXDCB5uPa7oxrFR6 +vbeq432FSN4+bY3pJy4kfuiCRdmkZwIDAQABAoICAAhBBbo7wX3amZ0I9ugC29jJ +0FK1UWhzESsV+VO3dO+4RlvfSHDw7cB31aF0Ye50VEksoBfHTAQMbMfVAURG1uPM +1uQSA5HvBrzJy7Uohvwmst7jopNsjxMNHiDpohc0AtiGR0LgwAYGbAUGqtKbEN4a +2gmc6j5j8rZ6EbmEhvQxqG/YHj6u8KRYizVzRaEOygdpjnzkqQ3jHZKldG/CXVY/ +rr6cso5Xa6GGyW9Bhaw4wm6R5M7RgxzsnnXbR6Cc2gYJ/7OOsrfV3zwjHHntuNxq +qIf4v38ytxnaCnFOwrOpOr0qHkSP5bLJ3zlcjvzzVituze1ZUZ/qF/2NtiGIkWOP +OJ1GSxybZkwCldagOKo2+ZOvCJbvlvD/7adyiBNrqbiV/G0ZOELxhtLYfl7QkVCi +FQb5g3eVtXW4FvAsaDBVZVPLFjWAAv3ezDLkaDrt3G+x9CXQ07IwrdMqAi1aMHvt +yOIPswktdyNzrS+tWAGSFMtr6r9Xs3WeOihhzad7rHL4Fa7/VOPtbkh3JqKeuzfM +89pJkDZ9bm7yflAx4HPkY146AAcHwnAljOepD/0YzT8frySk8WAeA+HqWVG7WBe+ +gJWJDjGTXiw/jAbcqH/+JIOSCpsFdgDohSrOIQ9LKRIX1bEzWOtfywOszTOYe3uY +x5kRjBPWqlZlnslTOnYBAoIBAQDhYzZfaskp+Wum1EwoxIam7G9p133oSbfxLFkb +bsuOWSmCcbdqSvJfCJLONOR1+2rHxkjih6/QwYKTbAH/yWyxU8aExgkJmvTedCBd +3U+qSOPyF8ITNIgyMG8CSJwgh/YGJNnoHRImCYfxENsD6QJHev6GYZpTya7DkTqa +hHJzBZBWwfCQglaHis4nHOoNiulooVlGUQUThcBS5sERM2hxMJAqLqPx6U0Pi9X0 +LL/kNghZuhrGQ7ikTwsi34iuqmtgPH/lGyf3mweT2HA+hM3APzQsx3eSbsLsrsMk +Ba1dVJUnDzi7keoUCyfgMcVNLaWb9swmvD2WicwAXlV0U/ChAoIBAQDT9tPoKQ7S +9BFOnypYx05wYZfb84xDAAJcYUIvuaPUz2De1qtIta/71h1ygxFtYG+mwkg+dNlj +rJMaKDBA6CUXlxVoqRtfkxfjFGncg7PR7oJMrOcATXyXu4beK5bNlj/QQByC8RSP +yzvehcuPguuK9sbMU+GrJDUOR8VdvBl3VJMe4OmsXprlGgxmKqh4Sfn6PsdhL7C8 +EAHJR3/zs+ATh7GBFbQUwe9uMEyDMPt11IqiBFT9Y6AKJfnwf5Sq8k2a0VvVKxJR +uvW9kt+w6Ge9mJadTRDBEjmtAdU8oGEEuMwAmLqwNKOd2HF/ehYSRZ89dgCoztbk +aMkSdCFWBBAHAoIBAQCCDJQYFG7EeHWkSoUzVMp5UJ2szbmJqdeoIjW8PX7YwRvR +d7oka+BT84d50k0swohHg2MUysQoyS+FzuGMbPX5j+RRQk9+qlm2bf0rDALmt65H +R2wlVr9rbkTvZWQo5SqROvw9EWEib4dwEpC9Pdqh0hdBBNpelFXx5D+TXu8G7WPu +m3MqfvpctTvkl6t5avjo9YikqSUrsZZaf2lRen8+KI5WuJjCgiKD4OTwQk3q6T6C +P38pfXPx4uYDFkYwuobAfcgyMywpk9uYdF+3wztRPWbmm6Wj6aMxnFle1KPG4YI6 +im7q0/Pj4tf8Zdt5f+g6CPqdMyq6TQJYCBZ4y+JhAoIBAQDOfrn2hHlCni92Lq1s +i84owYrV5rCQXSPvp3h5Uhr/KYCv1uZLEWV+rBtIAdQqgDaFeOQ0pFVcOzWb1+ca +dI4eKzvaGFToiT5OJQb0owG91OPvmsn4i4SDtVcwfbqOUn+ce7jK/94OL+oFYMbs +6phZawxE5rsEpWNipBzssmEE1cn13RsIp6my4+uMZKZ3eWoklqBaV8GPlPCIjDVk +z6nOnPUaUnB7gGunJzqC3Gc9ZXAhYqMBr9qryE90MnY9C4z69FbIlwtjwAXDR6DQ +kbjvBgZE/F0EGqNBnnWEeKHLndRwCFpnawCyt7fqfp+d8WGu2JTP36GOJ18rsSMA +zzhlAoIBACmF+exmvnQGBvevvZFprMxmhbvpys1KkUR088Q5vmsRJFxogsmdWDHd +/eJ8ImpS8jsLTti2IXMMotvvMkKBzsE/frJ5NC5N3ZVcJn61FFgPFHeGhSB3t/62 +bk5C2nOV3n3czzrTm/Y7ZAszbYRzHk61qweCeyg+jKc6BssfrUmiSIyySyUpJc+s +o3JX7/t7l6qDfK1IaBK9tyea+Gz5fiXqS6Kw07+0HOHViHX++3hOg6iZMLqgkKti +VLdvpKLFko6R6lXSbP8OgiUED0JXAJAvBNmyxw62G6OcGrIQSZsdp/COwWij5y42 +f3TlS/GcqT7/u2ww2HkmMrOpgvIdH6M= +-----END PRIVATE KEY----- diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..d947ab1 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..3775b37 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a8bb65b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..5fb748d --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6730126 --- /dev/null +++ b/pom.xml @@ -0,0 +1,231 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + com.gnx + telemedicine + 0.0.1-SNAPSHOT + telemedicine + telemedicine + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + runtime + + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + 0.13.0 + + + io.jsonwebtoken + jjwt-impl + 0.13.0 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.13.0 + runtime + + + + org.projectlombok + lombok + true + + + org.mapstruct + mapstruct + 1.6.3 + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + me.paulschwarz + spring-dotenv + 4.0.0 + + + org.springframework.boot + spring-boot-starter-mail + + + + org.springframework.boot + spring-boot-starter-websocket + + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.13 + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + dev.samstevens.totp + totp + 1.7.1 + + + + org.jsoup + jsoup + 1.17.2 + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-cache + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.apache.commons + commons-pool2 + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + -Xshare:off + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 25 + 25 + + + org.projectlombok + lombok + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + + + + org.flywaydb + flyway-maven-plugin + 11.13.1 + + ${flyway.url:jdbc:postgresql://localhost:5432/telemedicine} + ${flyway.user:postgres} + ${flyway.password:password} + false + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + io.jsonwebtoken + jjwt-jackson + + + + + + + + diff --git a/src/main/java/com/gnx/telemedicine/TelemedicineApplication.java b/src/main/java/com/gnx/telemedicine/TelemedicineApplication.java new file mode 100644 index 0000000..48ab175 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/TelemedicineApplication.java @@ -0,0 +1,15 @@ +package com.gnx.telemedicine; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@SpringBootApplication +public class TelemedicineApplication { + + public static void main(String[] args) { + SpringApplication.run(TelemedicineApplication.class, args); + } + +} diff --git a/src/main/java/com/gnx/telemedicine/annotation/LogPhiAccess.java b/src/main/java/com/gnx/telemedicine/annotation/LogPhiAccess.java new file mode 100644 index 0000000..56a469d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/annotation/LogPhiAccess.java @@ -0,0 +1,40 @@ +package com.gnx.telemedicine.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark methods that access PHI (Protected Health Information). + * Methods annotated with this will automatically log PHI access for HIPAA compliance. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LogPhiAccess { + /** + * The type of resource being accessed (e.g., MEDICAL_RECORD, PRESCRIPTION, PATIENT) + */ + String resourceType(); + + /** + * The patient ID parameter name (default: "patientId") + */ + String patientIdParam() default "patientId"; + + /** + * The resource ID parameter name (for direct resource access) + */ + String resourceIdParam() default ""; + + /** + * Access type (Treatment, Payment, Operations, Authorization) + */ + String accessType() default "Treatment"; + + /** + * Fields that will be accessed (optional, for detailed logging) + */ + String[] accessedFields() default {}; +} + diff --git a/src/main/java/com/gnx/telemedicine/aspect/HipaaAuditAspect.java b/src/main/java/com/gnx/telemedicine/aspect/HipaaAuditAspect.java new file mode 100644 index 0000000..3492afa --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/aspect/HipaaAuditAspect.java @@ -0,0 +1,278 @@ +package com.gnx.telemedicine.aspect; + +import com.gnx.telemedicine.annotation.LogPhiAccess; +import com.gnx.telemedicine.model.enums.ActionType; +import com.gnx.telemedicine.service.HipaaAuditService; +import com.gnx.telemedicine.service.PhiAccessLogService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.*; +import java.util.UUID; + +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class HipaaAuditAspect { + + private final HipaaAuditService hipaaAuditService; + private final PhiAccessLogService phiAccessLogService; + + @AfterReturning("@annotation(logPhiAccess)") + public void logPhiAccess(JoinPoint joinPoint, LogPhiAccess logPhiAccess) { + try { + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getName())) { + return; // Skip logging for unauthenticated users + } + + String userEmail = authentication.getName(); + + // Get HTTP request + HttpServletRequest request = getHttpServletRequest(); + if (request == null) { + return; + } + + // Extract parameters from method signature + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Parameter[] parameters = method.getParameters(); + Object[] args = joinPoint.getArgs(); + + // Extract patient ID - try multiple methods + UUID patientId = extractPatientId(parameters, args, logPhiAccess.patientIdParam()); + + // If patient ID not found directly, try to extract from request DTO + if (patientId == null && !logPhiAccess.patientIdParam().isEmpty()) { + patientId = extractPatientIdFromDto(args, logPhiAccess.patientIdParam()); + } + + // If still not found, try common patterns + if (patientId == null) { + // Look for any UUID parameter that might be a patient ID + for (int i = 0; i < parameters.length; i++) { + String paramName = parameters[i].getName().toLowerCase(); + if (paramName.contains("patient") || paramName.equals("patientid")) { + if (args[i] instanceof UUID) { + patientId = (UUID) args[i]; + break; + } else if (args[i] instanceof String) { + try { + patientId = UUID.fromString((String) args[i]); + break; + } catch (Exception e) { + // Ignore + } + } + } + } + } + + // Extract resource ID + UUID resourceId = extractResourceId(parameters, args, logPhiAccess.resourceIdParam()); + if (resourceId == null && args.length > 0) { + // Try common parameter names + for (int i = 0; i < parameters.length; i++) { + String paramName = parameters[i].getName().toLowerCase(); + if ((paramName.equals("id") || paramName.equals("recordid") || paramName.equals("prescriptionid")) + && args[i] instanceof UUID) { + resourceId = (UUID) args[i]; + break; + } + } + } + + // If we have resource ID but no patient ID, try to get patient ID from the response + // This would require executing the method first, so we'll skip it for now + // In production, you might want to fetch patient ID from the database using resource ID + + // Determine action type from method name + ActionType actionType = determineActionType(method.getName()); + + // Create details map + Map details = new HashMap<>(); + details.put("method", method.getName()); + details.put("className", joinPoint.getTarget().getClass().getSimpleName()); + if (resourceId != null) { + details.put("resourceId", resourceId.toString()); + } + if (logPhiAccess.accessedFields().length > 0) { + details.put("accessedFields", Arrays.asList(logPhiAccess.accessedFields())); + } + + // Log HIPAA audit + hipaaAuditService.logAccess( + userEmail, + actionType, + logPhiAccess.resourceType(), + resourceId != null ? resourceId : UUID.randomUUID(), // Fallback to random UUID if not found + patientId, + details, + request + ); + + // Log PHI access if patient ID is available + if (patientId != null) { + List accessedFields = new ArrayList<>(); + if (logPhiAccess.accessedFields().length > 0) { + accessedFields.addAll(Arrays.asList(logPhiAccess.accessedFields())); + } else { + accessedFields.add("ALL"); // If no specific fields, assume all fields accessed + } + + phiAccessLogService.logPhiAccess( + userEmail, + patientId, + logPhiAccess.accessType(), + accessedFields, + "Access via " + method.getName(), + request + ); + } + + } catch (Exception e) { + // Log error but don't throw - audit logging should not break the main flow + log.error("Error logging PHI access: {}", e.getMessage(), e); + } + } + + private UUID extractPatientId(Parameter[] parameters, Object[] args, String paramName) { + for (int i = 0; i < parameters.length; i++) { + if (parameters[i].getName().equals(paramName) || + parameters[i].getName().equals("patientId")) { + if (args[i] instanceof UUID) { + return (UUID) args[i]; + } else if (args[i] instanceof String) { + try { + return UUID.fromString((String) args[i]); + } catch (Exception e) { + // Ignore + } + } + } + } + return null; + } + + private UUID extractResourceId(Parameter[] parameters, Object[] args, String paramName) { + if (paramName == null || paramName.isEmpty()) { + return null; + } + + for (int i = 0; i < parameters.length; i++) { + if (parameters[i].getName().equals(paramName) || parameters[i].getName().equals("id")) { + if (args[i] instanceof UUID) { + return (UUID) args[i]; + } else if (args[i] instanceof String) { + try { + return UUID.fromString((String) args[i]); + } catch (Exception e) { + // Ignore + } + } + } + } + return null; + } + + private UUID extractPatientIdFromDto(Object[] args, String paramName) { + // Try to extract from DTO objects + for (Object arg : args) { + if (arg == null) continue; + + try { + // For record DTOs, try to get patientId() method (records use methods, not getters) + java.lang.reflect.Method[] methods = arg.getClass().getMethods(); + for (java.lang.reflect.Method method : methods) { + String methodName = method.getName().toLowerCase(); + // Try both getter pattern and record accessor pattern + if ((methodName.equals("getpatientid") || + methodName.equals("patientid") || + methodName.equals("patient_id")) + && method.getParameterCount() == 0) { + Object result = method.invoke(arg); + if (result instanceof UUID) { + return (UUID) result; + } else if (result instanceof String) { + try { + return UUID.fromString((String) result); + } catch (Exception e) { + // Ignore + } + } + } + } + + // Also try to access fields directly (for records) + try { + java.lang.reflect.Field[] fields = arg.getClass().getDeclaredFields(); + for (java.lang.reflect.Field field : fields) { + String fieldName = field.getName().toLowerCase(); + if (fieldName.equals("patientid") || fieldName.equals("patient_id")) { + field.setAccessible(true); + Object value = field.get(arg); + if (value instanceof UUID) { + return (UUID) value; + } else if (value instanceof String) { + try { + return UUID.fromString((String) value); + } catch (Exception e) { + // Ignore + } + } + } + } + } catch (Exception e) { + // Ignore + } + } catch (Exception e) { + // Ignore reflection errors + } + } + return null; + } + + private ActionType determineActionType(String methodName) { + String lowerMethodName = methodName.toLowerCase(); + if (lowerMethodName.contains("get") || lowerMethodName.contains("find") || lowerMethodName.contains("retrieve")) { + return ActionType.VIEW; + } else if (lowerMethodName.contains("create") || lowerMethodName.contains("save") || lowerMethodName.contains("add")) { + return ActionType.CREATE; + } else if (lowerMethodName.contains("update") || lowerMethodName.contains("modify") || lowerMethodName.contains("change")) { + return ActionType.UPDATE; + } else if (lowerMethodName.contains("delete") || lowerMethodName.contains("remove")) { + return ActionType.DELETE; + } else if (lowerMethodName.contains("export") || lowerMethodName.contains("download")) { + return ActionType.EXPORT; + } else if (lowerMethodName.contains("print")) { + return ActionType.PRINT; + } + return ActionType.VIEW; // Default to VIEW + } + + private HttpServletRequest getHttpServletRequest() { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } catch (Exception e) { + return null; + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/ApiVersionConfig.java b/src/main/java/com/gnx/telemedicine/config/ApiVersionConfig.java new file mode 100644 index 0000000..2024438 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/ApiVersionConfig.java @@ -0,0 +1,119 @@ +package com.gnx.telemedicine.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for API versioning. + * Supports multiple API versions with deprecation and sunset policies. + */ +@Configuration +@Getter +public class ApiVersionConfig { + + /** + * Current API version. + */ + @Value("${api.version.current:v1}") + private String currentVersion; + + /** + * Minimum supported API version. + */ + @Value("${api.version.minimum:v1}") + private String minimumVersion; + + /** + * API version prefix (e.g., "/api/v3"). + */ + @Value("${api.version.prefix:/api}") + private String versionPrefix; + + /** + * Enable API versioning. + */ + @Value("${api.versioning.enabled:true}") + private boolean versioningEnabled; + + /** + * Show version in response headers. + */ + @Value("${api.versioning.show-in-headers:true}") + private boolean showVersionInHeaders; + + /** + * Show deprecation warnings in response headers. + */ + @Value("${api.versioning.show-deprecation-warnings:true}") + private boolean showDeprecationWarnings; + + // Explicit getters for boolean fields (Lombok @Getter should generate these, but adding for safety) + public boolean isVersioningEnabled() { + return versioningEnabled; + } + + public boolean isShowVersionInHeaders() { + return showVersionInHeaders; + } + + public boolean isShowDeprecationWarnings() { + return showDeprecationWarnings; + } + + /** + * Get the full API path for a version. + * @param version API version (e.g., "v1") + * @return Full API path (e.g., "/api/v3") + */ + public String getApiPath(String version) { + return versionPrefix + "/" + version; + } + + /** + * Get the current API path. + * @return Current API path (e.g., "/api/v3") + */ + public String getCurrentApiPath() { + return getApiPath(currentVersion); + } + + /** + * Check if a version is supported. + * @param version API version to check + * @return true if version is supported + */ + public boolean isVersionSupported(String version) { + if (!versioningEnabled) { + return true; // If versioning is disabled, all versions are supported + } + + // Extract version number (e.g., "v1" -> 1) + int versionNum = extractVersionNumber(version); + int minVersionNum = extractVersionNumber(minimumVersion); + int currentVersionNum = extractVersionNumber(currentVersion); + + return versionNum >= minVersionNum && versionNum <= currentVersionNum; + } + + /** + * Extract version number from version string. + * @param version Version string (e.g., "v1", "1") + * @return Version number + */ + private int extractVersionNumber(String version) { + if (version == null || version.isEmpty()) { + return 1; // Default to v1 + } + + // Remove "v" prefix if present + String versionNum = version.startsWith("v") ? version.substring(1) : version; + + try { + return Integer.parseInt(versionNum); + } catch (NumberFormatException e) { + return 1; // Default to v1 if parsing fails + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/ApiVersionInterceptor.java b/src/main/java/com/gnx/telemedicine/config/ApiVersionInterceptor.java new file mode 100644 index 0000000..f65f79f --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/ApiVersionInterceptor.java @@ -0,0 +1,99 @@ +package com.gnx.telemedicine.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * Interceptor to add API version information to response headers. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ApiVersionInterceptor implements HandlerInterceptor { + + private final ApiVersionConfig apiVersionConfig; + + private static final String API_VERSION_HEADER = "API-Version"; + private static final String API_DEPRECATED_HEADER = "API-Deprecated"; + private static final String API_SUNSET_HEADER = "API-Sunset"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!apiVersionConfig.isVersioningEnabled()) { + return true; + } + + // Extract API version from request path + String requestPath = request.getRequestURI(); + String apiVersion = extractApiVersion(requestPath); + + if (apiVersion != null) { + // Add version to response headers + if (apiVersionConfig.isShowVersionInHeaders()) { + response.setHeader(API_VERSION_HEADER, apiVersion); + } + + // Check if version is deprecated + if (isDeprecated(apiVersion)) { + if (apiVersionConfig.isShowDeprecationWarnings()) { + response.setHeader(API_DEPRECATED_HEADER, "true"); + response.setHeader(API_SUNSET_HEADER, getSunsetDate(apiVersion)); + log.warn("Deprecated API version {} used for path: {}", apiVersion, requestPath); + } + } + } + + return true; + } + + /** + * Extract API version from request path. + * @param requestPath Request path (e.g., "/api/v3/auth/login") + * @return API version (e.g., "v1") or null if not found + */ + private String extractApiVersion(String requestPath) { + if (requestPath == null || requestPath.isEmpty()) { + return null; + } + + // Check for /api/vX pattern + String[] parts = requestPath.split("/"); + for (int i = 0; i < parts.length - 1; i++) { + if ("api".equals(parts[i]) && i + 1 < parts.length) { + String version = parts[i + 1]; + if (version.startsWith("v") && version.length() > 1) { + return version; + } + } + } + + return null; + } + + /** + * Check if an API version is deprecated. + * @param version API version to check + * @return true if version is deprecated + */ + private boolean isDeprecated(String version) { + // For now, only current version is not deprecated + // In the future, you can add deprecation logic here + return false; // TODO: Implement deprecation logic + } + + /** + * Get sunset date for a deprecated API version. + * @param version API version + * @return Sunset date in RFC 1123 format + */ + private String getSunsetDate(String version) { + // For now, return a default sunset date + // In the future, you can configure this per version + return "Sun, 01 Jan 2025 00:00:00 GMT"; // TODO: Configure per version + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/CacheConfig.java b/src/main/java/com/gnx/telemedicine/config/CacheConfig.java new file mode 100644 index 0000000..b01b7da --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/CacheConfig.java @@ -0,0 +1,122 @@ +package com.gnx.telemedicine.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurerSupport; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration for Redis caching. + * Provides cache management for read-heavy operations. + */ +@Configuration +@EnableCaching +@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis", matchIfMissing = false) +@Slf4j +public class CacheConfig extends CachingConfigurerSupport { + + @Value("${spring.cache.redis.time-to-live:3600000}") + private long defaultTtl; + + @Value("${spring.cache.redis.key-prefix:telemedicine:cache:}") + private String keyPrefix; + + /** + * Configure Redis cache manager with different TTLs for different caches. + */ + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMillis(defaultTtl)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) + .disableCachingNullValues(); + + // Define cache-specific configurations with different TTLs + Map cacheConfigurations = new HashMap<>(); + + // User cache - 30 minutes + cacheConfigurations.put("users", defaultConfig.entryTtl(Duration.ofMinutes(30))); + cacheConfigurations.put("userByEmail", defaultConfig.entryTtl(Duration.ofMinutes(30))); + cacheConfigurations.put("userProfile", defaultConfig.entryTtl(Duration.ofMinutes(30))); + + // Appointment cache - 15 minutes + cacheConfigurations.put("appointments", defaultConfig.entryTtl(Duration.ofMinutes(15))); + cacheConfigurations.put("appointmentsByPatient", defaultConfig.entryTtl(Duration.ofMinutes(15))); + cacheConfigurations.put("appointmentsByDoctor", defaultConfig.entryTtl(Duration.ofMinutes(15))); + cacheConfigurations.put("appointmentById", defaultConfig.entryTtl(Duration.ofMinutes(15))); + + // Doctor cache - 1 hour + cacheConfigurations.put("doctors", defaultConfig.entryTtl(Duration.ofHours(1))); + cacheConfigurations.put("doctorById", defaultConfig.entryTtl(Duration.ofHours(1))); + cacheConfigurations.put("doctorByUserId", defaultConfig.entryTtl(Duration.ofHours(1))); + cacheConfigurations.put("doctorAvailability", defaultConfig.entryTtl(Duration.ofMinutes(30))); + + // Patient cache - 1 hour + cacheConfigurations.put("patients", defaultConfig.entryTtl(Duration.ofHours(1))); + cacheConfigurations.put("patientById", defaultConfig.entryTtl(Duration.ofHours(1))); + cacheConfigurations.put("patientByUserId", defaultConfig.entryTtl(Duration.ofHours(1))); + + // Admin stats cache - 5 minutes + cacheConfigurations.put("adminStats", defaultConfig.entryTtl(Duration.ofMinutes(5))); + + // Prescription cache - 30 minutes + cacheConfigurations.put("prescriptions", defaultConfig.entryTtl(Duration.ofMinutes(30))); + cacheConfigurations.put("prescriptionsByPatient", defaultConfig.entryTtl(Duration.ofMinutes(30))); + cacheConfigurations.put("prescriptionsByDoctor", defaultConfig.entryTtl(Duration.ofMinutes(30))); + + // Medical records cache - 30 minutes + cacheConfigurations.put("medicalRecords", defaultConfig.entryTtl(Duration.ofMinutes(30))); + cacheConfigurations.put("medicalRecordsByPatient", defaultConfig.entryTtl(Duration.ofMinutes(30))); + + // Vital signs cache - 15 minutes + cacheConfigurations.put("vitalSigns", defaultConfig.entryTtl(Duration.ofMinutes(15))); + cacheConfigurations.put("vitalSignsByPatient", defaultConfig.entryTtl(Duration.ofMinutes(15))); + + // Lab results cache - 30 minutes + cacheConfigurations.put("labResults", defaultConfig.entryTtl(Duration.ofMinutes(30))); + cacheConfigurations.put("labResultsByPatient", defaultConfig.entryTtl(Duration.ofMinutes(30))); + + RedisCacheManager cacheManager = RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigurations) + .transactionAware() + .build(); + + log.info("Redis cache manager configured with {} cache configurations", cacheConfigurations.size()); + return cacheManager; + } + + /** + * Configure Redis template for manual cache operations. + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.afterPropertiesSet(); + return template; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/MetricsConfig.java b/src/main/java/com/gnx/telemedicine/config/MetricsConfig.java new file mode 100644 index 0000000..bbcd1c2 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/MetricsConfig.java @@ -0,0 +1,22 @@ +package com.gnx.telemedicine.config; + +import com.gnx.telemedicine.metrics.TelemedicineMetrics; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for metrics initialization. + */ +@Configuration +@RequiredArgsConstructor +public class MetricsConfig { + + private final TelemedicineMetrics telemedicineMetrics; + + @PostConstruct + public void initializeMetrics() { + telemedicineMetrics.initialize(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/OpenApiConfig.java b/src/main/java/com/gnx/telemedicine/config/OpenApiConfig.java new file mode 100644 index 0000000..cf5c55e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/OpenApiConfig.java @@ -0,0 +1,68 @@ +package com.gnx.telemedicine.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "Telemedicine API", + version = "1.0.0", + description = """ + A comprehensive telemedicine platform API that enables secure patient-doctor interactions, + appointment management, and AI-powered triage services. + + ## Features + - User authentication and authorization (JWT-based) + - Doctor and patient registration + - Appointment scheduling and management + - AI-powered symptom triage + - Admin dashboard for system management + - Email notifications + + ## Authentication + Most endpoints require JWT authentication. To authenticate: + 1. Register as a doctor or patient + 2. Login to receive a JWT token + 3. Add an authorization header and enter: `Bearer ` + + ## Roles + - **PATIENT**: Can book appointments, use triage services + - **DOCTOR**: Can manage appointments, access triage tools + - **ADMIN**: Full system access and user management + """, + contact = @Contact( + name = "GNX Soft LTD", + email = "sales@gnxsoft.com" + ), + license = @License( + name = "MIT License", + url = "https://opensource.org/licenses/MIT" + ) + ), + servers = { + @Server( + description = "Local Development Server", + url = "http://localhost:8080" + ) + }, + security = @SecurityRequirement(name = "bearerAuth") +) +@SecurityScheme( + name = "bearerAuth", + description = "JWT authentication. Format: Bearer ", + scheme = "bearer", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER +) +public class OpenApiConfig { +} diff --git a/src/main/java/com/gnx/telemedicine/config/RedisConfig.java b/src/main/java/com/gnx/telemedicine/config/RedisConfig.java new file mode 100644 index 0000000..19bc4b3 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/RedisConfig.java @@ -0,0 +1,44 @@ +package com.gnx.telemedicine.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +/** + * Redis connection configuration. + * Only enabled when Redis is configured. + */ +@Configuration +@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis") +@Slf4j +public class RedisConfig { + + @Value("${spring.data.redis.host:localhost}") + private String host; + + @Value("${spring.data.redis.port:6379}") + private int port; + + @Value("${spring.data.redis.password:}") + private String password; + + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(host); + config.setPort(port); + if (password != null && !password.isEmpty()) { + config.setPassword(password); + } + + LettuceConnectionFactory factory = new LettuceConnectionFactory(config); + log.info("Redis connection factory configured for {}:{}", host, port); + return factory; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/SecurityHeadersConfig.java b/src/main/java/com/gnx/telemedicine/config/SecurityHeadersConfig.java new file mode 100644 index 0000000..35d2446 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/SecurityHeadersConfig.java @@ -0,0 +1,226 @@ +package com.gnx.telemedicine.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Security headers configuration filter. + * Adds security headers to all HTTP responses for enhanced security. + * Headers are configurable via application properties. + */ +@Component +@Order(1) +@Slf4j +public class SecurityHeadersConfig extends OncePerRequestFilter { + + @Value("${server.ssl.enabled:false}") + private boolean sslEnabled; + + @Value("${security.headers.hsts.enabled:${server.ssl.enabled:false}}") + private boolean hstsEnabled; + + @Value("${security.headers.hsts.max-age:63072000}") + private long hstsMaxAge; + + @Value("${security.headers.hsts.include-subdomains:true}") + private boolean hstsIncludeSubdomains; + + @Value("${security.headers.hsts.preload:false}") + private boolean hstsPreload; + + @Value("${security.headers.csp.enabled:true}") + private boolean cspEnabled; + + @Value("${security.headers.frame-options:DENY}") + private String frameOptions; + + @Value("${security.headers.content-type-options:nosniff}") + private String contentTypeOptions; + + @Value("${security.headers.xss-protection:1; mode=block}") + private String xssProtection; + + @Value("${security.headers.referrer-policy:strict-origin-when-cross-origin}") + private String referrerPolicy; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // Strict Transport Security (HSTS) - Only enabled when SSL is enabled + if (hstsEnabled && sslEnabled) { + StringBuilder hstsValue = new StringBuilder("max-age=").append(hstsMaxAge); + if (hstsIncludeSubdomains) { + hstsValue.append("; includeSubDomains"); + } + if (hstsPreload) { + hstsValue.append("; preload"); + } + response.setHeader("Strict-Transport-Security", hstsValue.toString()); + log.debug("HSTS header set: {}", hstsValue); + } + + // Content Security Policy - Enhanced for enterprise security + if (cspEnabled) { + // Check if we're in production mode + String profile = System.getenv("SPRING_PROFILES_ACTIVE"); + boolean isProduction = "prod".equals(profile) || "production".equals(profile); + + // Build CSP based on environment + StringBuilder csp = new StringBuilder(); + + // Default source - only allow same origin + csp.append("default-src 'self'; "); + + // Script source - allow same origin, inline scripts for Swagger (development only) + if (isProduction) { + csp.append("script-src 'self'; "); + } else { + csp.append("script-src 'self' 'unsafe-inline' 'unsafe-eval'; "); // Allow inline scripts for Swagger in dev + } + + // Style source - allow same origin and inline styles + csp.append("style-src 'self' 'unsafe-inline'; "); + + // Image source - allow same origin, data URIs, and HTTPS + String cspImgSrc = "'self' data: https:"; + if (!isProduction) { + cspImgSrc += " http://localhost:8080 http://localhost:4200 http://127.0.0.1:8080 http://127.0.0.1:4200"; + } + csp.append("img-src ").append(cspImgSrc).append("; "); + + // Font source - allow same origin and data URIs + csp.append("font-src 'self' data:; "); + + // Connect source - allow same origin, WebSocket, and HTTPS + String cspConnectSrc = "'self' https:"; + if (!isProduction) { + cspConnectSrc += " ws://localhost:* wss://localhost:* ws://127.0.0.1:* wss://127.0.0.1:* http://localhost:8080 http://localhost:4200 http://127.0.0.1:8080 http://127.0.0.1:4200"; + } else { + cspConnectSrc += " wss:"; + } + csp.append("connect-src ").append(cspConnectSrc).append("; "); + + // Frame ancestors - prevent clickjacking + csp.append("frame-ancestors 'none'; "); + + // Base URI - only allow same origin + csp.append("base-uri 'self'; "); + + // Form action - only allow same origin + csp.append("form-action 'self'; "); + + // Upgrade insecure requests - only in production with SSL + if (isProduction && sslEnabled) { + csp.append("upgrade-insecure-requests; "); + } + + // Object source - prevent plugins + csp.append("object-src 'none'; "); + + // Media source - allow same origin + csp.append("media-src 'self'; "); + + // Worker source - allow same origin + csp.append("worker-src 'self'; "); + + // Manifest source - allow same origin + csp.append("manifest-src 'self'; "); + + response.setHeader("Content-Security-Policy", csp.toString()); + log.debug("CSP header set for {} environment", isProduction ? "production" : "development"); + } + + // Prevent clickjacking + response.setHeader("X-Frame-Options", frameOptions); + + // Prevent MIME type sniffing + response.setHeader("X-Content-Type-Options", contentTypeOptions); + + // Enable XSS protection (legacy header, CSP is preferred) + response.setHeader("X-XSS-Protection", xssProtection); + + // Referrer Policy - Enhanced for privacy + response.setHeader("Referrer-Policy", referrerPolicy); + + // Permissions Policy (formerly Feature Policy) - Enhanced for HIPAA compliance + response.setHeader("Permissions-Policy", + "geolocation=(), " + + "microphone=(), " + + "camera=(), " + + "payment=(), " + + "usb=(), " + + "magnetometer=(), " + + "gyroscope=(), " + + "accelerometer=()"); + + // Additional enterprise security headers + // Cross-Origin Embedder Policy - relaxed for file resources and API endpoints + String requestPathForCOEP = request.getRequestURI(); + if (requestPathForCOEP != null && (requestPathForCOEP.startsWith("/api/") || + requestPathForCOEP.startsWith("/files/") || + requestPathForCOEP.startsWith("/actuator/") || + requestPathForCOEP.startsWith("/auth/") || + requestPathForCOEP.startsWith("/ws/") || + requestPathForCOEP.contains("/avatars/") || + requestPathForCOEP.endsWith(".ico") || + requestPathForCOEP.endsWith(".png") || + requestPathForCOEP.endsWith(".jpg") || + requestPathForCOEP.endsWith(".jpeg") || + requestPathForCOEP.endsWith(".gif") || + requestPathForCOEP.endsWith(".svg"))) { + // Don't set COEP for API and file resources to allow cross-origin loading + // response.setHeader("Cross-Origin-Embedder-Policy", "unsafe-none"); + } else { + response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + } + + // Cross-Origin Opener Policy + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + + // Cross-Origin Resource Policy + // Allow cross-origin for file resources, API endpoints, and public resources + String requestPath = request.getRequestURI(); + if (requestPath != null && (requestPath.startsWith("/api/") || + requestPath.startsWith("/files/") || + requestPath.startsWith("/actuator/") || + requestPath.startsWith("/auth/") || + requestPath.startsWith("/ws/") || + requestPath.contains("/avatars/") || + requestPath.endsWith(".ico") || + requestPath.endsWith(".png") || + requestPath.endsWith(".jpg") || + requestPath.endsWith(".jpeg") || + requestPath.endsWith(".gif") || + requestPath.endsWith(".svg"))) { + response.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + } else { + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + } + + // Expect-CT (Certificate Transparency) - Disabled since SSL is not enabled + // Only set Expect-CT when SSL is enabled + // response.setHeader("Expect-CT", "max-age=86400, enforce"); + + // Clear Site Data (for GDPR compliance - can be used for logout) + // Note: This is set per request, not here. Use it when needed. + + // Privacy-focused headers + response.setHeader("X-Permitted-Cross-Domain-Policies", "none"); + + // DNS Prefetch Control + response.setHeader("X-DNS-Prefetch-Control", "off"); + + filterChain.doFilter(request, response); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/SessionManagementConfig.java b/src/main/java/com/gnx/telemedicine/config/SessionManagementConfig.java new file mode 100644 index 0000000..b1a9632 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/SessionManagementConfig.java @@ -0,0 +1,64 @@ +package com.gnx.telemedicine.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; + +/** + * Session management configuration. + * Configures session timeout and concurrent session limits. + */ +@Configuration +@EnableWebSecurity +@Slf4j +public class SessionManagementConfig { + + @Value("${security.session.timeout-seconds:1800}") + private int sessionTimeoutSeconds; // Default: 30 minutes + + @Value("${security.session.max-concurrent-sessions:1}") + private int maxConcurrentSessions; // Default: 1 session per user + + @Value("${security.session.max-sessions-prevents-login:false}") + private boolean maxSessionsPreventsLogin; // If true, prevents login when max sessions reached + + /** + * Session registry bean for tracking active sessions. + * + * @return SessionRegistry instance + */ + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + /** + * Gets session timeout in seconds. + * + * @return Session timeout in seconds + */ + public int getSessionTimeoutSeconds() { + return sessionTimeoutSeconds; + } + + /** + * Gets maximum concurrent sessions allowed per user. + * + * @return Maximum concurrent sessions + */ + public int getMaxConcurrentSessions() { + return maxConcurrentSessions; + } + + /** + * Checks if max sessions prevents login. + * + * @return true if login is prevented when max sessions reached + */ + public boolean isMaxSessionsPreventsLogin() { + return maxSessionsPreventsLogin; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/TurnConfig.java b/src/main/java/com/gnx/telemedicine/config/TurnConfig.java new file mode 100644 index 0000000..bad2f0e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/TurnConfig.java @@ -0,0 +1,17 @@ +package com.gnx.telemedicine.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "webrtc.turn") +@Data +public class TurnConfig { + private String server = "localhost"; + private Integer port = 3478; + private String username = "telemedicine"; + private String password = "changeme"; + private String realm = "localdomain"; +} + diff --git a/src/main/java/com/gnx/telemedicine/config/WebMvcConfig.java b/src/main/java/com/gnx/telemedicine/config/WebMvcConfig.java new file mode 100644 index 0000000..31643e4 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/WebMvcConfig.java @@ -0,0 +1,40 @@ +package com.gnx.telemedicine.config; + +import com.gnx.telemedicine.metrics.MetricsInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC configuration for API versioning and metrics. + */ +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final ApiVersionInterceptor apiVersionInterceptor; + private final MetricsInterceptor metricsInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // Add metrics interceptor first (runs first) + registry.addInterceptor(metricsInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns( + "/actuator/**", + "/error", + "/favicon.ico" + ); + + // Add API version interceptor + registry.addInterceptor(apiVersionInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns( + "/actuator/**", + "/error", + "/favicon.ico" + ); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/config/WebSocketConfig.java b/src/main/java/com/gnx/telemedicine/config/WebSocketConfig.java new file mode 100644 index 0000000..3f6508d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/config/WebSocketConfig.java @@ -0,0 +1,120 @@ +package com.gnx.telemedicine.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic", "/queue"); + config.setApplicationDestinationPrefixes("/app"); + config.setUserDestinationPrefix("/user"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // Allow all private network IPs and public IP for WebSocket connections + registry.addEndpoint("/ws") + .setAllowedOriginPatterns( + "http://localhost:*", + "https://localhost:*", + "http://127.0.0.1:*", + "https://127.0.0.1:*", + "http://192.168.1.6:*", // Explicitly allow common local IP + "https://192.168.1.6:*", + "http://192.168.*.*:*", + "https://192.168.*.*:*", + "http://10.*.*.*:*", + "https://10.*.*.*:*", + "http://172.16.*.*:*", + "https://172.16.*.*:*", + "http://172.17.*.*:*", + "https://172.17.*.*:*", + "http://172.18.*.*:*", + "https://172.18.*.*:*", + "http://172.19.*.*:*", + "https://172.19.*.*:*", + "http://172.20.*.*:*", + "https://172.20.*.*:*", + "http://172.21.*.*:*", + "https://172.21.*.*:*", + "http://172.22.*.*:*", + "https://172.22.*.*:*", + "http://172.23.*.*:*", + "https://172.23.*.*:*", + "http://172.24.*.*:*", + "https://172.24.*.*:*", + "http://172.25.*.*:*", + "https://172.25.*.*:*", + "http://172.26.*.*:*", + "https://172.26.*.*:*", + "http://172.27.*.*:*", + "https://172.27.*.*:*", + "http://172.28.*.*:*", + "https://172.28.*.*:*", + "http://172.29.*.*:*", + "https://172.29.*.*:*", + "http://172.30.*.*:*", + "https://172.30.*.*:*", + "http://172.31.*.*:*", + "https://172.31.*.*:*", + "http://193.194.155.249:*", // Public IP HTTP + "https://193.194.155.249:*" // Public IP HTTPS + ) + .withSockJS(); + registry.addEndpoint("/ws") + .setAllowedOriginPatterns( + "http://localhost:*", + "https://localhost:*", + "http://127.0.0.1:*", + "https://127.0.0.1:*", + "http://192.168.1.6:*", // Explicitly allow common local IP + "https://192.168.1.6:*", + "http://192.168.*.*:*", + "https://192.168.*.*:*", + "http://10.*.*.*:*", + "https://10.*.*.*:*", + "http://172.16.*.*:*", + "https://172.16.*.*:*", + "http://172.17.*.*:*", + "https://172.17.*.*:*", + "http://172.18.*.*:*", + "https://172.18.*.*:*", + "http://172.19.*.*:*", + "https://172.19.*.*:*", + "http://172.20.*.*:*", + "https://172.20.*.*:*", + "http://172.21.*.*:*", + "https://172.21.*.*:*", + "http://172.22.*.*:*", + "https://172.22.*.*:*", + "http://172.23.*.*:*", + "https://172.23.*.*:*", + "http://172.24.*.*:*", + "https://172.24.*.*:*", + "http://172.25.*.*:*", + "https://172.25.*.*:*", + "http://172.26.*.*:*", + "https://172.26.*.*:*", + "http://172.27.*.*:*", + "https://172.27.*.*:*", + "http://172.28.*.*:*", + "https://172.28.*.*:*", + "http://172.29.*.*:*", + "https://172.29.*.*:*", + "http://172.30.*.*:*", + "https://172.30.*.*:*", + "http://172.31.*.*:*", + "https://172.31.*.*:*", + "http://193.194.155.249:*", // Public IP HTTP + "https://193.194.155.249:*" // Public IP HTTPS + ); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/AdminController.java b/src/main/java/com/gnx/telemedicine/controller/AdminController.java new file mode 100644 index 0000000..1fdea9c --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/AdminController.java @@ -0,0 +1,336 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.admin.AdminStatsResponseDto; +import com.gnx.telemedicine.dto.admin.MetricsResponseDto; +import com.gnx.telemedicine.dto.admin.PaginatedResponse; +import com.gnx.telemedicine.dto.admin.UserManagementDto; +import com.gnx.telemedicine.dto.appointment.AppointmentResponseDto; +import com.gnx.telemedicine.service.AdminService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v3/admin") +@RequiredArgsConstructor +@Tag(name = "Admin", description = "Administrative endpoints for system management (Admin role required)") +@SecurityRequirement(name = "bearerAuth") +public class AdminController { + private final AdminService adminService; + + @Operation( + summary = "Get all users", + description = "Retrieve a list of all registered users in the system." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Users retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = UserManagementDto.class)) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @GetMapping("/users") + public ResponseEntity> getAllUsers() { + return ResponseEntity.ok(adminService.getAllUsers()); + } + + @Operation( + summary = "Get all users (paginated)", + description = """ + Retrieve a paginated list of all registered users in the system. + Supports pagination and sorting. + """, + parameters = { + @Parameter(name = "page", description = "Page number (0-indexed)", required = false), + @Parameter(name = "size", description = "Page size (1-100)", required = false), + @Parameter(name = "sortBy", description = "Field to sort by", required = false), + @Parameter(name = "direction", description = "Sort direction (ASC or DESC)", required = false) + } + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Users retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PaginatedResponse.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @GetMapping("/users/paginated") + public ResponseEntity> getAllUsersPaginated( + @RequestParam(required = false, defaultValue = "0") Integer page, + @RequestParam(required = false, defaultValue = "20") Integer size, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false, defaultValue = "ASC") String direction) { + return ResponseEntity.ok(adminService.getAllUsersPaginated(page, size, sortBy, direction)); + } + + @Operation( + summary = "Get all patients", + description = "Retrieve a list of all registered patients in the system." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Patients retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = UserManagementDto.class)) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @GetMapping("/patients") + public ResponseEntity> getAllPatients() { + return ResponseEntity.ok(adminService.getAllPatients()); + } + + @Operation( + summary = "Get all doctors", + description = "Retrieve a list of all registered doctors in the system." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Doctors retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = UserManagementDto.class)) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @GetMapping("/doctors") + public ResponseEntity> getAllDoctors() { + return ResponseEntity.ok(adminService.getAllDoctors()); + } + + @Operation( + summary = "Get system statistics", + description = """ + Retrieve comprehensive system statistics including: + - Total users count + - Total doctors count + - Total patients count + - Total appointments count + - Active users count + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Statistics retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AdminStatsResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @GetMapping("/stats") + public ResponseEntity getSystemStats() { + return ResponseEntity.ok(adminService.getSystemStats()); + } + + @Operation( + summary = "Deactivate a user account", + description = "Disable a user account", + parameters = @Parameter(description = "User email", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User deactivated successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserManagementDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @PatchMapping("/users/{email}/deactivate") + public ResponseEntity deactivateUser(@PathVariable String email) { + return new ResponseEntity<>(adminService.toggleUserActivity(email, false), HttpStatus.OK); + } + + @Operation( + summary = "Activate a user account", + description = "Enable a user account", + parameters = @Parameter(description = "User email", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User activated successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserManagementDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @PatchMapping("/users/{email}/activate") + public ResponseEntity activateUser(@PathVariable String email) { + return new ResponseEntity<>(adminService.toggleUserActivity(email, true), HttpStatus.OK); + } + + @Operation( + summary = "Verify a doctor", + description = "Mark a doctor as verified", + parameters = @Parameter(description = "Medical license number", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Doctor verified successfully" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @PatchMapping("/doctors/{medicalLicenseNumber}/verify") + public ResponseEntity verifyDoctor(@PathVariable String medicalLicenseNumber) { + adminService.verifyDoctor(medicalLicenseNumber, true); + return ResponseEntity.ok().build(); + } + + @Operation( + summary = "Unverify a doctor", + description = "Mark a doctor as unverified", + parameters = @Parameter(description = "Medical license number", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Doctor unverified successfully" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @PatchMapping("/doctors/{medicalLicenseNumber}/unverify") + public ResponseEntity unverifyDoctor(@PathVariable String medicalLicenseNumber) { + adminService.verifyDoctor(medicalLicenseNumber, false); + return ResponseEntity.ok().build(); + } + + @Operation( + summary = "Delete a user", + description = "Delete a user account", + parameters = @Parameter(description = "User email", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "User deleted successfully" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @DeleteMapping("/users/{email}") + public ResponseEntity deleteUser(@PathVariable String email) { + adminService.deleteUser(email); + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "Get all appointments", + description = "Retrieve a list of all appointments in the system. Admin only." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Appointments retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = AppointmentResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @GetMapping("/appointments") + public ResponseEntity> getAllAppointments() { + return ResponseEntity.ok(adminService.getAllAppointments()); + } + + @Operation( + summary = "Get system metrics", + description = """ + Retrieve comprehensive system metrics from TelemedicineMetrics including: + - Authentication metrics (login attempts, success, failures, 2FA) + - Appointment metrics (created, cancelled, completed) + - Prescription metrics (created, active) + - Message metrics (sent) + - API metrics (requests, errors, response times) + - Database metrics (query times) + - PHI access metrics + - Breach notification metrics + - User metrics (active, total) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Metrics retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MetricsResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Admin role required" + ) + }) + @GetMapping("/metrics") + public ResponseEntity getMetrics() { + return ResponseEntity.ok(adminService.getMetrics()); + } + +} diff --git a/src/main/java/com/gnx/telemedicine/controller/AppointmentController.java b/src/main/java/com/gnx/telemedicine/controller/AppointmentController.java new file mode 100644 index 0000000..be2e9dd --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/AppointmentController.java @@ -0,0 +1,498 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.appointment.AppointmentRequestDto; +import com.gnx.telemedicine.dto.appointment.AppointmentResponseDto; +import com.gnx.telemedicine.exception.ForbiddenException; +import com.gnx.telemedicine.exception.ResourceNotFoundException; +import com.gnx.telemedicine.model.Appointment; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.AppointmentStatus; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.service.AppointmentService; +import com.gnx.telemedicine.util.InputSanitizer; +import lombok.extern.slf4j.Slf4j; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/appointments") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Appointments", description = "Endpoints for managing appointments") +@SecurityRequirement(name = "bearerAuth") +public class AppointmentController { + private final AppointmentService appointmentService; + private final UserRepository userRepository; + private final DoctorRepository doctorRepository; + private final PatientRepository patientRepository; + + @Operation( + summary = "Get appointments by patient ID", + description = "Get appointments for a specific patient. Requires authentication.", + parameters = @Parameter(name = "id", description = "Patient ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "List of appointments for the patient retrieved successfully.", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = AppointmentResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/patient/{id}") + public ResponseEntity> getAppointmentsByPatientId( + Authentication authentication, + @PathVariable UUID id) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + // Check if user is the patient or the patient's treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + throw new ForbiddenException("Patient profile not found for user"); + } + Patient patient = patientOpt.get(); + if (!patient.getId().equals(id)) { + throw new ForbiddenException("Patients can only view their own appointments"); + } + return ResponseEntity.ok(appointmentService.getAppointmentsByPatientId(id)); + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + throw new ForbiddenException("Doctor profile not found for user"); + } + Doctor doctor = doctorOpt.get(); + // Doctors can only see appointments for their patients + List allAppointments = appointmentService.getAppointmentsByPatientId(id); + // Filter to only appointments where this doctor is the doctor + List filtered = allAppointments.stream() + .filter(apt -> apt.doctorId().equals(doctor.getId())) + .toList(); + return ResponseEntity.ok(filtered); + } + + throw new ForbiddenException("Access denied"); + } + + @Operation( + summary = "Get appointments by doctor ID", + description = "Get appointments for a specific doctor. Requires authentication.", + parameters = @Parameter(name = "id", description = "Doctor ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "List of appointments for the doctor retrieved successfully.", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = AppointmentResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/doctor/{id}") + public ResponseEntity> getAppointmentsByDoctorId( + Authentication authentication, + @PathVariable UUID id) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + // Check if user is the doctor or a patient viewing their appointments with this doctor + if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + throw new ForbiddenException("Doctor profile not found for user"); + } + Doctor doctor = doctorOpt.get(); + if (!doctor.getId().equals(id)) { + throw new ForbiddenException("Doctors can only view their own appointments"); + } + return ResponseEntity.ok(appointmentService.getAppointmentsByDoctorId(id)); + } else if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + throw new ForbiddenException("Patient profile not found for user"); + } + Patient patient = patientOpt.get(); + // Patients can see appointments with a specific doctor (for viewing doctor's schedule) + List allAppointments = appointmentService.getAppointmentsByDoctorId(id); + // Filter to only appointments where this patient is the patient + List filtered = allAppointments.stream() + .filter(apt -> apt.patientId().equals(patient.getId())) + .toList(); + return ResponseEntity.ok(filtered); + } + + throw new ForbiddenException("Access denied"); + } + + @Operation( + summary = "Create/Request an appointment", + description = """ + Schedule a new appointment for patient and doctor. Both doctors and patients can create appointments. + If the slot is available, the appointment is automatically confirmed. Otherwise, it remains in SCHEDULED status + and the doctor can confirm it later. Email notifications are sent automatically. + The system automatically checks for availability and conflicts.""", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Appointment details", + required = true, + content = @Content(schema = @Schema(implementation = AppointmentRequestDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Appointment created successfully.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AppointmentResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Validation error" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors and patients can create appointments." + ) + }) + @PostMapping + public ResponseEntity createAppointment(@RequestBody @Valid AppointmentRequestDto appointmentRequest) { + // Sanitize UUID inputs + if (appointmentRequest.patientId() != null) { + String sanitized = InputSanitizer.sanitizeUuid(appointmentRequest.patientId().toString()); + if (sanitized == null) { + log.warn("Invalid patient ID in appointment request"); + throw new IllegalArgumentException("Invalid patient ID format"); + } + } + if (appointmentRequest.doctorId() != null) { + String sanitized = InputSanitizer.sanitizeUuid(appointmentRequest.doctorId().toString()); + if (sanitized == null) { + log.warn("Invalid doctor ID in appointment request"); + throw new IllegalArgumentException("Invalid doctor ID format"); + } + } + + return ResponseEntity.ok(appointmentService.createAppointment(appointmentRequest)); + } + + @Operation( + summary = "Cancel an appointment", + description = "Change appointment status to CANCELLED.", + parameters = @Parameter(name = "id", description = "Appointment ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Appointment cancelled successfully", + content = @Content(schema = @Schema(implementation = AppointmentResponseDto.class)) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @PatchMapping("/{id}/cancel") + public ResponseEntity cancelAppointment(@PathVariable UUID id) { + return ResponseEntity.ok(appointmentService.changeAppointmentStatus(id, AppointmentStatus.CANCELLED)); + } + + @Operation( + summary = "Confirm an appointment", + description = "Change appointment status to CONFIRMED. Only doctors can confirm appointments. Patients can only request appointments, which are auto-confirmed if the slot is available.", + parameters = @Parameter(name = "id", description = "Appointment ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Appointment confirmed successfully", + content = @Content(schema = @Schema(implementation = AppointmentResponseDto.class)) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can confirm appointments" + ) + }) + @PatchMapping("/{id}/confirm") + public ResponseEntity confirmAppointment( + Authentication authentication, + @PathVariable UUID id) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + // Only doctors can confirm appointments + if (!user.getRole().name().equals("DOCTOR")) { + throw new ForbiddenException("Access denied"); + } + + // Verify the appointment belongs to this doctor + AppointmentResponseDto appointment = appointmentService.getAppointmentById(id); + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + throw new ForbiddenException("Access denied"); + } + Doctor doctor = doctorOpt.get(); + + // Check if this appointment belongs to the authenticated doctor + if (!appointment.doctorId().equals(doctor.getId())) { + throw new ForbiddenException("Access denied"); // Doctors can only confirm their own appointments + } + + return ResponseEntity.ok(appointmentService.changeAppointmentStatus(id, AppointmentStatus.CONFIRMED)); + } + + @Operation( + summary = "Complete an appointment", + description = "Change appointment status to COMPLETED.", + parameters = @Parameter(name = "id", description = "Appointment ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Appointment completed successfully", + content = @Content(schema = @Schema(implementation = AppointmentResponseDto.class)) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @PatchMapping("/{id}/complete") + public ResponseEntity completeAppointment(@PathVariable UUID id) { + return ResponseEntity.ok(appointmentService.changeAppointmentStatus(id, AppointmentStatus.COMPLETED)); + } + + @Operation( + summary = "Delete an appointment", + description = "Delete an appointment by ID. Patients and doctors can delete their own appointments. Admins can delete any appointment.", + parameters = @Parameter(name = "id", description = "Appointment ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Appointment deleted successfully" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - You can only delete your own appointments" + ) + }) + @DeleteMapping("/{id}") + @Transactional + public ResponseEntity deleteAppointment( + Authentication authentication, + @PathVariable UUID id) { + String userEmail = authentication.getName(); + log.info("Delete appointment request - User: {}, Appointment ID: {}", userEmail, id); + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Get appointment entity directly to check relationships + Appointment appointment = appointmentService.getAppointmentEntityById(id); + log.info("Appointment loaded - Patient: {}, Doctor: {}", + appointment.getPatient() != null ? appointment.getPatient().getId() : "null", + appointment.getDoctor() != null ? appointment.getDoctor().getId() : "null"); + + // Check if user is the patient or the doctor, or an admin + if (user.getRole().name().equals("ADMIN")) { + // Admins can hard delete any appointment + appointmentService.deleteAppointment(id); + return ResponseEntity.noContent().build(); + } else if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + log.warn("Patient record not found for user: {}", userEmail); + throw new ForbiddenException("Access denied"); + } + Patient patient = patientOpt.get(); + log.info("Current patient ID: {}", patient.getId()); + + // Patients can only delete their own appointments (soft delete) + if (appointment.getPatient() == null) { + log.error("Appointment {} has null patient relationship", id); + throw new ForbiddenException("Access denied"); + } + + UUID appointmentPatientId = appointment.getPatient().getId(); + UUID currentPatientId = patient.getId(); + log.info("Comparing - Appointment patient ID: {}, Current patient ID: {}, Match: {}", + appointmentPatientId, currentPatientId, appointmentPatientId.equals(currentPatientId)); + + if (!appointmentPatientId.equals(currentPatientId)) { + log.error("Authorization failed - Patient {} attempted to delete appointment {} which belongs to patient {}", + currentPatientId, id, appointmentPatientId); + throw new ForbiddenException("Access denied"); + } + + log.info("Authorization successful - deleting appointment {} for patient {}", id, currentPatientId); + appointmentService.softDeleteAppointmentByPatient(id); + return ResponseEntity.noContent().build(); + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + throw new ForbiddenException("Access denied"); + } + Doctor doctor = doctorOpt.get(); + // Doctors can only delete their own appointments (soft delete) + if (appointment.getDoctor() == null || !appointment.getDoctor().getId().equals(doctor.getId())) { + log.warn("Doctor {} attempted to delete appointment {} which belongs to doctor {}", + doctor.getId(), id, appointment.getDoctor() != null ? appointment.getDoctor().getId() : "null"); + throw new ForbiddenException("Access denied"); + } + appointmentService.softDeleteAppointmentByDoctor(id); + return ResponseEntity.noContent().build(); + } + + throw new ForbiddenException("Access denied"); + } + + @Operation( + summary = "Get available time slots for a doctor on a specific date", + description = "Get a list of available time slots for a doctor on a specific date based on doctor's duration preference. Takes into account doctor's availability schedule and existing appointments.", + parameters = { + @Parameter(name = "doctorId", description = "Doctor ID", required = true), + @Parameter(name = "date", description = "Date in YYYY-MM-DD format", required = true), + @Parameter(name = "durationMinutes", description = "Duration in minutes (optional, uses doctor's default if not provided)", required = false) + } + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Available time slots retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = String.class)) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/available-slots/{doctorId}/{date}") + public ResponseEntity> getAvailableTimeSlots( + @PathVariable UUID doctorId, + @PathVariable String date, + @RequestParam(required = false) Integer durationMinutes) { + return ResponseEntity.ok(appointmentService.getAvailableTimeSlots(doctorId, java.time.LocalDate.parse(date), durationMinutes)); + } + + @Operation( + summary = "Remove patient from doctor's history", + description = "Soft-delete all appointments between a doctor and patient. This removes the patient from the doctor's view. Only the doctor can perform this action.", + parameters = @Parameter(name = "patientId", description = "Patient ID to remove from history", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Patient removed from history successfully" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can remove patients from their history" + ) + }) + @PostMapping("/doctor/remove-patient/{patientId}") + @Transactional + public ResponseEntity removePatientFromHistory( + Authentication authentication, + @PathVariable UUID patientId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + if (!user.getRole().name().equals("DOCTOR")) { + throw new ForbiddenException("Access denied"); + } + + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + throw new ForbiddenException("Access denied"); + } + + Doctor doctor = doctorOpt.get(); + appointmentService.removePatientFromDoctorHistory(doctor.getId(), patientId); + log.info("Doctor {} removed patient {} from history", doctor.getId(), patientId); + return ResponseEntity.ok().build(); + } + + @Operation( + summary = "Remove doctor from patient's history", + description = "Soft-delete all appointments between a patient and doctor. This removes the doctor from the patient's view. Only the patient can perform this action.", + parameters = @Parameter(name = "doctorId", description = "Doctor ID to remove from history", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Doctor removed from history successfully" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only patients can remove doctors from their history" + ) + }) + @PostMapping("/patient/remove-doctor/{doctorId}") + @Transactional + public ResponseEntity removeDoctorFromHistory( + Authentication authentication, + @PathVariable UUID doctorId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + if (!user.getRole().name().equals("PATIENT")) { + throw new ForbiddenException("Access denied"); + } + + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + throw new ForbiddenException("Access denied"); + } + + Patient patient = patientOpt.get(); + appointmentService.removeDoctorFromPatientHistory(doctorId, patient.getId()); + log.info("Patient {} removed doctor {} from history", patient.getId(), doctorId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gnx/telemedicine/controller/AuthController.java b/src/main/java/com/gnx/telemedicine/controller/AuthController.java new file mode 100644 index 0000000..3a1641f --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/AuthController.java @@ -0,0 +1,201 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.auth.ForgotPasswordRequestDto; +import com.gnx.telemedicine.dto.auth.JwtResponseDto; +import com.gnx.telemedicine.dto.auth.PasswordResetResponseDto; +import com.gnx.telemedicine.dto.auth.RefreshTokenRequestDto; +import com.gnx.telemedicine.dto.auth.RefreshTokenResponseDto; +import com.gnx.telemedicine.dto.auth.ResetPasswordRequestDto; +import com.gnx.telemedicine.dto.auth.UserLoginDto; +import com.gnx.telemedicine.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v3/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication", description = "Endpoints for user authentication and authorization") +public class AuthController { + + private final AuthService authService; + + @Value("${jwt.cookie.enabled:true}") + private boolean cookieEnabled; + + @Value("${jwt.cookie.name:jwtToken}") + private String cookieName; + + @Value("${jwt.cookie.max-age:86400}") + private int cookieMaxAge; + + @Operation( + summary = "User login", + description = """ + Authenticate a user with email and password to receive a JWT token. + The token should be used in the Authorization header for subsequent requests. + """, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "User login credentials(email/password)", + required = true, + content = @Content(schema = @Schema(implementation = UserLoginDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User logged in successfully, JWT returned", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = JwtResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Invalid email or password", + content = @Content(mediaType = "application/json") + ), + @ApiResponse( + responseCode = "400", + description = "Validation error", + content = @Content(mediaType = "application/json") + ) + }) + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody UserLoginDto userLoginDto, + HttpServletRequest request, + HttpServletResponse response) { + JwtResponseDto jwtResponse = authService.login(userLoginDto, request); + + // Set httpOnly cookie for enhanced security (XSS protection) + if (cookieEnabled) { + jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie(cookieName, jwtResponse.token()); + cookie.setHttpOnly(true); + cookie.setSecure(request.isSecure() || "https".equalsIgnoreCase(request.getScheme())); + cookie.setPath("/"); + cookie.setMaxAge(cookieMaxAge); + // Set SameSite attribute to prevent CSRF + response.setHeader("Set-Cookie", + String.format("%s=%s; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=%d", + cookieName, jwtResponse.token(), cookieMaxAge)); + } + + return ResponseEntity.ok(jwtResponse); + } + + @Operation( + summary = "User logout", + description = "Log out the currently authenticated user and set status to OFFLINE.", + security = @io.swagger.v3.oas.annotations.security.SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User logged out successfully" + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @PostMapping("/logout") + public ResponseEntity logout(Authentication authentication) { + String email = authentication.getName(); + authService.logout(email); + return ResponseEntity.ok().build(); + } + + @Operation( + summary = "Request password reset", + description = "Request a password reset link to be sent to the user's email address." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Password reset email sent (if email exists)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PasswordResetResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Validation error", + content = @Content(mediaType = "application/json") + ) + }) + @PostMapping("/forgot-password") + public ResponseEntity forgotPassword(@Valid @RequestBody ForgotPasswordRequestDto request) { + return ResponseEntity.ok(authService.forgotPassword(request)); + } + + @Operation( + summary = "Reset password", + description = "Reset password using the token received via email." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Password reset successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PasswordResetResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Validation error or invalid/expired token", + content = @Content(mediaType = "application/json") + ) + }) + @PostMapping("/reset-password") + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequestDto request) { + return ResponseEntity.ok(authService.resetPassword(request)); + } + + @Operation( + summary = "Refresh access token", + description = "Refresh the access token using a valid refresh token. Returns a new access token." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Token refreshed successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = RefreshTokenResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Invalid or expired refresh token", + content = @Content(mediaType = "application/json") + ), + @ApiResponse( + responseCode = "400", + description = "Validation error", + content = @Content(mediaType = "application/json") + ) + }) + @PostMapping("/refresh") + public ResponseEntity refreshToken( + @Valid @RequestBody RefreshTokenRequestDto request, + HttpServletRequest httpRequest) { + RefreshTokenResponseDto response = authService.refreshToken(request, httpRequest); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/gnx/telemedicine/controller/BreachNotificationController.java b/src/main/java/com/gnx/telemedicine/controller/BreachNotificationController.java new file mode 100644 index 0000000..a66e1a1 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/BreachNotificationController.java @@ -0,0 +1,99 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.audit.BreachNotificationRequestDto; +import com.gnx.telemedicine.dto.audit.BreachNotificationResponseDto; +import com.gnx.telemedicine.model.enums.BreachStatus; +import com.gnx.telemedicine.service.BreachNotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/audit/breaches") +@RequiredArgsConstructor +@Tag(name = "Breach Notifications", description = "Endpoints for managing HIPAA breach notifications (Admin only)") +@SecurityRequirement(name = "bearerAuth") +public class BreachNotificationController { + + private final BreachNotificationService breachNotificationService; + + @Operation( + summary = "Get all breach notifications", + description = "Retrieve all breach notifications. Admin only.", + responses = @ApiResponse( + responseCode = "200", + description = "List of breach notifications retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = BreachNotificationResponseDto.class)) + ) + ) + ) + @GetMapping + public ResponseEntity> getAllBreachNotifications() { + return ResponseEntity.ok(breachNotificationService.getAllBreachNotifications()); + } + + @Operation( + summary = "Get breach notifications by status", + description = "Retrieve breach notifications filtered by status." + ) + @GetMapping("/status/{status}") + public ResponseEntity> getBreachNotificationsByStatus( + @PathVariable BreachStatus status) { + return ResponseEntity.ok(breachNotificationService.getBreachNotificationsByStatus(status)); + } + + @Operation( + summary = "Create a breach notification", + description = "Create a new breach notification. Admin only.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Breach notification details", + required = true, + content = @Content(schema = @Schema(implementation = BreachNotificationRequestDto.class)) + ) + ) + @PostMapping + public ResponseEntity createBreachNotification( + Authentication authentication, + @RequestBody @Valid BreachNotificationRequestDto requestDto) { + String userEmail = authentication.getName(); + BreachNotificationResponseDto response = breachNotificationService.createBreachNotification(userEmail, requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Update breach notification status", + description = "Update the status of a breach notification." + ) + @PatchMapping("/{id}/status") + public ResponseEntity updateBreachStatus( + @PathVariable UUID id, + @RequestParam BreachStatus status) { + BreachNotificationResponseDto response = breachNotificationService.updateBreachStatus(id, status); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get breach notification by ID", + description = "Retrieve a specific breach notification by its ID." + ) + @GetMapping("/{id}") + public ResponseEntity getBreachNotificationById(@PathVariable UUID id) { + return ResponseEntity.ok(breachNotificationService.getBreachNotificationById(id)); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/ClinicalAlertController.java b/src/main/java/com/gnx/telemedicine/controller/ClinicalAlertController.java new file mode 100644 index 0000000..91a3005 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/ClinicalAlertController.java @@ -0,0 +1,167 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.patient_safety.ClinicalAlertRequestDto; +import com.gnx.telemedicine.dto.patient_safety.ClinicalAlertResponseDto; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.service.ClinicalAlertService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/clinical-alerts") +@RequiredArgsConstructor +@Tag(name = "Clinical Alerts", description = "Endpoints for managing clinical safety alerts") +@SecurityRequirement(name = "bearerAuth") +public class ClinicalAlertController { + + private final ClinicalAlertService clinicalAlertService; + private final UserRepository userRepository; + private final DoctorRepository doctorRepository; + private final PatientRepository patientRepository; + + @Operation(summary = "Get all alerts for a patient") + @GetMapping("/patient/{patientId}") + public ResponseEntity> getAlertsByPatientId( + Authentication authentication, + @PathVariable UUID patientId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Check if user is the patient or the patient's treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Patient patient = patientOpt.get(); + if (!patient.getId().equals(patientId)) { + return ResponseEntity.status(403).build(); // Patients can only see their own alerts + } + return ResponseEntity.ok(clinicalAlertService.getAlertsByPatientId(patientId)); + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + // Doctors can only see alerts for their patients + return ResponseEntity.ok(clinicalAlertService.getAlertsByDoctorIdAndPatientId(doctor.getId(), patientId)); + } + + return ResponseEntity.status(403).build(); + } + + @Operation(summary = "Get unacknowledged alerts for a patient") + @GetMapping("/patient/{patientId}/unacknowledged") + public ResponseEntity> getUnacknowledgedAlertsByPatientId( + Authentication authentication, + @PathVariable UUID patientId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Check if user is the patient or the patient's treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Patient patient = patientOpt.get(); + if (!patient.getId().equals(patientId)) { + return ResponseEntity.status(403).build(); // Patients can only see their own alerts + } + return ResponseEntity.ok(clinicalAlertService.getUnacknowledgedAlertsByPatientId(patientId)); + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + // Doctors can only see alerts for their patients + return ResponseEntity.ok(clinicalAlertService.getUnacknowledgedAlertsByDoctorIdAndPatientId(doctor.getId(), patientId)); + } + + return ResponseEntity.status(403).build(); + } + + @Operation(summary = "Get all unacknowledged alerts for the current user") + @GetMapping("/unacknowledged") + public ResponseEntity> getAllUnacknowledgedAlerts( + Authentication authentication) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Doctors see alerts for their patients only + if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + return ResponseEntity.ok(clinicalAlertService.getUnacknowledgedAlertsByDoctorId(doctor.getId())); + } else if (user.getRole().name().equals("PATIENT")) { + // Patients see their own alerts + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Patient patient = patientOpt.get(); + return ResponseEntity.ok(clinicalAlertService.getUnacknowledgedAlertsByPatientId(patient.getId())); + } + + // Admins can see all alerts (for now, return all) + return ResponseEntity.ok(clinicalAlertService.getAllUnacknowledgedAlerts()); + } + + @Operation(summary = "Create a clinical alert") + @PostMapping + public ResponseEntity createAlert( + Authentication authentication, + @RequestBody @Valid ClinicalAlertRequestDto requestDto) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(clinicalAlertService.createAlert(userEmail, requestDto)); + } + + @Operation(summary = "Acknowledge an alert") + @PostMapping("/{alertId}/acknowledge") + public ResponseEntity acknowledgeAlert( + Authentication authentication, + @PathVariable UUID alertId) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(clinicalAlertService.acknowledgeAlert(userEmail, alertId)); + } + + @Operation(summary = "Resolve an alert") + @PostMapping("/{alertId}/resolve") + public ResponseEntity resolveAlert( + Authentication authentication, + @PathVariable UUID alertId) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(clinicalAlertService.resolveAlert(userEmail, alertId)); + } + + @Operation(summary = "Check for drug interactions") + @PostMapping("/check-interactions/{patientId}") + public ResponseEntity checkForDrugInteractions(@PathVariable UUID patientId) { + clinicalAlertService.checkForDrugInteractions(patientId); + return ResponseEntity.ok().build(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/CriticalResultController.java b/src/main/java/com/gnx/telemedicine/controller/CriticalResultController.java new file mode 100644 index 0000000..712e719 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/CriticalResultController.java @@ -0,0 +1,184 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.patient_safety.CriticalResultAcknowledgmentRequestDto; +import com.gnx.telemedicine.dto.patient_safety.CriticalResultResponseDto; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.service.CriticalResultManagementService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/critical-results") +@RequiredArgsConstructor +@Tag(name = "Critical Results", description = "Endpoints for managing critical lab results") +@SecurityRequirement(name = "bearerAuth") +public class CriticalResultController { + + private final CriticalResultManagementService criticalResultManagementService; + private final UserRepository userRepository; + private final DoctorRepository doctorRepository; + private final PatientRepository patientRepository; + + @Operation(summary = "Get critical results for a patient") + @GetMapping("/patient/{patientId}") + public ResponseEntity> getCriticalResultsByPatientId( + Authentication authentication, + @PathVariable UUID patientId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Check if user is the patient or the patient's treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Patient patient = patientOpt.get(); + if (!patient.getId().equals(patientId)) { + return ResponseEntity.status(403).build(); // Patients can only see their own critical results + } + return ResponseEntity.ok(criticalResultManagementService.getCriticalResultsByPatientId(patientId)); + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + // Doctors can only see critical results for their patients + List allResults = criticalResultManagementService.getCriticalResultsByPatientId(patientId); + // Filter to only results where this doctor is the doctor + List filtered = allResults.stream() + .filter(result -> result.doctorId() != null && result.doctorId().equals(doctor.getId())) + .toList(); + return ResponseEntity.ok(filtered); + } + + return ResponseEntity.status(403).build(); + } + + @Operation(summary = "Get unacknowledged critical results for a patient") + @GetMapping("/patient/{patientId}/unacknowledged") + public ResponseEntity> getUnacknowledgedCriticalResultsByPatientId( + Authentication authentication, + @PathVariable UUID patientId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Check if user is the patient or the patient's treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Patient patient = patientOpt.get(); + if (!patient.getId().equals(patientId)) { + return ResponseEntity.status(403).build(); // Patients can only see their own critical results + } + return ResponseEntity.ok(criticalResultManagementService.getUnacknowledgedCriticalResultsByPatientId(patientId)); + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + // Doctors can only see critical results for their patients + List allResults = criticalResultManagementService.getUnacknowledgedCriticalResultsByPatientId(patientId); + // Filter to only results where this doctor is the doctor + List filtered = allResults.stream() + .filter(result -> result.doctorId() != null && result.doctorId().equals(doctor.getId())) + .toList(); + return ResponseEntity.ok(filtered); + } + + return ResponseEntity.status(403).build(); + } + + @Operation(summary = "Get unacknowledged critical results for a doctor") + @GetMapping("/doctor/{doctorId}/unacknowledged") + public ResponseEntity> getUnacknowledgedCriticalResultsByDoctorId( + Authentication authentication, + @PathVariable UUID doctorId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Check if user is the doctor + if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + if (!doctor.getId().equals(doctorId)) { + return ResponseEntity.status(403).build(); // Doctors can only see their own critical results + } + return ResponseEntity.ok(criticalResultManagementService.getUnacknowledgedCriticalResultsByDoctorId(doctorId)); + } + + return ResponseEntity.status(403).build(); + } + + @Operation(summary = "Get all unacknowledged critical results for the current user") + @GetMapping("/unacknowledged") + public ResponseEntity> getAllUnacknowledgedCriticalResults( + Authentication authentication) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Doctors see critical results for their patients only + if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + return ResponseEntity.ok(criticalResultManagementService.getUnacknowledgedCriticalResultsByDoctorId(doctor.getId())); + } else if (user.getRole().name().equals("PATIENT")) { + // Patients see their own critical results + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Patient patient = patientOpt.get(); + return ResponseEntity.ok(criticalResultManagementService.getUnacknowledgedCriticalResultsByPatientId(patient.getId())); + } + + // Admins can see all results (for now, return all) + return ResponseEntity.ok(criticalResultManagementService.getAllUnacknowledgedCriticalResults()); + } + + @Operation(summary = "Acknowledge a critical result") + @PostMapping("/{resultId}/acknowledge") + public ResponseEntity acknowledgeCriticalResult( + Authentication authentication, + @PathVariable UUID resultId, + @RequestBody @Valid CriticalResultAcknowledgmentRequestDto requestDto) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(criticalResultManagementService.acknowledgeCriticalResult(userEmail, resultId, requestDto)); + } + + @Operation(summary = "Get critical result by ID") + @GetMapping("/{id}") + public ResponseEntity getCriticalResultById(@PathVariable UUID id) { + return ResponseEntity.ok(criticalResultManagementService.getCriticalResultById(id)); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/DataRetentionController.java b/src/main/java/com/gnx/telemedicine/controller/DataRetentionController.java new file mode 100644 index 0000000..76378ae --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/DataRetentionController.java @@ -0,0 +1,57 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.model.DataRetentionPolicy; +import com.gnx.telemedicine.service.DataRetentionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; + +@RestController +@RequestMapping("/api/v3/data-retention") +@RequiredArgsConstructor +@Tag(name = "Data Retention", description = "Data retention policy management endpoints") +@SecurityRequirement(name = "bearerAuth") +public class DataRetentionController { + + private final DataRetentionService retentionService; + + @Operation(summary = "Get all retention policies", description = "Get all data retention policies (Admin only)") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/policies") + public ResponseEntity> getAllPolicies() { + List policies = retentionService.getAllPolicies(); + return ResponseEntity.ok(policies); + } + + @Operation(summary = "Get policy by data type", description = "Get retention policy for a specific data type") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/policy") + public ResponseEntity getPolicyByDataType(@RequestParam @NotNull String dataType) { + Optional policy = retentionService.getPolicyByDataType(dataType); + return policy.map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @Operation(summary = "Create or update policy", description = "Create or update a data retention policy (Admin only)") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/policy") + public ResponseEntity createOrUpdatePolicy( + @RequestParam @NotNull String dataType, + @RequestParam @NotNull Integer retentionPeriodDays, + @RequestParam(required = false) Boolean autoDeleteEnabled, + @RequestParam(required = false) String legalRequirement) { + + DataRetentionPolicy policy = retentionService.createOrUpdatePolicy( + dataType, retentionPeriodDays, autoDeleteEnabled, legalRequirement); + return ResponseEntity.ok(policy); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/DoctorAvailabilityController.java b/src/main/java/com/gnx/telemedicine/controller/DoctorAvailabilityController.java new file mode 100644 index 0000000..dbefa4d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/DoctorAvailabilityController.java @@ -0,0 +1,254 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.availability.AvailabilityRequestDto; +import com.gnx.telemedicine.dto.availability.AvailabilityResponseDto; +import com.gnx.telemedicine.dto.availability.AvailabilityUpdateDto; +import com.gnx.telemedicine.dto.availability.BulkAvailabilityRequestDto; +import com.gnx.telemedicine.model.enums.DayOfWeek; +import com.gnx.telemedicine.service.DoctorAvailabilityService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/availability") +@RequiredArgsConstructor +@Tag(name = "Doctor Availability", description = "Endpoints for managing doctor availability schedules") +public class DoctorAvailabilityController { + + private final DoctorAvailabilityService doctorAvailabilityService; + + @Operation( + summary = "Get doctor's availability", + description = "Get all availability slots for a specific doctor.", + parameters = @Parameter(description = "Doctor ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Availability slots retrieved successfully.", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = AvailabilityResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/doctor/{doctorId}") + public ResponseEntity> getDoctorAvailability(@PathVariable UUID doctorId) { + return ResponseEntity.ok(doctorAvailabilityService.getDoctorAvailability(doctorId)); + } + + @Operation( + summary = "Get doctor's availability by day", + description = "Retrieve availability slots for a specific doctor on a specific day of the week", + parameters = { + @Parameter(description = "Doctor ID", required = true), + @Parameter(description = "Day of week (MONDAY, TUESDAY, etc.)", required = true) + } + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Availability slots retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = AvailabilityResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/doctor/{doctorId}/day/{dayOfWeek}") + public ResponseEntity> getDoctorAvailabilityByDay(@PathVariable UUID doctorId, @PathVariable DayOfWeek dayOfWeek) { + return ResponseEntity.ok(doctorAvailabilityService.getDoctorAvailabilityByDay(doctorId, dayOfWeek)); + } + + @Operation( + summary = "Update availability slot", + description = "Update an existing availability slot. Only doctors can update their own availability.", + parameters = @Parameter(description = "Doctor ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Active availability slots retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = AvailabilityResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/doctor/{doctorId}/active") + public ResponseEntity> getActiveAvailability(@PathVariable UUID doctorId) { + return ResponseEntity.ok(doctorAvailabilityService.getActiveAvailability(doctorId)); + } + + @Operation( + summary = "Create availability slot", + description = "Create a new availability time slot for a doctor. Only doctors can create their own availability.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Availability request", + required = true, + content = @Content(schema = @Schema(implementation = AvailabilityRequestDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Availability slot created successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AvailabilityResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Invalid request - overlapping slots or invalid time range" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can create availability" + ) + }) + @PostMapping + public ResponseEntity createAvailability(@Valid @RequestBody AvailabilityRequestDto request){ + return new ResponseEntity<>(doctorAvailabilityService.createAvailability(request), HttpStatus.CREATED); + } + + @Operation( + summary = "Create multiple availability slots", + description = "Create multiple availability time slots for a doctor at once. Only doctors can create their own availability.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Bulk Availability request", + required = true, + content = @Content(schema = @Schema(implementation = BulkAvailabilityRequestDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Availability slots created successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = AvailabilityResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Invalid request - overlapping slots or invalid time range" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can create availability" + ) + }) + @PostMapping("/bulk") + public ResponseEntity> createBulkAvailability( + @Valid @RequestBody BulkAvailabilityRequestDto request) { + return new ResponseEntity<>(doctorAvailabilityService.createBulkAvailability(request), HttpStatus.CREATED); + } + + @Operation( + summary = "Update availability slot", + description = "Update an existing availability slot. Only doctors can update their own availability.", + parameters = @Parameter(description = "Availability ID", required = true), + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Availability update request", + required = true, + content = @Content(schema = @Schema(implementation = AvailabilityUpdateDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Availability updated successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AvailabilityResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Invalid request - overlapping slots or invalid time range" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can update availability" + ) + }) + @PatchMapping("/{availabilityId}") + public ResponseEntity updateAvailability( + @PathVariable UUID availabilityId, + @Valid @RequestBody AvailabilityUpdateDto updateDto + ) { + return ResponseEntity.ok(doctorAvailabilityService.updateAvailability(availabilityId, updateDto)); + } + + @Operation( + summary = "Delete all doctor availability", + description = "Delete all availability slots for a doctor. Only doctors can delete their own availability.", + parameters = @Parameter(description = "Availability ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "All availability slots deleted successfully" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can delete their own availability" + ) + }) + @DeleteMapping("/{availabilityId}") + public ResponseEntity deleteAvailability( + @PathVariable UUID availabilityId) { + doctorAvailabilityService.deleteAvailability(availabilityId); + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "Delete all doctor availability", + description = "Delete all availability slots for a doctor. Only doctors can delete their own availability." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "All availability slots deleted successfully" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can delete their own availability" + ) + }) + @DeleteMapping("/doctor/{doctorId}") + public ResponseEntity deleteAllDoctorAvailability( + @Parameter(description = "Doctor ID", required = true) + @PathVariable UUID doctorId) { + doctorAvailabilityService.deleteAllDoctorAvailability(doctorId); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/com/gnx/telemedicine/controller/DuplicatePatientController.java b/src/main/java/com/gnx/telemedicine/controller/DuplicatePatientController.java new file mode 100644 index 0000000..42b2fbf --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/DuplicatePatientController.java @@ -0,0 +1,75 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.patient_safety.DuplicatePatientRecordResponseDto; +import com.gnx.telemedicine.dto.patient_safety.DuplicatePatientReviewRequestDto; +import com.gnx.telemedicine.model.enums.DuplicateStatus; +import com.gnx.telemedicine.service.DuplicatePatientDetectionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/duplicate-patients") +@RequiredArgsConstructor +@Tag(name = "Duplicate Patients", description = "Endpoints for managing duplicate patient detection") +@SecurityRequirement(name = "bearerAuth") +public class DuplicatePatientController { + + private final DuplicatePatientDetectionService duplicatePatientDetectionService; + + @Operation(summary = "Get all duplicate records") + @GetMapping + public ResponseEntity> getAllDuplicateRecords() { + return ResponseEntity.ok(duplicatePatientDetectionService.getAllDuplicateRecords()); + } + + @Operation(summary = "Get duplicate records by status") + @GetMapping("/status/{status}") + public ResponseEntity> getDuplicateRecordsByStatus(@PathVariable DuplicateStatus status) { + return ResponseEntity.ok(duplicatePatientDetectionService.getDuplicateRecordsByStatus(status)); + } + + @Operation(summary = "Get pending duplicate records") + @GetMapping("/pending") + public ResponseEntity> getPendingDuplicateRecords() { + return ResponseEntity.ok(duplicatePatientDetectionService.getPendingDuplicateRecords()); + } + + @Operation(summary = "Get duplicates for a specific patient") + @GetMapping("/patient/{patientId}") + public ResponseEntity> getDuplicatesForPatient(@PathVariable UUID patientId) { + return ResponseEntity.ok(duplicatePatientDetectionService.getDuplicatesForPatient(patientId)); + } + + @Operation(summary = "Review a duplicate record") + @PostMapping("/{duplicateId}/review") + public ResponseEntity reviewDuplicate( + Authentication authentication, + @PathVariable UUID duplicateId, + @RequestBody @Valid DuplicatePatientReviewRequestDto requestDto) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(duplicatePatientDetectionService.reviewDuplicate(userEmail, duplicateId, requestDto)); + } + + @Operation(summary = "Get duplicate record by ID") + @GetMapping("/{id}") + public ResponseEntity getDuplicateRecordById(@PathVariable UUID id) { + return ResponseEntity.ok(duplicatePatientDetectionService.getDuplicateRecordById(id)); + } + + @Operation(summary = "Scan for duplicate patients") + @PostMapping("/scan") + public ResponseEntity scanForDuplicates() { + duplicatePatientDetectionService.scanForDuplicates(); + return ResponseEntity.ok().build(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/FileUploadController.java b/src/main/java/com/gnx/telemedicine/controller/FileUploadController.java new file mode 100644 index 0000000..00cdeb6 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/FileUploadController.java @@ -0,0 +1,141 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.service.FileUploadService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import java.net.MalformedURLException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v3/files") +@RequiredArgsConstructor +@Tag(name = "File Upload", description = "Endpoints for file uploads (avatars)") +public class FileUploadController { + + private final FileUploadService fileUploadService; + private final String uploadDir = "uploads/avatars"; + + @Operation(summary = "Upload avatar", description = "Upload a profile photo for the current user") + @ApiResponse(responseCode = "200", description = "Avatar uploaded successfully") + @PostMapping("/avatar") + @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()") + public ResponseEntity> uploadAvatar( + @RequestParam("file") MultipartFile file, + Authentication authentication) { + try { + String userEmail = authentication.getName(); + String avatarUrl = fileUploadService.uploadAvatar(userEmail, file); + + Map response = new HashMap<>(); + response.put("avatarUrl", avatarUrl); + response.put("message", "Avatar uploaded successfully"); + + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.badRequest().body(error); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", "Failed to upload avatar: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + @Operation(summary = "Upload avatar (base64)", description = "Upload a profile photo using base64 encoded image") + @ApiResponse(responseCode = "200", description = "Avatar uploaded successfully") + @PostMapping("/avatar/base64") + @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()") + public ResponseEntity> uploadAvatarBase64( + @RequestBody Map request, + Authentication authentication) { + try { + String userEmail = authentication.getName(); + String base64Image = request.get("image"); + String avatarUrl = fileUploadService.uploadAvatarBase64(userEmail, base64Image); + + Map response = new HashMap<>(); + response.put("avatarUrl", avatarUrl); + response.put("message", "Avatar uploaded successfully"); + + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + return ResponseEntity.badRequest().body(error); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", "Failed to upload avatar: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + @Operation(summary = "Get avatar file", description = "Retrieve an avatar image file") + @GetMapping("/avatars/{filename:.+}") + public ResponseEntity getAvatar(@PathVariable String filename) { + try { + Path filePath = Paths.get(uploadDir).resolve(filename).normalize(); + Resource resource = new UrlResource(filePath.toUri()); + + if (resource.exists() && resource.isReadable()) { + String contentType = "image/jpeg"; + if (filename.toLowerCase().endsWith(".png")) { + contentType = "image/png"; + } else if (filename.toLowerCase().endsWith(".gif")) { + contentType = "image/gif"; + } else if (filename.toLowerCase().endsWith(".webp")) { + contentType = "image/webp"; + } + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + resource.getFilename() + "\"") + .header("Cross-Origin-Resource-Policy", "cross-origin") + .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Expose-Headers", "Cross-Origin-Resource-Policy") + .body(resource); + } else { + return ResponseEntity.notFound().build(); + } + } catch (MalformedURLException e) { + return ResponseEntity.badRequest().build(); + } + } + + @Operation(summary = "Delete avatar", description = "Delete the current user's avatar") + @ApiResponse(responseCode = "200", description = "Avatar deleted successfully") + @DeleteMapping("/avatar") + @org.springframework.security.access.prepost.PreAuthorize("isAuthenticated()") + public ResponseEntity> deleteAvatar(Authentication authentication) { + try { + String userEmail = authentication.getName(); + fileUploadService.deleteAvatar(userEmail); + + Map response = new HashMap<>(); + response.put("message", "Avatar deleted successfully"); + + return ResponseEntity.ok(response); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", "Failed to delete avatar: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/GdprController.java b/src/main/java/com/gnx/telemedicine/controller/GdprController.java new file mode 100644 index 0000000..c50eb78 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/GdprController.java @@ -0,0 +1,208 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.model.DataSubjectConsent; +import com.gnx.telemedicine.model.DataSubjectRequest; +import com.gnx.telemedicine.model.enums.ConsentType; +import com.gnx.telemedicine.model.enums.DataSubjectRequestStatus; +import com.gnx.telemedicine.model.enums.DataSubjectRequestType; +import com.gnx.telemedicine.service.GdprConsentService; +import com.gnx.telemedicine.service.GdprDataSubjectRequestService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/gdpr") +@RequiredArgsConstructor +@Tag(name = "GDPR Compliance", description = "GDPR data subject rights and consent management endpoints") +@SecurityRequirement(name = "bearerAuth") +public class GdprController { + + private final GdprConsentService consentService; + private final GdprDataSubjectRequestService requestService; + + @Operation(summary = "Grant consent", description = "Grant consent for a specific consent type (GDPR Article 7)") + @PostMapping("/consent") + public ResponseEntity grantConsent( + @RequestParam @NotNull ConsentType consentType, + @RequestParam(required = false) String consentVersion, + @RequestParam(required = false, defaultValue = "WEB_FORM") String consentMethod, + Authentication authentication, + HttpServletRequest request) { + + String userEmail = authentication.getName(); + DataSubjectConsent consent = consentService.grantConsent( + userEmail, consentType, consentVersion, consentMethod, request); + return ResponseEntity.ok(consent); + } + + @Operation(summary = "Withdraw consent", description = "Withdraw previously granted consent (GDPR Article 7)") + @PostMapping("/consent/withdraw") + public ResponseEntity withdrawConsent( + @RequestParam @NotNull ConsentType consentType, + Authentication authentication, + HttpServletRequest request) { + + String userEmail = authentication.getName(); + DataSubjectConsent consent = consentService.withdrawConsent(userEmail, consentType, request); + return ResponseEntity.ok(consent); + } + + @Operation(summary = "Get user consents", description = "Get all consent records for the authenticated user") + @GetMapping("/consent") + public ResponseEntity> getUserConsents(Authentication authentication) { + String userEmail = authentication.getName(); + List consents = consentService.getUserConsents(userEmail); + return ResponseEntity.ok(consents); + } + + @Operation(summary = "Check consent", description = "Check if user has granted consent for a specific type") + @GetMapping("/consent/check") + public ResponseEntity> checkConsent( + @RequestParam @NotNull ConsentType consentType, + Authentication authentication) { + + String userEmail = authentication.getName(); + boolean hasConsent = consentService.hasConsent(userEmail, consentType); + return ResponseEntity.ok(Map.of("hasConsent", hasConsent)); + } + + @Operation(summary = "Request data access", description = "Request access to personal data (GDPR Article 15)") + @PostMapping("/request/access") + public ResponseEntity requestAccess( + @RequestParam(required = false) String description, + Authentication authentication, + HttpServletRequest request) { + + String userEmail = authentication.getName(); + DataSubjectRequest dataRequest = requestService.createRequest( + userEmail, DataSubjectRequestType.ACCESS, description, request); + return ResponseEntity.status(HttpStatus.CREATED).body(dataRequest); + } + + @Operation(summary = "Request data erasure", description = "Request erasure of personal data (GDPR Article 17)") + @PostMapping("/request/erasure") + public ResponseEntity requestErasure( + @RequestParam(required = false) String description, + Authentication authentication, + HttpServletRequest request) { + + String userEmail = authentication.getName(); + DataSubjectRequest dataRequest = requestService.createRequest( + userEmail, DataSubjectRequestType.ERASURE, description, request); + return ResponseEntity.status(HttpStatus.CREATED).body(dataRequest); + } + + @Operation(summary = "Request data rectification", description = "Request rectification of inaccurate data (GDPR Article 16)") + @PostMapping("/request/rectification") + public ResponseEntity requestRectification( + @RequestParam(required = false) String description, + Authentication authentication, + HttpServletRequest request) { + + String userEmail = authentication.getName(); + DataSubjectRequest dataRequest = requestService.createRequest( + userEmail, DataSubjectRequestType.RECTIFICATION, description, request); + return ResponseEntity.status(HttpStatus.CREATED).body(dataRequest); + } + + @Operation(summary = "Request data portability", description = "Request data in a portable format (GDPR Article 20)") + @PostMapping("/request/portability") + public ResponseEntity requestPortability( + @RequestParam(required = false) String description, + Authentication authentication, + HttpServletRequest request) { + + String userEmail = authentication.getName(); + DataSubjectRequest dataRequest = requestService.createRequest( + userEmail, DataSubjectRequestType.PORTABILITY, description, request); + return ResponseEntity.status(HttpStatus.CREATED).body(dataRequest); + } + + @Operation(summary = "Get user requests", description = "Get all data subject requests for the authenticated user") + @GetMapping("/request") + public ResponseEntity> getUserRequests(Authentication authentication) { + String userEmail = authentication.getName(); + List requests = requestService.getUserRequests(userEmail); + return ResponseEntity.ok(requests); + } + + @Operation(summary = "Verify request", description = "Verify a data subject request using verification token") + @PostMapping("/request/verify") + public ResponseEntity verifyRequest(@RequestParam @NotNull String token) { + DataSubjectRequest request = requestService.verifyRequest(token); + return ResponseEntity.ok(request); + } + + @Operation(summary = "Process access request", description = "Process an access request (Admin only)") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/request/{id}/process/access") + public ResponseEntity processAccessRequest( + @PathVariable UUID id, + Authentication authentication) { + + String userEmail = authentication.getName(); + DataSubjectRequest request = requestService.processAccessRequest(id, userEmail); + return ResponseEntity.ok(request); + } + + @Operation(summary = "Process erasure request", description = "Process an erasure request (Admin only)") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/request/{id}/process/erasure") + public ResponseEntity processErasureRequest( + @PathVariable UUID id, + @RequestParam(required = false) String notes, + Authentication authentication) { + + String userEmail = authentication.getName(); + DataSubjectRequest request = requestService.processErasureRequest(id, userEmail, notes); + return ResponseEntity.ok(request); + } + + @Operation(summary = "Process portability request", description = "Process a portability request (Admin only)") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/request/{id}/process/portability") + public ResponseEntity processPortabilityRequest( + @PathVariable UUID id, + Authentication authentication) { + + String userEmail = authentication.getName(); + DataSubjectRequest request = requestService.processPortabilityRequest(id, userEmail); + return ResponseEntity.ok(request); + } + + @Operation(summary = "Reject request", description = "Reject a data subject request (Admin only)") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/request/{id}/reject") + public ResponseEntity rejectRequest( + @PathVariable UUID id, + @RequestParam @NotNull String rejectionReason, + Authentication authentication) { + + String userEmail = authentication.getName(); + DataSubjectRequest request = requestService.rejectRequest(id, userEmail, rejectionReason); + return ResponseEntity.ok(request); + } + + @Operation(summary = "Get pending requests", description = "Get all pending data subject requests (Admin only)") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/request/pending") + public ResponseEntity> getPendingRequests() { + List requests = requestService.getPendingRequests(); + return ResponseEntity.ok(requests); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/HealthController.java b/src/main/java/com/gnx/telemedicine/controller/HealthController.java new file mode 100644 index 0000000..04bb610 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/HealthController.java @@ -0,0 +1,114 @@ +package com.gnx.telemedicine.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * Health check endpoints for monitoring and orchestration systems. + */ +@RestController +@RequestMapping("/actuator") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Health", description = "Health check endpoints for monitoring") +public class HealthController { + + private final DataSource dataSource; + + @Value("${spring.application.name:telemedicine}") + private String applicationName; + + @Operation( + summary = "Health check", + description = "Basic health check endpoint. Returns 200 if the service is running." + ) + @ApiResponse(responseCode = "200", description = "Service is healthy") + @GetMapping("/health") + public ResponseEntity> health() { + Map health = new HashMap<>(); + health.put("status", "UP"); + health.put("application", applicationName); + health.put("timestamp", LocalDateTime.now()); + + return ResponseEntity.ok(health); + } + + @Operation( + summary = "Readiness check", + description = "Readiness check endpoint. Returns 200 if the service is ready to accept traffic." + ) + @ApiResponse(responseCode = "200", description = "Service is ready") + @GetMapping("/health/readiness") + public ResponseEntity> readiness() { + Map readiness = new HashMap<>(); + readiness.put("status", "READY"); + readiness.put("application", applicationName); + readiness.put("timestamp", LocalDateTime.now()); + + return ResponseEntity.ok(readiness); + } + + @Operation( + summary = "Liveness check", + description = "Liveness check endpoint. Returns 200 if the service is alive." + ) + @ApiResponse(responseCode = "200", description = "Service is alive") + @GetMapping("/health/liveness") + public ResponseEntity> liveness() { + Map liveness = new HashMap<>(); + liveness.put("status", "ALIVE"); + liveness.put("application", applicationName); + liveness.put("timestamp", LocalDateTime.now()); + + return ResponseEntity.ok(liveness); + } + + @Operation( + summary = "Database health check", + description = "Checks database connectivity. Returns 200 if database is accessible. " + + "Note: Use /actuator/health/db for detailed database health information." + ) + @ApiResponse(responseCode = "200", description = "Database is accessible") + @ApiResponse(responseCode = "503", description = "Database is not accessible") + @GetMapping("/health/db") + public ResponseEntity> databaseHealth() { + Map dbHealth = new HashMap<>(); + + try (Connection connection = dataSource.getConnection()) { + boolean isValid = connection.isValid(5); // 5 second timeout + + if (isValid) { + dbHealth.put("status", "UP"); + dbHealth.put("database", "connected"); + dbHealth.put("timestamp", LocalDateTime.now()); + return ResponseEntity.ok(dbHealth); + } else { + dbHealth.put("status", "DOWN"); + dbHealth.put("database", "connection failed"); + dbHealth.put("timestamp", LocalDateTime.now()); + return ResponseEntity.status(503).body(dbHealth); + } + } catch (Exception e) { + log.error("Database health check failed", e); + dbHealth.put("status", "DOWN"); + dbHealth.put("database", "error: " + e.getMessage()); + dbHealth.put("timestamp", LocalDateTime.now()); + return ResponseEntity.status(503).body(dbHealth); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/HipaaAccountingOfDisclosureController.java b/src/main/java/com/gnx/telemedicine/controller/HipaaAccountingOfDisclosureController.java new file mode 100644 index 0000000..e5e7ade --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/HipaaAccountingOfDisclosureController.java @@ -0,0 +1,90 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.model.AccountingOfDisclosure; +import com.gnx.telemedicine.model.enums.DisclosureType; +import com.gnx.telemedicine.service.HipaaAccountingOfDisclosureService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/hipaa/accounting") +@RequiredArgsConstructor +@Tag(name = "HIPAA Accounting of Disclosures", description = "HIPAA accounting of disclosures endpoints (45 CFR §164.528)") +@SecurityRequirement(name = "bearerAuth") +public class HipaaAccountingOfDisclosureController { + + private final HipaaAccountingOfDisclosureService disclosureService; + + @Operation(summary = "Log disclosure", description = "Log a PHI disclosure (HIPAA accounting of disclosures)") + @PreAuthorize("hasAnyRole('DOCTOR', 'ADMIN')") + @PostMapping("/log") + public ResponseEntity logDisclosure( + @RequestParam @NotNull UUID patientId, + @RequestParam @NotNull DisclosureType disclosureType, + @RequestParam @NotNull String purpose, + @RequestParam(required = false) String recipientName, + @RequestParam(required = false) String recipientAddress, + @RequestParam(required = false) String informationDisclosed, + @RequestParam(required = false) UUID authorizedByUserId, + Authentication authentication, + HttpServletRequest request) { + + String userEmail = authentication.getName(); + AccountingOfDisclosure disclosure = disclosureService.logDisclosure( + patientId, userEmail, disclosureType, purpose, + recipientName, recipientAddress, informationDisclosed, + authorizedByUserId, request); + return ResponseEntity.ok(disclosure); + } + + @Operation(summary = "Get disclosures by patient", description = "Get all disclosures for a patient (HIPAA patient right)") + @PreAuthorize("hasAnyRole('PATIENT', 'DOCTOR', 'ADMIN')") + @GetMapping("/patient/{patientId}") + public ResponseEntity> getDisclosuresByPatient( + @PathVariable UUID patientId, + Authentication authentication) { + + // Verify patient access + List disclosures = disclosureService.getDisclosuresByPatient(patientId); + return ResponseEntity.ok(disclosures); + } + + @Operation(summary = "Get disclosures by date range", description = "Get disclosures for a patient within a date range") + @PreAuthorize("hasAnyRole('PATIENT', 'DOCTOR', 'ADMIN')") + @GetMapping("/patient/{patientId}/range") + public ResponseEntity> getDisclosuresByDateRange( + @PathVariable UUID patientId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant endDate, + Authentication authentication) { + + List disclosures = disclosureService.getDisclosuresByPatientAndDateRange( + patientId, startDate, endDate); + return ResponseEntity.ok(disclosures); + } + + @Operation(summary = "Get disclosures by user", description = "Get all disclosures made by a user (Admin only)") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/user") + public ResponseEntity> getDisclosuresByUser( + @RequestParam String userEmail, + Authentication authentication) { + + List disclosures = disclosureService.getDisclosuresByUser(userEmail); + return ResponseEntity.ok(disclosures); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/HipaaAuditController.java b/src/main/java/com/gnx/telemedicine/controller/HipaaAuditController.java new file mode 100644 index 0000000..66bfa06 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/HipaaAuditController.java @@ -0,0 +1,135 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.audit.HipaaAuditLogResponseDto; +import com.gnx.telemedicine.model.HipaaAuditLog; +import com.gnx.telemedicine.model.enums.ActionType; +import com.gnx.telemedicine.service.HipaaAuditService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/v3/audit/hipaa") +@RequiredArgsConstructor +@Tag(name = "HIPAA Audit Logs", description = "Endpoints for viewing HIPAA compliance audit logs (Admin only)") +@SecurityRequirement(name = "bearerAuth") +public class HipaaAuditController { + + private final HipaaAuditService hipaaAuditService; + + @Operation( + summary = "Get audit logs by patient ID", + description = "Retrieve all HIPAA audit logs for a specific patient. Admin only.", + parameters = @Parameter(name = "patientId", description = "Patient ID", required = true) + ) + @ApiResponse( + responseCode = "200", + description = "List of audit logs retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = HipaaAuditLogResponseDto.class)) + ) + ) + @GetMapping("/patient/{patientId}") + public ResponseEntity> getAuditLogsByPatientId( + @PathVariable UUID patientId) { + List logs = hipaaAuditService.getAuditLogsByPatientId(patientId); + List response = logs.stream() + .map(this::toDto) + .collect(Collectors.toList()); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get audit logs by patient ID and date range", + description = "Retrieve HIPAA audit logs for a patient within a date range." + ) + @GetMapping("/patient/{patientId}/date-range") + public ResponseEntity> getAuditLogsByPatientIdAndDateRange( + @PathVariable UUID patientId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant endDate) { + List logs = hipaaAuditService.getAuditLogsByPatientIdAndDateRange(patientId, startDate, endDate); + List response = logs.stream() + .map(this::toDto) + .collect(Collectors.toList()); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get audit logs by user ID", + description = "Retrieve all HIPAA audit logs for a specific user." + ) + @GetMapping("/user/{userId}") + public ResponseEntity> getAuditLogsByUserId( + @PathVariable UUID userId) { + List logs = hipaaAuditService.getAuditLogsByUserId(userId); + List response = logs.stream() + .map(this::toDto) + .collect(Collectors.toList()); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get audit logs by resource", + description = "Retrieve all HIPAA audit logs for a specific resource." + ) + @GetMapping("/resource/{resourceType}/{resourceId}") + public ResponseEntity> getAuditLogsByResource( + @PathVariable String resourceType, + @PathVariable UUID resourceId) { + List logs = hipaaAuditService.getAuditLogsByResource(resourceType, resourceId); + List response = logs.stream() + .map(this::toDto) + .collect(Collectors.toList()); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get access count for patient", + description = "Get the number of PHI accesses for a patient since a given date." + ) + @GetMapping("/patient/{patientId}/access-count") + public ResponseEntity getAccessCount( + @PathVariable UUID patientId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant since) { + Long count = hipaaAuditService.countAccessesByPatientIdSince(patientId, since); + return ResponseEntity.ok(count); + } + + private HipaaAuditLogResponseDto toDto(HipaaAuditLog log) { + return new HipaaAuditLogResponseDto( + log.getId(), + log.getUser().getId(), + log.getUser().getFirstName() + " " + log.getUser().getLastName(), + log.getUser().getEmail(), + log.getActionType(), + log.getResourceType(), + log.getResourceId(), + log.getPatient() != null ? log.getPatient().getId() : null, + log.getPatient() != null ? + log.getPatient().getUser().getFirstName() + " " + log.getPatient().getUser().getLastName() : null, + log.getIpAddress(), + log.getUserAgent(), + log.getTimestamp(), + log.getDetails(), + log.getSuccess(), + log.getErrorMessage() + ); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/LabResultController.java b/src/main/java/com/gnx/telemedicine/controller/LabResultController.java new file mode 100644 index 0000000..803ec05 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/LabResultController.java @@ -0,0 +1,162 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.annotation.LogPhiAccess; +import com.gnx.telemedicine.dto.medical.LabResultRequestDto; +import com.gnx.telemedicine.dto.medical.LabResultResponseDto; +import com.gnx.telemedicine.model.enums.LabResultStatus; +import com.gnx.telemedicine.service.LabResultService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/lab-results") +@RequiredArgsConstructor +@Tag(name = "Lab Results", description = "Endpoints for managing laboratory results") +@SecurityRequirement(name = "bearerAuth") +public class LabResultController { + + private final LabResultService labResultService; + + @Operation( + summary = "Get lab results by patient ID", + description = "Retrieve all lab results for a specific patient.", + parameters = @Parameter(name = "patientId", description = "Patient ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "List of lab results retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = LabResultResponseDto.class)) + ) + ) + }) + @LogPhiAccess( + resourceType = "LAB_RESULT", + patientIdParam = "patientId", + accessType = "Treatment", + accessedFields = {"testName", "resultValue", "status", "resultFileUrl"} + ) + @GetMapping("/patient/{patientId}") + public ResponseEntity> getLabResultsByPatientId( + @PathVariable UUID patientId) { + return ResponseEntity.ok(labResultService.getLabResultsByPatientId(patientId)); + } + + @Operation( + summary = "Get lab results by patient ID and status", + description = "Retrieve lab results filtered by status for a specific patient.", + parameters = { + @Parameter(name = "patientId", description = "Patient ID", required = true), + @Parameter(name = "status", description = "Status (NORMAL, ABNORMAL, CRITICAL, PENDING)", required = true) + } + ) + @GetMapping("/patient/{patientId}/status/{status}") + public ResponseEntity> getLabResultsByPatientIdAndStatus( + @PathVariable UUID patientId, + @PathVariable LabResultStatus status) { + return ResponseEntity.ok(labResultService.getLabResultsByPatientIdAndStatus(patientId, status)); + } + + @Operation( + summary = "Get abnormal lab results by patient ID", + description = "Retrieve only abnormal or critical lab results for a patient." + ) + @GetMapping("/patient/{patientId}/abnormal") + public ResponseEntity> getAbnormalLabResultsByPatientId( + @PathVariable UUID patientId) { + return ResponseEntity.ok(labResultService.getAbnormalLabResultsByPatientId(patientId)); + } + + @Operation( + summary = "Get lab results by medical record ID", + description = "Retrieve all lab results associated with a specific medical record." + ) + @GetMapping("/medical-record/{medicalRecordId}") + public ResponseEntity> getLabResultsByMedicalRecordId( + @PathVariable UUID medicalRecordId) { + return ResponseEntity.ok(labResultService.getLabResultsByMedicalRecordId(medicalRecordId)); + } + + @Operation( + summary = "Create a lab result", + description = "Create a new lab result. Must be associated with a medical record.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Lab result details", + required = true, + content = @Content(schema = @Schema(implementation = LabResultRequestDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Lab result created successfully", + content = @Content(schema = @Schema(implementation = LabResultResponseDto.class)) + ), + @ApiResponse(responseCode = "400", description = "Validation error") + }) + @PostMapping + public ResponseEntity createLabResult( + Authentication authentication, + @RequestBody @Valid LabResultRequestDto requestDto) { + String userEmail = authentication.getName(); + LabResultResponseDto response = labResultService.createLabResult(userEmail, requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get lab result by ID", + description = "Retrieve a specific lab result by its ID." + ) + @LogPhiAccess( + resourceType = "LAB_RESULT", + resourceIdParam = "id", + accessType = "Treatment", + accessedFields = {"testName", "resultValue", "status", "resultFileUrl"} + ) + @GetMapping("/{id}") + public ResponseEntity getLabResultById(@PathVariable UUID id) { + return ResponseEntity.ok(labResultService.getLabResultById(id)); + } + + @Operation( + summary = "Update a lab result", + description = "Update an existing lab result." + ) + @PutMapping("/{id}") + public ResponseEntity updateLabResult( + Authentication authentication, + @PathVariable UUID id, + @RequestBody @Valid LabResultRequestDto requestDto) { + String userEmail = authentication.getName(); + LabResultResponseDto response = labResultService.updateLabResult(userEmail, id, requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Delete a lab result", + description = "Delete a lab result by ID." + ) + @DeleteMapping("/{id}") + public ResponseEntity deleteLabResult(@PathVariable UUID id) { + labResultService.deleteLabResult(id); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/MedicalRecordController.java b/src/main/java/com/gnx/telemedicine/controller/MedicalRecordController.java new file mode 100644 index 0000000..93b011c --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/MedicalRecordController.java @@ -0,0 +1,229 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.annotation.LogPhiAccess; +import com.gnx.telemedicine.dto.medical.MedicalRecordRequestDto; +import com.gnx.telemedicine.dto.medical.MedicalRecordResponseDto; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.RecordType; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.service.MedicalRecordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/medical-records") +@RequiredArgsConstructor +@Tag(name = "Medical Records", description = "Endpoints for managing medical records (EHR)") +@SecurityRequirement(name = "bearerAuth") +public class MedicalRecordController { + + private final MedicalRecordService medicalRecordService; + private final UserRepository userRepository; + private final DoctorRepository doctorRepository; + private final PatientRepository patientRepository; + + @Operation( + summary = "Get medical records by patient ID", + description = "Retrieve all medical records for a specific patient. Requires authentication.", + parameters = @Parameter(name = "patientId", description = "Patient ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "List of medical records retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = MedicalRecordResponseDto.class)) + ) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + @LogPhiAccess( + resourceType = "MEDICAL_RECORD", + patientIdParam = "patientId", + accessType = "Treatment", + accessedFields = {"title", "content", "diagnosisCode", "recordType"} + ) + @GetMapping("/patient/{patientId}") + public ResponseEntity> getMedicalRecordsByPatientId( + Authentication authentication, + @PathVariable UUID patientId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Check if user is the patient or the patient's treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Patient patient = patientOpt.get(); + if (!patient.getId().equals(patientId)) { + return ResponseEntity.status(403).build(); // Patients can only see their own records + } + return ResponseEntity.ok(medicalRecordService.getMedicalRecordsByPatientId(patientId)); + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + // Doctors can only see records for their patients + return ResponseEntity.ok(medicalRecordService.getMedicalRecordsByPatientIdAndDoctorId(patientId, doctor.getId())); + } + + return ResponseEntity.status(403).build(); + } + + @Operation( + summary = "Get medical records by patient ID and type", + description = "Retrieve medical records filtered by record type for a specific patient.", + parameters = { + @Parameter(name = "patientId", description = "Patient ID", required = true), + @Parameter(name = "recordType", description = "Record type (DIAGNOSIS, LAB_RESULT, etc.)", required = true) + } + ) + @GetMapping("/patient/{patientId}/type/{recordType}") + public ResponseEntity> getMedicalRecordsByPatientIdAndType( + @PathVariable UUID patientId, + @PathVariable RecordType recordType) { + return ResponseEntity.ok(medicalRecordService.getMedicalRecordsByPatientIdAndType(patientId, recordType)); + } + + @Operation( + summary = "Get medical records by doctor ID", + description = "Retrieve all medical records created by a specific doctor." + ) + @GetMapping("/doctor/{doctorId}") + public ResponseEntity> getMedicalRecordsByDoctorId( + @PathVariable UUID doctorId) { + return ResponseEntity.ok(medicalRecordService.getMedicalRecordsByDoctorId(doctorId)); + } + + @Operation( + summary = "Get medical records by appointment ID", + description = "Retrieve all medical records associated with a specific appointment." + ) + @GetMapping("/appointment/{appointmentId}") + public ResponseEntity> getMedicalRecordsByAppointmentId( + @PathVariable UUID appointmentId) { + return ResponseEntity.ok(medicalRecordService.getMedicalRecordsByAppointmentId(appointmentId)); + } + + @Operation( + summary = "Create a medical record", + description = "Create a new medical record. Only doctors can create medical records.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Medical record details", + required = true, + content = @Content(schema = @Schema(implementation = MedicalRecordRequestDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Medical record created successfully", + content = @Content(schema = @Schema(implementation = MedicalRecordResponseDto.class)) + ), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "403", description = "Forbidden - Only doctors can create medical records") + }) + @LogPhiAccess( + resourceType = "MEDICAL_RECORD", + patientIdParam = "requestDto", + accessType = "Treatment", + accessedFields = {"title", "content", "diagnosisCode", "recordType"} + ) + @PostMapping + public ResponseEntity createMedicalRecord( + Authentication authentication, + @RequestBody @Valid MedicalRecordRequestDto requestDto) { + String userEmail = authentication.getName(); + MedicalRecordResponseDto response = medicalRecordService.createMedicalRecord(userEmail, requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get medical record by ID", + description = "Retrieve a specific medical record by its ID." + ) + @LogPhiAccess( + resourceType = "MEDICAL_RECORD", + resourceIdParam = "id", + accessType = "Treatment", + accessedFields = {"title", "content", "diagnosisCode", "recordType"} + ) + @GetMapping("/{id}") + public ResponseEntity getMedicalRecordById( + Authentication authentication, + @PathVariable UUID id) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + MedicalRecordResponseDto record = medicalRecordService.getMedicalRecordById(id); + + // Check if user is the patient or the treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty() || !patientOpt.get().getId().equals(record.patientId())) { + return ResponseEntity.status(403).build(); + } + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty() || !doctorOpt.get().getId().equals(record.doctorId())) { + return ResponseEntity.status(403).build(); + } + } else { + return ResponseEntity.status(403).build(); + } + + return ResponseEntity.ok(record); + } + + @Operation( + summary = "Update a medical record", + description = "Update an existing medical record." + ) + @PutMapping("/{id}") + public ResponseEntity updateMedicalRecord( + Authentication authentication, + @PathVariable UUID id, + @RequestBody @Valid MedicalRecordRequestDto requestDto) { + String userEmail = authentication.getName(); + MedicalRecordResponseDto response = medicalRecordService.updateMedicalRecord(userEmail, id, requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Delete a medical record", + description = "Delete a medical record by ID." + ) + @DeleteMapping("/{id}") + public ResponseEntity deleteMedicalRecord(@PathVariable UUID id) { + medicalRecordService.deleteMedicalRecord(id); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/MedicationIntakeLogController.java b/src/main/java/com/gnx/telemedicine/controller/MedicationIntakeLogController.java new file mode 100644 index 0000000..ee33f0e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/MedicationIntakeLogController.java @@ -0,0 +1,120 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.prescription.MedicationIntakeLogRequestDto; +import com.gnx.telemedicine.dto.prescription.MedicationIntakeLogResponseDto; +import com.gnx.telemedicine.service.MedicationIntakeLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/medication-intake-logs") +@RequiredArgsConstructor +@Tag(name = "Medication Intake Logs", description = "Endpoints for managing medication adherence logs") +@SecurityRequirement(name = "bearerAuth") +public class MedicationIntakeLogController { + + private final MedicationIntakeLogService medicationIntakeLogService; + + @Operation( + summary = "Get medication intake logs by prescription ID", + description = "Retrieve all medication intake logs for a specific prescription.", + parameters = @Parameter(name = "prescriptionId", description = "Prescription ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "List of intake logs retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = MedicationIntakeLogResponseDto.class)) + ) + ) + }) + @GetMapping("/prescription/{prescriptionId}") + public ResponseEntity> getLogsByPrescriptionId( + @PathVariable UUID prescriptionId) { + return ResponseEntity.ok(medicationIntakeLogService.getLogsByPrescriptionId(prescriptionId)); + } + + @Operation( + summary = "Get missed doses", + description = "Retrieve all missed medication doses for a prescription." + ) + @GetMapping("/prescription/{prescriptionId}/missed") + public ResponseEntity> getMissedDoses( + @PathVariable UUID prescriptionId) { + return ResponseEntity.ok(medicationIntakeLogService.getMissedDoses(prescriptionId)); + } + + @Operation( + summary = "Get medication adherence rate", + description = "Calculate the adherence rate (percentage) for a prescription." + ) + @GetMapping("/prescription/{prescriptionId}/adherence") + public ResponseEntity> getAdherenceRate( + @PathVariable UUID prescriptionId) { + Long adherenceRate = medicationIntakeLogService.getAdherenceRate(prescriptionId); + return ResponseEntity.ok(Map.of("prescriptionId", prescriptionId, "adherenceRate", adherenceRate)); + } + + @Operation( + summary = "Create a medication intake log", + description = "Create a new medication intake log entry.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Intake log details", + required = true, + content = @Content(schema = @Schema(implementation = MedicationIntakeLogRequestDto.class)) + ) + ) + @PostMapping + public ResponseEntity createIntakeLog( + @RequestBody @Valid MedicationIntakeLogRequestDto requestDto) { + MedicationIntakeLogResponseDto response = medicationIntakeLogService.createIntakeLog(requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Mark dose as taken", + description = "Mark a scheduled medication dose as taken." + ) + @PatchMapping("/{id}/mark-taken") + public ResponseEntity markDoseAsTaken(@PathVariable UUID id) { + MedicationIntakeLogResponseDto response = medicationIntakeLogService.markDoseAsTaken(id); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get intake log by ID", + description = "Retrieve a specific medication intake log by its ID." + ) + @GetMapping("/{id}") + public ResponseEntity getIntakeLogById(@PathVariable UUID id) { + return ResponseEntity.ok(medicationIntakeLogService.getIntakeLogById(id)); + } + + @Operation( + summary = "Delete an intake log", + description = "Delete a medication intake log by ID." + ) + @DeleteMapping("/{id}") + public ResponseEntity deleteIntakeLog(@PathVariable UUID id) { + medicationIntakeLogService.deleteIntakeLog(id); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/MessageController.java b/src/main/java/com/gnx/telemedicine/controller/MessageController.java new file mode 100644 index 0000000..758b5c5 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/MessageController.java @@ -0,0 +1,199 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.message.ChatUserDto; +import com.gnx.telemedicine.dto.message.ConversationDto; +import com.gnx.telemedicine.dto.message.MessageRequestDto; +import com.gnx.telemedicine.dto.message.MessageResponseDto; +import com.gnx.telemedicine.service.MessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/messages") +@RequiredArgsConstructor +@Tag(name = "Messages", description = "Endpoints for messaging between doctors and patients") +public class MessageController { + + private final MessageService messageService; + + @Operation(summary = "Send a message", description = "Send a message to another user") + @ApiResponse(responseCode = "200", description = "Message sent successfully") + @PostMapping + public ResponseEntity sendMessage( + @Valid @RequestBody MessageRequestDto requestDto, + Authentication authentication) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(messageService.sendMessage(userEmail, requestDto)); + } + + @Operation(summary = "Get conversation", description = "Get all messages in a conversation with another user") + @ApiResponse(responseCode = "200", description = "Conversation retrieved successfully") + @GetMapping("/conversation/{otherUserId}") + public ResponseEntity> getConversation( + @PathVariable UUID otherUserId, + Authentication authentication) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(messageService.getConversation(userEmail, otherUserId)); + } + + @Operation(summary = "Get all conversations", description = "Get all conversations for the current user") + @ApiResponse(responseCode = "200", description = "Conversations retrieved successfully") + @GetMapping("/conversations") + public ResponseEntity> getConversations(Authentication authentication) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(messageService.getConversations(userEmail)); + } + + @Operation(summary = "Mark messages as read", description = "Mark all messages from a sender as read") + @ApiResponse(responseCode = "200", description = "Messages marked as read") + @PutMapping("/read/{senderId}") + public ResponseEntity markAsRead(@PathVariable UUID senderId, Authentication authentication) { + String userEmail = authentication.getName(); + messageService.markMessagesAsRead(userEmail, senderId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Get unread message count", description = "Get the count of unread messages") + @ApiResponse(responseCode = "200", description = "Unread count retrieved successfully") + @GetMapping("/unread/count") + public ResponseEntity getUnreadCount(Authentication authentication) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(messageService.getUnreadMessageCount(userEmail)); + } + + @Operation(summary = "Update online status", description = "Update user's online status") + @ApiResponse(responseCode = "200", description = "Status updated successfully") + @PutMapping("/status/online") + public ResponseEntity updateOnlineStatus( + @RequestParam boolean online, + Authentication authentication) { + String userEmail = authentication.getName(); + messageService.updateOnlineStatus(userEmail, online); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Update user status", description = "Update user's status (ONLINE, OFFLINE, BUSY)") + @ApiResponse(responseCode = "200", description = "Status updated successfully") + @ApiResponse(responseCode = "400", description = "Invalid status value") + @PutMapping("/status") + public ResponseEntity updateUserStatus( + @RequestParam String status, + Authentication authentication) { + String userEmail = authentication.getName(); + messageService.updateUserStatus(userEmail, status); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Search doctors", description = "Search for doctors to chat with (patients only)") + @ApiResponse(responseCode = "200", description = "Doctors retrieved successfully") + @GetMapping("/search/doctors") + public ResponseEntity> searchDoctors( + @RequestParam(required = false, defaultValue = "") String query, + Authentication authentication) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(messageService.searchDoctors(userEmail, query)); + } + + @Operation(summary = "Search patients", description = "Search for patients to chat with (doctors only)") + @ApiResponse(responseCode = "200", description = "Patients retrieved successfully") + @GetMapping("/search/patients") + public ResponseEntity> searchPatients( + @RequestParam(required = false, defaultValue = "") String query, + Authentication authentication) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(messageService.searchPatients(userEmail, query)); + } + + @Operation(summary = "Delete a message", description = "Delete a specific message by ID (only if user is sender or receiver)") + @ApiResponse(responseCode = "200", description = "Message deleted successfully") + @ApiResponse(responseCode = "404", description = "Message not found") + @ApiResponse(responseCode = "403", description = "Not authorized to delete this message") + @DeleteMapping("/message/{messageId}") + public ResponseEntity deleteMessage( + @PathVariable UUID messageId, + Authentication authentication) { + String userEmail = authentication.getName(); + messageService.deleteMessage(userEmail, messageId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Delete conversation", description = "Delete all messages in a conversation with another user") + @ApiResponse(responseCode = "200", description = "Conversation deleted successfully") + @DeleteMapping("/conversation/{otherUserId}") + public ResponseEntity deleteConversation( + @PathVariable UUID otherUserId, + Authentication authentication) { + String userEmail = authentication.getName(); + messageService.deleteConversation(userEmail, otherUserId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Block a user", description = "Block a user from sending messages to you. Blocks are bidirectional - you cannot message them either.") + @ApiResponse(responseCode = "200", description = "User blocked successfully") + @ApiResponse(responseCode = "400", description = "Invalid request (e.g., trying to block yourself or user already blocked)") + @PostMapping("/block/{userId}") + public ResponseEntity> blockUser( + @PathVariable UUID userId, + Authentication authentication) { + String userEmail = authentication.getName(); + messageService.blockUser(userEmail, userId); + Map response = new java.util.HashMap<>(); + response.put("message", "User blocked successfully"); + return ResponseEntity.ok(response); + } + + @Operation(summary = "Unblock a user", description = "Unblock a user to allow them to send messages to you again.") + @ApiResponse(responseCode = "200", description = "User unblocked successfully") + @ApiResponse(responseCode = "400", description = "User is not blocked") + @DeleteMapping("/block/{userId}") + public ResponseEntity> unblockUser( + @PathVariable UUID userId, + Authentication authentication) { + String userEmail = authentication.getName(); + messageService.unblockUser(userEmail, userId); + Map response = new java.util.HashMap<>(); + response.put("message", "User unblocked successfully"); + return ResponseEntity.ok(response); + } + + @Operation(summary = "Check if user is blocked", description = "Check if a user is blocked (either by you or you by them)") + @ApiResponse(responseCode = "200", description = "Block status retrieved successfully") + @GetMapping("/block/status/{userId}") + public ResponseEntity> isBlocked( + @PathVariable UUID userId, + Authentication authentication) { + String userEmail = authentication.getName(); + boolean blocked = messageService.isBlocked(userEmail, userId); + Map response = new java.util.HashMap<>(); + response.put("blocked", blocked); + return ResponseEntity.ok(response); + } + + @Operation(summary = "Get blocked users", description = "Get list of user IDs that the current user has blocked") + @ApiResponse(responseCode = "200", description = "Blocked users retrieved successfully") + @GetMapping("/block/list") + public ResponseEntity> getBlockedUsers(Authentication authentication) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(messageService.getBlockedUsers(userEmail)); + } + + @Operation(summary = "Get blocked users with details", description = "Get list of blocked users with full information (name, avatar, etc.)") + @ApiResponse(responseCode = "200", description = "Blocked users with details retrieved successfully") + @GetMapping("/block/list/details") + public ResponseEntity> getBlockedUsersWithDetails(Authentication authentication) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(messageService.getBlockedUsersWithDetails(userEmail)); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/PhiAccessLogController.java b/src/main/java/com/gnx/telemedicine/controller/PhiAccessLogController.java new file mode 100644 index 0000000..5f5e8f9 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/PhiAccessLogController.java @@ -0,0 +1,112 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.audit.PhiAccessLogResponseDto; +import com.gnx.telemedicine.model.PhiAccessLog; +import com.gnx.telemedicine.service.PhiAccessLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/v3/audit/phi-access") +@RequiredArgsConstructor +@Tag(name = "PHI Access Logs", description = "Endpoints for viewing PHI access logs (Admin only)") +@SecurityRequirement(name = "bearerAuth") +public class PhiAccessLogController { + + private final PhiAccessLogService phiAccessLogService; + + @Operation( + summary = "Get PHI access logs by patient ID", + description = "Retrieve all PHI access logs for a specific patient. Admin only.", + parameters = @Parameter(name = "patientId", description = "Patient ID", required = true) + ) + @ApiResponse( + responseCode = "200", + description = "List of PHI access logs retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = PhiAccessLogResponseDto.class)) + ) + ) + @GetMapping("/patient/{patientId}") + public ResponseEntity> getPhiAccessLogsByPatientId( + @PathVariable UUID patientId) { + List logs = phiAccessLogService.getPhiAccessLogsByPatientId(patientId); + List response = logs.stream() + .map(this::toDto) + .collect(Collectors.toList()); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get PHI access logs by patient ID and date range", + description = "Retrieve PHI access logs for a patient within a date range." + ) + @GetMapping("/patient/{patientId}/date-range") + public ResponseEntity> getPhiAccessLogsByPatientIdAndDateRange( + @PathVariable UUID patientId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant endDate) { + List logs = phiAccessLogService.getPhiAccessLogsByPatientIdAndDateRange(patientId, startDate, endDate); + List response = logs.stream() + .map(this::toDto) + .collect(Collectors.toList()); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get PHI access logs by user ID", + description = "Retrieve all PHI access logs for a specific user. Admin only.", + parameters = @Parameter(name = "userId", description = "User ID", required = true) + ) + @ApiResponse( + responseCode = "200", + description = "List of PHI access logs retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = PhiAccessLogResponseDto.class)) + ) + ) + @GetMapping("/user/{userId}") + public ResponseEntity> getPhiAccessLogsByUserId( + @PathVariable UUID userId) { + List logs = phiAccessLogService.getPhiAccessLogsByUserId(userId); + List response = logs.stream() + .map(this::toDto) + .collect(Collectors.toList()); + return ResponseEntity.ok(response); + } + + private PhiAccessLogResponseDto toDto(PhiAccessLog log) { + return new PhiAccessLogResponseDto( + log.getId(), + log.getUser().getId(), + log.getUser().getFirstName() + " " + log.getUser().getLastName(), + log.getUser().getEmail(), + log.getPatient().getId(), + log.getPatient().getUser().getFirstName() + " " + log.getPatient().getUser().getLastName(), + log.getAccessType(), + log.getAccessedFields(), + log.getPurpose(), + log.getIpAddress(), + log.getUserAgent(), + log.getTimestamp() + ); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/PrescriptionController.java b/src/main/java/com/gnx/telemedicine/controller/PrescriptionController.java new file mode 100644 index 0000000..631b3bf --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/PrescriptionController.java @@ -0,0 +1,272 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.annotation.LogPhiAccess; +import com.gnx.telemedicine.dto.prescription.PrescriptionRequestDto; +import com.gnx.telemedicine.dto.prescription.PrescriptionResponseDto; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.Prescription; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.PrescriptionStatus; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.service.PrescriptionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/prescriptions") +@RequiredArgsConstructor +@Tag(name = "Prescriptions", description = "Endpoints for managing prescriptions") +@SecurityRequirement(name = "bearerAuth") +public class PrescriptionController { + + private final PrescriptionService prescriptionService; + private final UserRepository userRepository; + private final DoctorRepository doctorRepository; + private final PatientRepository patientRepository; + + @Operation( + summary = "Get prescriptions by patient ID", + description = "Retrieve all prescriptions for a specific patient.", + parameters = @Parameter(name = "patientId", description = "Patient ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "List of prescriptions retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = PrescriptionResponseDto.class)) + ) + ) + }) + @LogPhiAccess( + resourceType = "PRESCRIPTION", + patientIdParam = "patientId", + accessType = "Treatment", + accessedFields = {"medicationName", "dosage", "frequency", "instructions"} + ) + @GetMapping("/patient/{patientId}") + public ResponseEntity> getPrescriptionsByPatientId( + Authentication authentication, + @PathVariable UUID patientId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Check if user is the patient or the patient's treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Patient patient = patientOpt.get(); + if (!patient.getId().equals(patientId)) { + return ResponseEntity.status(403).build(); // Patients can only see their own prescriptions + } + return ResponseEntity.ok(prescriptionService.getPrescriptionsByPatientId(patientId)); + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty()) { + return ResponseEntity.status(403).build(); + } + Doctor doctor = doctorOpt.get(); + // Doctors can only see prescriptions for their patients + return ResponseEntity.ok(prescriptionService.getPrescriptionsByPatientIdAndDoctorId(patientId, doctor.getId())); + } + + return ResponseEntity.status(403).build(); + } + + @Operation( + summary = "Get active prescriptions by patient ID", + description = "Retrieve only active prescriptions for a patient." + ) + @GetMapping("/patient/{patientId}/active") + public ResponseEntity> getActivePrescriptionsByPatientId( + @PathVariable UUID patientId) { + return ResponseEntity.ok(prescriptionService.getActivePrescriptionsByPatientId(patientId)); + } + + @Operation( + summary = "Get prescriptions by patient ID and status", + description = "Retrieve prescriptions filtered by status for a specific patient." + ) + @GetMapping("/patient/{patientId}/status/{status}") + public ResponseEntity> getPrescriptionsByPatientIdAndStatus( + @PathVariable UUID patientId, + @PathVariable PrescriptionStatus status) { + return ResponseEntity.ok(prescriptionService.getPrescriptionsByPatientIdAndStatus(patientId, status)); + } + + @Operation( + summary = "Get prescriptions by doctor ID", + description = "Retrieve all prescriptions created by a specific doctor." + ) + @GetMapping("/doctor/{doctorId}") + public ResponseEntity> getPrescriptionsByDoctorId( + @PathVariable UUID doctorId) { + return ResponseEntity.ok(prescriptionService.getPrescriptionsByDoctorId(doctorId)); + } + + @Operation( + summary = "Get prescriptions by appointment ID", + description = "Retrieve all prescriptions associated with a specific appointment." + ) + @GetMapping("/appointment/{appointmentId}") + public ResponseEntity> getPrescriptionsByAppointmentId( + @PathVariable UUID appointmentId) { + return ResponseEntity.ok(prescriptionService.getPrescriptionsByAppointmentId(appointmentId)); + } + + @Operation( + summary = "Get prescription by prescription number", + description = "Retrieve a prescription by its unique prescription number." + ) + @GetMapping("/number/{prescriptionNumber}") + public ResponseEntity getPrescriptionByNumber( + @PathVariable String prescriptionNumber) { + return ResponseEntity.ok(prescriptionService.getPrescriptionByNumber(prescriptionNumber)); + } + + @Operation( + summary = "Create a prescription", + description = "Create a new prescription. Only doctors can create prescriptions. A unique prescription number is automatically generated.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Prescription details", + required = true, + content = @Content(schema = @Schema(implementation = PrescriptionRequestDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Prescription created successfully", + content = @Content(schema = @Schema(implementation = PrescriptionResponseDto.class)) + ), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "403", description = "Forbidden - Only doctors can create prescriptions") + }) + @LogPhiAccess( + resourceType = "PRESCRIPTION", + patientIdParam = "requestDto", + accessType = "Treatment", + accessedFields = {"medicationName", "dosage", "frequency", "instructions"} + ) + @PostMapping + public ResponseEntity createPrescription( + Authentication authentication, + @RequestBody @Valid PrescriptionRequestDto requestDto) { + String userEmail = authentication.getName(); + PrescriptionResponseDto response = prescriptionService.createPrescription(userEmail, requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get prescription by ID", + description = "Retrieve a specific prescription by its ID." + ) + @LogPhiAccess( + resourceType = "PRESCRIPTION", + resourceIdParam = "id", + accessType = "Treatment", + accessedFields = {"medicationName", "dosage", "frequency", "instructions"} + ) + @GetMapping("/{id}") + public ResponseEntity getPrescriptionById( + Authentication authentication, + @PathVariable UUID id) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + PrescriptionResponseDto prescription = prescriptionService.getPrescriptionById(id); + + // Check if user is the patient or the treating doctor + if (user.getRole().name().equals("PATIENT")) { + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isEmpty() || !patientOpt.get().getId().equals(prescription.patientId())) { + return ResponseEntity.status(403).build(); + } + } else if (user.getRole().name().equals("DOCTOR")) { + Optional doctorOpt = doctorRepository.findByUser(user); + if (doctorOpt.isEmpty() || !doctorOpt.get().getId().equals(prescription.doctorId())) { + return ResponseEntity.status(403).build(); + } + } else { + return ResponseEntity.status(403).build(); + } + + return ResponseEntity.ok(prescription); + } + + @Operation( + summary = "Update prescription status", + description = "Update the status of a prescription (ACTIVE, COMPLETED, CANCELLED, DISCONTINUED)." + ) + @PatchMapping("/{id}/status") + public ResponseEntity updatePrescriptionStatus( + Authentication authentication, + @PathVariable UUID id, + @RequestParam PrescriptionStatus status) { + String userEmail = authentication.getName(); + PrescriptionResponseDto response = prescriptionService.updatePrescriptionStatus(userEmail, id, status); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Mark e-prescription as sent", + description = "Mark that an e-prescription has been sent to the pharmacy." + ) + @PatchMapping("/{id}/mark-sent") + public ResponseEntity markEPrescriptionSent( + Authentication authentication, + @PathVariable UUID id) { + String userEmail = authentication.getName(); + PrescriptionResponseDto response = prescriptionService.markEPrescriptionSent(userEmail, id); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Update a prescription", + description = "Update an existing prescription." + ) + @PutMapping("/{id}") + public ResponseEntity updatePrescription( + Authentication authentication, + @PathVariable UUID id, + @RequestBody @Valid PrescriptionRequestDto requestDto) { + String userEmail = authentication.getName(); + PrescriptionResponseDto response = prescriptionService.updatePrescription(userEmail, id, requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Delete a prescription", + description = "Delete a prescription by ID." + ) + @DeleteMapping("/{id}") + public ResponseEntity deletePrescription(@PathVariable UUID id) { + prescriptionService.deletePrescription(id); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/SentinelEventController.java b/src/main/java/com/gnx/telemedicine/controller/SentinelEventController.java new file mode 100644 index 0000000..c00330b --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/SentinelEventController.java @@ -0,0 +1,82 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.patient_safety.SentinelEventRequestDto; +import com.gnx.telemedicine.dto.patient_safety.SentinelEventResponseDto; +import com.gnx.telemedicine.dto.patient_safety.SentinelEventUpdateRequestDto; +import com.gnx.telemedicine.model.enums.SentinelEventStatus; +import com.gnx.telemedicine.service.SentinelEventReportingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/sentinel-events") +@RequiredArgsConstructor +@Tag(name = "Sentinel Events", description = "Endpoints for reporting and managing sentinel events") +@SecurityRequirement(name = "bearerAuth") +public class SentinelEventController { + + private final SentinelEventReportingService sentinelEventReportingService; + + @Operation(summary = "Get all sentinel events") + @GetMapping + public ResponseEntity> getAllSentinelEvents() { + return ResponseEntity.ok(sentinelEventReportingService.getAllSentinelEvents()); + } + + @Operation(summary = "Get sentinel events by status") + @GetMapping("/status/{status}") + public ResponseEntity> getSentinelEventsByStatus(@PathVariable SentinelEventStatus status) { + return ResponseEntity.ok(sentinelEventReportingService.getSentinelEventsByStatus(status)); + } + + @Operation(summary = "Get active sentinel events") + @GetMapping("/active") + public ResponseEntity> getActiveSentinelEvents() { + return ResponseEntity.ok(sentinelEventReportingService.getActiveSentinelEvents()); + } + + @Operation(summary = "Get sentinel events by time range") + @GetMapping("/time-range") + public ResponseEntity> getSentinelEventsByTimeRange( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant endDate) { + return ResponseEntity.ok(sentinelEventReportingService.getSentinelEventsByTimeRange(startDate, endDate)); + } + + @Operation(summary = "Report a sentinel event") + @PostMapping + public ResponseEntity reportSentinelEvent( + Authentication authentication, + @RequestBody @Valid SentinelEventRequestDto requestDto) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(sentinelEventReportingService.reportSentinelEvent(userEmail, requestDto)); + } + + @Operation(summary = "Update a sentinel event") + @PutMapping("/{eventId}") + public ResponseEntity updateSentinelEvent( + Authentication authentication, + @PathVariable UUID eventId, + @RequestBody @Valid SentinelEventUpdateRequestDto requestDto) { + String userEmail = authentication.getName(); + return ResponseEntity.ok(sentinelEventReportingService.updateSentinelEvent(userEmail, eventId, requestDto)); + } + + @Operation(summary = "Get sentinel event by ID") + @GetMapping("/{id}") + public ResponseEntity getSentinelEventById(@PathVariable UUID id) { + return ResponseEntity.ok(sentinelEventReportingService.getSentinelEventById(id)); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/TurnController.java b/src/main/java/com/gnx/telemedicine/controller/TurnController.java new file mode 100644 index 0000000..c833ccf --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/TurnController.java @@ -0,0 +1,125 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.config.TurnConfig; +import com.gnx.telemedicine.dto.turn.TurnConfigResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v3/turn") +@RequiredArgsConstructor +@Tag(name = "TURN Configuration", description = "Endpoints for WebRTC TURN server configuration") +public class TurnController { + + private final TurnConfig turnConfig; + + @Value("${webrtc.turn.public-ip:193.194.155.249}") + private String publicIp; + + @Value("${webrtc.turn.public-port:${webrtc.turn.port:3478}}") + private Integer publicPort; + + @Operation( + summary = "Get TURN server configuration", + description = "Retrieve the TURN server configuration for WebRTC connections. This endpoint is public and does not require authentication." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "TURN configuration retrieved successfully" + ) + }) + @GetMapping("/config") + public ResponseEntity getTurnConfig(HttpServletRequest request) { + // Detect if request is from public IP, local network IP, or localhost + String origin = request.getHeader("Origin"); + String host = request.getHeader("Host"); + String remoteAddr = request.getRemoteAddr(); + String serverIp = turnConfig.getServer(); + Integer port = turnConfig.getPort(); + + // Check if request is from public IP (not localhost or private network) + boolean isPublicAccess = false; + String requestIp = null; + + // Extract IP from origin or host + if (origin != null && !origin.contains("localhost") && !origin.contains("127.0.0.1")) { + // Extract IP from origin (e.g., "https://192.168.1.6:4200") + String originHost = origin.replace("https://", "").replace("http://", "").split(":")[0]; + requestIp = originHost; + } else if (host != null && !host.contains("localhost") && !host.contains("127.0.0.1")) { + // Extract IP from host header + requestIp = host.split(":")[0]; + } else if (remoteAddr != null && !remoteAddr.equals("localhost") && !remoteAddr.equals("127.0.0.1")) { + requestIp = remoteAddr; + } + + // Determine if it's a public IP (not private network range) + if (requestIp != null && !requestIp.equals("localhost") && !requestIp.equals("127.0.0.1")) { + // Check if it's a private network IP + boolean isPrivateNetwork = requestIp.startsWith("192.168.") || + requestIp.startsWith("10.") || + requestIp.startsWith("172.16.") || + requestIp.startsWith("172.17.") || + requestIp.startsWith("172.18.") || + requestIp.startsWith("172.19.") || + requestIp.startsWith("172.20.") || + requestIp.startsWith("172.21.") || + requestIp.startsWith("172.22.") || + requestIp.startsWith("172.23.") || + requestIp.startsWith("172.24.") || + requestIp.startsWith("172.25.") || + requestIp.startsWith("172.26.") || + requestIp.startsWith("172.27.") || + requestIp.startsWith("172.28.") || + requestIp.startsWith("172.29.") || + requestIp.startsWith("172.30.") || + requestIp.startsWith("172.31."); + + if (isPrivateNetwork) { + // Use the local network IP for TURN server + serverIp = requestIp; + port = turnConfig.getPort(); // Use local port + } else { + // Public IP access + isPublicAccess = true; + serverIp = publicIp; + port = publicPort; + } + } + + // Build STUN/TURN URLs with public STUN servers as fallback + String stunUrls; + String turnUrls; + + if (isPublicAccess) { + // For public access, use public STUN servers and configured TURN + stunUrls = String.format("stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302,stun:%s:%d", serverIp, port); + turnUrls = String.format("turn:%s:%d", serverIp, port); + } else { + // For localhost, use local TURN server + stunUrls = String.format("stun:%s:%d", serverIp, port); + turnUrls = String.format("turn:%s:%d", serverIp, port); + } + + TurnConfigResponseDto config = TurnConfigResponseDto.builder() + .server(serverIp) + .port(port) + .username(turnConfig.getUsername()) + .password(turnConfig.getPassword()) + .realm(turnConfig.getRealm()) + .stunUrls(stunUrls) + .turnUrls(turnUrls) + .build(); + + return ResponseEntity.ok(config); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/TwoFactorAuthController.java b/src/main/java/com/gnx/telemedicine/controller/TwoFactorAuthController.java new file mode 100644 index 0000000..66707fc --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/TwoFactorAuthController.java @@ -0,0 +1,143 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.auth.TwoFactorSetupResponseDto; +import com.gnx.telemedicine.dto.auth.TwoFactorVerifyRequestDto; +import com.gnx.telemedicine.model.TwoFactorAuth; +import com.gnx.telemedicine.service.TwoFactorAuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v3/2fa") +@RequiredArgsConstructor +@Tag(name = "Two-Factor Authentication", description = "Endpoints for managing 2FA") +@SecurityRequirement(name = "bearerAuth") +public class TwoFactorAuthController { + + private final TwoFactorAuthService twoFactorAuthService; + + @Operation( + summary = "Setup 2FA", + description = "Initialize 2FA setup for the current user. Returns secret key and backup codes.", + responses = @ApiResponse( + responseCode = "200", + description = "2FA setup initialized", + content = @Content(schema = @Schema(implementation = TwoFactorSetupResponseDto.class)) + ) + ) + @PostMapping("/setup") + public ResponseEntity> setup2FA(Authentication authentication) { + String userEmail = authentication.getName(); + TwoFactorAuth twoFactorAuth = twoFactorAuthService.setup2FA(userEmail); + + String qrCodeUrl = twoFactorAuthService.generateQrCodeUrl(userEmail); + + Map response = new HashMap<>(); + response.put("userId", twoFactorAuth.getUser().getId()); + response.put("secretKey", twoFactorAuth.getSecretKey()); + response.put("qrCodeUrl", qrCodeUrl); + response.put("backupCodes", twoFactorAuth.getBackupCodes()); + response.put("enabled", twoFactorAuth.getEnabled()); + + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Enable 2FA", + description = "Enable 2FA after verifying the setup code." + ) + @PostMapping("/enable") + public ResponseEntity> enable2FA( + Authentication authentication, + @RequestBody @Valid TwoFactorVerifyRequestDto requestDto) { + String userEmail = authentication.getName(); + twoFactorAuthService.enable2FA(userEmail, requestDto.code()); + + Map response = new HashMap<>(); + response.put("message", "2FA enabled successfully"); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Disable 2FA", + description = "Disable 2FA for the current user. Requires verification code." + ) + @PostMapping("/disable") + public ResponseEntity> disable2FA( + Authentication authentication, + @RequestBody @Valid TwoFactorVerifyRequestDto requestDto) { + String userEmail = authentication.getName(); + twoFactorAuthService.disable2FA(userEmail, requestDto.code()); + + Map response = new HashMap<>(); + response.put("message", "2FA disabled successfully"); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Check if 2FA is enabled", + description = "Check if 2FA is enabled for the current user." + ) + @GetMapping("/status") + public ResponseEntity> get2FAStatus(Authentication authentication) { + String userEmail = authentication.getName(); + boolean enabled = twoFactorAuthService.is2FAEnabled(userEmail); + + Map response = new HashMap<>(); + response.put("enabled", enabled); + + if (enabled) { + twoFactorAuthService.get2FASetup(userEmail).ifPresent(setup -> { + response.put("hasBackupCodes", setup.getBackupCodes() != null && !setup.getBackupCodes().isEmpty()); + response.put("backupCodesCount", setup.getBackupCodes() != null ? setup.getBackupCodes().size() : 0); + }); + } + + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Regenerate backup codes", + description = "Generate new backup codes. Old codes will be invalidated." + ) + @PostMapping("/backup-codes/regenerate") + public ResponseEntity> regenerateBackupCodes( + Authentication authentication, + @RequestBody @Valid TwoFactorVerifyRequestDto requestDto) { + String userEmail = authentication.getName(); + List backupCodes = twoFactorAuthService.regenerateBackupCodes(userEmail, requestDto.code()); + + Map response = new HashMap<>(); + response.put("backupCodes", backupCodes); + response.put("message", "Backup codes regenerated successfully"); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get QR code URL", + description = "Get the QR code URL for 2FA setup." + ) + @GetMapping("/qr-code") + public ResponseEntity> getQrCode(Authentication authentication) { + String userEmail = authentication.getName(); + String qrCodeUrl = twoFactorAuthService.generateQrCodeUrl(userEmail); + + Map response = new HashMap<>(); + response.put("qrCodeUrl", qrCodeUrl); + return ResponseEntity.ok(response); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/UserController.java b/src/main/java/com/gnx/telemedicine/controller/UserController.java new file mode 100644 index 0000000..e835b41 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/UserController.java @@ -0,0 +1,458 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.user.*; +import com.gnx.telemedicine.dto.admin.UserManagementDto; +import com.gnx.telemedicine.dto.auth.PasswordChangeDto; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/users") +@RequiredArgsConstructor +@Tag(name = "User Management", description = "Endpoints for user registration (doctors, patients, and admins)") +public class UserController { + + private final UserService userService; + private final UserRepository userRepository; + + @Operation( + summary = "Register a new doctor", + description = "Register a new doctor account.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Doctor registration details", + required = true, + content = @Content(schema = @Schema(implementation = DoctorRegistrationDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Doctor registered successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = DoctorResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Validation error or duplicate email", + content = @Content(mediaType = "application/json") + ) + }) + @PostMapping("/register/doctor") + public ResponseEntity registerDoctor(@Valid @RequestBody DoctorRegistrationDto doctorRegistrationDto) { + return new ResponseEntity<>(userService.registerDoctor(doctorRegistrationDto), HttpStatus.CREATED); + } + + @Operation( + summary = "Register a new patient", + description = "Register a new patient account.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Patient registration details", + required = true, + content = @Content(schema = @Schema(implementation = PatientRegistrationDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Patient registered successfully.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PatientResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Validation error or duplicate email", + content = @Content(mediaType = "application/json") + ) + }) + @PostMapping("/register/patient") + public ResponseEntity registerPatient(@Valid @RequestBody PatientRegistrationDto patientRegistrationDto) { + return new ResponseEntity<>(userService.registerPatient(patientRegistrationDto), HttpStatus.CREATED); + } + + @Operation( + summary = "Register a new admin", + description = "Register a new admin account. This is restricted and only can be in email in configuration.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Admin registration details", + required = true, + content = @Content(schema = @Schema(implementation = UserRegistrationDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Admin registered successfully." + ), + @ApiResponse( + responseCode = "400", + description = "Validation error", + content = @Content(mediaType = "application/json") + ) + }) + @PostMapping("/register/admin") + public ResponseEntity registerAdmin(@Valid @RequestBody UserRegistrationDto userRegistrationDto) { + userService.registerAdmin(userRegistrationDto); + return new ResponseEntity<>(HttpStatus.CREATED); + } + + @Operation( + summary = "Get current user profile", + description = "Get the profile of the currently authenticated user.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User profile retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserManagementDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/me") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + return ResponseEntity.ok(userService.getCurrentUser(email)); + } + + @Operation( + summary = "Get current doctor profile", + description = "Get the doctor-specific profile of the currently authenticated doctor.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Doctor profile retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = DoctorResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can access doctor profile" + ) + }) + @GetMapping("/doctors/me") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity getCurrentDoctorProfile() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + return ResponseEntity.ok(userService.getCurrentDoctorProfile(email)); + } + + @Operation( + summary = "Get current patient profile", + description = "Get the patient-specific profile of the currently authenticated patient.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Patient profile retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PatientResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only patients can access patient profile" + ) + }) + @GetMapping("/patients/me") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity getCurrentPatientProfile() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + return ResponseEntity.ok(userService.getCurrentPatientProfile(email)); + } + + @Operation( + summary = "Update current user profile", + description = "Update the profile of the currently authenticated user (firstName, lastName, phoneNumber).", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User profile updated successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserManagementDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @PatchMapping("/me") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity updateUserProfile(@Valid @RequestBody UserUpdateDto updateDto) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + return ResponseEntity.ok(userService.updateUserProfile(email, updateDto)); + } + + @Operation( + summary = "Update current doctor profile", + description = "Update the doctor-specific profile of the currently authenticated doctor (specialization, yearsOfExperience, biography, consultationFee).", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Doctor profile updated successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = DoctorResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can update doctor profile" + ) + }) + @PatchMapping("/doctors/me") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity updateDoctorProfile(@Valid @RequestBody DoctorUpdateDto updateDto) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + return ResponseEntity.ok(userService.updateDoctorProfile(email, updateDto)); + } + + @Operation( + summary = "Update current patient profile", + description = "Update the patient-specific profile of the currently authenticated patient (emergencyContactName, emergencyContactPhone, bloodType, allergies).", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Patient profile updated successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PatientResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only patients can update patient profile" + ) + }) + @PatchMapping("/patients/me") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity updatePatientProfile(@Valid @RequestBody PatientUpdateDto updateDto) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + return ResponseEntity.ok(userService.updatePatientProfile(email, updateDto)); + } + + @Operation( + summary = "Get all doctors", + description = "Get a list of all active doctors in the system. Available to all authenticated users.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Doctors retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = DoctorResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/doctors") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity> getAllDoctors() { + return ResponseEntity.ok(userService.getAllDoctors()); + } + + @Operation( + summary = "Get all patients", + description = "Get a list of all active patients in the system. Available to all authenticated users.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Patients retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = PatientResponseDto.class)) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ) + }) + @GetMapping("/patients") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity> getAllPatients() { + return ResponseEntity.ok(userService.getAllPatients()); + } + + @Operation( + summary = "Get doctor profile by ID", + description = "Get full doctor profile by ID. Patients can view full doctor profiles to make informed decisions.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Doctor profile retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = DoctorResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ), + @ApiResponse( + responseCode = "404", + description = "Doctor not found" + ) + }) + @GetMapping("/doctors/{doctorId}") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity getDoctorProfileById( + Authentication authentication, + @PathVariable java.util.UUID doctorId) { + return ResponseEntity.ok(userService.getDoctorProfileById(doctorId)); + } + + @Operation( + summary = "Get patient profile by ID", + description = "Get full patient profile by ID. Doctors can view full patient profiles for comprehensive care.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Patient profile retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PatientResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT" + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - Only doctors can view patient profiles" + ), + @ApiResponse( + responseCode = "404", + description = "Patient not found" + ) + }) + @GetMapping("/patients/{patientId}") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity getPatientProfileById( + Authentication authentication, + @PathVariable java.util.UUID patientId) { + String userEmail = authentication.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Only doctors can view full patient profiles + if (!user.getRole().name().equals("DOCTOR")) { + return ResponseEntity.status(403).build(); + } + + return ResponseEntity.ok(userService.getPatientProfileById(patientId)); + } + + @Operation( + summary = "Change password", + description = "Change the password for the currently authenticated user. Requires current password and new password.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Password changed successfully" + ), + @ApiResponse( + responseCode = "400", + description = "Validation error or password does not meet requirements", + content = @Content(mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - Missing or expired JWT, or current password is incorrect" + ) + }) + @PostMapping("/me/change-password") + @SecurityRequirement(name = "bearerAuth") + public ResponseEntity changePassword( + Authentication authentication, + @Valid @RequestBody PasswordChangeDto passwordChangeDto) { + String email = authentication.getName(); + userService.changePassword( + email, + passwordChangeDto.currentPassword(), + passwordChangeDto.newPassword(), + passwordChangeDto.confirmNewPassword() + ); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gnx/telemedicine/controller/VitalSignsController.java b/src/main/java/com/gnx/telemedicine/controller/VitalSignsController.java new file mode 100644 index 0000000..0ffbad1 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/VitalSignsController.java @@ -0,0 +1,130 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.annotation.LogPhiAccess; +import com.gnx.telemedicine.dto.medical.VitalSignsRequestDto; +import com.gnx.telemedicine.dto.medical.VitalSignsResponseDto; +import com.gnx.telemedicine.service.VitalSignsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v3/vital-signs") +@RequiredArgsConstructor +@Tag(name = "Vital Signs", description = "Endpoints for managing patient vital signs") +@SecurityRequirement(name = "bearerAuth") +public class VitalSignsController { + + private final VitalSignsService vitalSignsService; + + @Operation( + summary = "Get vital signs by patient ID", + description = "Retrieve all vital signs records for a specific patient.", + parameters = @Parameter(name = "patientId", description = "Patient ID", required = true) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "List of vital signs retrieved successfully", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = VitalSignsResponseDto.class)) + ) + ) + }) + @LogPhiAccess( + resourceType = "VITAL_SIGNS", + patientIdParam = "patientId", + accessType = "Treatment", + accessedFields = {"temperature", "bloodPressureSystolic", "bloodPressureDiastolic", "heartRate", "oxygenSaturation"} + ) + @GetMapping("/patient/{patientId}") + public ResponseEntity> getVitalSignsByPatientId( + @PathVariable UUID patientId) { + return ResponseEntity.ok(vitalSignsService.getVitalSignsByPatientId(patientId)); + } + + @Operation( + summary = "Get latest vital signs by patient ID", + description = "Retrieve the most recent vital signs record for a patient." + ) + @GetMapping("/patient/{patientId}/latest") + public ResponseEntity getLatestVitalSignsByPatientId( + @PathVariable UUID patientId) { + VitalSignsResponseDto vitalSigns = vitalSignsService.getLatestVitalSignsByPatientId(patientId); + if (vitalSigns == null) { + return ResponseEntity.notFound().build(); // Return 404 when no vital signs found + } + return ResponseEntity.ok(vitalSigns); + } + + @Operation( + summary = "Get vital signs by appointment ID", + description = "Retrieve vital signs recorded during a specific appointment." + ) + @GetMapping("/appointment/{appointmentId}") + public ResponseEntity> getVitalSignsByAppointmentId( + @PathVariable UUID appointmentId) { + return ResponseEntity.ok(vitalSignsService.getVitalSignsByAppointmentId(appointmentId)); + } + + @Operation( + summary = "Create vital signs record", + description = "Record new vital signs for a patient. BMI is automatically calculated if weight and height are provided.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Vital signs data", + required = true, + content = @Content(schema = @Schema(implementation = VitalSignsRequestDto.class)) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Vital signs recorded successfully", + content = @Content(schema = @Schema(implementation = VitalSignsResponseDto.class)) + ), + @ApiResponse(responseCode = "400", description = "Validation error") + }) + @PostMapping + public ResponseEntity createVitalSigns( + Authentication authentication, + @RequestBody @Valid VitalSignsRequestDto requestDto) { + String userEmail = authentication.getName(); + VitalSignsResponseDto response = vitalSignsService.createVitalSigns(userEmail, requestDto); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get vital signs by ID", + description = "Retrieve a specific vital signs record by its ID." + ) + @GetMapping("/{id}") + public ResponseEntity getVitalSignsById(@PathVariable UUID id) { + return ResponseEntity.ok(vitalSignsService.getVitalSignsById(id)); + } + + @Operation( + summary = "Delete vital signs record", + description = "Delete a vital signs record by ID." + ) + @DeleteMapping("/{id}") + public ResponseEntity deleteVitalSigns(@PathVariable UUID id) { + vitalSignsService.deleteVitalSigns(id); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/controller/WebSocketMessageController.java b/src/main/java/com/gnx/telemedicine/controller/WebSocketMessageController.java new file mode 100644 index 0000000..6214ffb --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/controller/WebSocketMessageController.java @@ -0,0 +1,411 @@ +package com.gnx.telemedicine.controller; + +import com.gnx.telemedicine.dto.message.MessageRequestDto; +import com.gnx.telemedicine.dto.message.MessageResponseDto; +import com.gnx.telemedicine.dto.call.CallRequestDto; +import com.gnx.telemedicine.dto.call.CallResponseDto; +import com.gnx.telemedicine.dto.call.WebRtcSignalDto; +import com.gnx.telemedicine.exception.ResourceNotFoundException; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.service.MessageService; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; + +import java.security.Principal; +import java.util.UUID; + +@Controller +@RequiredArgsConstructor +@Slf4j +public class WebSocketMessageController { + + private final MessageService messageService; + private final SimpMessagingTemplate messagingTemplate; + private final UserRepository userRepository; + + @MessageMapping("/chat.send") + public void sendMessage(@Payload MessageRequestDto messageRequest, Principal principal) { + try { + String userEmail = principal.getName(); + MessageResponseDto messageResponse = messageService.sendMessage(userEmail, messageRequest); + + // Get receiver email to send message + UserModel receiver = userRepository.findById(messageRequest.getReceiverId()) + .orElseThrow(() -> new ResourceNotFoundException("Receiver", messageRequest.getReceiverId())); + + // Send to the receiver using their email + messagingTemplate.convertAndSendToUser( + receiver.getEmail(), + "/queue/messages", + messageResponse + ); + + // Send confirmation back to sender + messagingTemplate.convertAndSendToUser( + userEmail, + "/queue/messages", + messageResponse + ); + + // Broadcast online status updates + messagingTemplate.convertAndSend("/topic/online-status", messageResponse); + } catch (Exception e) { + log.error("Error sending message via WebSocket", e); + } + } + + @MessageMapping("/chat.typing") + public void handleTyping(@Payload TypingNotification notification, Principal principal) { + try { + String userEmail = principal.getName(); + + // Get sender info + UserModel sender = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("Sender", userEmail)); + + // Get receiver email + UserModel receiver = userRepository.findById(UUID.fromString(notification.getReceiverId())) + .orElseThrow(() -> new ResourceNotFoundException("Receiver", notification.getReceiverId())); + + // Create typing notification with sender info + TypingNotification response = new TypingNotification(); + response.setSenderId(sender.getId().toString()); + response.setSenderName(sender.getFirstName() + " " + sender.getLastName()); + response.setReceiverId(receiver.getId().toString()); + response.setTyping(notification.isTyping()); + + log.info("Typing notification: sender={} ({}), receiver={} ({}), isTyping={}", + sender.getId(), sender.getEmail(), receiver.getId(), receiver.getEmail(), notification.isTyping()); + + // Send typing status to receiver using their email + messagingTemplate.convertAndSendToUser( + receiver.getEmail(), + "/queue/typing", + response + ); + + log.debug("Typing notification sent from {} to {}: isTyping={}", + userEmail, receiver.getEmail(), notification.isTyping()); + } catch (Exception e) { + log.error("Error handling typing notification", e); + } + } + + @MessageMapping("/call.initiate") + public void initiateCall(@Payload CallRequestDto callRequest, Principal principal) { + try { + log.info("=== CALL INITIATE HANDLER CALLED ==="); + log.info("Principal: {}", principal != null ? principal.getName() : "NULL"); + log.info("CallRequest: {}", callRequest); + if (callRequest != null) { + log.info("CallRequest.receiverId: {}, CallRequest.callType: {}", callRequest.getReceiverId(), callRequest.getCallType()); + } else { + log.error("ERROR: CallRequest is NULL - deserialization may have failed!"); + } + + if (callRequest == null) { + log.error("CallRequest is NULL!"); + return; + } + + if (principal == null) { + log.error("Principal is NULL - authentication failed!"); + return; + } + + if (callRequest.getReceiverId() == null) { + log.error("ReceiverId is null in call.initiate"); + return; + } + + String userEmail = principal.getName(); + log.debug("Processing call initiation from: {}", userEmail); + + UserModel sender = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("Sender", userEmail)); + + log.debug("Sender found: {} ({})", sender.getId(), sender.getEmail()); + + UserModel receiver = userRepository.findById(callRequest.getReceiverId()) + .orElseThrow(() -> new ResourceNotFoundException("Receiver", callRequest.getReceiverId())); + + log.debug("Receiver found: {} ({})", receiver.getId(), receiver.getEmail()); + + // Validate that sender is either doctor or patient, and receiver is the opposite role + if (!((sender.getRole().name().equals("DOCTOR") && receiver.getRole().name().equals("PATIENT")) || + (sender.getRole().name().equals("PATIENT") && receiver.getRole().name().equals("DOCTOR")))) { + log.warn("Invalid call attempt: {} to {}", sender.getRole(), receiver.getRole()); + return; + } + + java.util.UUID callId = java.util.UUID.randomUUID(); + + CallResponseDto callResponse = CallResponseDto.builder() + .callId(callId) + .senderId(sender.getId()) + .senderName(sender.getFirstName() + " " + sender.getLastName()) + .senderAvatarUrl(sender.getAvatarUrl()) + .receiverId(receiver.getId()) + .receiverName(receiver.getFirstName() + " " + receiver.getLastName()) + .receiverAvatarUrl(receiver.getAvatarUrl()) + .callType(callRequest.getCallType()) + .callStatus("ringing") + .build(); + + log.debug("About to send call notification to receiver: {}", receiver.getEmail()); + // Send call notification to receiver + messagingTemplate.convertAndSendToUser( + receiver.getEmail(), + "/queue/call", + callResponse + ); + log.info("Call notification sent to receiver: {} at email: {}, destination: /user/{}/queue/call", + receiver.getId(), receiver.getEmail(), receiver.getEmail()); + + log.debug("About to send call confirmation to sender: {}", userEmail); + // Send confirmation to sender + messagingTemplate.convertAndSendToUser( + userEmail, + "/queue/call", + callResponse + ); + log.info("Call confirmation sent to sender: {} at email: {}, destination: /user/{}/queue/call", + sender.getId(), userEmail, userEmail); + + log.info("Call initiated from {} ({}) to {} ({})", userEmail, sender.getId(), receiver.getEmail(), receiver.getId()); + } catch (Exception e) { + log.error("Error initiating call", e); + } + } + + @MessageExceptionHandler + public void handleException(Exception e) { + log.error("=== WEBSOCKET EXCEPTION HANDLER ===", e); + log.error("Exception type: {}", e.getClass().getName()); + log.error("Exception message: {}", e.getMessage()); + } + + @MessageMapping("/call.answer") + public void answerCall(@Payload CallResponseDto callResponse, Principal principal) { + try { + String userEmail = principal.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + UUID otherUserId = user.getId().equals(callResponse.getSenderId()) + ? callResponse.getReceiverId() + : callResponse.getSenderId(); + UserModel otherUser = userRepository.findById(otherUserId) + .orElseThrow(() -> new ResourceNotFoundException("User", otherUserId)); + + callResponse.setCallStatus("accepted"); + + // Notify both parties + messagingTemplate.convertAndSendToUser( + otherUser.getEmail(), + "/queue/call", + callResponse + ); + + messagingTemplate.convertAndSendToUser( + userEmail, + "/queue/call", + callResponse + ); + + log.info("Call answered by {}", userEmail); + } catch (Exception e) { + log.error("Error answering call", e); + } + } + + @MessageMapping("/call.reject") + public void rejectCall(@Payload CallResponseDto callResponse, Principal principal) { + try { + String userEmail = principal.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + UUID otherUserId = user.getId().equals(callResponse.getSenderId()) + ? callResponse.getReceiverId() + : callResponse.getSenderId(); + UserModel otherUser = userRepository.findById(otherUserId) + .orElseThrow(() -> new ResourceNotFoundException("User", otherUserId)); + + callResponse.setCallStatus("rejected"); + + // Notify caller + messagingTemplate.convertAndSendToUser( + otherUser.getEmail(), + "/queue/call", + callResponse + ); + + messagingTemplate.convertAndSendToUser( + userEmail, + "/queue/call", + callResponse + ); + + log.info("Call rejected by {}", userEmail); + } catch (Exception e) { + log.error("Error rejecting call", e); + } + } + + @MessageMapping("/call.end") + public void endCall(@Payload CallResponseDto callResponse, Principal principal) { + try { + String userEmail = principal.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + UUID otherUserId = user.getId().equals(callResponse.getSenderId()) + ? callResponse.getReceiverId() + : callResponse.getSenderId(); + UserModel otherUser = userRepository.findById(otherUserId) + .orElseThrow(() -> new ResourceNotFoundException("User", otherUserId)); + + callResponse.setCallStatus("ended"); + + // Notify both parties + messagingTemplate.convertAndSendToUser( + otherUser.getEmail(), + "/queue/call", + callResponse + ); + + messagingTemplate.convertAndSendToUser( + userEmail, + "/queue/call", + callResponse + ); + + log.info("Call ended by {}", userEmail); + } catch (Exception e) { + log.error("Error ending call", e); + } + } + + @MessageMapping("/call.cancel") + public void cancelCall(@Payload CallResponseDto callResponse, Principal principal) { + try { + String userEmail = principal.getName(); + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("User", userEmail)); + + UUID otherUserId = user.getId().equals(callResponse.getSenderId()) + ? callResponse.getReceiverId() + : callResponse.getSenderId(); + UserModel otherUser = userRepository.findById(otherUserId) + .orElseThrow(() -> new ResourceNotFoundException("User", otherUserId)); + + callResponse.setCallStatus("cancelled"); + + // Notify receiver + messagingTemplate.convertAndSendToUser( + otherUser.getEmail(), + "/queue/call", + callResponse + ); + + messagingTemplate.convertAndSendToUser( + userEmail, + "/queue/call", + callResponse + ); + + log.info("Call cancelled by {}", userEmail); + } catch (Exception e) { + log.error("Error cancelling call", e); + } + } + + @MessageMapping("/call.signal") + public void handleWebRtcSignal(@Payload WebRtcSignalDto signal, Principal principal) { + try { + String userEmail = principal.getName(); + UserModel sender = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ResourceNotFoundException("Sender", userEmail)); + + UserModel receiver = userRepository.findById(signal.getReceiverId()) + .orElseThrow(() -> new ResourceNotFoundException("Receiver", signal.getReceiverId())); + + // Forward WebRTC signal to the other peer + messagingTemplate.convertAndSendToUser( + receiver.getEmail(), + "/queue/webrtc-signal", + signal + ); + + log.debug("WebRTC signal forwarded from {} to {}", userEmail, receiver.getEmail()); + } catch (Exception e) { + log.error("Error handling WebRTC signal", e); + } + } +} + +class TypingNotification { + @JsonProperty("senderId") + private String senderId; + + @JsonProperty("senderName") + private String senderName; + + @JsonProperty("receiverId") + private String receiverId; + + @JsonProperty("isTyping") + private boolean typing; + + @JsonProperty("senderId") + public String getSenderId() { + return senderId; + } + + @JsonProperty("senderId") + public void setSenderId(String senderId) { + this.senderId = senderId; + } + + @JsonProperty("receiverId") + public String getReceiverId() { + return receiverId; + } + + @JsonProperty("receiverId") + public void setReceiverId(String receiverId) { + this.receiverId = receiverId; + } + + @JsonProperty("isTyping") + public boolean isTyping() { + return typing; + } + + @JsonProperty("isTyping") + public void setTyping(boolean typing) { + this.typing = typing; + } + + @JsonProperty("senderName") + public String getSenderName() { + return senderName; + } + + @JsonProperty("senderName") + public void setSenderName(String senderName) { + this.senderName = senderName; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/admin/AdminStatsResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/admin/AdminStatsResponseDto.java new file mode 100644 index 0000000..2dfba59 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/admin/AdminStatsResponseDto.java @@ -0,0 +1,10 @@ +package com.gnx.telemedicine.dto.admin; + +public record AdminStatsResponseDto( + Long totalUsers, + Long totalDoctors, + Long totalPatients, + Long totalAppointments, + Long activeUsers +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/admin/MetricsResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/admin/MetricsResponseDto.java new file mode 100644 index 0000000..dfeae2e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/admin/MetricsResponseDto.java @@ -0,0 +1,49 @@ +package com.gnx.telemedicine.dto.admin; + +import java.util.Map; + +public record MetricsResponseDto( + // Authentication metrics + Double loginAttempts, + Double loginSuccess, + Double loginFailure, + Double passwordResetRequests, + Double passwordResetSuccess, + Double twoFactorAuthEnabled, + Double twoFactorAuthVerified, + + // Appointment metrics + Double appointmentsCreated, + Double appointmentsCancelled, + Double appointmentsCompleted, + Integer activeAppointments, + Long totalAppointments, + + // Prescription metrics + Double prescriptionsCreated, + Integer activePrescriptions, + Long totalPrescriptions, + + // Message metrics + Double messagesSent, + + // API metrics + Double apiRequests, + Double apiErrors, + Map apiResponseTime, + + // Database metrics + Map databaseQueryTime, + + // PHI access metrics + Double phiAccessCount, + + // Breach notification metrics + Double breachNotifications, + + // User metrics + Integer activeUsers, + Long totalUsers +) { +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/admin/PaginatedResponse.java b/src/main/java/com/gnx/telemedicine/dto/admin/PaginatedResponse.java new file mode 100644 index 0000000..71cefc0 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/admin/PaginatedResponse.java @@ -0,0 +1,36 @@ +package com.gnx.telemedicine.dto.admin; + +import java.util.List; + +/** + * Generic paginated response wrapper. + * + * @param The type of items in the list + */ +public record PaginatedResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean first, + boolean last +) { + public static PaginatedResponse of( + List content, + int page, + int size, + long totalElements) { + int totalPages = (int) Math.ceil((double) totalElements / size); + return new PaginatedResponse<>( + content, + page, + size, + totalElements, + totalPages, + page == 0, + page >= totalPages - 1 + ); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/admin/UserManagementDto.java b/src/main/java/com/gnx/telemedicine/dto/admin/UserManagementDto.java new file mode 100644 index 0000000..c694220 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/admin/UserManagementDto.java @@ -0,0 +1,21 @@ +package com.gnx.telemedicine.dto.admin; + +import java.time.LocalDate; +import java.util.UUID; + +public record UserManagementDto( + UUID id, + String email, + String firstName, + String lastName, + String phoneNumber, + String role, + Boolean isActive, + LocalDate createdAt, + String avatarUrl, + Boolean isOnline, + String status, + String medicalLicenseNumber, + Boolean isVerified +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/appointment/AppointmentRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/appointment/AppointmentRequestDto.java new file mode 100644 index 0000000..32750bf --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/appointment/AppointmentRequestDto.java @@ -0,0 +1,32 @@ +package com.gnx.telemedicine.dto.appointment; + +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.UUID; + + +public record AppointmentRequestDto( + + @NotNull(message = "Patient ID cannot be null.") + UUID patientId, + + @NotNull(message = "Doctor ID cannot be null.") + UUID doctorId, + + @NotNull(message = "Date can't be null.") + @FutureOrPresent(message = "Cannot be scheduled in past.") + LocalDate scheduledDate, + + @NotNull(message = "Time cannot be null") + LocalTime scheduledTime, + + @NotNull(message = "Duration cannot be null") + @Max(message = "Duration cannot be longer than 2 hours.", value = 120) + @Positive(message = "Duration cannot be zero or lower.") + Integer durationMinutes +) {} \ No newline at end of file diff --git a/src/main/java/com/gnx/telemedicine/dto/appointment/AppointmentResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/appointment/AppointmentResponseDto.java new file mode 100644 index 0000000..c58b746 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/appointment/AppointmentResponseDto.java @@ -0,0 +1,22 @@ +package com.gnx.telemedicine.dto.appointment; + +import com.gnx.telemedicine.model.enums.AppointmentStatus; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.UUID; + +public record AppointmentResponseDto( + UUID id, + UUID patientId, + String patientFirstName, + String patientLastName, + UUID doctorId, + String doctorFirstName, + String doctorLastName, + LocalDate scheduledDate, + LocalTime scheduledTime, + Integer durationInMinutes, + AppointmentStatus status +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/audit/BreachNotificationRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/audit/BreachNotificationRequestDto.java new file mode 100644 index 0000000..6046cdb --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/audit/BreachNotificationRequestDto.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.dto.audit; + +import com.gnx.telemedicine.model.enums.BreachType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record BreachNotificationRequestDto( + @NotNull(message = "Incident date cannot be null") + LocalDate incidentDate, + + @NotNull(message = "Discovery date cannot be null") + LocalDate discoveryDate, + + @NotNull(message = "Breach type cannot be null") + BreachType breachType, + + Integer affectedPatientsCount, + + @NotBlank(message = "Description cannot be blank") + String description, + + String mitigationSteps +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/audit/BreachNotificationResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/audit/BreachNotificationResponseDto.java new file mode 100644 index 0000000..980b8e2 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/audit/BreachNotificationResponseDto.java @@ -0,0 +1,25 @@ +package com.gnx.telemedicine.dto.audit; + +import com.gnx.telemedicine.model.enums.BreachStatus; +import com.gnx.telemedicine.model.enums.BreachType; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +public record BreachNotificationResponseDto( + UUID id, + LocalDate incidentDate, + LocalDate discoveryDate, + BreachType breachType, + Integer affectedPatientsCount, + String description, + String mitigationSteps, + Instant notifiedAt, + BreachStatus status, + UUID createdById, + String createdByName, + Instant createdAt, + Instant updatedAt +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/audit/HipaaAuditLogResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/audit/HipaaAuditLogResponseDto.java new file mode 100644 index 0000000..1dd9d81 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/audit/HipaaAuditLogResponseDto.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.dto.audit; + +import com.gnx.telemedicine.model.enums.ActionType; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +public record HipaaAuditLogResponseDto( + UUID id, + UUID userId, + String userName, + String userEmail, + ActionType actionType, + String resourceType, + UUID resourceId, + UUID patientId, + String patientName, + String ipAddress, + String userAgent, + Instant timestamp, + Map details, + Boolean success, + String errorMessage +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/audit/PhiAccessLogResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/audit/PhiAccessLogResponseDto.java new file mode 100644 index 0000000..4475f28 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/audit/PhiAccessLogResponseDto.java @@ -0,0 +1,21 @@ +package com.gnx.telemedicine.dto.audit; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record PhiAccessLogResponseDto( + UUID id, + UUID userId, + String userName, + String userEmail, + UUID patientId, + String patientName, + String accessType, + List accessedFields, + String purpose, + String ipAddress, + String userAgent, + Instant timestamp +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/ForgotPasswordRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/ForgotPasswordRequestDto.java new file mode 100644 index 0000000..13bc99b --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/ForgotPasswordRequestDto.java @@ -0,0 +1,11 @@ +package com.gnx.telemedicine.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record ForgotPasswordRequestDto( + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + String email +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/JwtResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/JwtResponseDto.java new file mode 100644 index 0000000..d790c17 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/JwtResponseDto.java @@ -0,0 +1,16 @@ +package com.gnx.telemedicine.dto.auth; + +/** + * DTO for JWT response including access token and refresh token. + */ +public record JwtResponseDto( + String token, + String refreshToken +) { + /** + * Constructor for backward compatibility (access token only). + */ + public JwtResponseDto(String token) { + this(token, null); + } +} diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/PasswordChangeDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/PasswordChangeDto.java new file mode 100644 index 0000000..0789439 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/PasswordChangeDto.java @@ -0,0 +1,19 @@ +package com.gnx.telemedicine.dto.auth; + +import jakarta.validation.constraints.NotBlank; + +/** + * DTO for password change request. + */ +public record PasswordChangeDto( + @NotBlank(message = "Current password is required") + String currentPassword, + + @NotBlank(message = "New password is required") + String newPassword, + + @NotBlank(message = "Confirm new password is required") + String confirmNewPassword +) { +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/PasswordResetResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/PasswordResetResponseDto.java new file mode 100644 index 0000000..fcf9c1b --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/PasswordResetResponseDto.java @@ -0,0 +1,6 @@ +package com.gnx.telemedicine.dto.auth; + +public record PasswordResetResponseDto( + String message +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/RefreshTokenRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/RefreshTokenRequestDto.java new file mode 100644 index 0000000..75e1e1f --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/RefreshTokenRequestDto.java @@ -0,0 +1,13 @@ +package com.gnx.telemedicine.dto.auth; + +import jakarta.validation.constraints.NotBlank; + +/** + * DTO for refresh token request. + */ +public record RefreshTokenRequestDto( + @NotBlank(message = "Refresh token is required") + String refreshToken +) { +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/RefreshTokenResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/RefreshTokenResponseDto.java new file mode 100644 index 0000000..b90fe61 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/RefreshTokenResponseDto.java @@ -0,0 +1,11 @@ +package com.gnx.telemedicine.dto.auth; + +/** + * DTO for refresh token response. + */ +public record RefreshTokenResponseDto( + String accessToken, + String refreshToken +) { +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/ResetPasswordRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/ResetPasswordRequestDto.java new file mode 100644 index 0000000..cd5ab3d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/ResetPasswordRequestDto.java @@ -0,0 +1,17 @@ +package com.gnx.telemedicine.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ResetPasswordRequestDto( + @NotBlank(message = "Token is required") + String token, + + @NotBlank(message = "New password is required") + @Size(min = 8, message = "Password must be at least 8 characters long") + String newPassword, + + @NotBlank(message = "Confirm password is required") + String confirmPassword +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorLoginRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorLoginRequestDto.java new file mode 100644 index 0000000..021c241 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorLoginRequestDto.java @@ -0,0 +1,18 @@ +package com.gnx.telemedicine.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record TwoFactorLoginRequestDto( + @NotBlank(message = "Email cannot be blank") + @Email(message = "Email must be valid") + String email, + + @NotBlank(message = "Password cannot be blank") + String password, + + String code, // TOTP code or backup code + + String deviceFingerprint // For trusted device +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorSetupResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorSetupResponseDto.java new file mode 100644 index 0000000..663a3b6 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorSetupResponseDto.java @@ -0,0 +1,13 @@ +package com.gnx.telemedicine.dto.auth; + +import java.util.List; +import java.util.UUID; + +public record TwoFactorSetupResponseDto( + UUID userId, + String secretKey, + String qrCodeUrl, + List backupCodes, + boolean enabled +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorVerifyRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorVerifyRequestDto.java new file mode 100644 index 0000000..46f4887 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/TwoFactorVerifyRequestDto.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.dto.auth; + +import jakarta.validation.constraints.NotBlank; + +public record TwoFactorVerifyRequestDto( + @NotBlank(message = "Code cannot be blank") + String code +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/auth/UserLoginDto.java b/src/main/java/com/gnx/telemedicine/dto/auth/UserLoginDto.java new file mode 100644 index 0000000..7a2d211 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/auth/UserLoginDto.java @@ -0,0 +1,17 @@ +package com.gnx.telemedicine.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record UserLoginDto( + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + String email, + + @NotBlank(message = "Password is required") + String password, + + String code, // Optional: 2FA code or backup code + + String deviceFingerprint // Optional: For trusted device +) {} diff --git a/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityRequestDto.java new file mode 100644 index 0000000..d274d42 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityRequestDto.java @@ -0,0 +1,22 @@ +package com.gnx.telemedicine.dto.availability; + +import com.gnx.telemedicine.model.enums.DayOfWeek; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalTime; +import java.util.UUID; + +public record AvailabilityRequestDto( + @NotNull(message = "Doctor ID is required") + UUID doctorId, + + @NotNull(message = "Day of week is required") + DayOfWeek dayOfWeek, + + @NotNull(message = "Start time is required") + LocalTime startTime, + + @NotNull(message = "End time is required") + LocalTime endTime +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityResponseDto.java new file mode 100644 index 0000000..fce3c45 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityResponseDto.java @@ -0,0 +1,18 @@ +package com.gnx.telemedicine.dto.availability; + +import com.gnx.telemedicine.model.enums.DayOfWeek; + +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record AvailabilityResponseDto( + UUID id, + UUID doctorId, + DayOfWeek dayOfWeek, + LocalTime startTime, + LocalTime endTime, + Boolean isAvailable, + OffsetDateTime createdAt +){ +} diff --git a/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityUpdateDto.java b/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityUpdateDto.java new file mode 100644 index 0000000..cb95fa1 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/availability/AvailabilityUpdateDto.java @@ -0,0 +1,13 @@ +package com.gnx.telemedicine.dto.availability; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalTime; + +public record AvailabilityUpdateDto( + LocalTime startTime, + LocalTime endTime, + @NotNull(message = "Availability status is required") + Boolean isAvailable +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/availability/BulkAvailabilityRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/availability/BulkAvailabilityRequestDto.java new file mode 100644 index 0000000..c2ccf7e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/availability/BulkAvailabilityRequestDto.java @@ -0,0 +1,29 @@ +package com.gnx.telemedicine.dto.availability; + +import com.gnx.telemedicine.model.enums.DayOfWeek; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +public record BulkAvailabilityRequestDto( + @NotNull(message = "Doctor ID is required") + UUID doctorId, + + @NotEmpty(message = "At least one availability slot is required") + List slots +) { + public record AvailabilitySlot( + @NotNull(message = "Day of week is required") + DayOfWeek dayOfWeek, + + @NotNull(message = "Start time is required") + LocalTime startTime, + + @NotNull(message = "End time is required") + LocalTime endTime + ) { + } +} diff --git a/src/main/java/com/gnx/telemedicine/dto/call/CallRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/call/CallRequestDto.java new file mode 100644 index 0000000..f76021d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/call/CallRequestDto.java @@ -0,0 +1,18 @@ +package com.gnx.telemedicine.dto.call; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CallRequestDto { + private UUID receiverId; + private String callType; // "video" or "audio" +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/call/CallResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/call/CallResponseDto.java new file mode 100644 index 0000000..fc27f2d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/call/CallResponseDto.java @@ -0,0 +1,25 @@ +package com.gnx.telemedicine.dto.call; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CallResponseDto { + private UUID callId; + private UUID senderId; + private String senderName; + private String senderAvatarUrl; + private UUID receiverId; + private String receiverName; + private String receiverAvatarUrl; + private String callType; // "video" or "audio" + private String callStatus; // "ringing", "accepted", "rejected", "ended", "cancelled" +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/call/WebRtcSignalDto.java b/src/main/java/com/gnx/telemedicine/dto/call/WebRtcSignalDto.java new file mode 100644 index 0000000..22a375d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/call/WebRtcSignalDto.java @@ -0,0 +1,21 @@ +package com.gnx.telemedicine.dto.call; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WebRtcSignalDto { + private UUID callId; + private UUID senderId; + private UUID receiverId; + private String signalType; // "offer", "answer", "ice-candidate" + private Object signalData; // RTCSessionDescription or RTCIceCandidate data (JSON) +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/error/ErrorResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/error/ErrorResponseDto.java new file mode 100644 index 0000000..5317db8 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/error/ErrorResponseDto.java @@ -0,0 +1,30 @@ +package com.gnx.telemedicine.dto.error; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * Standardized error response DTO for consistent error handling across the application. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponseDto { + private String error; + private String message; + private Integer status; + private LocalDateTime timestamp; + private String path; + private String correlationId; + private Map errors; // For validation errors + private Map details; // For additional error details +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/medical/LabResultRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/medical/LabResultRequestDto.java new file mode 100644 index 0000000..fe73881 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/medical/LabResultRequestDto.java @@ -0,0 +1,27 @@ +package com.gnx.telemedicine.dto.medical; + +import com.gnx.telemedicine.model.enums.LabResultStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; +import java.util.UUID; + +public record LabResultRequestDto( + UUID medicalRecordId, // Optional: lab results can exist independently of medical records + + @NotNull(message = "Patient ID cannot be null") + UUID patientId, + + @NotBlank(message = "Test name cannot be blank") + String testName, + + String testCode, + String resultValue, + String unit, + String referenceRange, + LabResultStatus status, + Instant performedAt, + String resultFileUrl +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/medical/LabResultResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/medical/LabResultResponseDto.java new file mode 100644 index 0000000..2bca994 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/medical/LabResultResponseDto.java @@ -0,0 +1,25 @@ +package com.gnx.telemedicine.dto.medical; + +import com.gnx.telemedicine.model.enums.LabResultStatus; + +import java.time.Instant; +import java.util.UUID; + +public record LabResultResponseDto( + UUID id, + UUID medicalRecordId, + UUID patientId, + String patientName, + String testName, + String testCode, + String resultValue, + String unit, + String referenceRange, + LabResultStatus status, + Instant performedAt, + String resultFileUrl, + UUID orderedById, + String orderedByName, + Instant createdAt +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/medical/MedicalRecordRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/medical/MedicalRecordRequestDto.java new file mode 100644 index 0000000..b0c9807 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/medical/MedicalRecordRequestDto.java @@ -0,0 +1,28 @@ +package com.gnx.telemedicine.dto.medical; + +import com.gnx.telemedicine.model.enums.RecordType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +public record MedicalRecordRequestDto( + @NotNull(message = "Patient ID cannot be null") + UUID patientId, + + @NotNull(message = "Doctor ID cannot be null") + UUID doctorId, + + UUID appointmentId, // Optional + + @NotNull(message = "Record type cannot be null") + RecordType recordType, + + @NotBlank(message = "Title cannot be blank") + String title, + + String content, + + String diagnosisCode // ICD-10 codes +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/medical/MedicalRecordResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/medical/MedicalRecordResponseDto.java new file mode 100644 index 0000000..7f50565 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/medical/MedicalRecordResponseDto.java @@ -0,0 +1,24 @@ +package com.gnx.telemedicine.dto.medical; + +import com.gnx.telemedicine.model.enums.RecordType; + +import java.time.Instant; +import java.util.UUID; + +public record MedicalRecordResponseDto( + UUID id, + UUID patientId, + String patientName, + UUID doctorId, + String doctorName, + UUID appointmentId, + RecordType recordType, + String title, + String content, + String diagnosisCode, + Instant createdAt, + Instant updatedAt, + UUID createdById, + String createdByName +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/medical/VitalSignsRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/medical/VitalSignsRequestDto.java new file mode 100644 index 0000000..2ddc629 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/medical/VitalSignsRequestDto.java @@ -0,0 +1,24 @@ +package com.gnx.telemedicine.dto.medical; + +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.util.UUID; + +public record VitalSignsRequestDto( + @NotNull(message = "Patient ID cannot be null") + UUID patientId, + + UUID appointmentId, // Optional + + BigDecimal temperature, // Celsius + Integer bloodPressureSystolic, + Integer bloodPressureDiastolic, + Integer heartRate, // BPM + Integer respiratoryRate, // BPM + BigDecimal oxygenSaturation, // Percentage + BigDecimal weightKg, + BigDecimal heightCm, + String notes +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/medical/VitalSignsResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/medical/VitalSignsResponseDto.java new file mode 100644 index 0000000..1be00d7 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/medical/VitalSignsResponseDto.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.dto.medical; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record VitalSignsResponseDto( + UUID id, + UUID patientId, + String patientName, + UUID appointmentId, + Instant recordedAt, + UUID recordedById, + String recordedByName, + BigDecimal temperature, + Integer bloodPressureSystolic, + Integer bloodPressureDiastolic, + Integer heartRate, + Integer respiratoryRate, + BigDecimal oxygenSaturation, + BigDecimal weightKg, + BigDecimal heightCm, + BigDecimal bmi, + String notes +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/message/ChatUserDto.java b/src/main/java/com/gnx/telemedicine/dto/message/ChatUserDto.java new file mode 100644 index 0000000..c04c95e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/message/ChatUserDto.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.dto.message; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatUserDto { + private UUID userId; + private String firstName; + private String lastName; + private String specialization; // For doctors only + private Boolean isVerified; // For doctors only + private Boolean isOnline; + private String status; // ONLINE, OFFLINE, BUSY + private String avatarUrl; +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/message/ConversationDto.java b/src/main/java/com/gnx/telemedicine/dto/message/ConversationDto.java new file mode 100644 index 0000000..d040b40 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/message/ConversationDto.java @@ -0,0 +1,30 @@ +package com.gnx.telemedicine.dto.message; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConversationDto { + private UUID otherUserId; + private String otherUserName; + private String otherUserRole; + private Boolean isOnline; + private String otherUserStatus; // ONLINE, OFFLINE, BUSY + private LocalDateTime lastSeen; + private Long unreadCount; + private MessageResponseDto lastMessage; + private List messages; + private String otherUserAvatarUrl; +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/message/MessageRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/message/MessageRequestDto.java new file mode 100644 index 0000000..8415a68 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/message/MessageRequestDto.java @@ -0,0 +1,19 @@ +package com.gnx.telemedicine.dto.message; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class MessageRequestDto { + @NotNull(message = "Receiver ID is required") + private UUID receiverId; + + @NotBlank(message = "Message content cannot be empty") + private String content; +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/message/MessageResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/message/MessageResponseDto.java new file mode 100644 index 0000000..710ec9c --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/message/MessageResponseDto.java @@ -0,0 +1,27 @@ +package com.gnx.telemedicine.dto.message; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MessageResponseDto { + private UUID id; + private UUID senderId; + private String senderName; + private UUID receiverId; + private String receiverName; + private String content; + private Boolean isRead; + private LocalDateTime createdAt; +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/ClinicalAlertRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/ClinicalAlertRequestDto.java new file mode 100644 index 0000000..7e56d74 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/ClinicalAlertRequestDto.java @@ -0,0 +1,17 @@ +package com.gnx.telemedicine.dto.patient_safety; + +import com.gnx.telemedicine.model.enums.AlertSeverity; +import com.gnx.telemedicine.model.enums.ClinicalAlertType; + +import java.util.UUID; + +public record ClinicalAlertRequestDto( + UUID patientId, + ClinicalAlertType alertType, + AlertSeverity severity, + String title, + String description, + String medicationName, + UUID relatedPrescriptionId +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/ClinicalAlertResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/ClinicalAlertResponseDto.java new file mode 100644 index 0000000..0fcf570 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/ClinicalAlertResponseDto.java @@ -0,0 +1,30 @@ +package com.gnx.telemedicine.dto.patient_safety; + +import com.gnx.telemedicine.model.enums.AlertSeverity; +import com.gnx.telemedicine.model.enums.ClinicalAlertType; + +import java.time.Instant; +import java.util.UUID; + +public record ClinicalAlertResponseDto( + UUID id, + UUID patientId, + String patientName, + UUID doctorId, + String doctorName, + ClinicalAlertType alertType, + AlertSeverity severity, + String title, + String description, + String medicationName, + UUID relatedPrescriptionId, + Boolean acknowledged, + Instant acknowledgedAt, + UUID acknowledgedById, + String acknowledgedByName, + Instant createdAt, + Instant resolvedAt, + UUID resolvedById, + String resolvedByName +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/CriticalResultAcknowledgmentRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/CriticalResultAcknowledgmentRequestDto.java new file mode 100644 index 0000000..1a93a60 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/CriticalResultAcknowledgmentRequestDto.java @@ -0,0 +1,7 @@ +package com.gnx.telemedicine.dto.patient_safety; + +public record CriticalResultAcknowledgmentRequestDto( + String acknowledgmentMethod, + String followUpStatus +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/CriticalResultResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/CriticalResultResponseDto.java new file mode 100644 index 0000000..7c6f50d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/CriticalResultResponseDto.java @@ -0,0 +1,31 @@ +package com.gnx.telemedicine.dto.patient_safety; + +import com.gnx.telemedicine.model.enums.CriticalityLevel; + +import java.time.Instant; +import java.util.UUID; + +public record CriticalResultResponseDto( + UUID id, + UUID labResultId, + UUID patientId, + String patientName, + UUID doctorId, + String doctorName, + CriticalityLevel criticalityLevel, + String testName, + String resultValue, + String referenceRange, + String clinicalSignificance, + Boolean acknowledgmentRequired, + Boolean acknowledged, + Instant acknowledgedAt, + UUID acknowledgedById, + String acknowledgedByName, + String acknowledgmentMethod, + Boolean followUpRequired, + String followUpStatus, + Instant notifiedAt, + Instant createdAt +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/DuplicatePatientRecordResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/DuplicatePatientRecordResponseDto.java new file mode 100644 index 0000000..6525d97 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/DuplicatePatientRecordResponseDto.java @@ -0,0 +1,25 @@ +package com.gnx.telemedicine.dto.patient_safety; + +import com.gnx.telemedicine.model.enums.DuplicateStatus; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record DuplicatePatientRecordResponseDto( + UUID id, + UUID primaryPatientId, + String primaryPatientName, + UUID duplicatePatientId, + String duplicatePatientName, + BigDecimal matchScore, + List matchReasons, + DuplicateStatus status, + UUID reviewedById, + String reviewedByName, + Instant reviewedAt, + String reviewNotes, + Instant createdAt +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/DuplicatePatientReviewRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/DuplicatePatientReviewRequestDto.java new file mode 100644 index 0000000..fd606f3 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/DuplicatePatientReviewRequestDto.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.dto.patient_safety; + +import com.gnx.telemedicine.model.enums.DuplicateStatus; + +public record DuplicatePatientReviewRequestDto( + DuplicateStatus status, + String reviewNotes +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventRequestDto.java new file mode 100644 index 0000000..16523ba --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventRequestDto.java @@ -0,0 +1,19 @@ +package com.gnx.telemedicine.dto.patient_safety; + +import com.gnx.telemedicine.model.enums.SentinelEventType; +import com.gnx.telemedicine.model.enums.SentinelSeverity; + +import java.time.Instant; +import java.util.UUID; + +public record SentinelEventRequestDto( + SentinelEventType eventType, + SentinelSeverity severity, + UUID patientId, + UUID doctorId, + UUID appointmentId, + String description, + String location, + Instant occurredAt +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventResponseDto.java new file mode 100644 index 0000000..acb9049 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventResponseDto.java @@ -0,0 +1,34 @@ +package com.gnx.telemedicine.dto.patient_safety; + +import com.gnx.telemedicine.model.enums.SentinelEventStatus; +import com.gnx.telemedicine.model.enums.SentinelEventType; +import com.gnx.telemedicine.model.enums.SentinelSeverity; + +import java.time.Instant; +import java.util.UUID; + +public record SentinelEventResponseDto( + UUID id, + SentinelEventType eventType, + SentinelSeverity severity, + UUID patientId, + String patientName, + UUID doctorId, + String doctorName, + UUID appointmentId, + String description, + String location, + Instant occurredAt, + Instant reportedAt, + UUID reportedById, + String reportedByName, + SentinelEventStatus status, + String investigationNotes, + String rootCauseAnalysis, + String correctiveAction, + Instant resolvedAt, + UUID resolvedById, + String resolvedByName, + Instant createdAt +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventUpdateRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventUpdateRequestDto.java new file mode 100644 index 0000000..c710bb2 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/patient-safety/SentinelEventUpdateRequestDto.java @@ -0,0 +1,11 @@ +package com.gnx.telemedicine.dto.patient_safety; + +import com.gnx.telemedicine.model.enums.SentinelEventStatus; + +public record SentinelEventUpdateRequestDto( + SentinelEventStatus status, + String investigationNotes, + String rootCauseAnalysis, + String correctiveAction +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/prescription/MedicationIntakeLogRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/prescription/MedicationIntakeLogRequestDto.java new file mode 100644 index 0000000..ee2fb64 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/prescription/MedicationIntakeLogRequestDto.java @@ -0,0 +1,19 @@ +package com.gnx.telemedicine.dto.prescription; + +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; +import java.util.UUID; + +public record MedicationIntakeLogRequestDto( + @NotNull(message = "Prescription ID cannot be null") + UUID prescriptionId, + + @NotNull(message = "Scheduled time cannot be null") + Instant scheduledTime, + + Boolean taken, + + String notes +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/prescription/MedicationIntakeLogResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/prescription/MedicationIntakeLogResponseDto.java new file mode 100644 index 0000000..7a7dcc8 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/prescription/MedicationIntakeLogResponseDto.java @@ -0,0 +1,15 @@ +package com.gnx.telemedicine.dto.prescription; + +import java.time.Instant; +import java.util.UUID; + +public record MedicationIntakeLogResponseDto( + UUID id, + UUID prescriptionId, + Instant scheduledTime, + Instant takenAt, + Boolean taken, + String notes, + Instant createdAt +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/prescription/PrescriptionRequestDto.java b/src/main/java/com/gnx/telemedicine/dto/prescription/PrescriptionRequestDto.java new file mode 100644 index 0000000..d68b18d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/prescription/PrescriptionRequestDto.java @@ -0,0 +1,50 @@ +package com.gnx.telemedicine.dto.prescription; + +import com.gnx.telemedicine.model.enums.PrescriptionStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.time.LocalDate; +import java.util.UUID; + +public record PrescriptionRequestDto( + @NotNull(message = "Patient ID cannot be null") + UUID patientId, + + @NotNull(message = "Doctor ID cannot be null") + UUID doctorId, + + UUID appointmentId, // Optional + + @NotBlank(message = "Medication name cannot be blank") + String medicationName, + + String medicationCode, // NDC code + + @NotBlank(message = "Dosage cannot be blank") + String dosage, + + @NotBlank(message = "Frequency cannot be blank") + String frequency, + + @NotNull(message = "Quantity cannot be null") + @Positive(message = "Quantity must be positive") + Integer quantity, + + Integer refills, // Default 0 + + String instructions, + + @NotNull(message = "Start date cannot be null") + LocalDate startDate, + + LocalDate endDate, + + PrescriptionStatus status, // Default ACTIVE + + String pharmacyName, + String pharmacyAddress, + String pharmacyPhone +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/prescription/PrescriptionResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/prescription/PrescriptionResponseDto.java new file mode 100644 index 0000000..8f3f5f0 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/prescription/PrescriptionResponseDto.java @@ -0,0 +1,36 @@ +package com.gnx.telemedicine.dto.prescription; + +import com.gnx.telemedicine.model.enums.PrescriptionStatus; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +public record PrescriptionResponseDto( + UUID id, + UUID patientId, + String patientName, + UUID doctorId, + String doctorName, + UUID appointmentId, + String medicationName, + String medicationCode, + String dosage, + String frequency, + Integer quantity, + Integer refills, + String instructions, + LocalDate startDate, + LocalDate endDate, + PrescriptionStatus status, + String pharmacyName, + String pharmacyAddress, + String pharmacyPhone, + String prescriptionNumber, + Boolean ePrescriptionSent, + Instant ePrescriptionSentAt, + Instant createdAt, + UUID createdById, + String createdByName +) {} + diff --git a/src/main/java/com/gnx/telemedicine/dto/search/DoctorSearchCriteria.java b/src/main/java/com/gnx/telemedicine/dto/search/DoctorSearchCriteria.java new file mode 100644 index 0000000..c2508e0 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/search/DoctorSearchCriteria.java @@ -0,0 +1,20 @@ +package com.gnx.telemedicine.dto.search; + +import com.gnx.telemedicine.model.enums.DayOfWeek; + +import java.math.BigDecimal; +import java.time.LocalTime; + +public record DoctorSearchCriteria( + String specialization, + String location, + BigDecimal minFee, + BigDecimal maxFee, + Integer minExperience, + Boolean isVerified, + DayOfWeek availableDay, + LocalTime availableStartTime, + LocalTime availableEndTime, + String name +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/search/DoctorSearchResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/search/DoctorSearchResponseDto.java new file mode 100644 index 0000000..6b7eb14 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/search/DoctorSearchResponseDto.java @@ -0,0 +1,17 @@ +package com.gnx.telemedicine.dto.search; + +import java.math.BigDecimal; +import java.util.UUID; + +public record DoctorSearchResponseDto( + UUID id, + String firstName, + String lastName, + String email, + String phoneNumber, + String specialization, + Integer yearsOfExperience, + BigDecimal consultationFee, + Boolean isVerified +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/turn/TurnConfigResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/turn/TurnConfigResponseDto.java new file mode 100644 index 0000000..162f050 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/turn/TurnConfigResponseDto.java @@ -0,0 +1,23 @@ +package com.gnx.telemedicine.dto.turn; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TurnConfigResponseDto { + private String server; + private Integer port; + private String username; + private String password; + private String realm; + + // Additional fields for ICE server configuration + private String stunUrls; + private String turnUrls; +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/user/DoctorRegistrationDto.java b/src/main/java/com/gnx/telemedicine/dto/user/DoctorRegistrationDto.java new file mode 100644 index 0000000..fb536ec --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/DoctorRegistrationDto.java @@ -0,0 +1,54 @@ +package com.gnx.telemedicine.dto.user; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; +import java.util.List; + +public record DoctorRegistrationDto( + @Valid UserRegistrationDto user, + + @NotBlank(message = "Medical license number is required") + @Size(max = 100, message = "Medical license number must be at most 100 characters") + String medicalLicenseNumber, + + @NotBlank(message = "Specialization is required") + @Size(max = 100, message = "Specialization must be at most 100 characters") + String specialization, + + @Min(value = 0, message = "Experience must be at least 0 years") + @Max(value = 50, message = "Experience must be at most 50 years") + Integer yearsOfExperience, + + @Size(max = 1000) + String biography, + + @NotNull(message = "Consultation fee is required") + @DecimalMin(value = "0.0", message = "Fee must be at least 0") + BigDecimal consultationFee, + // Enterprise fields (optional during registration) + @Size(max = 255, message = "Street address must not exceed 255 characters") + String streetAddress, + @Size(max = 100, message = "City must not exceed 100 characters") + String city, + @Size(max = 100, message = "State must not exceed 100 characters") + String state, + @Size(max = 20, message = "Zip code must not exceed 20 characters") + String zipCode, + @Size(max = 100, message = "Country must not exceed 100 characters") + String country, + @Size(max = 200, message = "Education degree must not exceed 200 characters") + String educationDegree, + @Size(max = 200, message = "Education university must not exceed 200 characters") + String educationUniversity, + @Min(value = 1900, message = "Graduation year must be at least 1900") + @Max(value = 2100, message = "Graduation year cannot exceed 2100") + Integer educationGraduationYear, + List certifications, + List languagesSpoken, + List hospitalAffiliations, + List insuranceAccepted, + List professionalMemberships +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/user/DoctorResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/user/DoctorResponseDto.java new file mode 100644 index 0000000..e4249f3 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/DoctorResponseDto.java @@ -0,0 +1,36 @@ +package com.gnx.telemedicine.dto.user; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public record DoctorResponseDto( + UUID id, + String firstName, + String lastName, + String phoneNumber, + String email, + String medicalLicenseNumber, + String specialization, + Integer yearsOfExperience, + String biography, + BigDecimal consultationFee, + Integer defaultDurationMinutes, + Boolean isVerified, + String avatarUrl, + // Enterprise fields + String streetAddress, + String city, + String state, + String zipCode, + String country, + String educationDegree, + String educationUniversity, + Integer educationGraduationYear, + List certifications, + List languagesSpoken, + List hospitalAffiliations, + List insuranceAccepted, + List professionalMemberships +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/user/DoctorUpdateDto.java b/src/main/java/com/gnx/telemedicine/dto/user/DoctorUpdateDto.java new file mode 100644 index 0000000..21d4dc2 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/DoctorUpdateDto.java @@ -0,0 +1,49 @@ +package com.gnx.telemedicine.dto.user; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.util.List; + +public record DoctorUpdateDto( + @Size(min = 2, max = 100, message = "Medical license number must be between 2 and 100 characters") + String medicalLicenseNumber, + @Size(min = 2, max = 100, message = "Specialization must be between 2 and 100 characters") + String specialization, + @Min(value = 0, message = "Years of experience cannot be negative") + Integer yearsOfExperience, + @Size(max = 1000, message = "Biography must not exceed 1000 characters") + String biography, + @Positive(message = "Consultation fee must be positive") + BigDecimal consultationFee, + @Min(value = 15, message = "Default duration must be at least 15 minutes") + @Max(value = 120, message = "Default duration cannot exceed 120 minutes") + Integer defaultDurationMinutes, + // Enterprise fields + @Size(max = 255, message = "Street address must not exceed 255 characters") + String streetAddress, + @Size(max = 100, message = "City must not exceed 100 characters") + String city, + @Size(max = 100, message = "State must not exceed 100 characters") + String state, + @Size(max = 20, message = "Zip code must not exceed 20 characters") + String zipCode, + @Size(max = 100, message = "Country must not exceed 100 characters") + String country, + @Size(max = 200, message = "Education degree must not exceed 200 characters") + String educationDegree, + @Size(max = 200, message = "Education university must not exceed 200 characters") + String educationUniversity, + @Min(value = 1900, message = "Graduation year must be at least 1900") + @Max(value = 2100, message = "Graduation year cannot exceed 2100") + Integer educationGraduationYear, + List certifications, + List languagesSpoken, + List hospitalAffiliations, + List insuranceAccepted, + List professionalMemberships +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/user/PasswordDto.java b/src/main/java/com/gnx/telemedicine/dto/user/PasswordDto.java new file mode 100644 index 0000000..f31d303 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/PasswordDto.java @@ -0,0 +1,13 @@ +package com.gnx.telemedicine.dto.user; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PasswordDto( + @NotBlank(message = "Password is required") + @Size(min = 8, max = 20, message = "Password must be between 8 and 20 characters") + String password, + @NotBlank(message = "Confirm password is required") + String confirmPassword +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/user/PatientRegistrationDto.java b/src/main/java/com/gnx/telemedicine/dto/user/PatientRegistrationDto.java new file mode 100644 index 0000000..b308e3d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/PatientRegistrationDto.java @@ -0,0 +1,55 @@ +package com.gnx.telemedicine.dto.user; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; +import java.util.List; + +public record PatientRegistrationDto( + @Valid UserRegistrationDto user, + + @Size(max = 200, message = "Emergency contact name must be at most 200 characters") + String emergencyContactName, + + @Pattern(regexp = "^\\+?[1-9][0-9]\\d{1,14}", message = "Emergency contact phone must be a valid phone number") + String emergencyContactPhone, + + @Pattern(regexp = "^(A|B|AB|O)[+-]", message = "Blood type must be A, B, AB, O, + or -") + String bloodType, + + List allergies, + // Enterprise fields (optional during registration) + LocalDate dateOfBirth, + @Size(max = 20, message = "Gender must not exceed 20 characters") + String gender, + @Size(max = 255, message = "Street address must not exceed 255 characters") + String streetAddress, + @Size(max = 100, message = "City must not exceed 100 characters") + String city, + @Size(max = 100, message = "State must not exceed 100 characters") + String state, + @Size(max = 20, message = "Zip code must not exceed 20 characters") + String zipCode, + @Size(max = 100, message = "Country must not exceed 100 characters") + String country, + @Size(max = 200, message = "Insurance provider must not exceed 200 characters") + String insuranceProvider, + @Size(max = 100, message = "Insurance policy number must not exceed 100 characters") + String insurancePolicyNumber, + @Size(max = 2000, message = "Medical history summary must not exceed 2000 characters") + String medicalHistorySummary, + List currentMedications, + @Size(max = 200, message = "Primary care physician name must not exceed 200 characters") + String primaryCarePhysicianName, + @Size(max = 20, message = "Primary care physician phone must not exceed 20 characters") + String primaryCarePhysicianPhone, + @Size(max = 50, message = "Preferred language must not exceed 50 characters") + String preferredLanguage, + @Size(max = 200, message = "Occupation must not exceed 200 characters") + String occupation, + @Size(max = 20, message = "Marital status must not exceed 20 characters") + String maritalStatus +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/user/PatientResponseDto.java b/src/main/java/com/gnx/telemedicine/dto/user/PatientResponseDto.java new file mode 100644 index 0000000..0db1922 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/PatientResponseDto.java @@ -0,0 +1,37 @@ +package com.gnx.telemedicine.dto.user; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public record PatientResponseDto( + UUID id, + UUID userId, + String firstName, + String lastName, + String phoneNumber, + String email, + String bloodType, + String emergencyContactName, + String emergencyContactPhone, + List allergies, + String avatarUrl, + // Enterprise fields + LocalDate dateOfBirth, + String gender, + String streetAddress, + String city, + String state, + String zipCode, + String country, + String insuranceProvider, + String insurancePolicyNumber, + String medicalHistorySummary, + List currentMedications, + String primaryCarePhysicianName, + String primaryCarePhysicianPhone, + String preferredLanguage, + String occupation, + String maritalStatus +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/user/PatientUpdateDto.java b/src/main/java/com/gnx/telemedicine/dto/user/PatientUpdateDto.java new file mode 100644 index 0000000..bcf1cd3 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/PatientUpdateDto.java @@ -0,0 +1,52 @@ +package com.gnx.telemedicine.dto.user; + +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; +import java.util.List; + +public record PatientUpdateDto( + @Size(min = 2, max = 50, message = "Emergency contact name must be between 2 and 50 characters") + String emergencyContactName, + + @Size(min = 10, max = 15, message = "Emergency contact phone must be between 10 and 15 characters") + String emergencyContactPhone, + + @Size(min = 2, max = 3, message = "Blood type must be 2-3 characters") + String bloodType, + + List allergies, + // Enterprise fields + LocalDate dateOfBirth, + @Size(max = 20, message = "Gender must not exceed 20 characters") + String gender, + @Size(max = 255, message = "Street address must not exceed 255 characters") + String streetAddress, + @Size(max = 100, message = "City must not exceed 100 characters") + String city, + @Size(max = 100, message = "State must not exceed 100 characters") + String state, + @Size(max = 20, message = "Zip code must not exceed 20 characters") + String zipCode, + @Size(max = 100, message = "Country must not exceed 100 characters") + String country, + @Size(max = 200, message = "Insurance provider must not exceed 200 characters") + String insuranceProvider, + @Size(max = 100, message = "Insurance policy number must not exceed 100 characters") + String insurancePolicyNumber, + @Size(max = 2000, message = "Medical history summary must not exceed 2000 characters") + String medicalHistorySummary, + List currentMedications, + @Size(max = 200, message = "Primary care physician name must not exceed 200 characters") + String primaryCarePhysicianName, + @Size(max = 20, message = "Primary care physician phone must not exceed 20 characters") + String primaryCarePhysicianPhone, + @Size(max = 50, message = "Preferred language must not exceed 50 characters") + String preferredLanguage, + @Size(max = 200, message = "Occupation must not exceed 200 characters") + String occupation, + @Size(max = 20, message = "Marital status must not exceed 20 characters") + String maritalStatus +) { +} + diff --git a/src/main/java/com/gnx/telemedicine/dto/user/UserRegistrationDto.java b/src/main/java/com/gnx/telemedicine/dto/user/UserRegistrationDto.java new file mode 100644 index 0000000..524b216 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/UserRegistrationDto.java @@ -0,0 +1,24 @@ +package com.gnx.telemedicine.dto.user; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + +public record UserRegistrationDto( + @NotBlank(message = "Email is required") + @Email(message = "Must be a valid email") + String email, + + @Valid PasswordDto password, + + @NotBlank(message = "First name is required") + @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") + String firstName, + + @NotBlank(message = "Last name is required") + @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") + String lastName, + + @Pattern(regexp = "^\\+?[1-9][0-9]\\d{1,14}", message = "Emergency contact phone must be a valid phone number") + String phoneNumber +) { +} diff --git a/src/main/java/com/gnx/telemedicine/dto/user/UserUpdateDto.java b/src/main/java/com/gnx/telemedicine/dto/user/UserUpdateDto.java new file mode 100644 index 0000000..aa88a6e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/dto/user/UserUpdateDto.java @@ -0,0 +1,16 @@ +package com.gnx.telemedicine.dto.user; + +import jakarta.validation.constraints.Size; + +public record UserUpdateDto( + @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") + String firstName, + + @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") + String lastName, + + @Size(min = 10, max = 15, message = "Phone number must be between 10 and 15 characters") + String phoneNumber +) { +} + diff --git a/src/main/java/com/gnx/telemedicine/exception/AvailabilityException.java b/src/main/java/com/gnx/telemedicine/exception/AvailabilityException.java new file mode 100644 index 0000000..2e2acb9 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/AvailabilityException.java @@ -0,0 +1,7 @@ +package com.gnx.telemedicine.exception; + +public class AvailabilityException extends RuntimeException{ + public AvailabilityException(String message) { + super(message); + } +} diff --git a/src/main/java/com/gnx/telemedicine/exception/ConflictException.java b/src/main/java/com/gnx/telemedicine/exception/ConflictException.java new file mode 100644 index 0000000..574a3e3 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/ConflictException.java @@ -0,0 +1,11 @@ +package com.gnx.telemedicine.exception; + +/** + * Exception thrown when a request conflicts with the current state of the resource. + */ +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/exception/DuplicateUserException.java b/src/main/java/com/gnx/telemedicine/exception/DuplicateUserException.java new file mode 100644 index 0000000..543080a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/DuplicateUserException.java @@ -0,0 +1,7 @@ +package com.gnx.telemedicine.exception; + +public class DuplicateUserException extends RuntimeException { + public DuplicateUserException(String message) { + super(message); + } +} diff --git a/src/main/java/com/gnx/telemedicine/exception/ForbiddenException.java b/src/main/java/com/gnx/telemedicine/exception/ForbiddenException.java new file mode 100644 index 0000000..8706994 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/ForbiddenException.java @@ -0,0 +1,15 @@ +package com.gnx.telemedicine.exception; + +/** + * Exception thrown when access to a resource is forbidden. + */ +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } + + public ForbiddenException() { + super("Access forbidden"); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/exception/GlobalExceptionHandler.java b/src/main/java/com/gnx/telemedicine/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..ad08543 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/GlobalExceptionHandler.java @@ -0,0 +1,490 @@ +package com.gnx.telemedicine.exception; + +import com.gnx.telemedicine.dto.error.ErrorResponseDto; +import com.gnx.telemedicine.util.CorrelationIdUtil; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@ControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + /** + * Build standardized error response. + */ + private ErrorResponseDto buildErrorResponse( + String error, + String message, + HttpStatus status, + HttpServletRequest request, + Map errors, + Map details) { + // Get correlation ID from MDC or generate a new one + String correlationId = CorrelationIdUtil.getCorrelationId(); + if (correlationId == null || correlationId.isEmpty()) { + correlationId = CorrelationIdUtil.generateCorrelationId(); + CorrelationIdUtil.setCorrelationId(correlationId); + } + String path = request != null ? request.getRequestURI() : null; + + ErrorResponseDto.ErrorResponseDtoBuilder builder = ErrorResponseDto.builder() + .error(error) + .message(message) + .status(status.value()) + .timestamp(LocalDateTime.now()) + .correlationId(correlationId) + .path(path); + + if (errors != null && !errors.isEmpty()) { + builder.errors(errors); + } + + if (details != null && !details.isEmpty()) { + builder.details(details); + } + + return builder.build(); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation( + MethodArgumentNotValidException ex, + HttpServletRequest request) { + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + ErrorResponseDto errorResponse = buildErrorResponse( + "Validation failed", + "Validation failed. Please check the input fields.", + HttpStatus.BAD_REQUEST, + request, + errors, + null + ); + + log.warn("Validation error: {} | Path: {}", errors, request.getRequestURI()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(TwoFactorAuthenticationRequiredException.class) + public ResponseEntity handleTwoFactorRequired( + TwoFactorAuthenticationRequiredException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "2FA code required"; + Map details = new HashMap<>(); + details.put("requires2FA", true); + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.BAD_REQUEST, + request, + null, + details + ); + + log.debug("2FA code required for user | Path: {}", request.getRequestURI()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument( + IllegalArgumentException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Bad request"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.BAD_REQUEST, + request, + null, + null + ); + + log.warn("Illegal argument: {} | Path: {}", errorMessage, request.getRequestURI()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentials( + BadCredentialsException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Invalid credentials"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.UNAUTHORIZED, + request, + null, + null + ); + + log.warn("Bad credentials attempt | Path: {}", request.getRequestURI()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + @ExceptionHandler(DisabledException.class) + public ResponseEntity handleDisabled( + DisabledException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Account is disabled"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.FORBIDDEN, + request, + null, + null + ); + + log.warn("Disabled account access attempt | Path: {}", request.getRequestURI()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, + HttpServletRequest request) { + String message = ex.getMessage(); + String errorMessage; + if (message != null && (message.contains("UUID") || message.contains("patientId"))) { + errorMessage = "Invalid UUID format for patient ID. Please select a valid patient."; + } else if (message != null && message.contains("status")) { + errorMessage = "Invalid status value. Must be one of: PENDING, NORMAL, ABNORMAL, CRITICAL."; + } else { + errorMessage = "Invalid request format. " + (message != null ? message : "Please check all required fields."); + } + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.BAD_REQUEST, + request, + null, + null + ); + + log.warn("Request parsing error: {} | Path: {}", errorMessage, request.getRequestURI()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch( + MethodArgumentTypeMismatchException ex, + HttpServletRequest request) { + String errorMessage = String.format("Invalid value '%s' for parameter '%s'. Expected type: %s", + ex.getValue(), ex.getName(), ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.BAD_REQUEST, + request, + null, + null + ); + + log.warn("Type mismatch error: {} | Path: {}", errorMessage, request.getRequestURI()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler({DuplicateUserException.class, AvailabilityException.class, PasswordMismatchException.class}) + public ResponseEntity handleBusinessExceptions( + RuntimeException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Business rule violation"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.BAD_REQUEST, + request, + null, + null + ); + + log.warn("Business exception: {} | Path: {}", errorMessage, request.getRequestURI()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleResourceNotFound( + ResourceNotFoundException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Resource not found"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.NOT_FOUND, + request, + null, + null + ); + + log.warn("Resource not found: {} | Path: {}", errorMessage, request.getRequestURI()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorized( + UnauthorizedException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Unauthorized access"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.UNAUTHORIZED, + request, + null, + null + ); + + log.warn("Unauthorized access attempt | Path: {}", request.getRequestURI()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbidden( + ForbiddenException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Access forbidden"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.FORBIDDEN, + request, + null, + null + ); + + log.warn("Forbidden access attempt | Path: {}", request.getRequestURI()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); + } + + @ExceptionHandler(ConflictException.class) + public ResponseEntity handleConflict( + ConflictException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Resource conflict"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.CONFLICT, + request, + null, + null + ); + + log.warn("Conflict: {} | Path: {}", errorMessage, request.getRequestURI()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + @ExceptionHandler(EmptyResultDataAccessException.class) + public ResponseEntity handleEmptyResult( + EmptyResultDataAccessException ex, + HttpServletRequest request) { + String errorMessage = "Requested resource not found"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.NOT_FOUND, + request, + null, + null + ); + + log.warn("Empty result data access | Path: {}", request.getRequestURI()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolation( + DataIntegrityViolationException ex, + HttpServletRequest request) { + String errorMessage = "Data integrity violation. The operation conflicts with existing data."; + String rootCause = ex.getRootCause() != null ? ex.getRootCause().getMessage() : null; + + Map details = new HashMap<>(); + if (rootCause != null) { + details.put("rootCause", rootCause); + } + + ErrorResponseDto errorResponse = buildErrorResponse( + "Data integrity violation", + errorMessage, + HttpStatus.CONFLICT, + request, + null, + details + ); + + log.warn("Data integrity violation | Path: {} | Root cause: {}", request.getRequestURI(), rootCause); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied( + AccessDeniedException ex, + HttpServletRequest request) { + String errorMessage = "Access denied. You do not have permission to perform this action."; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.FORBIDDEN, + request, + null, + null + ); + + log.warn("Access denied | Path: {}", request.getRequestURI()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthentication( + AuthenticationException ex, + HttpServletRequest request) { + String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Authentication failed"; + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.UNAUTHORIZED, + request, + null, + null + ); + + log.warn("Authentication failed | Path: {}", request.getRequestURI()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParameter( + MissingServletRequestParameterException ex, + HttpServletRequest request) { + String errorMessage = String.format("Missing required parameter: %s", ex.getParameterName()); + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.BAD_REQUEST, + request, + null, + null + ); + + log.warn("Missing request parameter: {} | Path: {}", ex.getParameterName(), request.getRequestURI()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported( + HttpRequestMethodNotSupportedException ex, + HttpServletRequest request) { + String errorMessage = String.format("HTTP method '%s' is not supported for this endpoint. Supported methods: %s", + ex.getMethod(), ex.getSupportedHttpMethods()); + + ErrorResponseDto errorResponse = buildErrorResponse( + errorMessage, + errorMessage, + HttpStatus.METHOD_NOT_ALLOWED, + request, + null, + null + ); + + log.warn("Method not supported: {} | Path: {}", ex.getMethod(), request.getRequestURI()); + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(errorResponse); + } + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNoHandlerFound( + NoHandlerFoundException ex, + HttpServletRequest request) { + String errorMessage = String.format("No handler found for %s %s", ex.getHttpMethod(), ex.getRequestURL()); + + ErrorResponseDto errorResponse = buildErrorResponse( + "Endpoint not found", + errorMessage, + HttpStatus.NOT_FOUND, + request, + null, + null + ); + + log.warn("No handler found: {} {} | Path: {}", ex.getHttpMethod(), ex.getRequestURL(), request.getRequestURI()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException( + RuntimeException ex, + HttpServletRequest request) { + String correlationId = CorrelationIdUtil.getCorrelationId(); + ErrorResponseDto errorResponse = buildErrorResponse( + "An unexpected error occurred", + "An unexpected error occurred. Please try again later.", + HttpStatus.INTERNAL_SERVER_ERROR, + request, + null, + null + ); + + log.error("Unexpected runtime exception | Correlation ID: {} | Path: {}", correlationId, request.getRequestURI(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException( + Exception ex, + HttpServletRequest request) { + String correlationId = CorrelationIdUtil.getCorrelationId(); + ErrorResponseDto errorResponse = buildErrorResponse( + "An internal server error occurred", + "An internal server error occurred. Please try again later.", + HttpStatus.INTERNAL_SERVER_ERROR, + request, + null, + null + ); + + log.error("Unexpected exception | Correlation ID: {} | Path: {}", correlationId, request.getRequestURI(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} diff --git a/src/main/java/com/gnx/telemedicine/exception/PasswordMismatchException.java b/src/main/java/com/gnx/telemedicine/exception/PasswordMismatchException.java new file mode 100644 index 0000000..91c9841 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/PasswordMismatchException.java @@ -0,0 +1,7 @@ +package com.gnx.telemedicine.exception; + +public class PasswordMismatchException extends RuntimeException { + public PasswordMismatchException(String message) { + super(message); + } +} diff --git a/src/main/java/com/gnx/telemedicine/exception/ResourceNotFoundException.java b/src/main/java/com/gnx/telemedicine/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..872edb1 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/ResourceNotFoundException.java @@ -0,0 +1,15 @@ +package com.gnx.telemedicine.exception; + +/** + * Exception thrown when a requested resource is not found. + */ +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } + + public ResourceNotFoundException(String resourceType, Object identifier) { + super(String.format("%s with identifier '%s' not found", resourceType, identifier)); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/exception/TwoFactorAuthenticationRequiredException.java b/src/main/java/com/gnx/telemedicine/exception/TwoFactorAuthenticationRequiredException.java new file mode 100644 index 0000000..8c258b8 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/TwoFactorAuthenticationRequiredException.java @@ -0,0 +1,16 @@ +package com.gnx.telemedicine.exception; + +/** + * Exception thrown when 2FA is enabled but no code was provided during login. + * This is not an error condition, but rather indicates that the user needs to provide their 2FA code. + */ +public class TwoFactorAuthenticationRequiredException extends RuntimeException { + public TwoFactorAuthenticationRequiredException(String message) { + super(message); + } + + public TwoFactorAuthenticationRequiredException() { + super("2FA code required"); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/exception/UnauthorizedException.java b/src/main/java/com/gnx/telemedicine/exception/UnauthorizedException.java new file mode 100644 index 0000000..e229af4 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/exception/UnauthorizedException.java @@ -0,0 +1,15 @@ +package com.gnx.telemedicine.exception; + +/** + * Exception thrown when a user is not authorized to perform an action. + */ +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } + + public UnauthorizedException() { + super("Unauthorized access"); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/filter/CorrelationIdFilter.java b/src/main/java/com/gnx/telemedicine/filter/CorrelationIdFilter.java new file mode 100644 index 0000000..bf1acdd --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/filter/CorrelationIdFilter.java @@ -0,0 +1,64 @@ +package com.gnx.telemedicine.filter; + +import com.gnx.telemedicine.util.CorrelationIdUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; + +/** + * Filter to generate and propagate correlation IDs across the request lifecycle. + * This filter should be executed early in the filter chain to ensure correlation IDs + * are available for all subsequent processing. + */ +@Component +@Order(1) // Execute early in the filter chain +@Slf4j +public class CorrelationIdFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + // Get or generate correlation ID + String correlationId = CorrelationIdUtil.getOrGenerateCorrelationId(request); + + // Set correlation ID in MDC and response header + CorrelationIdUtil.setCorrelationId(correlationId, response); + CorrelationIdUtil.setRequestPath(request.getRequestURI()); + CorrelationIdUtil.setRequestMethod(request.getMethod()); + + // Log request start + log.debug("Request started: {} {} | Correlation ID: {}", + request.getMethod(), + request.getRequestURI(), + correlationId); + + // Continue filter chain + filterChain.doFilter(request, response); + + // Log request completion + log.debug("Request completed: {} {} | Status: {} | Correlation ID: {}", + request.getMethod(), + request.getRequestURI(), + response.getStatus(), + correlationId); + + } finally { + // Always clear MDC to prevent memory leaks + CorrelationIdUtil.clear(); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/health/DatabaseHealthIndicator.java b/src/main/java/com/gnx/telemedicine/health/DatabaseHealthIndicator.java new file mode 100644 index 0000000..7c580ec --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/health/DatabaseHealthIndicator.java @@ -0,0 +1,106 @@ +package com.gnx.telemedicine.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; + +/** + * Custom health indicator for database connectivity and performance. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DatabaseHealthIndicator implements HealthIndicator { + + private final DataSource dataSource; + + @Override + public Health health() { + Map details = new HashMap<>(); + + try (Connection connection = dataSource.getConnection()) { + // Check connection validity + boolean isValid = connection.isValid(5); // 5 second timeout + + if (!isValid) { + return Health.down() + .withDetail("status", "Connection validation failed") + .withDetail("database", "PostgreSQL") + .build(); + } + + // Get database metadata + DatabaseMetaData metaData = connection.getMetaData(); + details.put("database", metaData.getDatabaseProductName()); + details.put("version", metaData.getDatabaseProductVersion()); + details.put("driver", metaData.getDriverName()); + details.put("driverVersion", metaData.getDriverVersion()); + details.put("url", metaData.getURL()); + details.put("username", metaData.getUserName()); + + // Test query performance + long startTime = System.currentTimeMillis(); + try (Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT 1")) { + if (rs.next()) { + long queryTime = System.currentTimeMillis() - startTime; + details.put("queryTime", queryTime + "ms"); + + if (queryTime > 1000) { + return Health.up() + .withDetails(details) + .withDetail("status", "UP") + .withDetail("warning", "Query response time is slow: " + queryTime + "ms") + .build(); + } + } + } + + // Check connection pool status (if HikariCP) + if (dataSource instanceof com.zaxxer.hikari.HikariDataSource) { + com.zaxxer.hikari.HikariDataSource hikariDataSource = + (com.zaxxer.hikari.HikariDataSource) dataSource; + com.zaxxer.hikari.HikariPoolMXBean poolBean = hikariDataSource.getHikariPoolMXBean(); + + details.put("activeConnections", poolBean.getActiveConnections()); + details.put("idleConnections", poolBean.getIdleConnections()); + details.put("totalConnections", poolBean.getTotalConnections()); + details.put("threadsAwaitingConnection", poolBean.getThreadsAwaitingConnection()); + + // Warn if connection pool is exhausted + if (poolBean.getThreadsAwaitingConnection() > 0) { + return Health.up() + .withDetails(details) + .withDetail("status", "UP") + .withDetail("warning", "Connection pool has waiting threads") + .build(); + } + } + + return Health.up() + .withDetails(details) + .withDetail("status", "UP") + .build(); + + } catch (Exception e) { + log.error("Database health check failed", e); + return Health.down() + .withDetail("status", "DOWN") + .withDetail("error", e.getMessage()) + .withDetail("database", "PostgreSQL") + .withException(e) + .build(); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/health/EmailServiceHealthIndicator.java b/src/main/java/com/gnx/telemedicine/health/EmailServiceHealthIndicator.java new file mode 100644 index 0000000..e396147 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/health/EmailServiceHealthIndicator.java @@ -0,0 +1,92 @@ +package com.gnx.telemedicine.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * Custom health indicator for email service connectivity. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class EmailServiceHealthIndicator implements HealthIndicator { + + private final JavaMailSender mailSender; + + @Override + public Health health() { + Map details = new HashMap<>(); + + try { + // Test email service connectivity by checking session + // Cast to JavaMailSenderImpl to access session + if (!(mailSender instanceof JavaMailSenderImpl)) { + return Health.down() + .withDetail("status", "DOWN") + .withDetail("error", "JavaMailSender is not an instance of JavaMailSenderImpl") + .withDetail("service", "Email (SMTP)") + .build(); + } + + JavaMailSenderImpl mailSenderImpl = (JavaMailSenderImpl) mailSender; + var session = mailSenderImpl.getSession(); + details.put("host", session.getProperty("mail.smtp.host")); + details.put("port", session.getProperty("mail.smtp.port")); + details.put("protocol", session.getProperty("mail.transport.protocol")); + + // Try to connect to the mail server + var transport = session.getTransport(); + try { + String host = session.getProperty("mail.smtp.host"); + String username = session.getProperty("mail.smtp.user"); + String password = session.getProperty("mail.smtp.password"); + + if (host != null) { + // Test connection (without actually sending) + transport.connect(host, username, password); + transport.close(); + + details.put("status", "UP"); + details.put("connectivity", "Connected"); + + return Health.up() + .withDetails(details) + .withDetail("service", "Email (SMTP)") + .build(); + } else { + return Health.down() + .withDetail("status", "DOWN") + .withDetail("error", "Email host not configured") + .withDetail("service", "Email (SMTP)") + .build(); + } + } catch (Exception e) { + log.warn("Email service connectivity check failed: {}", e.getMessage()); + return Health.down() + .withDetails(details) + .withDetail("status", "DOWN") + .withDetail("error", "Cannot connect to email server: " + e.getMessage()) + .withDetail("service", "Email (SMTP)") + .withException(e) + .build(); + } + } catch (Exception e) { + log.error("Email service health check failed", e); + return Health.down() + .withDetail("status", "DOWN") + .withDetail("error", e.getMessage()) + .withDetail("service", "Email (SMTP)") + .withException(e) + .build(); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/health/GeminiApiHealthIndicator.java b/src/main/java/com/gnx/telemedicine/health/GeminiApiHealthIndicator.java new file mode 100644 index 0000000..adc7137 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/health/GeminiApiHealthIndicator.java @@ -0,0 +1,108 @@ +package com.gnx.telemedicine.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +/** + * Custom health indicator for Google Gemini API connectivity. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class GeminiApiHealthIndicator implements HealthIndicator { + + @Value("${gemini.api-key:}") + private String apiKey; + + private static final String GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com"; + private static final int TIMEOUT_SECONDS = 5; + + @Override + public Health health() { + Map details = new HashMap<>(); + + // Check if API key is configured + if (apiKey == null || apiKey.isEmpty()) { + return Health.down() + .withDetail("status", "DOWN") + .withDetail("error", "Gemini API key not configured") + .withDetail("service", "Google Gemini API") + .build(); + } + + try { + // Create HTTP client with timeout + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS)) + .build(); + + // Test API connectivity with a simple request + // Note: This is a lightweight check - actual API calls would require proper request body + String testUrl = GEMINI_API_BASE_URL + "/v1/models?key=" + apiKey; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(testUrl)) + .timeout(Duration.ofSeconds(TIMEOUT_SECONDS)) + .GET() + .build(); + + long startTime = System.currentTimeMillis(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + long responseTime = System.currentTimeMillis() - startTime; + + details.put("status", "UP"); + details.put("service", "Google Gemini API"); + details.put("baseUrl", GEMINI_API_BASE_URL); + details.put("responseTime", responseTime + "ms"); + details.put("httpStatus", response.statusCode()); + + if (response.statusCode() == 200) { + return Health.up() + .withDetails(details) + .withDetail("connectivity", "Connected") + .build(); + } else if (response.statusCode() == 401 || response.statusCode() == 403) { + return Health.down() + .withDetails(details) + .withDetail("error", "API key authentication failed") + .build(); + } else { + return Health.down() + .withDetails(details) + .withDetail("error", "Unexpected response code: " + response.statusCode()) + .build(); + } + + } catch (java.net.http.HttpTimeoutException e) { + log.warn("Gemini API health check timeout: {}", e.getMessage()); + return Health.down() + .withDetails(details) + .withDetail("status", "DOWN") + .withDetail("error", "Connection timeout: " + e.getMessage()) + .withDetail("service", "Google Gemini API") + .withException(e) + .build(); + } catch (Exception e) { + log.error("Gemini API health check failed", e); + return Health.down() + .withDetails(details) + .withDetail("status", "DOWN") + .withDetail("error", "Cannot connect to Gemini API: " + e.getMessage()) + .withDetail("service", "Google Gemini API") + .withException(e) + .build(); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/AppointmentMapper.java b/src/main/java/com/gnx/telemedicine/mappers/AppointmentMapper.java new file mode 100644 index 0000000..8508711 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/AppointmentMapper.java @@ -0,0 +1,21 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.appointment.AppointmentRequestDto; +import com.gnx.telemedicine.dto.appointment.AppointmentResponseDto; +import com.gnx.telemedicine.model.Appointment; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface AppointmentMapper { + Appointment toAppointment(AppointmentRequestDto appointmentRequestDto); + + @Mapping(target = "id", source = "id") + @Mapping(target = "patientId", source = "patient.id", nullValuePropertyMappingStrategy = org.mapstruct.NullValuePropertyMappingStrategy.IGNORE) + @Mapping(target = "patientFirstName", source = "patient.user.firstName") + @Mapping(target = "patientLastName", source = "patient.user.lastName") + @Mapping(target = "doctorId", source = "doctor.id", nullValuePropertyMappingStrategy = org.mapstruct.NullValuePropertyMappingStrategy.IGNORE) + @Mapping(target = "doctorFirstName", source = "doctor.user.firstName") + @Mapping(target = "doctorLastName", source = "doctor.user.lastName") + AppointmentResponseDto toAppointmentResponseDto(Appointment appointment); +} diff --git a/src/main/java/com/gnx/telemedicine/mappers/AvailabilityMapper.java b/src/main/java/com/gnx/telemedicine/mappers/AvailabilityMapper.java new file mode 100644 index 0000000..07bb624 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/AvailabilityMapper.java @@ -0,0 +1,21 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.availability.AvailabilityRequestDto; +import com.gnx.telemedicine.dto.availability.AvailabilityResponseDto; +import com.gnx.telemedicine.model.DoctorAvailability; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface AvailabilityMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "doctor", ignore = true) + @Mapping(target = "isAvailable", constant = "true") + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + DoctorAvailability toEntity(AvailabilityRequestDto availabilityRequestDto); + + @Mapping(target = "doctorId", source = "doctor.id") + AvailabilityResponseDto toResponseDto(DoctorAvailability availability); +} \ No newline at end of file diff --git a/src/main/java/com/gnx/telemedicine/mappers/ClinicalAlertMapper.java b/src/main/java/com/gnx/telemedicine/mappers/ClinicalAlertMapper.java new file mode 100644 index 0000000..8934cc9 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/ClinicalAlertMapper.java @@ -0,0 +1,34 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.patient_safety.ClinicalAlertRequestDto; +import com.gnx.telemedicine.dto.patient_safety.ClinicalAlertResponseDto; +import com.gnx.telemedicine.model.ClinicalAlert; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ClinicalAlertMapper { + + @Mapping(target = "patient", ignore = true) + @Mapping(target = "doctor", ignore = true) + @Mapping(target = "relatedPrescription", ignore = true) + @Mapping(target = "acknowledgedBy", ignore = true) + @Mapping(target = "resolvedBy", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "acknowledgedAt", ignore = true) + @Mapping(target = "resolvedAt", ignore = true) + @Mapping(target = "createdAt", ignore = true) + ClinicalAlert toClinicalAlert(ClinicalAlertRequestDto dto); + + @Mapping(target = "patientId", source = "patient.id") + @Mapping(target = "patientName", expression = "java(alert.getPatient().getUser().getFirstName() + \" \" + alert.getPatient().getUser().getLastName())") + @Mapping(target = "doctorId", source = "doctor.id") + @Mapping(target = "doctorName", expression = "java(alert.getDoctor() != null && alert.getDoctor().getUser() != null ? alert.getDoctor().getUser().getFirstName() + \" \" + alert.getDoctor().getUser().getLastName() : null)") + @Mapping(target = "relatedPrescriptionId", source = "relatedPrescription.id") + @Mapping(target = "acknowledgedById", source = "acknowledgedBy.id") + @Mapping(target = "acknowledgedByName", expression = "java(alert.getAcknowledgedBy() != null ? alert.getAcknowledgedBy().getFirstName() + \" \" + alert.getAcknowledgedBy().getLastName() : null)") + @Mapping(target = "resolvedById", source = "resolvedBy.id") + @Mapping(target = "resolvedByName", expression = "java(alert.getResolvedBy() != null ? alert.getResolvedBy().getFirstName() + \" \" + alert.getResolvedBy().getLastName() : null)") + ClinicalAlertResponseDto toClinicalAlertResponseDto(ClinicalAlert alert); +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/CriticalResultMapper.java b/src/main/java/com/gnx/telemedicine/mappers/CriticalResultMapper.java new file mode 100644 index 0000000..1e730d9 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/CriticalResultMapper.java @@ -0,0 +1,20 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.patient_safety.CriticalResultResponseDto; +import com.gnx.telemedicine.model.CriticalResult; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface CriticalResultMapper { + + @Mapping(target = "labResultId", source = "labResult.id") + @Mapping(target = "patientId", source = "patient.id") + @Mapping(target = "patientName", expression = "java(result.getPatient().getUser().getFirstName() + \" \" + result.getPatient().getUser().getLastName())") + @Mapping(target = "doctorId", source = "doctor.id") + @Mapping(target = "doctorName", expression = "java(result.getDoctor().getUser().getFirstName() + \" \" + result.getDoctor().getUser().getLastName())") + @Mapping(target = "acknowledgedById", source = "acknowledgedBy.id") + @Mapping(target = "acknowledgedByName", expression = "java(result.getAcknowledgedBy() != null ? result.getAcknowledgedBy().getFirstName() + \" \" + result.getAcknowledgedBy().getLastName() : null)") + CriticalResultResponseDto toCriticalResultResponseDto(CriticalResult result); +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/DuplicatePatientRecordMapper.java b/src/main/java/com/gnx/telemedicine/mappers/DuplicatePatientRecordMapper.java new file mode 100644 index 0000000..166f0a2 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/DuplicatePatientRecordMapper.java @@ -0,0 +1,32 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.patient_safety.DuplicatePatientRecordResponseDto; +import com.gnx.telemedicine.model.DuplicatePatientRecord; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring") +public interface DuplicatePatientRecordMapper { + + @Mapping(target = "primaryPatientId", source = "primaryPatient.id") + @Mapping(target = "primaryPatientName", expression = "java(record.getPrimaryPatient().getUser().getFirstName() + \" \" + record.getPrimaryPatient().getUser().getLastName())") + @Mapping(target = "duplicatePatientId", source = "duplicatePatient.id") + @Mapping(target = "duplicatePatientName", expression = "java(record.getDuplicatePatient().getUser().getFirstName() + \" \" + record.getDuplicatePatient().getUser().getLastName())") + @Mapping(target = "matchReasons", expression = "java(extractReasons(record))") + @Mapping(target = "reviewedById", source = "reviewedBy.id") + @Mapping(target = "reviewedByName", expression = "java(record.getReviewedBy() != null ? record.getReviewedBy().getFirstName() + \" \" + record.getReviewedBy().getLastName() : null)") + DuplicatePatientRecordResponseDto toDuplicatePatientRecordResponseDto(DuplicatePatientRecord record); + + default List extractReasons(DuplicatePatientRecord record) { + if (record.getMatchReasons() == null) { + return List.of(); + } + return record.getMatchReasons().stream() + .map(reason -> reason.getReason()) + .collect(Collectors.toList()); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/LabResultMapper.java b/src/main/java/com/gnx/telemedicine/mappers/LabResultMapper.java new file mode 100644 index 0000000..98af198 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/LabResultMapper.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.medical.LabResultRequestDto; +import com.gnx.telemedicine.dto.medical.LabResultResponseDto; +import com.gnx.telemedicine.model.LabResult; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface LabResultMapper { + + @Mapping(target = "medicalRecord", ignore = true) + @Mapping(target = "patient", ignore = true) + @Mapping(target = "orderedBy", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + LabResult toLabResult(LabResultRequestDto dto); + + @Mapping(target = "medicalRecordId", source = "medicalRecord.id") + @Mapping(target = "patientId", source = "patient.id") + @Mapping(target = "patientName", expression = "java(labResult.getPatient().getUser().getFirstName() + \" \" + labResult.getPatient().getUser().getLastName())") + @Mapping(target = "orderedById", source = "orderedBy.id") + @Mapping(target = "orderedByName", expression = "java(labResult.getOrderedBy() != null ? labResult.getOrderedBy().getFirstName() + \" \" + labResult.getOrderedBy().getLastName() : null)") + LabResultResponseDto toLabResultResponseDto(LabResult labResult); +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/MedicalRecordMapper.java b/src/main/java/com/gnx/telemedicine/mappers/MedicalRecordMapper.java new file mode 100644 index 0000000..f2e405e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/MedicalRecordMapper.java @@ -0,0 +1,30 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.medical.MedicalRecordRequestDto; +import com.gnx.telemedicine.dto.medical.MedicalRecordResponseDto; +import com.gnx.telemedicine.model.MedicalRecord; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface MedicalRecordMapper { + + @Mapping(target = "patient", ignore = true) + @Mapping(target = "doctor", ignore = true) + @Mapping(target = "appointment", ignore = true) + @Mapping(target = "createdBy", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + MedicalRecord toMedicalRecord(MedicalRecordRequestDto dto); + + @Mapping(target = "patientId", source = "patient.id") + @Mapping(target = "patientName", expression = "java(medicalRecord.getPatient().getUser().getFirstName() + \" \" + medicalRecord.getPatient().getUser().getLastName())") + @Mapping(target = "doctorId", source = "doctor.id") + @Mapping(target = "doctorName", expression = "java(medicalRecord.getDoctor().getUser().getFirstName() + \" \" + medicalRecord.getDoctor().getUser().getLastName())") + @Mapping(target = "appointmentId", source = "appointment.id") + @Mapping(target = "createdById", source = "createdBy.id") + @Mapping(target = "createdByName", expression = "java(medicalRecord.getCreatedBy().getFirstName() + \" \" + medicalRecord.getCreatedBy().getLastName())") + MedicalRecordResponseDto toMedicalRecordResponseDto(MedicalRecord medicalRecord); +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/MedicationIntakeLogMapper.java b/src/main/java/com/gnx/telemedicine/mappers/MedicationIntakeLogMapper.java new file mode 100644 index 0000000..5f7c411 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/MedicationIntakeLogMapper.java @@ -0,0 +1,20 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.prescription.MedicationIntakeLogRequestDto; +import com.gnx.telemedicine.dto.prescription.MedicationIntakeLogResponseDto; +import com.gnx.telemedicine.model.MedicationIntakeLog; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface MedicationIntakeLogMapper { + + @Mapping(target = "prescription", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + MedicationIntakeLog toMedicationIntakeLog(MedicationIntakeLogRequestDto dto); + + @Mapping(target = "prescriptionId", source = "prescription.id") + MedicationIntakeLogResponseDto toMedicationIntakeLogResponseDto(MedicationIntakeLog log); +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/MessageMapper.java b/src/main/java/com/gnx/telemedicine/mappers/MessageMapper.java new file mode 100644 index 0000000..f23089b --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/MessageMapper.java @@ -0,0 +1,23 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.message.MessageResponseDto; +import com.gnx.telemedicine.model.Message; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface MessageMapper { + MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class); + + @Mapping(source = "sender.id", target = "senderId") + @Mapping(expression = "java(message.getSender().getFirstName() + \" \" + message.getSender().getLastName())", target = "senderName") + @Mapping(source = "receiver.id", target = "receiverId") + @Mapping(expression = "java(message.getReceiver().getFirstName() + \" \" + message.getReceiver().getLastName())", target = "receiverName") + MessageResponseDto toDto(Message message); + + List toDtoList(List messages); +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/PrescriptionMapper.java b/src/main/java/com/gnx/telemedicine/mappers/PrescriptionMapper.java new file mode 100644 index 0000000..5bfbe7b --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/PrescriptionMapper.java @@ -0,0 +1,32 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.prescription.PrescriptionRequestDto; +import com.gnx.telemedicine.dto.prescription.PrescriptionResponseDto; +import com.gnx.telemedicine.model.Prescription; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface PrescriptionMapper { + + @Mapping(target = "patient", ignore = true) + @Mapping(target = "doctor", ignore = true) + @Mapping(target = "appointment", ignore = true) + @Mapping(target = "createdBy", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "prescriptionNumber", ignore = true) + @Mapping(target = "EPrescriptionSent", ignore = true) + @Mapping(target = "EPrescriptionSentAt", ignore = true) + @Mapping(target = "createdAt", ignore = true) + Prescription toPrescription(PrescriptionRequestDto dto); + + @Mapping(target = "patientId", source = "patient.id") + @Mapping(target = "patientName", expression = "java(prescription.getPatient().getUser().getFirstName() + \" \" + prescription.getPatient().getUser().getLastName())") + @Mapping(target = "doctorId", source = "doctor.id") + @Mapping(target = "doctorName", expression = "java(prescription.getDoctor().getUser().getFirstName() + \" \" + prescription.getDoctor().getUser().getLastName())") + @Mapping(target = "appointmentId", source = "appointment.id") + @Mapping(target = "createdById", source = "createdBy.id") + @Mapping(target = "createdByName", expression = "java(prescription.getCreatedBy().getFirstName() + \" \" + prescription.getCreatedBy().getLastName())") + PrescriptionResponseDto toPrescriptionResponseDto(Prescription prescription); +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/SentinelEventMapper.java b/src/main/java/com/gnx/telemedicine/mappers/SentinelEventMapper.java new file mode 100644 index 0000000..e834131 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/SentinelEventMapper.java @@ -0,0 +1,34 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.patient_safety.SentinelEventRequestDto; +import com.gnx.telemedicine.dto.patient_safety.SentinelEventResponseDto; +import com.gnx.telemedicine.model.SentinelEvent; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface SentinelEventMapper { + + @Mapping(target = "patient", ignore = true) + @Mapping(target = "doctor", ignore = true) + @Mapping(target = "appointment", ignore = true) + @Mapping(target = "reportedBy", ignore = true) + @Mapping(target = "resolvedBy", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "reportedAt", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "status", ignore = true) + SentinelEvent toSentinelEvent(SentinelEventRequestDto dto); + + @Mapping(target = "patientId", source = "patient.id") + @Mapping(target = "patientName", expression = "java(event.getPatient() != null ? event.getPatient().getUser().getFirstName() + \" \" + event.getPatient().getUser().getLastName() : null)") + @Mapping(target = "doctorId", source = "doctor.id") + @Mapping(target = "doctorName", expression = "java(event.getDoctor() != null ? event.getDoctor().getUser().getFirstName() + \" \" + event.getDoctor().getUser().getLastName() : null)") + @Mapping(target = "appointmentId", source = "appointment.id") + @Mapping(target = "reportedById", source = "reportedBy.id") + @Mapping(target = "reportedByName", expression = "java(event.getReportedBy() != null ? event.getReportedBy().getFirstName() + \" \" + event.getReportedBy().getLastName() : null)") + @Mapping(target = "resolvedById", source = "resolvedBy.id") + @Mapping(target = "resolvedByName", expression = "java(event.getResolvedBy() != null ? event.getResolvedBy().getFirstName() + \" \" + event.getResolvedBy().getLastName() : null)") + SentinelEventResponseDto toSentinelEventResponseDto(SentinelEvent event); +} + diff --git a/src/main/java/com/gnx/telemedicine/mappers/UserMapper.java b/src/main/java/com/gnx/telemedicine/mappers/UserMapper.java new file mode 100644 index 0000000..7e9bcfd --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/UserMapper.java @@ -0,0 +1,61 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.admin.UserManagementDto; +import com.gnx.telemedicine.dto.user.*; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserMapper { + + @Mapping(target = "password", source = "password.password") + UserModel toUser(UserRegistrationDto userRegistrationDto); + + Patient toPatient(PatientRegistrationDto patientRegistrationDto); + + Doctor toDoctor(DoctorRegistrationDto doctorRegistrationDto); + + @Mapping(target = "id", source = "id") + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "firstName", source = "user.firstName") + @Mapping(target = "lastName", source = "user.lastName") + @Mapping(target = "phoneNumber", source = "user.phoneNumber") + @Mapping(target = "email", source = "user.email") + @Mapping(target = "bloodType", source = "bloodType") + @Mapping(target = "emergencyContactName", source = "emergencyContactName") + @Mapping(target = "emergencyContactPhone", source = "emergencyContactPhone") + @Mapping(target = "allergies", source = "allergies") + @Mapping(target = "avatarUrl", source = "user.avatarUrl") + PatientResponseDto toPatientResponse(Patient patient); + + @Mapping(target = "id", source = "id") + @Mapping(target = "firstName", source = "user.firstName") + @Mapping(target = "lastName", source = "user.lastName") + @Mapping(target = "phoneNumber", source = "user.phoneNumber") + @Mapping(target = "email", source = "user.email") + @Mapping(target = "medicalLicenseNumber", source = "medicalLicenseNumber") + @Mapping(target = "defaultDurationMinutes", source = "defaultDurationMinutes") + @Mapping(target = "avatarUrl", source = "user.avatarUrl") + DoctorResponseDto toDoctorResponse(Doctor doctor); + + default UserManagementDto toUserManagementDto(UserModel user) { + return new UserManagementDto( + user.getId(), + user.getEmail(), + user.getFirstName(), + user.getLastName(), + user.getPhoneNumber(), + user.getRole().name(), + user.getIsActive(), + user.getCreatedAt(), + user.getAvatarUrl(), + user.getIsOnline(), + user.getUserStatus() != null ? user.getUserStatus().name() : "OFFLINE", + null, // medicalLicenseNumber - set in service layer + null // isVerified - set in service layer + ); + } +} diff --git a/src/main/java/com/gnx/telemedicine/mappers/VitalSignsMapper.java b/src/main/java/com/gnx/telemedicine/mappers/VitalSignsMapper.java new file mode 100644 index 0000000..d57f37f --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/mappers/VitalSignsMapper.java @@ -0,0 +1,39 @@ +package com.gnx.telemedicine.mappers; + +import com.gnx.telemedicine.dto.medical.VitalSignsRequestDto; +import com.gnx.telemedicine.dto.medical.VitalSignsResponseDto; +import com.gnx.telemedicine.model.VitalSigns; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Mapper(componentModel = "spring") +public interface VitalSignsMapper { + + @Mapping(target = "patient", ignore = true) + @Mapping(target = "appointment", ignore = true) + @Mapping(target = "recordedBy", ignore = true) + @Mapping(target = "medicalRecord", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "recordedAt", ignore = true) + @Mapping(target = "bmi", ignore = true) + VitalSigns toVitalSigns(VitalSignsRequestDto dto); + + @Mapping(target = "patientId", source = "patient.id") + @Mapping(target = "patientName", expression = "java(vitalSigns.getPatient().getUser().getFirstName() + \" \" + vitalSigns.getPatient().getUser().getLastName())") + @Mapping(target = "appointmentId", source = "appointment.id") + @Mapping(target = "recordedById", source = "recordedBy.id") + @Mapping(target = "recordedByName", expression = "java(vitalSigns.getRecordedBy() != null ? vitalSigns.getRecordedBy().getFirstName() + \" \" + vitalSigns.getRecordedBy().getLastName() : null)") + VitalSignsResponseDto toVitalSignsResponseDto(VitalSigns vitalSigns); + + default BigDecimal calculateBmi(BigDecimal weightKg, BigDecimal heightCm) { + if (weightKg == null || heightCm == null || heightCm.compareTo(BigDecimal.ZERO) == 0) { + return null; + } + BigDecimal heightM = heightCm.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + return weightKg.divide(heightM.multiply(heightM), 1, RoundingMode.HALF_UP); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/metrics/MetricsInterceptor.java b/src/main/java/com/gnx/telemedicine/metrics/MetricsInterceptor.java new file mode 100644 index 0000000..552e9fe --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/metrics/MetricsInterceptor.java @@ -0,0 +1,61 @@ +package com.gnx.telemedicine.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * Interceptor to record API metrics for all requests. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class MetricsInterceptor implements HandlerInterceptor { + + private final TelemedicineMetrics telemedicineMetrics; + private final MeterRegistry meterRegistry; + private static final String START_TIME_ATTRIBUTE = "metrics.startTime"; + private static final String TIMER_SAMPLE_ATTRIBUTE = "metrics.timerSample"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // Record start time + request.setAttribute(START_TIME_ATTRIBUTE, System.currentTimeMillis()); + + // Start timer + Timer.Sample sample = Timer.start(meterRegistry); + request.setAttribute(TIMER_SAMPLE_ATTRIBUTE, sample); + + // Record API request + String endpoint = request.getRequestURI(); + String method = request.getMethod(); + telemedicineMetrics.recordApiRequest(endpoint, method); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // Record response time + Timer.Sample sample = (Timer.Sample) request.getAttribute(TIMER_SAMPLE_ATTRIBUTE); + if (sample != null) { + String endpoint = request.getRequestURI(); + String method = request.getMethod(); + + // Record response time + telemedicineMetrics.recordApiResponseTime(sample, endpoint, method); + + // Record errors + if (ex != null || response.getStatus() >= 400) { + String status = String.valueOf(response.getStatus()); + telemedicineMetrics.recordApiError(endpoint, method, status); + } + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/metrics/TelemedicineMetrics.java b/src/main/java/com/gnx/telemedicine/metrics/TelemedicineMetrics.java new file mode 100644 index 0000000..b5d0e03 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/metrics/TelemedicineMetrics.java @@ -0,0 +1,346 @@ +package com.gnx.telemedicine.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.Gauge; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Custom metrics for telemedicine application. + * Provides business-specific metrics for monitoring and observability. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TelemedicineMetrics { + + private final MeterRegistry meterRegistry; + + // Counters + private Counter loginAttemptsCounter; + private Counter loginSuccessCounter; + private Counter loginFailureCounter; + private Counter appointmentCreatedCounter; + private Counter appointmentCancelledCounter; + private Counter appointmentCompletedCounter; + private Counter prescriptionCreatedCounter; + private Counter messageSentCounter; + private Counter apiRequestCounter; + private Counter apiErrorCounter; + private Counter passwordResetRequestCounter; + private Counter passwordResetSuccessCounter; + private Counter twoFactorAuthEnabledCounter; + private Counter twoFactorAuthVerifiedCounter; + private Counter phiAccessCounter; + private Counter breachNotificationCounter; + + // Timers + private Timer apiResponseTimeTimer; + private Timer databaseQueryTimer; + private Timer authenticationTimer; + private Timer appointmentCreationTimer; + private Timer prescriptionCreationTimer; + private Timer messageProcessingTimer; + + // Gauges + private final AtomicInteger activeUsers = new AtomicInteger(0); + private final AtomicInteger activeAppointments = new AtomicInteger(0); + private final AtomicInteger activePrescriptions = new AtomicInteger(0); + private final AtomicLong totalUsers = new AtomicLong(0); + private final AtomicLong totalAppointments = new AtomicLong(0); + private final AtomicLong totalPrescriptions = new AtomicLong(0); + + /** + * Initialize metrics after construction. + */ + public void initialize() { + // Initialize counters + loginAttemptsCounter = Counter.builder("telemedicine.auth.login.attempts") + .description("Total number of login attempts") + .tag("type", "attempt") + .register(meterRegistry); + + loginSuccessCounter = Counter.builder("telemedicine.auth.login.success") + .description("Total number of successful logins") + .tag("type", "success") + .register(meterRegistry); + + loginFailureCounter = Counter.builder("telemedicine.auth.login.failure") + .description("Total number of failed logins") + .tag("type", "failure") + .register(meterRegistry); + + appointmentCreatedCounter = Counter.builder("telemedicine.appointments.created") + .description("Total number of appointments created") + .register(meterRegistry); + + appointmentCancelledCounter = Counter.builder("telemedicine.appointments.cancelled") + .description("Total number of appointments cancelled") + .register(meterRegistry); + + appointmentCompletedCounter = Counter.builder("telemedicine.appointments.completed") + .description("Total number of appointments completed") + .register(meterRegistry); + + prescriptionCreatedCounter = Counter.builder("telemedicine.prescriptions.created") + .description("Total number of prescriptions created") + .register(meterRegistry); + + messageSentCounter = Counter.builder("telemedicine.messages.sent") + .description("Total number of messages sent") + .register(meterRegistry); + + apiRequestCounter = Counter.builder("telemedicine.api.requests") + .description("Total number of API requests") + .register(meterRegistry); + + apiErrorCounter = Counter.builder("telemedicine.api.errors") + .description("Total number of API errors") + .register(meterRegistry); + + passwordResetRequestCounter = Counter.builder("telemedicine.auth.password.reset.requests") + .description("Total number of password reset requests") + .register(meterRegistry); + + passwordResetSuccessCounter = Counter.builder("telemedicine.auth.password.reset.success") + .description("Total number of successful password resets") + .register(meterRegistry); + + twoFactorAuthEnabledCounter = Counter.builder("telemedicine.auth.2fa.enabled") + .description("Total number of 2FA enabled") + .register(meterRegistry); + + twoFactorAuthVerifiedCounter = Counter.builder("telemedicine.auth.2fa.verified") + .description("Total number of 2FA verifications") + .register(meterRegistry); + + phiAccessCounter = Counter.builder("telemedicine.phi.access") + .description("Total number of PHI access events") + .register(meterRegistry); + + breachNotificationCounter = Counter.builder("telemedicine.breach.notifications") + .description("Total number of breach notifications") + .register(meterRegistry); + + // Initialize timers + apiResponseTimeTimer = Timer.builder("telemedicine.api.response.time") + .description("API response time") + .register(meterRegistry); + + databaseQueryTimer = Timer.builder("telemedicine.database.query.time") + .description("Database query execution time") + .register(meterRegistry); + + authenticationTimer = Timer.builder("telemedicine.auth.authentication.time") + .description("Authentication processing time") + .register(meterRegistry); + + appointmentCreationTimer = Timer.builder("telemedicine.appointments.creation.time") + .description("Appointment creation time") + .register(meterRegistry); + + prescriptionCreationTimer = Timer.builder("telemedicine.prescriptions.creation.time") + .description("Prescription creation time") + .register(meterRegistry); + + messageProcessingTimer = Timer.builder("telemedicine.messages.processing.time") + .description("Message processing time") + .register(meterRegistry); + + // Initialize gauges + Gauge.builder("telemedicine.users.active", activeUsers, AtomicInteger::get) + .description("Number of active users") + .register(meterRegistry); + + Gauge.builder("telemedicine.appointments.active", activeAppointments, AtomicInteger::get) + .description("Number of active appointments") + .register(meterRegistry); + + Gauge.builder("telemedicine.prescriptions.active", activePrescriptions, AtomicInteger::get) + .description("Number of active prescriptions") + .register(meterRegistry); + + Gauge.builder("telemedicine.users.total", totalUsers, AtomicLong::get) + .description("Total number of users") + .register(meterRegistry); + + Gauge.builder("telemedicine.appointments.total", totalAppointments, AtomicLong::get) + .description("Total number of appointments") + .register(meterRegistry); + + Gauge.builder("telemedicine.prescriptions.total", totalPrescriptions, AtomicLong::get) + .description("Total number of prescriptions") + .register(meterRegistry); + + log.info("Telemedicine metrics initialized"); + } + + // Authentication metrics + public void recordLoginAttempt() { + loginAttemptsCounter.increment(); + } + + public void recordLoginSuccess() { + loginSuccessCounter.increment(); + } + + public void recordLoginFailure() { + loginFailureCounter.increment(); + } + + public Timer.Sample startAuthenticationTimer() { + return Timer.start(meterRegistry); + } + + public void recordAuthenticationTime(Timer.Sample sample) { + sample.stop(authenticationTimer); + } + + // Appointment metrics + public void recordAppointmentCreated() { + appointmentCreatedCounter.increment(); + activeAppointments.incrementAndGet(); + totalAppointments.incrementAndGet(); + } + + public void recordAppointmentCancelled() { + appointmentCancelledCounter.increment(); + activeAppointments.decrementAndGet(); + } + + public void recordAppointmentCompleted() { + appointmentCompletedCounter.increment(); + activeAppointments.decrementAndGet(); + } + + public Timer.Sample startAppointmentCreationTimer() { + return Timer.start(meterRegistry); + } + + public void recordAppointmentCreationTime(Timer.Sample sample) { + sample.stop(appointmentCreationTimer); + } + + // Prescription metrics + public void recordPrescriptionCreated() { + prescriptionCreatedCounter.increment(); + activePrescriptions.incrementAndGet(); + totalPrescriptions.incrementAndGet(); + } + + public Timer.Sample startPrescriptionCreationTimer() { + return Timer.start(meterRegistry); + } + + public void recordPrescriptionCreationTime(Timer.Sample sample) { + sample.stop(prescriptionCreationTimer); + } + + // Message metrics + public void recordMessageSent() { + messageSentCounter.increment(); + } + + public Timer.Sample startMessageProcessingTimer() { + return Timer.start(meterRegistry); + } + + public void recordMessageProcessingTime(Timer.Sample sample) { + sample.stop(messageProcessingTimer); + } + + // API metrics + public void recordApiRequest(String endpoint, String method) { + meterRegistry.counter("telemedicine.api.requests", + io.micrometer.core.instrument.Tags.of("endpoint", endpoint, "method", method) + ).increment(); + } + + public void recordApiError(String endpoint, String method, String status) { + meterRegistry.counter("telemedicine.api.errors", + io.micrometer.core.instrument.Tags.of("endpoint", endpoint, "method", method, "status", status) + ).increment(); + } + + public Timer.Sample startApiResponseTimer() { + return Timer.start(meterRegistry); + } + + public void recordApiResponseTime(Timer.Sample sample, String endpoint, String method) { + sample.stop(Timer.builder("telemedicine.api.response.time") + .description("API response time") + .tag("endpoint", endpoint) + .tag("method", method) + .register(meterRegistry)); + } + + // Database metrics + public Timer.Sample startDatabaseQueryTimer() { + return Timer.start(meterRegistry); + } + + public void recordDatabaseQueryTime(Timer.Sample sample, String queryType) { + sample.stop(Timer.builder("telemedicine.database.query.time") + .description("Database query execution time") + .tag("query.type", queryType) + .register(meterRegistry)); + } + + // Password reset metrics + public void recordPasswordResetRequest() { + passwordResetRequestCounter.increment(); + } + + public void recordPasswordResetSuccess() { + passwordResetSuccessCounter.increment(); + } + + // 2FA metrics + public void record2FAEnabled() { + twoFactorAuthEnabledCounter.increment(); + } + + public void record2FAVerified() { + twoFactorAuthVerifiedCounter.increment(); + } + + // PHI access metrics + public void recordPHIAccess(String resourceType, String action) { + meterRegistry.counter("telemedicine.phi.access", + io.micrometer.core.instrument.Tags.of("resource", resourceType, "action", action) + ).increment(); + } + + // Breach notification metrics + public void recordBreachNotification() { + breachNotificationCounter.increment(); + } + + // User metrics + public void incrementActiveUsers() { + activeUsers.incrementAndGet(); + } + + public void decrementActiveUsers() { + activeUsers.decrementAndGet(); + } + + public void setTotalUsers(long count) { + totalUsers.set(count); + } + + public void setTotalAppointments(long count) { + totalAppointments.set(count); + } + + public void setTotalPrescriptions(long count) { + totalPrescriptions.set(count); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/model/AccountingOfDisclosure.java b/src/main/java/com/gnx/telemedicine/model/AccountingOfDisclosure.java new file mode 100644 index 0000000..00399d9 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/AccountingOfDisclosure.java @@ -0,0 +1,58 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.DisclosureType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "accounting_of_disclosures") +public class AccountingOfDisclosure { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @Column(name = "disclosure_date", nullable = false, updatable = false) + private Instant disclosureDate = Instant.now(); + + @Enumerated(EnumType.STRING) + @Column(name = "disclosure_type", nullable = false) + private DisclosureType disclosureType; + + @Column(name = "purpose", nullable = false, columnDefinition = "TEXT") + private String purpose; + + @Column(name = "recipient_name") + private String recipientName; + + @Column(name = "recipient_address", columnDefinition = "TEXT") + private String recipientAddress; + + @Column(name = "information_disclosed", columnDefinition = "TEXT") + private String informationDisclosed; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "authorized_by") + private UserModel authorizedBy; + + @Column(name = "authorization_date") + private Instant authorizationDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/Appointment.java b/src/main/java/com/gnx/telemedicine/model/Appointment.java new file mode 100644 index 0000000..3aa3a1e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/Appointment.java @@ -0,0 +1,53 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.AppointmentStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "appointments") +public class Appointment { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id") + private Patient patient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doctor_id") + private Doctor doctor; + + @Column(name = "scheduled_date") + private LocalDate scheduledDate; + + @Column(name = "scheduled_time") + private LocalTime scheduledTime; + + @Column(name = "duration_minutes") + private Integer durationMinutes; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private AppointmentStatus status; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "deleted_by_patient") + private Boolean deletedByPatient = false; + + @Column(name = "deleted_by_doctor") + private Boolean deletedByDoctor = false; + +} \ No newline at end of file diff --git a/src/main/java/com/gnx/telemedicine/model/BreachNotification.java b/src/main/java/com/gnx/telemedicine/model/BreachNotification.java new file mode 100644 index 0000000..ef3d46c --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/BreachNotification.java @@ -0,0 +1,61 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.BreachStatus; +import com.gnx.telemedicine.model.enums.BreachType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "breach_notifications") +public class BreachNotification { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @Column(name = "incident_date", nullable = false) + private LocalDate incidentDate; + + @Column(name = "discovery_date", nullable = false) + private LocalDate discoveryDate; + + @Enumerated(EnumType.STRING) + @Column(name = "breach_type", nullable = false) + private BreachType breachType; + + @Column(name = "affected_patients_count") + private Integer affectedPatientsCount; + + @Column(name = "description", nullable = false, columnDefinition = "TEXT") + private String description; + + @Column(name = "mitigation_steps", columnDefinition = "TEXT") + private String mitigationSteps; + + @Column(name = "notified_at") + private Instant notifiedAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private BreachStatus status = BreachStatus.INVESTIGATING; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false) + private UserModel createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/ClinicalAlert.java b/src/main/java/com/gnx/telemedicine/model/ClinicalAlert.java new file mode 100644 index 0000000..b7cbcf3 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/ClinicalAlert.java @@ -0,0 +1,73 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.AlertSeverity; +import com.gnx.telemedicine.model.enums.ClinicalAlertType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "clinical_alerts") +public class ClinicalAlert { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doctor_id") + private Doctor doctor; + + @Enumerated(EnumType.STRING) + @Column(name = "alert_type", nullable = false) + private ClinicalAlertType alertType; + + @Enumerated(EnumType.STRING) + @Column(name = "severity", nullable = false) + private AlertSeverity severity; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description", nullable = false, columnDefinition = "TEXT") + private String description; + + @Column(name = "medication_name") + private String medicationName; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "related_prescription_id") + private Prescription relatedPrescription; + + @Column(name = "acknowledged") + private Boolean acknowledged = false; + + @Column(name = "acknowledged_at") + private Instant acknowledgedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "acknowledged_by") + private UserModel acknowledgedBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "resolved_at") + private Instant resolvedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resolved_by") + private UserModel resolvedBy; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/CriticalResult.java b/src/main/java/com/gnx/telemedicine/model/CriticalResult.java new file mode 100644 index 0000000..9f095cf --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/CriticalResult.java @@ -0,0 +1,79 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.CriticalityLevel; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "critical_results") +public class CriticalResult { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lab_result_id", nullable = false) + private LabResult labResult; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doctor_id", nullable = false) + private Doctor doctor; + + @Enumerated(EnumType.STRING) + @Column(name = "criticality_level", nullable = false) + private CriticalityLevel criticalityLevel; + + @Column(name = "test_name", nullable = false) + private String testName; + + @Column(name = "result_value") + private String resultValue; + + @Column(name = "reference_range") + private String referenceRange; + + @Column(name = "clinical_significance", columnDefinition = "TEXT") + private String clinicalSignificance; + + @Column(name = "acknowledgment_required") + private Boolean acknowledgmentRequired = true; + + @Column(name = "acknowledged") + private Boolean acknowledged = false; + + @Column(name = "acknowledged_at") + private Instant acknowledgedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "acknowledged_by") + private UserModel acknowledgedBy; + + @Column(name = "acknowledgment_method") + private String acknowledgmentMethod; // CALL, EMAIL, SMS, IN_PERSON + + @Column(name = "follow_up_required") + private Boolean followUpRequired = true; + + @Column(name = "follow_up_status") + private String followUpStatus; + + @Column(name = "notified_at") + private Instant notifiedAt; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/DataMinimizationLog.java b/src/main/java/com/gnx/telemedicine/model/DataMinimizationLog.java new file mode 100644 index 0000000..fd2528c --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/DataMinimizationLog.java @@ -0,0 +1,45 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "data_minimization_logs") +public class DataMinimizationLog { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @Column(name = "action_type", nullable = false) + private String actionType; // COLLECTED, ACCESSED, MODIFIED, DELETED + + @Column(name = "data_category", nullable = false) + private String dataCategory; + + @Column(name = "purpose", columnDefinition = "TEXT") + private String purpose; + + @Column(name = "legal_basis") + private String legalBasis; + + @Column(name = "timestamp", nullable = false, updatable = false) + private Instant timestamp = Instant.now(); + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "user_agent", columnDefinition = "TEXT") + private String userAgent; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/DataProcessingRecord.java b/src/main/java/com/gnx/telemedicine/model/DataProcessingRecord.java new file mode 100644 index 0000000..2a3318f --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/DataProcessingRecord.java @@ -0,0 +1,59 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "data_processing_records") +public class DataProcessingRecord { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @Column(name = "processing_purpose", nullable = false) + private String processingPurpose; + + @Column(name = "data_categories", columnDefinition = "TEXT[]") + private List dataCategories; + + @Column(name = "data_subjects", columnDefinition = "TEXT[]") + private List dataSubjects; + + @Column(name = "recipients", columnDefinition = "TEXT[]") + private List recipients; + + @Column(name = "transfers_to_third_countries", columnDefinition = "TEXT[]") + private List transfersToThirdCountries; + + @Column(name = "retention_period") + private String retentionPeriod; + + @Column(name = "security_measures", columnDefinition = "TEXT[]") + private List securityMeasures; + + @Column(name = "data_controller") + private String dataController; + + @Column(name = "data_processor") + private String dataProcessor; + + @Column(name = "legal_basis") + private String legalBasis; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/DataRetentionPolicy.java b/src/main/java/com/gnx/telemedicine/model/DataRetentionPolicy.java new file mode 100644 index 0000000..1225845 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/DataRetentionPolicy.java @@ -0,0 +1,45 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "data_retention_policies", uniqueConstraints = { + @UniqueConstraint(columnNames = {"data_type"}) +}) +public class DataRetentionPolicy { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @Column(name = "data_type", nullable = false, unique = true) + private String dataType; + + @Column(name = "retention_period_days", nullable = false) + private Integer retentionPeriodDays; + + @Column(name = "auto_delete_enabled") + private Boolean autoDeleteEnabled = false; + + @Column(name = "legal_requirement", columnDefinition = "TEXT") + private String legalRequirement; + + @Column(name = "last_cleanup_at") + private Instant lastCleanupAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/DataSubjectConsent.java b/src/main/java/com/gnx/telemedicine/model/DataSubjectConsent.java new file mode 100644 index 0000000..515f871 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/DataSubjectConsent.java @@ -0,0 +1,65 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.ConsentStatus; +import com.gnx.telemedicine.model.enums.ConsentType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "data_subject_consents", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "consent_type"}) +}) +public class DataSubjectConsent { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @Enumerated(EnumType.STRING) + @Column(name = "consent_type", nullable = false) + private ConsentType consentType; + + @Enumerated(EnumType.STRING) + @Column(name = "consent_status", nullable = false) + private ConsentStatus consentStatus = ConsentStatus.PENDING; + + @Column(name = "consent_version") + private String consentVersion; + + @Column(name = "consent_method") + private String consentMethod; // WEB_FORM, EMAIL, IN_PERSON, API + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "user_agent", columnDefinition = "TEXT") + private String userAgent; + + @Column(name = "granted_at") + private Instant grantedAt; + + @Column(name = "withdrawn_at") + private Instant withdrawnAt; + + @Column(name = "expires_at") + private Instant expiresAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/DataSubjectRequest.java b/src/main/java/com/gnx/telemedicine/model/DataSubjectRequest.java new file mode 100644 index 0000000..f053ce8 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/DataSubjectRequest.java @@ -0,0 +1,77 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.DataSubjectRequestStatus; +import com.gnx.telemedicine.model.enums.DataSubjectRequestType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "data_subject_requests") +public class DataSubjectRequest { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @Enumerated(EnumType.STRING) + @Column(name = "request_type", nullable = false) + private DataSubjectRequestType requestType; + + @Enumerated(EnumType.STRING) + @Column(name = "request_status", nullable = false) + private DataSubjectRequestStatus requestStatus = DataSubjectRequestStatus.PENDING; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "requested_at", nullable = false, updatable = false) + private Instant requestedAt = Instant.now(); + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(name = "rejected_at") + private Instant rejectedAt; + + @Column(name = "rejection_reason", columnDefinition = "TEXT") + private String rejectionReason; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "response_data", columnDefinition = "jsonb") + private Map responseData; + + @Column(name = "verification_token") + private String verificationToken; + + @Column(name = "verified_at") + private Instant verifiedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "processed_by") + private UserModel processedBy; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/Doctor.java b/src/main/java/com/gnx/telemedicine/model/Doctor.java new file mode 100644 index 0000000..9365f66 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/Doctor.java @@ -0,0 +1,93 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "doctors") +public class Doctor { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private UserModel user; + + @Column(name = "medical_license_number") + private String medicalLicenseNumber; + + @Column(name = "specialization") + private String specialization; + + @Column(name = "years_of_experience") + private Integer yearsOfExperience; + + @Column(name = "biography") + private String biography; + + @Column(name = "consultation_fee") + private BigDecimal consultationFee; + + @Column(name = "default_duration_minutes", columnDefinition = "INTEGER DEFAULT 30") + private Integer defaultDurationMinutes; + + @Column(name = "is_verified") + private Boolean isVerified; + + @Column(name = "street_address") + private String streetAddress; + + @Column(name = "city") + private String city; + + @Column(name = "state") + private String state; + + @Column(name = "zip_code") + private String zipCode; + + @Column(name = "country") + private String country; + + @Column(name = "education_degree") + private String educationDegree; + + @Column(name = "education_university") + private String educationUniversity; + + @Column(name = "education_graduation_year") + private Integer educationGraduationYear; + + @Column(name = "certifications") + private java.util.List certifications; + + @Column(name = "languages_spoken") + private java.util.List languagesSpoken; + + @Column(name = "hospital_affiliations") + private java.util.List hospitalAffiliations; + + @Column(name = "insurance_accepted") + private java.util.List insuranceAccepted; + + @Column(name = "professional_memberships") + private java.util.List professionalMemberships; + + @CreationTimestamp + @Column(name = "created_at") + private LocalDate createdAt; + +} \ No newline at end of file diff --git a/src/main/java/com/gnx/telemedicine/model/DoctorAvailability.java b/src/main/java/com/gnx/telemedicine/model/DoctorAvailability.java new file mode 100644 index 0000000..31597b2 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/DoctorAvailability.java @@ -0,0 +1,49 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.DayOfWeek; +import jakarta.persistence.*; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.*; + +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "doctor_availability") +public class DoctorAvailability { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doctor_id") + private Doctor doctor; + + @Enumerated(EnumType.STRING) + @Column(name = "day_of_week") + private DayOfWeek dayOfWeek; + + @Column(name = "start_time") + private LocalTime startTime; + + @Column(name = "end_time") + private LocalTime endTime; + + @Column(name = "is_available") + private Boolean isAvailable; + + @CreationTimestamp + @Column(name = "created_at") + private OffsetDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + +} \ No newline at end of file diff --git a/src/main/java/com/gnx/telemedicine/model/DuplicateMatchReason.java b/src/main/java/com/gnx/telemedicine/model/DuplicateMatchReason.java new file mode 100644 index 0000000..a5c1c46 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/DuplicateMatchReason.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "duplicate_match_reasons") +public class DuplicateMatchReason { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "duplicate_record_id", nullable = false) + private DuplicatePatientRecord duplicateRecord; + + @Column(name = "reason", nullable = false, columnDefinition = "TEXT") + private String reason; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/DuplicatePatientRecord.java b/src/main/java/com/gnx/telemedicine/model/DuplicatePatientRecord.java new file mode 100644 index 0000000..8ac96b8 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/DuplicatePatientRecord.java @@ -0,0 +1,65 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.DuplicateStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "duplicate_patient_records") +public class DuplicatePatientRecord { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "primary_patient_id", nullable = false) + private Patient primaryPatient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "duplicate_patient_id", nullable = false) + private Patient duplicatePatient; + + @Column(name = "match_score", nullable = false, precision = 5, scale = 2) + private BigDecimal matchScore; // 0-100 + + @OneToMany(mappedBy = "duplicateRecord", cascade = CascadeType.ALL, orphanRemoval = true) + private List matchReasons; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private DuplicateStatus status = DuplicateStatus.PENDING; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reviewed_by") + private UserModel reviewedBy; + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + @Column(name = "review_notes", columnDefinition = "TEXT") + private String reviewNotes; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @PrePersist + @PreUpdate + private void ensureUniquePatients() { + if (primaryPatient != null && duplicatePatient != null && + primaryPatient.getId().equals(duplicatePatient.getId())) { + throw new IllegalArgumentException("Primary and duplicate patient cannot be the same"); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/model/HipaaAuditLog.java b/src/main/java/com/gnx/telemedicine/model/HipaaAuditLog.java new file mode 100644 index 0000000..a7ab6f1 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/HipaaAuditLog.java @@ -0,0 +1,61 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.ActionType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "hipaa_audit_logs") +public class HipaaAuditLog { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @Enumerated(EnumType.STRING) + @Column(name = "action_type", nullable = false) + private ActionType actionType; + + @Column(name = "resource_type", nullable = false) + private String resourceType; // MEDICAL_RECORD, PRESCRIPTION, APPOINTMENT, etc. + + @Column(name = "resource_id", nullable = false) + private UUID resourceId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id") + private Patient patient; + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "user_agent", columnDefinition = "TEXT") + private String userAgent; + + @Column(name = "timestamp", nullable = false) + private Instant timestamp = Instant.now(); + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "details", columnDefinition = "jsonb") + private Map details; + + @Column(name = "success") + private Boolean success = true; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/LabResult.java b/src/main/java/com/gnx/telemedicine/model/LabResult.java new file mode 100644 index 0000000..dafa462 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/LabResult.java @@ -0,0 +1,63 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.LabResultStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "lab_results") +public class LabResult { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "medical_record_id") + private MedicalRecord medicalRecord; // Optional: lab results can exist independently + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + @Column(name = "test_name", nullable = false) + private String testName; + + @Column(name = "test_code") + private String testCode; + + @Column(name = "result_value") + private String resultValue; + + @Column(name = "unit") + private String unit; + + @Column(name = "reference_range") + private String referenceRange; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private LabResultStatus status = LabResultStatus.PENDING; + + @Column(name = "performed_at") + private Instant performedAt; + + @Column(name = "result_file_url", columnDefinition = "TEXT") + private String resultFileUrl; // For PDF/lab reports + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ordered_by") + private UserModel orderedBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/LoginAttempt.java b/src/main/java/com/gnx/telemedicine/model/LoginAttempt.java new file mode 100644 index 0000000..e321429 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/LoginAttempt.java @@ -0,0 +1,35 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "login_attempts") +public class LoginAttempt { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @Column(name = "email", nullable = false, length = 100) + private String email; + + @Column(name = "ip_address", length = 45) + private String ipAddress; + + @Column(name = "success") + private Boolean success = false; + + @Column(name = "failure_reason", length = 255) + private String failureReason; + + @Column(name = "timestamp", nullable = false) + private Instant timestamp = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/MedicalRecord.java b/src/main/java/com/gnx/telemedicine/model/MedicalRecord.java new file mode 100644 index 0000000..7e379f9 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/MedicalRecord.java @@ -0,0 +1,58 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.RecordType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "medical_records") +public class MedicalRecord { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doctor_id", nullable = false) + private Doctor doctor; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "appointment_id") + private Appointment appointment; + + @Enumerated(EnumType.STRING) + @Column(name = "record_type", nullable = false) + private RecordType recordType; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "diagnosis_code") + private String diagnosisCode; // ICD-10 codes + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false) + private UserModel createdBy; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/MedicationIntakeLog.java b/src/main/java/com/gnx/telemedicine/model/MedicationIntakeLog.java new file mode 100644 index 0000000..962b9a1 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/MedicationIntakeLog.java @@ -0,0 +1,41 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "medication_intake_logs") +public class MedicationIntakeLog { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "prescription_id", nullable = false) + private Prescription prescription; + + @Column(name = "scheduled_time", nullable = false) + private Instant scheduledTime; + + @Column(name = "taken_at") + private Instant takenAt; + + @Column(name = "taken") + private Boolean taken = false; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/Message.java b/src/main/java/com/gnx/telemedicine/model/Message.java new file mode 100644 index 0000000..35bd851 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/Message.java @@ -0,0 +1,45 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "messages") +public class Message { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = false) + private UserModel sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = false) + private UserModel receiver; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "is_read", nullable = false) + private Boolean isRead = false; + + @Column(name = "deleted_by_sender") + private Boolean deletedBySender = false; + + @Column(name = "deleted_by_receiver") + private Boolean deletedByReceiver = false; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/PasswordHistory.java b/src/main/java/com/gnx/telemedicine/model/PasswordHistory.java new file mode 100644 index 0000000..56de84a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/PasswordHistory.java @@ -0,0 +1,47 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +/** + * Entity for storing password history to prevent password reuse. + */ +@Entity +@Table(name = "password_history", indexes = { + @Index(name = "idx_password_history_user_id", columnList = "user_id"), + @Index(name = "idx_password_history_created_at", columnList = "created_at") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PasswordHistory { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/model/Patient.java b/src/main/java/com/gnx/telemedicine/model/Patient.java new file mode 100644 index 0000000..4ab88d4 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/Patient.java @@ -0,0 +1,90 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "patients") +public class Patient { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private UserModel user; + + @Column(name = "emergency_contact_name") + private String emergencyContactName; + + @Column(name = "emergency_contact_phone") + private String emergencyContactPhone; + + @Column(name = "blood_type") + private String bloodType; + + @Column(name = "allergies") + private List allergies; + + @Column(name = "date_of_birth") + private java.time.LocalDate dateOfBirth; + + @Column(name = "gender") + private String gender; + + @Column(name = "street_address") + private String streetAddress; + + @Column(name = "city") + private String city; + + @Column(name = "state") + private String state; + + @Column(name = "zip_code") + private String zipCode; + + @Column(name = "country") + private String country; + + @Column(name = "insurance_provider") + private String insuranceProvider; + + @Column(name = "insurance_policy_number") + private String insurancePolicyNumber; + + @Column(name = "medical_history_summary") + private String medicalHistorySummary; + + @Column(name = "current_medications") + private List currentMedications; + + @Column(name = "primary_care_physician_name") + private String primaryCarePhysicianName; + + @Column(name = "primary_care_physician_phone") + private String primaryCarePhysicianPhone; + + @Column(name = "preferred_language") + private String preferredLanguage; + + @Column(name = "occupation") + private String occupation; + + @Column(name = "marital_status") + private String maritalStatus; + + @CreationTimestamp + @Column(name = "created_at") + private LocalDate createdAt; + +} \ No newline at end of file diff --git a/src/main/java/com/gnx/telemedicine/model/PhiAccessLog.java b/src/main/java/com/gnx/telemedicine/model/PhiAccessLog.java new file mode 100644 index 0000000..d70c820 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/PhiAccessLog.java @@ -0,0 +1,47 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "phi_access_logs") +public class PhiAccessLog { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + @Column(name = "access_type", nullable = false) + private String accessType; // Treatment, Payment, Operations, Authorization + + @Column(name = "accessed_fields", columnDefinition = "TEXT[]") + private List accessedFields; + + @Column(name = "purpose") + private String purpose; + + @Column(name = "timestamp", nullable = false) + private Instant timestamp = Instant.now(); + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "user_agent", columnDefinition = "TEXT") + private String userAgent; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/Prescription.java b/src/main/java/com/gnx/telemedicine/model/Prescription.java new file mode 100644 index 0000000..321f8c5 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/Prescription.java @@ -0,0 +1,92 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.PrescriptionStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "prescriptions") +public class Prescription { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doctor_id", nullable = false) + private Doctor doctor; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "appointment_id") + private Appointment appointment; + + @Column(name = "medication_name", nullable = false) + private String medicationName; + + @Column(name = "medication_code") + private String medicationCode; // NDC code + + @Column(name = "dosage", nullable = false) + private String dosage; + + @Column(name = "frequency", nullable = false) + private String frequency; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Column(name = "refills") + private Integer refills = 0; + + @Column(name = "instructions", columnDefinition = "TEXT") + private String instructions; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private PrescriptionStatus status = PrescriptionStatus.ACTIVE; + + @Column(name = "pharmacy_name") + private String pharmacyName; + + @Column(name = "pharmacy_address", columnDefinition = "TEXT") + private String pharmacyAddress; + + @Column(name = "pharmacy_phone") + private String pharmacyPhone; + + @Column(name = "prescription_number", unique = true) + private String prescriptionNumber; + + @Column(name = "e_prescription_sent") + private Boolean ePrescriptionSent = false; + + @Column(name = "e_prescription_sent_at") + private Instant ePrescriptionSentAt; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false) + private UserModel createdBy; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/RefreshToken.java b/src/main/java/com/gnx/telemedicine/model/RefreshToken.java new file mode 100644 index 0000000..b8b2078 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/RefreshToken.java @@ -0,0 +1,107 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +/** + * Entity for storing refresh tokens. + * Refresh tokens are long-lived tokens used to obtain new access tokens. + */ +@Entity +@Table(name = "refresh_tokens", indexes = { + @Index(name = "idx_refresh_token_token", columnList = "token"), + @Index(name = "idx_refresh_token_user_id", columnList = "user_id"), + @Index(name = "idx_refresh_token_expiry", columnList = "expiry_date") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true, length = 500) + private String token; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @Column(name = "expiry_date", nullable = false) + private Instant expiryDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "last_used_at") + private Instant lastUsedAt; + + @Column(name = "device_fingerprint", length = 500) + private String deviceFingerprint; + + @Column(name = "ip_address", length = 45) + private String ipAddress; + + @Column(name = "user_agent", length = 500) + private String userAgent; + + @Column(name = "revoked", nullable = false) + @Builder.Default + private Boolean revoked = false; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Column(name = "revoked_reason", length = 500) + private String revokedReason; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + if (revoked == null) { + revoked = false; + } + } + + /** + * Check if the refresh token is expired. + */ + public boolean isExpired() { + return expiryDate.isBefore(Instant.now()); + } + + /** + * Check if the refresh token is valid (not expired and not revoked). + */ + public boolean isValid() { + return !revoked && !isExpired(); + } + + /** + * Revoke the refresh token. + */ + public void revoke(String reason) { + this.revoked = true; + this.revokedAt = Instant.now(); + this.revokedReason = reason; + } + + /** + * Update last used timestamp. + */ + public void updateLastUsed() { + this.lastUsedAt = Instant.now(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/model/SentinelEvent.java b/src/main/java/com/gnx/telemedicine/model/SentinelEvent.java new file mode 100644 index 0000000..3460f27 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/SentinelEvent.java @@ -0,0 +1,84 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.SentinelEventStatus; +import com.gnx.telemedicine.model.enums.SentinelEventType; +import com.gnx.telemedicine.model.enums.SentinelSeverity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "sentinel_events") +public class SentinelEvent { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false) + private SentinelEventType eventType; + + @Enumerated(EnumType.STRING) + @Column(name = "severity", nullable = false) + private SentinelSeverity severity; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id") + private Patient patient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "doctor_id") + private Doctor doctor; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "appointment_id") + private Appointment appointment; + + @Column(name = "description", nullable = false, columnDefinition = "TEXT") + private String description; + + @Column(name = "location") + private String location; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Column(name = "reported_at", nullable = false, updatable = false) + private Instant reportedAt = Instant.now(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_by", nullable = false) + private UserModel reportedBy; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private SentinelEventStatus status = SentinelEventStatus.REPORTED; + + @Column(name = "investigation_notes", columnDefinition = "TEXT") + private String investigationNotes; + + @Column(name = "root_cause_analysis", columnDefinition = "TEXT") + private String rootCauseAnalysis; + + @Column(name = "corrective_action", columnDefinition = "TEXT") + private String correctiveAction; + + @Column(name = "resolved_at") + private Instant resolvedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resolved_by") + private UserModel resolvedBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/TrustedDevice.java b/src/main/java/com/gnx/telemedicine/model/TrustedDevice.java new file mode 100644 index 0000000..d7efff4 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/TrustedDevice.java @@ -0,0 +1,44 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "trusted_devices", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "device_fingerprint"}) +}) +public class TrustedDevice { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserModel user; + + @Column(name = "device_name") + private String deviceName; + + @Column(name = "device_fingerprint", nullable = false, length = 255) + private String deviceFingerprint; + + @Column(name = "ip_address", length = 45) + private String ipAddress; + + @Column(name = "first_seen", nullable = false) + private Instant firstSeen = Instant.now(); + + @Column(name = "last_seen", nullable = false) + private Instant lastSeen = Instant.now(); + + @Column(name = "is_trusted") + private Boolean isTrusted = false; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/TwoFactorAuth.java b/src/main/java/com/gnx/telemedicine/model/TwoFactorAuth.java new file mode 100644 index 0000000..b96bb5b --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/TwoFactorAuth.java @@ -0,0 +1,42 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "two_factor_auth") +public class TwoFactorAuth { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private UserModel user; + + @Column(name = "secret_key", nullable = false) + private String secretKey; + + @Column(name = "enabled") + private Boolean enabled = false; + + @Column(name = "backup_codes", columnDefinition = "TEXT[]") + private List backupCodes; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); +} + diff --git a/src/main/java/com/gnx/telemedicine/model/UserBlock.java b/src/main/java/com/gnx/telemedicine/model/UserBlock.java new file mode 100644 index 0000000..c196ea6 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/UserBlock.java @@ -0,0 +1,35 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "user_blocks", uniqueConstraints = { + @UniqueConstraint(columnNames = {"blocker_id", "blocked_id"}) +}) +public class UserBlock { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blocker_id", nullable = false) + private UserModel blocker; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blocked_id", nullable = false) + private UserModel blocked; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/UserModel.java b/src/main/java/com/gnx/telemedicine/model/UserModel.java new file mode 100644 index 0000000..c9c8958 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/UserModel.java @@ -0,0 +1,93 @@ +package com.gnx.telemedicine.model; + +import com.gnx.telemedicine.model.enums.Role; +import com.gnx.telemedicine.model.enums.UserStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "users") +public class UserModel { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "email") + private String email; + + @Column(name = "password") + private String password; + + @Enumerated(EnumType.STRING) + @Column(name = "role") + private Role role; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "phone_number") + private String phoneNumber; + + @Column(name = "is_active") + private Boolean isActive; + + @CreationTimestamp + @Column(name = "created_at") + private LocalDate createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDate updatedAt; + + @Column(name = "is_online") + private Boolean isOnline = false; + + @Enumerated(EnumType.STRING) + @Column(name = "user_status") + private UserStatus userStatus = UserStatus.OFFLINE; + + @Column(name = "last_seen") + private java.time.LocalDateTime lastSeen; + + @Column(name = "avatar_url", length = 500) + private String avatarUrl; + + @Column(name = "failed_login_attempts") + private Integer failedLoginAttempts = 0; + + @Column(name = "account_locked_until") + private java.time.Instant accountLockedUntil; + + @Column(name = "last_failed_login") + private java.time.Instant lastFailedLogin; + + @Column(name = "password_reset_token") + private String passwordResetToken; + + @Column(name = "password_reset_token_expiry") + private java.time.Instant passwordResetTokenExpiry; + + @Column(name = "password_changed_at") + private java.time.Instant passwordChangedAt; + + @Column(name = "password_expires_at") + private java.time.Instant passwordExpiresAt; + + @Column(name = "password_expiration_days") + private Integer passwordExpirationDays = 90; // Default: 90 days + + @Column(name = "password_history_count") + private Integer passwordHistoryCount = 5; // Default: keep last 5 passwords +} \ No newline at end of file diff --git a/src/main/java/com/gnx/telemedicine/model/VitalSigns.java b/src/main/java/com/gnx/telemedicine/model/VitalSigns.java new file mode 100644 index 0000000..93b2d01 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/VitalSigns.java @@ -0,0 +1,70 @@ +package com.gnx.telemedicine.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "vital_signs") +public class VitalSigns { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "appointment_id") + private Appointment appointment; + + @Column(name = "recorded_at", nullable = false) + private Instant recordedAt = Instant.now(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recorded_by") + private UserModel recordedBy; + + @Column(name = "temperature", precision = 4, scale = 1) + private BigDecimal temperature; // Celsius + + @Column(name = "blood_pressure_systolic") + private Integer bloodPressureSystolic; + + @Column(name = "blood_pressure_diastolic") + private Integer bloodPressureDiastolic; + + @Column(name = "heart_rate") + private Integer heartRate; // BPM + + @Column(name = "respiratory_rate") + private Integer respiratoryRate; // BPM + + @Column(name = "oxygen_saturation", precision = 4, scale = 1) + private BigDecimal oxygenSaturation; // Percentage + + @Column(name = "weight_kg", precision = 5, scale = 2) + private BigDecimal weightKg; + + @Column(name = "height_cm", precision = 5, scale = 2) + private BigDecimal heightCm; + + @Column(name = "bmi", precision = 4, scale = 1) + private BigDecimal bmi; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "medical_record_id") + private MedicalRecord medicalRecord; +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/ActionType.java b/src/main/java/com/gnx/telemedicine/model/enums/ActionType.java new file mode 100644 index 0000000..eeadc74 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/ActionType.java @@ -0,0 +1,12 @@ +package com.gnx.telemedicine.model.enums; + +public enum ActionType { + VIEW, + CREATE, + UPDATE, + DELETE, + EXPORT, + PRINT, + DOWNLOAD +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/AlertSeverity.java b/src/main/java/com/gnx/telemedicine/model/enums/AlertSeverity.java new file mode 100644 index 0000000..845bfd1 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/AlertSeverity.java @@ -0,0 +1,8 @@ +package com.gnx.telemedicine.model.enums; + +public enum AlertSeverity { + INFO, + WARNING, + CRITICAL +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/AppointmentStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/AppointmentStatus.java new file mode 100644 index 0000000..bbbc68f --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/AppointmentStatus.java @@ -0,0 +1,5 @@ +package com.gnx.telemedicine.model.enums; + +public enum AppointmentStatus { + SCHEDULED, CANCELLED, CONFIRMED, COMPLETED +} diff --git a/src/main/java/com/gnx/telemedicine/model/enums/BreachStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/BreachStatus.java new file mode 100644 index 0000000..0f42583 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/BreachStatus.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.model.enums; + +public enum BreachStatus { + INVESTIGATING, + CONTAINED, + RESOLVED, + REPORTED +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/BreachType.java b/src/main/java/com/gnx/telemedicine/model/enums/BreachType.java new file mode 100644 index 0000000..8b38c83 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/BreachType.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.model.enums; + +public enum BreachType { + UNAUTHORIZED_ACCESS, + DISCLOSURE, + LOSS, + THEFT +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/ClinicalAlertType.java b/src/main/java/com/gnx/telemedicine/model/enums/ClinicalAlertType.java new file mode 100644 index 0000000..a9bdf35 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/ClinicalAlertType.java @@ -0,0 +1,15 @@ +package com.gnx.telemedicine.model.enums; + +public enum ClinicalAlertType { + DRUG_INTERACTION, + ALLERGY, + CONTRAINDICATION, + OVERDOSE_RISK, + DUPLICATE_THERAPY, + DOSE_ADJUSTMENT, + LAB_RESULT_ALERT, + VITAL_SIGN_ALERT, + COMPLIANCE_ALERT, + OTHER +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/ConsentStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/ConsentStatus.java new file mode 100644 index 0000000..e6d4723 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/ConsentStatus.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.model.enums; + +public enum ConsentStatus { + GRANTED, + DENIED, + WITHDRAWN, + PENDING +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/ConsentType.java b/src/main/java/com/gnx/telemedicine/model/enums/ConsentType.java new file mode 100644 index 0000000..02f1577 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/ConsentType.java @@ -0,0 +1,12 @@ +package com.gnx.telemedicine.model.enums; + +public enum ConsentType { + PRIVACY_POLICY, + COOKIES, + MARKETING, + DATA_PROCESSING, + THIRD_PARTY_SHARING, + ANALYTICS, + ADVERTISING +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/CriticalityLevel.java b/src/main/java/com/gnx/telemedicine/model/enums/CriticalityLevel.java new file mode 100644 index 0000000..49a6257 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/CriticalityLevel.java @@ -0,0 +1,8 @@ +package com.gnx.telemedicine.model.enums; + +public enum CriticalityLevel { + URGENT, + CRITICAL, + CRITICAL_PANIC +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/DataSubjectRequestStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/DataSubjectRequestStatus.java new file mode 100644 index 0000000..85b653d --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/DataSubjectRequestStatus.java @@ -0,0 +1,10 @@ +package com.gnx.telemedicine.model.enums; + +public enum DataSubjectRequestStatus { + PENDING, + IN_PROGRESS, + COMPLETED, + REJECTED, + CANCELLED +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/DataSubjectRequestType.java b/src/main/java/com/gnx/telemedicine/model/enums/DataSubjectRequestType.java new file mode 100644 index 0000000..5af03ee --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/DataSubjectRequestType.java @@ -0,0 +1,11 @@ +package com.gnx.telemedicine.model.enums; + +public enum DataSubjectRequestType { + ACCESS, // Article 15 - Right of access + RECTIFICATION, // Article 16 - Right to rectification + ERASURE, // Article 17 - Right to erasure (right to be forgotten) + RESTRICTION, // Article 18 - Right to restriction of processing + PORTABILITY, // Article 20 - Right to data portability + OBJECTION // Article 21 - Right to object +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/DayOfWeek.java b/src/main/java/com/gnx/telemedicine/model/enums/DayOfWeek.java new file mode 100644 index 0000000..7e2fee7 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/DayOfWeek.java @@ -0,0 +1,5 @@ +package com.gnx.telemedicine.model.enums; + +public enum DayOfWeek { + MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY +} diff --git a/src/main/java/com/gnx/telemedicine/model/enums/DisclosureType.java b/src/main/java/com/gnx/telemedicine/model/enums/DisclosureType.java new file mode 100644 index 0000000..68ff0f1 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/DisclosureType.java @@ -0,0 +1,12 @@ +package com.gnx.telemedicine.model.enums; + +public enum DisclosureType { + ROUTINE, // Routine disclosures for treatment + NON_ROUTINE, // Non-routine disclosures + AUTHORIZED, // Patient-authorized disclosures + REQUIRED_BY_LAW, // Required by law + PUBLIC_HEALTH, // Public health purposes + RESEARCH, // Research purposes + JUDICIAL // Judicial proceedings +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/DuplicateStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/DuplicateStatus.java new file mode 100644 index 0000000..7a5d070 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/DuplicateStatus.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.model.enums; + +public enum DuplicateStatus { + PENDING, + CONFIRMED, + RESOLVED, + REJECTED +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/LabResultStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/LabResultStatus.java new file mode 100644 index 0000000..c02da04 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/LabResultStatus.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.model.enums; + +public enum LabResultStatus { + NORMAL, + ABNORMAL, + CRITICAL, + PENDING +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/PrescriptionStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/PrescriptionStatus.java new file mode 100644 index 0000000..22d445a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/PrescriptionStatus.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.model.enums; + +public enum PrescriptionStatus { + ACTIVE, + COMPLETED, + CANCELLED, + DISCONTINUED +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/RecordType.java b/src/main/java/com/gnx/telemedicine/model/enums/RecordType.java new file mode 100644 index 0000000..879a75a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/RecordType.java @@ -0,0 +1,13 @@ +package com.gnx.telemedicine.model.enums; + +public enum RecordType { + DIAGNOSIS, + LAB_RESULT, + IMAGING, + VITAL_SIGNS, + NOTE, + PROCEDURE, + TREATMENT_PLAN, + OTHER +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/Role.java b/src/main/java/com/gnx/telemedicine/model/enums/Role.java new file mode 100644 index 0000000..25b20ad --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/Role.java @@ -0,0 +1,5 @@ +package com.gnx.telemedicine.model.enums; + +public enum Role { + PATIENT, DOCTOR, ADMIN +} diff --git a/src/main/java/com/gnx/telemedicine/model/enums/SentinelEventStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/SentinelEventStatus.java new file mode 100644 index 0000000..1260ea9 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/SentinelEventStatus.java @@ -0,0 +1,9 @@ +package com.gnx.telemedicine.model.enums; + +public enum SentinelEventStatus { + REPORTED, + UNDER_INVESTIGATION, + RESOLVED, + CLOSED +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/SentinelEventType.java b/src/main/java/com/gnx/telemedicine/model/enums/SentinelEventType.java new file mode 100644 index 0000000..b69c8c7 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/SentinelEventType.java @@ -0,0 +1,17 @@ +package com.gnx.telemedicine.model.enums; + +public enum SentinelEventType { + DEATH, + SURGICAL_ERROR, + MEDICATION_ERROR, + WRONG_PATIENT, + WRONG_SITE_SURGERY, + RAPE, + INFANT_ABDUCTION, + FALL, + RESTRAINT_DEATH, + TRANSFUSION_ERROR, + INFECTION_OUTBREAK, + OTHER +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/SentinelSeverity.java b/src/main/java/com/gnx/telemedicine/model/enums/SentinelSeverity.java new file mode 100644 index 0000000..70da915 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/SentinelSeverity.java @@ -0,0 +1,8 @@ +package com.gnx.telemedicine.model.enums; + +public enum SentinelSeverity { + SEVERE, + MODERATE, + MILD +} + diff --git a/src/main/java/com/gnx/telemedicine/model/enums/UrgencyLevel.java b/src/main/java/com/gnx/telemedicine/model/enums/UrgencyLevel.java new file mode 100644 index 0000000..84a392a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/UrgencyLevel.java @@ -0,0 +1,5 @@ +package com.gnx.telemedicine.model.enums; + +public enum UrgencyLevel { + LOW, MEDIUM, HIGH, EMERGENCY +} diff --git a/src/main/java/com/gnx/telemedicine/model/enums/UserStatus.java b/src/main/java/com/gnx/telemedicine/model/enums/UserStatus.java new file mode 100644 index 0000000..d09dff7 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/model/enums/UserStatus.java @@ -0,0 +1,8 @@ +package com.gnx.telemedicine.model.enums; + +public enum UserStatus { + ONLINE, + OFFLINE, + BUSY +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/AccountingOfDisclosureRepository.java b/src/main/java/com/gnx/telemedicine/repository/AccountingOfDisclosureRepository.java new file mode 100644 index 0000000..e0719d3 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/AccountingOfDisclosureRepository.java @@ -0,0 +1,23 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.AccountingOfDisclosure; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface AccountingOfDisclosureRepository extends JpaRepository { + List findByPatient(Patient patient); + + List findByPatientAndDisclosureDateBetween( + Patient patient, Instant startDate, Instant endDate + ); + + List findByUser(UserModel user); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/AppointmentRepository.java b/src/main/java/com/gnx/telemedicine/repository/AppointmentRepository.java new file mode 100644 index 0000000..83e2b45 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/AppointmentRepository.java @@ -0,0 +1,91 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.Appointment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface AppointmentRepository extends JpaRepository { + + @Query("SELECT DISTINCT a FROM Appointment a " + + "LEFT JOIN FETCH a.patient p " + + "LEFT JOIN FETCH p.user " + + "LEFT JOIN FETCH a.doctor d " + + "LEFT JOIN FETCH d.user " + + "WHERE a.id = :id") + Optional findById(@Param("id") UUID id); + + @Query("SELECT DISTINCT a FROM Appointment a " + + "LEFT JOIN FETCH a.patient p " + + "LEFT JOIN FETCH p.user " + + "LEFT JOIN FETCH a.doctor d " + + "LEFT JOIN FETCH d.user " + + "WHERE a.doctor.id = :doctorId " + + "AND (a.deletedByDoctor IS NULL OR a.deletedByDoctor = false)") + List findByDoctorId(UUID doctorId); + + @Query("SELECT DISTINCT a FROM Appointment a " + + "LEFT JOIN FETCH a.patient p " + + "LEFT JOIN FETCH p.user " + + "LEFT JOIN FETCH a.doctor d " + + "LEFT JOIN FETCH d.user " + + "WHERE a.patient.id = :patientId " + + "AND (a.deletedByPatient IS NULL OR a.deletedByPatient = false)") + List findByPatientId(UUID patientId); + + @Query(value = "SELECT * FROM appointments a WHERE a.doctor_id = CAST(:doctorId AS UUID) " + + "AND a.scheduled_date = CAST(:date AS DATE) " + + "AND a.status != 'CANCELLED' " + + "AND (a.deleted_by_doctor IS NULL OR a.deleted_by_doctor = false) " + + "AND (a.deleted_by_patient IS NULL OR a.deleted_by_patient = false) " + + "AND ((a.scheduled_time < CAST(:endTime AS TIME) AND " + + "(a.scheduled_time + (a.duration_minutes || ' minutes')::INTERVAL) > CAST(:startTime AS TIME)))", + nativeQuery = true) + List findOverlappingAppointments( + @Param("doctorId") UUID doctorId, + @Param("date") LocalDate date, + @Param("startTime") LocalTime startTime, + @Param("endTime") LocalTime endTime + ); + + @Query("SELECT a FROM Appointment a WHERE a.doctor.id = :doctorId " + + "AND a.scheduledDate = :date " + + "AND a.status != 'CANCELLED' " + + "AND (a.deletedByDoctor IS NULL OR a.deletedByDoctor = false) " + + "AND (a.deletedByPatient IS NULL OR a.deletedByPatient = false)") + List findByDoctorIdAndDate( + @Param("doctorId") UUID doctorId, + @Param("date") LocalDate date + ); + + @Query("SELECT a FROM Appointment a WHERE a.patient.id = :patientId " + + "AND a.scheduledDate = :date " + + "AND a.status != 'CANCELLED' " + + "AND (a.deletedByPatient IS NULL OR a.deletedByPatient = false) " + + "AND (a.deletedByDoctor IS NULL OR a.deletedByDoctor = false)") + List findByPatientIdAndDate( + @Param("patientId") UUID patientId, + @Param("date") LocalDate date + ); + + @Query("SELECT DISTINCT a FROM Appointment a " + + "LEFT JOIN FETCH a.patient p " + + "LEFT JOIN FETCH p.user " + + "LEFT JOIN FETCH a.doctor d " + + "LEFT JOIN FETCH d.user") + List findAllWithRelations(); + + // Find all appointments between a specific doctor and patient + @Query("SELECT a FROM Appointment a WHERE a.doctor.id = :doctorId AND a.patient.id = :patientId " + + "AND (a.deletedByDoctor IS NULL OR a.deletedByDoctor = false) " + + "AND (a.deletedByPatient IS NULL OR a.deletedByPatient = false)") + List findByDoctorIdAndPatientId(@Param("doctorId") UUID doctorId, @Param("patientId") UUID patientId); +} diff --git a/src/main/java/com/gnx/telemedicine/repository/BreachNotificationRepository.java b/src/main/java/com/gnx/telemedicine/repository/BreachNotificationRepository.java new file mode 100644 index 0000000..f25c9b2 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/BreachNotificationRepository.java @@ -0,0 +1,21 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.BreachNotification; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.BreachStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface BreachNotificationRepository extends JpaRepository { + + List findByStatusOrderByIncidentDateDesc(BreachStatus status); + + List findAllByOrderByIncidentDateDesc(); + + void deleteByCreatedBy(UserModel createdBy); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/ClinicalAlertRepository.java b/src/main/java/com/gnx/telemedicine/repository/ClinicalAlertRepository.java new file mode 100644 index 0000000..57c299a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/ClinicalAlertRepository.java @@ -0,0 +1,49 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.ClinicalAlert; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.enums.AlertSeverity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface ClinicalAlertRepository extends JpaRepository { + List findByPatientOrderByCreatedAtDesc(Patient patient); + + List findByPatientAndAcknowledgedFalseOrderBySeverityDescCreatedAtDesc(Patient patient); + + List findByAcknowledgedFalseOrderBySeverityDescCreatedAtDesc(); + + long countByPatientAndAcknowledgedFalse(Patient patient); + + List findBySeverityAndAcknowledgedFalseOrderByCreatedAtDesc(AlertSeverity severity); + + @Query("SELECT ca FROM ClinicalAlert ca WHERE ca.patient.id = :patientId AND ca.acknowledged = false ORDER BY ca.severity DESC, ca.createdAt DESC") + List findUnacknowledgedAlertsByPatientId(UUID patientId); + + // Find alerts for a specific doctor (alerts assigned to this doctor) + @Query("SELECT ca FROM ClinicalAlert ca WHERE ca.doctor.id = :doctorId AND ca.acknowledged = false ORDER BY ca.severity DESC, ca.createdAt DESC") + List findUnacknowledgedAlertsByDoctorId(UUID doctorId); + + // Find alerts for a specific doctor and patient + @Query("SELECT ca FROM ClinicalAlert ca WHERE ca.doctor.id = :doctorId AND ca.patient.id = :patientId ORDER BY ca.severity DESC, ca.createdAt DESC") + List findByDoctorIdAndPatientId(@Param("doctorId") UUID doctorId, @Param("patientId") UUID patientId); + + // Find unacknowledged alerts for a specific doctor and patient + @Query("SELECT ca FROM ClinicalAlert ca WHERE ca.doctor.id = :doctorId AND ca.patient.id = :patientId AND ca.acknowledged = false ORDER BY ca.severity DESC, ca.createdAt DESC") + List findUnacknowledgedAlertsByDoctorIdAndPatientId(@Param("doctorId") UUID doctorId, @Param("patientId") UUID patientId); + + // Check for duplicate unacknowledged drug interaction alerts + @Query("SELECT ca FROM ClinicalAlert ca WHERE ca.patient.id = :patientId AND ca.alertType = 'DRUG_INTERACTION' AND ca.acknowledged = false AND (ca.description LIKE CONCAT('%', :med1, '%') OR ca.description LIKE CONCAT('%', :med2, '%'))") + List findDuplicateDrugInteractionAlerts(@Param("patientId") UUID patientId, @Param("med1") String medication1, @Param("med2") String medication2); + + // Check for duplicate unacknowledged allergy alerts + @Query("SELECT ca FROM ClinicalAlert ca WHERE ca.patient.id = :patientId AND ca.alertType = 'ALLERGY' AND ca.acknowledged = false AND ca.medicationName = :medicationName") + List findDuplicateAllergyAlerts(@Param("patientId") UUID patientId, @Param("medicationName") String medicationName); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/CriticalResultRepository.java b/src/main/java/com/gnx/telemedicine/repository/CriticalResultRepository.java new file mode 100644 index 0000000..c513b5b --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/CriticalResultRepository.java @@ -0,0 +1,36 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.CriticalResult; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.enums.CriticalityLevel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface CriticalResultRepository extends JpaRepository { + List findByPatientOrderByCreatedAtDesc(Patient patient); + + List findByDoctorOrderByCreatedAtDesc(Doctor doctor); + + List findByPatientAndAcknowledgedFalseOrderByCriticalityLevelDescCreatedAtDesc(Patient patient); + + List findByAcknowledgedFalseOrderByCriticalityLevelDescCreatedAtDesc(); + + long countByPatientAndAcknowledgedFalse(Patient patient); + + long countByDoctorAndAcknowledgedFalse(Doctor doctor); + + List findByCriticalityLevelAndAcknowledgedFalseOrderByCreatedAtDesc(CriticalityLevel criticalityLevel); + + @Query("SELECT cr FROM CriticalResult cr WHERE cr.patient.id = :patientId AND cr.acknowledged = false ORDER BY cr.criticalityLevel DESC, cr.createdAt DESC") + List findUnacknowledgedCriticalResultsByPatientId(UUID patientId); + + @Query("SELECT cr FROM CriticalResult cr WHERE cr.doctor.id = :doctorId AND cr.acknowledged = false ORDER BY cr.criticalityLevel DESC, cr.createdAt DESC") + List findUnacknowledgedCriticalResultsByDoctorId(UUID doctorId); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/DataMinimizationLogRepository.java b/src/main/java/com/gnx/telemedicine/repository/DataMinimizationLogRepository.java new file mode 100644 index 0000000..64b2c34 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/DataMinimizationLogRepository.java @@ -0,0 +1,22 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.DataMinimizationLog; +import com.gnx.telemedicine.model.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface DataMinimizationLogRepository extends JpaRepository { + List findByUser(UserModel user); + + List findByUserAndTimestampBetween( + UserModel user, Instant startDate, Instant endDate + ); + + List findByDataCategory(String dataCategory); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/DataProcessingRecordRepository.java b/src/main/java/com/gnx/telemedicine/repository/DataProcessingRecordRepository.java new file mode 100644 index 0000000..ad9b519 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/DataProcessingRecordRepository.java @@ -0,0 +1,12 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.DataProcessingRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface DataProcessingRecordRepository extends JpaRepository { +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/DataRetentionPolicyRepository.java b/src/main/java/com/gnx/telemedicine/repository/DataRetentionPolicyRepository.java new file mode 100644 index 0000000..b6f8c6f --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/DataRetentionPolicyRepository.java @@ -0,0 +1,14 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.DataRetentionPolicy; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DataRetentionPolicyRepository extends JpaRepository { + Optional findByDataType(String dataType); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/DataSubjectConsentRepository.java b/src/main/java/com/gnx/telemedicine/repository/DataSubjectConsentRepository.java new file mode 100644 index 0000000..dbbdfdf --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/DataSubjectConsentRepository.java @@ -0,0 +1,24 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.DataSubjectConsent; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.ConsentStatus; +import com.gnx.telemedicine.model.enums.ConsentType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DataSubjectConsentRepository extends JpaRepository { + Optional findByUserAndConsentType(UserModel user, ConsentType consentType); + + List findByUser(UserModel user); + + List findByUserAndConsentStatus(UserModel user, ConsentStatus status); + + boolean existsByUserAndConsentTypeAndConsentStatus(UserModel user, ConsentType consentType, ConsentStatus status); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/DataSubjectRequestRepository.java b/src/main/java/com/gnx/telemedicine/repository/DataSubjectRequestRepository.java new file mode 100644 index 0000000..9cd2972 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/DataSubjectRequestRepository.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.DataSubjectRequest; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.DataSubjectRequestStatus; +import com.gnx.telemedicine.model.enums.DataSubjectRequestType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DataSubjectRequestRepository extends JpaRepository { + List findByUser(UserModel user); + + List findByRequestStatus(DataSubjectRequestStatus status); + + List findByRequestType(DataSubjectRequestType requestType); + + Optional findByVerificationToken(String token); + + List findByUserAndRequestType(UserModel user, DataSubjectRequestType requestType); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/DoctorAvailabilityRepository.java b/src/main/java/com/gnx/telemedicine/repository/DoctorAvailabilityRepository.java new file mode 100644 index 0000000..9beded6 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/DoctorAvailabilityRepository.java @@ -0,0 +1,55 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.DoctorAvailability; +import com.gnx.telemedicine.model.enums.DayOfWeek; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DoctorAvailabilityRepository extends JpaRepository { + + List findByDoctorId(UUID doctorId); + + List findByDoctorIdAndDayOfWeek(UUID doctorId, DayOfWeek dayOfWeek); + + @Query("SELECT da FROM DoctorAvailability da WHERE da.doctor.id = :doctorId " + + "AND da.dayOfWeek = :dayOfWeek AND da.startTime <= :time AND da.endTime >= :time") + Optional findAvailabilityForTime( + @Param("doctorId") UUID doctorId, + @Param("dayOfWeek") DayOfWeek dayOfWeek, + @Param("time") LocalTime time + ); + + @Query("SELECT da FROM DoctorAvailability da WHERE da.doctor.id = :doctorId " + + "AND da.dayOfWeek = :dayOfWeek " + + "AND ((da.startTime < :endTime AND da.endTime > :startTime) " + + "OR (da.startTime = :startTime AND da.endTime = :endTime))") + List findOverlappingAvailabilities( + @Param("doctorId") UUID doctorId, + @Param("dayOfWeek") DayOfWeek dayOfWeek, + @Param("startTime") LocalTime startTime, + @Param("endTime") LocalTime endTime + ); + + @Query("SELECT CASE WHEN COUNT(da) > 0 THEN true ELSE false END " + + "FROM DoctorAvailability da WHERE da.doctor.id = :doctorId " + + "AND da.dayOfWeek = :dayOfWeek AND da.isAvailable = true " + + "AND da.startTime <= :startTime AND da.endTime >= :endTime") + boolean isDoctorAvailableForSlot( + @Param("doctorId") UUID doctorId, + @Param("dayOfWeek") DayOfWeek dayOfWeek, + @Param("startTime") LocalTime startTime, + @Param("endTime") LocalTime endTime + ); + + void deleteByDoctorId(UUID doctorId); + + List findByDoctorIdAndIsAvailable(UUID doctorId, Boolean isAvailable); +} diff --git a/src/main/java/com/gnx/telemedicine/repository/DoctorRepository.java b/src/main/java/com/gnx/telemedicine/repository/DoctorRepository.java new file mode 100644 index 0000000..beacb73 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/DoctorRepository.java @@ -0,0 +1,39 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DoctorRepository extends JpaRepository { + Optional findByMedicalLicenseNumber(String medicalLicenseNumber); + Optional findByUser(UserModel user); + + @Query("SELECT d FROM Doctor d JOIN FETCH d.user WHERE d.user.isActive IS NULL OR d.user.isActive = true") + List findAllWithActiveUser(); + + @Query("SELECT d FROM Doctor d JOIN FETCH d.user WHERE (d.user.isActive IS NULL OR d.user.isActive = true) AND " + + "(LOWER(d.user.firstName) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(d.user.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(d.specialization) LIKE LOWER(CONCAT('%', :query, '%')))") + List searchDoctors(@org.springframework.data.repository.query.Param("query") String query); + + // For chat search - return all doctors except explicitly deactivated ones + @Query("SELECT d FROM Doctor d JOIN FETCH d.user WHERE (d.user.isActive IS NULL OR d.user.isActive = true)") + List findAllForChat(); + + @Query("SELECT d FROM Doctor d JOIN FETCH d.user WHERE (d.user.isActive IS NULL OR d.user.isActive = true) AND " + + "(LOWER(d.user.firstName) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(d.user.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(d.specialization) LIKE LOWER(CONCAT('%', :query, '%')))") + List searchDoctorsForChat(@org.springframework.data.repository.query.Param("query") String query); + + @Query("SELECT d FROM Doctor d JOIN FETCH d.user WHERE d.user.id IN :userIds") + List findByUserIds(@org.springframework.data.repository.query.Param("userIds") List userIds); +} diff --git a/src/main/java/com/gnx/telemedicine/repository/DuplicatePatientRecordRepository.java b/src/main/java/com/gnx/telemedicine/repository/DuplicatePatientRecordRepository.java new file mode 100644 index 0000000..4b62949 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/DuplicatePatientRecordRepository.java @@ -0,0 +1,30 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.DuplicatePatientRecord; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.enums.DuplicateStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DuplicatePatientRecordRepository extends JpaRepository { + List findByPrimaryPatientOrderByCreatedAtDesc(Patient patient); + + List findByDuplicatePatientOrderByCreatedAtDesc(Patient patient); + + List findByStatusOrderByCreatedAtDesc(DuplicateStatus status); + + Optional findByPrimaryPatientAndDuplicatePatient(Patient primary, Patient duplicate); + + @Query("SELECT COUNT(d) FROM DuplicatePatientRecord d WHERE d.status = 'PENDING'") + long countPendingDuplicates(); + + @Query("SELECT d FROM DuplicatePatientRecord d WHERE d.status = 'PENDING' ORDER BY d.matchScore DESC, d.createdAt DESC") + List findAllPendingOrderedByMatchScore(); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/HipaaAuditLogRepository.java b/src/main/java/com/gnx/telemedicine/repository/HipaaAuditLogRepository.java new file mode 100644 index 0000000..f52bfce --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/HipaaAuditLogRepository.java @@ -0,0 +1,42 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.HipaaAuditLog; +import com.gnx.telemedicine.model.enums.ActionType; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface HipaaAuditLogRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"user", "patient", "patient.user"}) + List findByPatientIdOrderByTimestampDesc(UUID patientId); + + @EntityGraph(attributePaths = {"user", "patient", "patient.user"}) + List findByUserIdOrderByTimestampDesc(UUID userId); + + void deleteByUserId(UUID userId); + + void deleteByPatientId(UUID patientId); + + @EntityGraph(attributePaths = {"user", "patient", "patient.user"}) + List findByResourceTypeAndResourceIdOrderByTimestampDesc(String resourceType, UUID resourceId); + + @EntityGraph(attributePaths = {"user", "patient", "patient.user"}) + @Query("SELECT hal FROM HipaaAuditLog hal WHERE hal.patient.id = :patientId AND hal.timestamp >= :startDate AND hal.timestamp <= :endDate ORDER BY hal.timestamp DESC") + List findByPatientIdAndDateRange(@Param("patientId") UUID patientId, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @EntityGraph(attributePaths = {"user", "patient", "patient.user"}) + @Query("SELECT hal FROM HipaaAuditLog hal WHERE hal.user.id = :userId AND hal.actionType = :actionType ORDER BY hal.timestamp DESC") + List findByUserIdAndActionType(@Param("userId") UUID userId, @Param("actionType") ActionType actionType); + + @Query("SELECT COUNT(hal) FROM HipaaAuditLog hal WHERE hal.patient.id = :patientId AND hal.timestamp >= :startDate") + Long countAccessesByPatientIdSince(@Param("patientId") UUID patientId, @Param("startDate") Instant startDate); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/LabResultRepository.java b/src/main/java/com/gnx/telemedicine/repository/LabResultRepository.java new file mode 100644 index 0000000..a1a61fc --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/LabResultRepository.java @@ -0,0 +1,28 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.LabResult; +import com.gnx.telemedicine.model.enums.LabResultStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface LabResultRepository extends JpaRepository { + + List findByPatientIdOrderByCreatedAtDesc(UUID patientId); + + List findByMedicalRecordId(UUID medicalRecordId); + + List findByPatientIdAndStatusOrderByCreatedAtDesc(UUID patientId, LabResultStatus status); + + @Query("SELECT lr FROM LabResult lr WHERE lr.patient.id = :patientId AND lr.status IN ('ABNORMAL', 'CRITICAL') ORDER BY lr.createdAt DESC") + List findAbnormalResultsByPatientId(@Param("patientId") UUID patientId); + + @Query("SELECT COUNT(lr) FROM LabResult lr WHERE lr.patient.id = :patientId AND lr.status = :status") + Long countByPatientIdAndStatus(@Param("patientId") UUID patientId, @Param("status") LabResultStatus status); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/LoginAttemptRepository.java b/src/main/java/com/gnx/telemedicine/repository/LoginAttemptRepository.java new file mode 100644 index 0000000..a3f3b91 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/LoginAttemptRepository.java @@ -0,0 +1,25 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.LoginAttempt; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface LoginAttemptRepository extends JpaRepository { + + @Query("SELECT la FROM LoginAttempt la WHERE la.email = :email AND la.timestamp >= :since ORDER BY la.timestamp DESC") + List findRecentAttemptsByEmail(@Param("email") String email, @Param("since") Instant since); + + @Query("SELECT COUNT(la) FROM LoginAttempt la WHERE la.email = :email AND la.success = false AND la.timestamp >= :since") + Long countFailedAttemptsSince(@Param("email") String email, @Param("since") Instant since); + + @Query("SELECT la FROM LoginAttempt la WHERE la.ipAddress = :ipAddress AND la.timestamp >= :since ORDER BY la.timestamp DESC") + List findRecentAttemptsByIpAddress(@Param("ipAddress") String ipAddress, @Param("since") Instant since); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/MedicalRecordRepository.java b/src/main/java/com/gnx/telemedicine/repository/MedicalRecordRepository.java new file mode 100644 index 0000000..305f107 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/MedicalRecordRepository.java @@ -0,0 +1,30 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.MedicalRecord; +import com.gnx.telemedicine.model.enums.RecordType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface MedicalRecordRepository extends JpaRepository { + + List findByPatientIdOrderByCreatedAtDesc(UUID patientId); + + List findByPatientIdAndRecordTypeOrderByCreatedAtDesc(UUID patientId, RecordType recordType); + + List findByDoctorIdOrderByCreatedAtDesc(UUID doctorId); + + List findByAppointmentId(UUID appointmentId); + + @Query("SELECT mr FROM MedicalRecord mr WHERE mr.patient.id = :patientId AND mr.doctor.id = :doctorId ORDER BY mr.createdAt DESC") + List findByPatientIdAndDoctorId(@Param("patientId") UUID patientId, @Param("doctorId") UUID doctorId); + + @Query("SELECT COUNT(mr) FROM MedicalRecord mr WHERE mr.patient.id = :patientId AND mr.recordType = :recordType") + Long countByPatientIdAndRecordType(@Param("patientId") UUID patientId, @Param("recordType") RecordType recordType); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/MedicationIntakeLogRepository.java b/src/main/java/com/gnx/telemedicine/repository/MedicationIntakeLogRepository.java new file mode 100644 index 0000000..8105389 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/MedicationIntakeLogRepository.java @@ -0,0 +1,27 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.MedicationIntakeLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface MedicationIntakeLogRepository extends JpaRepository { + + List findByPrescriptionIdOrderByScheduledTimeDesc(UUID prescriptionId); + + @Query("SELECT mil FROM MedicationIntakeLog mil WHERE mil.prescription.id = :prescriptionId AND mil.taken = false AND mil.scheduledTime <= :currentTime ORDER BY mil.scheduledTime ASC") + List findMissedDoses(@Param("prescriptionId") UUID prescriptionId, @Param("currentTime") Instant currentTime); + + @Query("SELECT COUNT(mil) FROM MedicationIntakeLog mil WHERE mil.prescription.id = :prescriptionId AND mil.taken = true") + Long countTakenDosesByPrescriptionId(@Param("prescriptionId") UUID prescriptionId); + + @Query("SELECT COUNT(mil) FROM MedicationIntakeLog mil WHERE mil.prescription.id = :prescriptionId") + Long countTotalDosesByPrescriptionId(@Param("prescriptionId") UUID prescriptionId); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/MessageRepository.java b/src/main/java/com/gnx/telemedicine/repository/MessageRepository.java new file mode 100644 index 0000000..29513fb --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/MessageRepository.java @@ -0,0 +1,73 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.Message; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface MessageRepository extends JpaRepository { + + @Query("SELECT m FROM Message m WHERE " + + "((m.sender.id = :userId1 AND m.receiver.id = :userId2) OR " + + "(m.sender.id = :userId2 AND m.receiver.id = :userId1)) " + + "AND (m.sender.id != :currentUserId OR (m.deletedBySender IS NULL OR m.deletedBySender = false)) " + + "AND (m.receiver.id != :currentUserId OR (m.deletedByReceiver IS NULL OR m.deletedByReceiver = false)) " + + "ORDER BY m.createdAt ASC") + List findConversationBetweenUsers(@Param("userId1") UUID userId1, @Param("userId2") UUID userId2, @Param("currentUserId") UUID currentUserId); + + @Query("SELECT COUNT(m) FROM Message m WHERE m.receiver.id = :userId AND m.isRead = false " + + "AND (m.deletedByReceiver IS NULL OR m.deletedByReceiver = false)") + Long countUnreadMessages(@Param("userId") UUID userId); + + @Query("SELECT COUNT(m) FROM Message m WHERE m.receiver.id = :userId AND m.sender.id = :senderId AND m.isRead = false " + + "AND (m.deletedByReceiver IS NULL OR m.deletedByReceiver = false)") + Long countUnreadMessagesFromSender(@Param("userId") UUID userId, @Param("senderId") UUID senderId); + + @Modifying + @Query("UPDATE Message m SET m.isRead = true WHERE m.receiver.id = :userId AND m.sender.id = :senderId AND m.isRead = false " + + "AND (m.deletedByReceiver IS NULL OR m.deletedByReceiver = false)") + void markMessagesAsRead(@Param("userId") UUID userId, @Param("senderId") UUID senderId); + + @Query("SELECT m FROM Message m WHERE m.receiver.id = :userId AND m.isRead = false " + + "AND (m.deletedByReceiver IS NULL OR m.deletedByReceiver = false) " + + "ORDER BY m.createdAt DESC") + List findUnreadMessages(@Param("userId") UUID userId); + + @Query("SELECT DISTINCT CASE " + + "WHEN m.sender.id = :userId THEN m.receiver.id " + + "ELSE m.sender.id END " + + "FROM Message m WHERE (m.sender.id = :userId OR m.receiver.id = :userId) " + + "AND (m.sender.id != :userId OR (m.deletedBySender IS NULL OR m.deletedBySender = false)) " + + "AND (m.receiver.id != :userId OR (m.deletedByReceiver IS NULL OR m.deletedByReceiver = false))") + List findConversationPartners(@Param("userId") UUID userId); + + @Query("SELECT MAX(m.createdAt) FROM Message m WHERE " + + "((m.sender.id = :userId1 AND m.receiver.id = :userId2) OR " + + "(m.sender.id = :userId2 AND m.receiver.id = :userId1)) " + + "AND (m.sender.id != :currentUserId OR (m.deletedBySender IS NULL OR m.deletedBySender = false)) " + + "AND (m.receiver.id != :currentUserId OR (m.deletedByReceiver IS NULL OR m.deletedByReceiver = false))") + java.time.LocalDateTime getLastMessageTime(@Param("userId1") UUID userId1, @Param("userId2") UUID userId2, @Param("currentUserId") UUID currentUserId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Message m SET m.deletedBySender = true WHERE m.id = :messageId AND m.sender.id = :userId") + void softDeleteMessageBySender(@Param("messageId") UUID messageId, @Param("userId") UUID userId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Message m SET m.deletedByReceiver = true WHERE m.id = :messageId AND m.receiver.id = :userId") + void softDeleteMessageByReceiver(@Param("messageId") UUID messageId, @Param("userId") UUID userId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Message m SET m.deletedBySender = true WHERE m.sender.id = :userId AND m.receiver.id = :otherUserId") + void softDeleteConversationBySender(@Param("userId") UUID userId, @Param("otherUserId") UUID otherUserId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Message m SET m.deletedByReceiver = true WHERE m.receiver.id = :userId AND m.sender.id = :otherUserId") + void softDeleteConversationByReceiver(@Param("userId") UUID userId, @Param("otherUserId") UUID otherUserId); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/PasswordHistoryRepository.java b/src/main/java/com/gnx/telemedicine/repository/PasswordHistoryRepository.java new file mode 100644 index 0000000..e5dc8ed --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/PasswordHistoryRepository.java @@ -0,0 +1,55 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.PasswordHistory; +import com.gnx.telemedicine.model.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * Repository for password history operations. + */ +@Repository +public interface PasswordHistoryRepository extends JpaRepository { + + /** + * Find all password history entries for a user, ordered by creation date (newest first). + */ + List findByUserOrderByCreatedAtDesc(UserModel user); + + /** + * Find recent password history entries for a user (last N passwords). + */ + @Query("SELECT ph FROM PasswordHistory ph WHERE ph.user = :user ORDER BY ph.createdAt DESC") + List findRecentPasswordsByUser(@Param("user") UserModel user); + + /** + * Check if a password hash exists in user's password history. + */ + @Query("SELECT COUNT(ph) > 0 FROM PasswordHistory ph WHERE ph.user = :user AND ph.passwordHash = :passwordHash") + boolean existsByUserAndPasswordHash(@Param("user") UserModel user, @Param("passwordHash") String passwordHash); + + /** + * Delete old password history entries (older than specified days). + */ + @Modifying + @Query("DELETE FROM PasswordHistory ph WHERE ph.createdAt < :cutoffDate") + void deleteOldPasswordHistory(@Param("cutoffDate") Instant cutoffDate); + + /** + * Delete all password history for a user. + */ + void deleteByUser(UserModel user); + + /** + * Count password history entries for a user. + */ + long countByUser(UserModel user); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/PatientRepository.java b/src/main/java/com/gnx/telemedicine/repository/PatientRepository.java new file mode 100644 index 0000000..e6455f8 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/PatientRepository.java @@ -0,0 +1,36 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface PatientRepository extends JpaRepository { + Optional findByUser(UserModel user); + + @Query("SELECT p FROM Patient p JOIN FETCH p.user WHERE p.user.isActive IS NULL OR p.user.isActive = true") + List findAllWithActiveUser(); + + @Query("SELECT p FROM Patient p JOIN FETCH p.user WHERE (p.user.isActive IS NULL OR p.user.isActive = true) AND " + + "(LOWER(p.user.firstName) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(p.user.lastName) LIKE LOWER(CONCAT('%', :query, '%')))") + List searchPatients(@org.springframework.data.repository.query.Param("query") String query); + + // For chat search - return all patients except explicitly deactivated ones + @Query("SELECT p FROM Patient p JOIN FETCH p.user WHERE (p.user.isActive IS NULL OR p.user.isActive = true)") + List findAllForChat(); + + @Query("SELECT p FROM Patient p JOIN FETCH p.user WHERE (p.user.isActive IS NULL OR p.user.isActive = true) AND " + + "(LOWER(p.user.firstName) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(p.user.lastName) LIKE LOWER(CONCAT('%', :query, '%')))") + List searchPatientsForChat(@org.springframework.data.repository.query.Param("query") String query); + + @Query("SELECT p FROM Patient p JOIN FETCH p.user WHERE p.user.id IN :userIds") + List findByUserIds(@org.springframework.data.repository.query.Param("userIds") List userIds); +} diff --git a/src/main/java/com/gnx/telemedicine/repository/PhiAccessLogRepository.java b/src/main/java/com/gnx/telemedicine/repository/PhiAccessLogRepository.java new file mode 100644 index 0000000..0506d8a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/PhiAccessLogRepository.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.PhiAccessLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface PhiAccessLogRepository extends JpaRepository { + + List findByPatientIdOrderByTimestampDesc(UUID patientId); + + List findByUserIdOrderByTimestampDesc(UUID userId); + + @Query("SELECT pal FROM PhiAccessLog pal WHERE pal.patient.id = :patientId AND pal.timestamp >= :startDate AND pal.timestamp <= :endDate ORDER BY pal.timestamp DESC") + List findByPatientIdAndDateRange(@Param("patientId") UUID patientId, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query("SELECT COUNT(DISTINCT pal.user.id) FROM PhiAccessLog pal WHERE pal.patient.id = :patientId AND pal.timestamp >= :startDate") + Long countDistinctUsersAccessingPatient(@Param("patientId") UUID patientId, @Param("startDate") Instant startDate); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/PrescriptionRepository.java b/src/main/java/com/gnx/telemedicine/repository/PrescriptionRepository.java new file mode 100644 index 0000000..4668c4e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/PrescriptionRepository.java @@ -0,0 +1,37 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.Prescription; +import com.gnx.telemedicine.model.enums.PrescriptionStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface PrescriptionRepository extends JpaRepository { + + List findByPatientIdOrderByCreatedAtDesc(UUID patientId); + + List findByPatientIdAndStatusOrderByCreatedAtDesc(UUID patientId, PrescriptionStatus status); + + List findByDoctorIdOrderByCreatedAtDesc(UUID doctorId); + + List findByAppointmentId(UUID appointmentId); + + Optional findByPrescriptionNumber(String prescriptionNumber); + + @Query("SELECT p FROM Prescription p WHERE p.patient.id = :patientId AND p.status = 'ACTIVE' AND p.endDate >= :currentDate ORDER BY p.startDate ASC") + List findActivePrescriptionsByPatientId(@Param("patientId") UUID patientId, @Param("currentDate") LocalDate currentDate); + + @Query("SELECT COUNT(p) FROM Prescription p WHERE p.patient.id = :patientId AND p.status = :status") + Long countByPatientIdAndStatus(@Param("patientId") UUID patientId, @Param("status") PrescriptionStatus status); + + @Query("SELECT p FROM Prescription p WHERE p.patient.id = :patientId AND p.doctor.id = :doctorId ORDER BY p.createdAt DESC") + List findByPatientIdAndDoctorId(@Param("patientId") UUID patientId, @Param("doctorId") UUID doctorId); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/RefreshTokenRepository.java b/src/main/java/com/gnx/telemedicine/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..8cb7575 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/RefreshTokenRepository.java @@ -0,0 +1,68 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.RefreshToken; +import com.gnx.telemedicine.model.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for refresh token operations. + */ +@Repository +public interface RefreshTokenRepository extends JpaRepository { + + /** + * Find refresh token by token string. + */ + Optional findByToken(String token); + + /** + * Find all refresh tokens for a user. + */ + List findByUser(UserModel user); + + /** + * Find all valid (not revoked and not expired) refresh tokens for a user. + */ + @Query("SELECT rt FROM RefreshToken rt WHERE rt.user = :user AND rt.revoked = false AND rt.expiryDate > :now") + List findValidTokensByUser(@Param("user") UserModel user, @Param("now") Instant now); + + /** + * Find refresh token by token and user. + */ + Optional findByTokenAndUser(String token, UserModel user); + + /** + * Delete all expired refresh tokens. + */ + @Modifying + @Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < :now") + void deleteExpiredTokens(@Param("now") Instant now); + + /** + * Revoke all refresh tokens for a user. + */ + @Modifying + @Query("UPDATE RefreshToken rt SET rt.revoked = true, rt.revokedAt = :now, rt.revokedReason = :reason WHERE rt.user = :user AND rt.revoked = false") + void revokeAllTokensForUser(@Param("user") UserModel user, @Param("now") Instant now, @Param("reason") String reason); + + /** + * Delete all refresh tokens for a user. + */ + void deleteByUser(UserModel user); + + /** + * Count active (not revoked and not expired) refresh tokens for a user. + */ + @Query("SELECT COUNT(rt) FROM RefreshToken rt WHERE rt.user = :user AND rt.revoked = false AND rt.expiryDate > :now") + long countActiveTokensByUser(@Param("user") UserModel user, @Param("now") Instant now); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/SentinelEventRepository.java b/src/main/java/com/gnx/telemedicine/repository/SentinelEventRepository.java new file mode 100644 index 0000000..ed21465 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/SentinelEventRepository.java @@ -0,0 +1,33 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.SentinelEvent; +import com.gnx.telemedicine.model.enums.SentinelEventStatus; +import com.gnx.telemedicine.model.enums.SentinelEventType; +import com.gnx.telemedicine.model.enums.SentinelSeverity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface SentinelEventRepository extends JpaRepository { + List findByStatusOrderByOccurredAtDesc(SentinelEventStatus status); + + List findByEventTypeOrderByOccurredAtDesc(SentinelEventType eventType); + + List findBySeverityOrderByOccurredAtDesc(SentinelSeverity severity); + + @Query("SELECT COUNT(s) FROM SentinelEvent s WHERE s.status = 'REPORTED' OR s.status = 'UNDER_INVESTIGATION'") + long countActiveIncidents(); + + @Query("SELECT s FROM SentinelEvent s WHERE s.status IN ('REPORTED', 'UNDER_INVESTIGATION') ORDER BY s.occurredAt DESC") + List findAllActiveIncidents(); + + @Query("SELECT s FROM SentinelEvent s WHERE s.occurredAt BETWEEN :startDate AND :endDate ORDER BY s.occurredAt DESC") + List findByOccurredAtBetween(@Param("startDate") Instant startDate, @Param("endDate") Instant endDate); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/TrustedDeviceRepository.java b/src/main/java/com/gnx/telemedicine/repository/TrustedDeviceRepository.java new file mode 100644 index 0000000..83ee24a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/TrustedDeviceRepository.java @@ -0,0 +1,22 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.TrustedDevice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface TrustedDeviceRepository extends JpaRepository { + + List findByUserIdOrderByLastSeenDesc(UUID userId); + + Optional findByUserIdAndDeviceFingerprint(UUID userId, String deviceFingerprint); + + List findByUserIdAndIsTrustedTrue(UUID userId); + + void deleteByUserIdAndDeviceFingerprint(UUID userId, String deviceFingerprint); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/TwoFactorAuthRepository.java b/src/main/java/com/gnx/telemedicine/repository/TwoFactorAuthRepository.java new file mode 100644 index 0000000..50669f4 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/TwoFactorAuthRepository.java @@ -0,0 +1,19 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.TwoFactorAuth; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface TwoFactorAuthRepository extends JpaRepository { + + Optional findByUserId(UUID userId); + + Optional findByUserEmail(String email); + + boolean existsByUserId(UUID userId); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/UserBlockRepository.java b/src/main/java/com/gnx/telemedicine/repository/UserBlockRepository.java new file mode 100644 index 0000000..421754a --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/UserBlockRepository.java @@ -0,0 +1,30 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.UserBlock; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserBlockRepository extends JpaRepository { + + @Query("SELECT ub FROM UserBlock ub WHERE ub.blocker.id = :blockerId AND ub.blocked.id = :blockedId") + Optional findByBlockerIdAndBlockedId(@Param("blockerId") UUID blockerId, @Param("blockedId") UUID blockedId); + + @Query("SELECT COUNT(ub) > 0 FROM UserBlock ub WHERE " + + "(ub.blocker.id = :userId1 AND ub.blocked.id = :userId2) OR " + + "(ub.blocker.id = :userId2 AND ub.blocked.id = :userId1)") + boolean isBlocked(@Param("userId1") UUID userId1, @Param("userId2") UUID userId2); + + @Query("SELECT COUNT(ub) > 0 FROM UserBlock ub WHERE ub.blocker.id = :blockerId AND ub.blocked.id = :blockedId") + boolean isBlockedBy(@Param("blockerId") UUID blockerId, @Param("blockedId") UUID blockedId); + + @Query("SELECT ub.blocked.id FROM UserBlock ub WHERE ub.blocker.id = :blockerId") + List findBlockedUserIdsByBlockerId(@Param("blockerId") UUID blockerId); +} + diff --git a/src/main/java/com/gnx/telemedicine/repository/UserRepository.java b/src/main/java/com/gnx/telemedicine/repository/UserRepository.java new file mode 100644 index 0000000..a7357b9 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/UserRepository.java @@ -0,0 +1,19 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + Boolean existsByEmail(String email); + Optional findByEmail(String email); + void deleteByEmail(String email); + List findByRole(Role role); + Optional findByPasswordResetToken(String token); +} diff --git a/src/main/java/com/gnx/telemedicine/repository/VitalSignsRepository.java b/src/main/java/com/gnx/telemedicine/repository/VitalSignsRepository.java new file mode 100644 index 0000000..d172df5 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/repository/VitalSignsRepository.java @@ -0,0 +1,26 @@ +package com.gnx.telemedicine.repository; + +import com.gnx.telemedicine.model.VitalSigns; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface VitalSignsRepository extends JpaRepository { + + List findByPatientIdOrderByRecordedAtDesc(UUID patientId); + + List findByAppointmentId(UUID appointmentId); + + @Query("SELECT vs FROM VitalSigns vs WHERE vs.patient.id = :patientId AND vs.recordedAt >= :startDate ORDER BY vs.recordedAt DESC") + List findByPatientIdAndRecordedAtAfter(@Param("patientId") UUID patientId, @Param("startDate") Instant startDate); + + @Query(value = "SELECT * FROM vital_signs WHERE patient_id = :patientId ORDER BY recorded_at DESC LIMIT 1", nativeQuery = true) + VitalSigns findLatestByPatientId(@Param("patientId") UUID patientId); +} + diff --git a/src/main/java/com/gnx/telemedicine/security/CustomUserDetailsService.java b/src/main/java/com/gnx/telemedicine/security/CustomUserDetailsService.java new file mode 100644 index 0000000..8923a26 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/security/CustomUserDetailsService.java @@ -0,0 +1,36 @@ +package com.gnx.telemedicine.security; + +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + UserModel user = userRepository.findByEmail(username).orElseThrow(() -> + new UsernameNotFoundException("User not found")); + + // Check if user account is active + boolean isEnabled = Boolean.TRUE.equals(user.getIsActive()); + + return new User(user.getEmail(), + user.getPassword(), + isEnabled, // account enabled/disabled based on isActive + true, // accountNonExpired + true, // credentialsNonExpired + true, // accountNonLocked + List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))); + } +} diff --git a/src/main/java/com/gnx/telemedicine/security/JwtAuthenticationFilter.java b/src/main/java/com/gnx/telemedicine/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..4538bd6 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/security/JwtAuthenticationFilter.java @@ -0,0 +1,85 @@ +package com.gnx.telemedicine.security; + +import com.gnx.telemedicine.util.CorrelationIdUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtUtils jwtUtils; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + // Skip if token is null or empty + if (token == null || token.isEmpty() || token.equals("null")) { + log.debug("JWT: Empty or null token for {}", request.getRequestURI()); + filterChain.doFilter(request, response); + return; + } + + try { + if (jwtUtils.validateToken(token)) { + String email = jwtUtils.getEmailFromToken(token); + log.debug("JWT: Token validated successfully for user: {} on {}", email, request.getRequestURI()); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + + // Check if user account is still enabled (active) + if (!userDetails.isEnabled()) { + log.warn("JWT: Account is deactivated for user: {}", email); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.getWriter().write("{\"message\":\"Account is deactivated\"}"); + response.setContentType("application/json"); + return; // Stop processing if user is deactivated + } + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + email, + null, + userDetails.getAuthorities() + ); + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // Set user ID in MDC for logging + CorrelationIdUtil.setUserId(email); + + log.debug("JWT: Authentication set for user: {} with roles: {}", email, userDetails.getAuthorities()); + } else { + // Token validation failed + log.warn("JWT: Token validation failed for request: {}", request.getRequestURI()); + log.debug("JWT: Token (first 20 chars): {}", token.length() > 20 ? token.substring(0, 20) + "..." : token); + } + } catch (Exception e) { + // Log but don't fail - let Spring Security handle authentication failure + // Invalid tokens will result in 401 Unauthorized + log.error("JWT validation error for {}: {}", request.getRequestURI(), e.getMessage(), e); + } + } else { + log.debug("JWT: No Authorization header found for {}", request.getRequestURI()); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/gnx/telemedicine/security/JwtUtils.java b/src/main/java/com/gnx/telemedicine/security/JwtUtils.java new file mode 100644 index 0000000..4f4758f --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/security/JwtUtils.java @@ -0,0 +1,142 @@ +package com.gnx.telemedicine.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.io.DecodingException; +import io.jsonwebtoken.io.Decoders; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +@Slf4j +public class JwtUtils { + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.expiration}") + private int jwtExpiration; + + @Value("${jwt.refresh-expiration:604800}") // Default: 7 days + private int refreshExpiration; + + private javax.crypto.SecretKey getSigningKey() { + byte[] keyBytes = null; + try { + keyBytes = Decoders.BASE64.decode(jwtSecret); + } catch (DecodingException | IllegalArgumentException ignored) { + // Not valid Base64, we'll derive from UTF-8 string below + } + + if (keyBytes != null && keyBytes.length >= 32) { + return Keys.hmacShaKeyFor(keyBytes); + } + + // Fallback: derive a stable 256-bit key from the provided secret using SHA-256 + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] derived = digest.digest(jwtSecret.getBytes(StandardCharsets.UTF_8)); + return Keys.hmacShaKeyFor(derived); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm not available for JWT key derivation", e); + } + } + + public String generateToken(String email) { + return Jwts.builder() + .subject(email) + .issuedAt(new Date()) + .expiration(new Date(new Date().getTime() + jwtExpiration * 1000L)) + .signWith(getSigningKey()) + .compact(); + } + + /** + * Generate a refresh token (long-lived token for obtaining new access tokens). + */ + public String generateRefreshToken(String email) { + return Jwts.builder() + .subject(email) + .claim("type", "refresh") + .issuedAt(new Date()) + .expiration(new Date(new Date().getTime() + refreshExpiration * 1000L)) + .signWith(getSigningKey()) + .compact(); + } + + /** + * Validate refresh token. + */ + public boolean validateRefreshToken(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + // Check if it's a refresh token + String type = claims.get("type", String.class); + if (!"refresh".equals(type)) { + log.warn("Token is not a refresh token"); + return false; + } + + Date expiration = claims.getExpiration(); + boolean isExpired = expiration.before(new Date()); + + if (isExpired) { + log.warn("Refresh token expired. Expiration: {}, Current: {}", expiration, new Date()); + } + + return !isExpired; + + } catch (JwtException e) { + log.warn("Refresh token validation exception: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + return false; + } catch (Exception e) { + log.error("Refresh token validation error: {} - {}", e.getClass().getSimpleName(), e.getMessage(), e); + return false; + } + } + + public boolean validateToken(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + Date expiration = claims.getExpiration(); + boolean isExpired = expiration.before(new Date()); + + if (isExpired) { + log.warn("JWT token expired. Expiration: {}, Current: {}", expiration, new Date()); + } + + return !isExpired; + + } catch (JwtException e) { + log.warn("JWT validation exception: {} - {}", e.getClass().getSimpleName(), e.getMessage()); + return false; + } catch (Exception e) { + log.error("JWT validation error: {} - {}", e.getClass().getSimpleName(), e.getMessage(), e); + return false; + } + } + + public String getEmailFromToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } +} diff --git a/src/main/java/com/gnx/telemedicine/security/RateLimitingFilter.java b/src/main/java/com/gnx/telemedicine/security/RateLimitingFilter.java new file mode 100644 index 0000000..d837ae6 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/security/RateLimitingFilter.java @@ -0,0 +1,92 @@ +package com.gnx.telemedicine.security; + +import com.gnx.telemedicine.service.RateLimitingService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class RateLimitingFilter extends OncePerRequestFilter { + + private final RateLimitingService rateLimitingService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String endpoint = request.getRequestURI(); + String method = request.getMethod(); + + // Get user identifier + String userIdentifier = getUserIdentifier(request); + + // Check rate limit and get remaining requests + RateLimitingService.RateLimitResult rateLimitResult = rateLimitingService.checkRateLimit(userIdentifier, endpoint, method); + + if (!rateLimitResult.isAllowed()) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType("application/json"); + response.setHeader("X-RateLimit-Limit", String.valueOf(rateLimitResult.getLimit())); + response.setHeader("X-RateLimit-Remaining", "0"); + response.setHeader("X-RateLimit-Reset", String.valueOf(rateLimitResult.getResetTime())); + response.getWriter().write("{\"error\":\"Too many requests. Please try again later.\",\"retryAfter\":" + rateLimitResult.getResetTime() + "}"); + return; + } + + // Add rate limit headers to response + response.setHeader("X-RateLimit-Limit", String.valueOf(rateLimitResult.getLimit())); + response.setHeader("X-RateLimit-Remaining", String.valueOf(rateLimitResult.getRemaining())); + response.setHeader("X-RateLimit-Reset", String.valueOf(rateLimitResult.getResetTime())); + + filterChain.doFilter(request, response); + } + + private String getUserIdentifier(HttpServletRequest request) { + // Try to get authenticated user first + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getName())) { + return "user:" + authentication.getName(); + } + + // Fall back to IP address + String ipAddress = getClientIpAddress(request); + return "ip:" + ipAddress; + } + + private String getClientIpAddress(HttpServletRequest request) { + String ipAddress = request.getHeader("X-Forwarded-For"); + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getHeader("Proxy-Client-IP"); + } + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getHeader("WL-Proxy-Client-IP"); + } + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getRemoteAddr(); + } + if (ipAddress != null && ipAddress.contains(",")) { + ipAddress = ipAddress.split(",")[0].trim(); + } + return ipAddress != null ? ipAddress : "unknown"; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // Skip rate limiting for public endpoints + String path = request.getRequestURI(); + return path.startsWith("/swagger-ui") || + path.startsWith("/v3/api-docs") || + path.equals("/swagger-ui.html"); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/security/SecurityConfig.java b/src/main/java/com/gnx/telemedicine/security/SecurityConfig.java new file mode 100644 index 0000000..be7d165 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/security/SecurityConfig.java @@ -0,0 +1,201 @@ +package com.gnx.telemedicine.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import com.gnx.telemedicine.config.SecurityHeadersConfig; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final CustomUserDetailsService customUserDetailsService; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final RateLimitingFilter rateLimitingFilter; + private final SecurityHeadersConfig securityHeadersConfig; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(req -> req + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/v3/auth/**").permitAll() + .requestMatchers("/api/v3/users/register/**").permitAll() + .requestMatchers("/api/v3/turn/**").permitAll() + + .requestMatchers(HttpMethod.POST, "/api/v3/appointments").hasAnyRole("DOCTOR", "PATIENT") + .requestMatchers(HttpMethod.DELETE, "/api/v3/appointments/**").hasAnyRole("DOCTOR", "PATIENT", "ADMIN") + + .requestMatchers("/api/v3/admin/**").hasRole("ADMIN") + .requestMatchers("/api/v3/audit/**").hasRole("ADMIN") + + .requestMatchers(HttpMethod.POST, "/api/v3/availability/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.PATCH, "/api/v3/availability/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.DELETE, "/api/v3/availability/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.GET, "/api/v3/availability/**").authenticated() + + .requestMatchers("/api/v3/messages/**").authenticated() + .requestMatchers("/api/v3/files/avatars/**").permitAll() + .requestMatchers("/api/v3/files/avatar").authenticated() + .requestMatchers("/api/v3/2fa/**").authenticated() + .requestMatchers("/api/v3/gdpr/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v3/medical-records/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.PUT, "/api/v3/medical-records/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.DELETE, "/api/v3/medical-records/**").hasRole("DOCTOR") + .requestMatchers("/api/v3/medical-records/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v3/vital-signs/**").hasAnyRole("DOCTOR", "PATIENT") + .requestMatchers(HttpMethod.DELETE, "/api/v3/vital-signs/**").hasRole("DOCTOR") + .requestMatchers("/api/v3/vital-signs/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v3/lab-results/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.PUT, "/api/v3/lab-results/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.DELETE, "/api/v3/lab-results/**").hasRole("DOCTOR") + .requestMatchers("/api/v3/lab-results/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v3/prescriptions/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.PUT, "/api/v3/prescriptions/**").hasRole("DOCTOR") + .requestMatchers(HttpMethod.DELETE, "/api/v3/prescriptions/**").hasRole("DOCTOR") + .requestMatchers("/api/v3/prescriptions/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v3/medication-intake-logs/**").authenticated() + .requestMatchers("/api/v3/medication-intake-logs/**").authenticated() + .requestMatchers("/ws/**").permitAll() + + .requestMatchers("/swagger-ui.html").permitAll() + .requestMatchers("/v3/api-docs/**").permitAll() + .requestMatchers("/swagger-ui/**").permitAll() + + // Health check endpoints + .requestMatchers("/actuator/health/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + + .anyRequest().authenticated() + ) + .exceptionHandling(e -> e + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler(((request, response, accessDeniedException) -> + response.setStatus(HttpStatus.FORBIDDEN.value()))) + ) + .anonymous(AbstractHttpConfigurer::disable) + .addFilterBefore(securityHeadersConfig, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .authenticationProvider(authenticationProvider()); + + http.cors(c -> {}); // enable CORS using CorsConfigurationSource bean + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // Check if we're in production mode + String profile = System.getenv("SPRING_PROFILES_ACTIVE"); + boolean isProduction = "prod".equals(profile) || "production".equals(profile); + + // Get allowed origins from environment or use defaults + String allowedOriginsEnv = System.getenv("CORS_ALLOWED_ORIGINS"); + if (allowedOriginsEnv != null && !allowedOriginsEnv.isEmpty()) { + // Parse comma-separated origins from environment variable + List allowedOrigins = List.of(allowedOriginsEnv.split(",")); + config.setAllowedOrigins(allowedOrigins); + } else if (isProduction) { + // In production, require explicit configuration - fail safe by allowing nothing + // This forces explicit configuration via environment variable + config.setAllowedOrigins(List.of()); + // Log warning - this should be configured via environment variable + // Note: Using System.err here as logger may not be initialized yet during bean creation + // In production, this should be caught during deployment validation + } else { + // Development mode: Allow localhost and specific common development origins only + // Do NOT use wildcards for private networks in production + config.setAllowedOrigins(List.of( + "http://localhost:4200", + "https://localhost:4200", + "http://127.0.0.1:4200", + "https://127.0.0.1:4200", + "http://localhost:5173", + "https://localhost:5173", + "http://127.0.0.1:5173", + "https://127.0.0.1:5173" + )); + + // For development, allow specific IP patterns but be more restrictive + // Only allow specific known development IPs, not all private networks + String devIp = System.getenv("DEV_IP"); + if (devIp != null && !devIp.isEmpty()) { + List devOrigins = new java.util.ArrayList<>(config.getAllowedOrigins()); + devOrigins.add("http://" + devIp + ":4200"); + devOrigins.add("https://" + devIp + ":4200"); + devOrigins.add("http://" + devIp + ":5173"); + devOrigins.add("https://" + devIp + ":5173"); + config.setAllowedOrigins(devOrigins); + } + } + + // Get allowed methods from environment or use defaults + String allowedMethodsEnv = System.getenv("CORS_ALLOWED_METHODS"); + if (allowedMethodsEnv != null && !allowedMethodsEnv.isEmpty()) { + config.setAllowedMethods(List.of(allowedMethodsEnv.split(","))); + } else { + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + } + + // Restrict allowed headers - don't allow all headers + String allowedHeadersEnv = System.getenv("CORS_ALLOWED_HEADERS"); + if (allowedHeadersEnv != null && !allowedHeadersEnv.isEmpty()) { + config.setAllowedHeaders(List.of(allowedHeadersEnv.split(","))); + } else { + config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With", "Accept", "Origin")); + } + + config.setExposedHeaders(List.of("Authorization", "X-RateLimit-Remaining", "X-RateLimit-Reset")); + config.setAllowCredentials(true); + + // Get max age from environment or use default + String maxAgeEnv = System.getenv("CORS_MAX_AGE"); + long maxAge = maxAgeEnv != null ? Long.parseLong(maxAgeEnv) : 3600L; + config.setMaxAge(maxAge); // Cache preflight requests + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(customUserDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + +} diff --git a/src/main/java/com/gnx/telemedicine/security/WebSocketSecurityConfig.java b/src/main/java/com/gnx/telemedicine/security/WebSocketSecurityConfig.java new file mode 100644 index 0000000..f7a4697 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/security/WebSocketSecurityConfig.java @@ -0,0 +1,209 @@ +package com.gnx.telemedicine.security; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.security.Principal; + +@Configuration +@EnableWebSocketMessageBroker +@Order(Ordered.HIGHEST_PRECEDENCE + 99) +@RequiredArgsConstructor +@Slf4j +public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer { + + private final JwtUtils jwtUtils; + private final CustomUserDetailsService customUserDetailsService; + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null) { + StompCommand command = accessor.getCommand(); + String destination = accessor.getDestination(); + + // Log ALL SEND commands with full details + if (StompCommand.SEND.equals(command)) { + log.debug("========================================"); + log.debug(">>> SEND COMMAND INTERCEPTED"); + log.debug(">>> Destination: {}", destination); + log.debug(">>> Principal BEFORE auth: {}", accessor.getUser() != null ? accessor.getUser().getName() : "NULL"); + log.debug(">>> Command: {}", command); + log.debug(">>> Session ID: {}", accessor.getSessionId()); + + // Log message payload + try { + Object payload = message.getPayload(); + log.debug(">>> Payload type: {}", payload != null ? payload.getClass().getName() : "NULL"); + if (payload instanceof byte[]) { + String bodyStr = new String((byte[]) payload); + log.debug(">>> Payload body: {}", bodyStr); + } else { + log.debug(">>> Payload: {}", payload); + } + } catch (Exception e) { + log.warn(">>> Error reading payload: {}", e.getMessage()); + } + + // Log all native headers + java.util.Map> nativeHeaders = accessor.toNativeHeaderMap(); + if (nativeHeaders != null && !nativeHeaders.isEmpty()) { + log.debug(">>> Native headers: {}", nativeHeaders); + } else { + log.debug(">>> No native headers found"); + } + + if (destination != null && destination.contains("call")) { + log.debug(">>> *** CALL MESSAGE TYPE ***"); + if (destination.contains("initiate")) { + log.debug(">>> *** CALL.INITIATE SPECIFICALLY ***"); + } + } + log.debug("========================================"); + } + + // Also log CONNECT commands to verify interceptor is working + if (StompCommand.CONNECT.equals(command)) { + log.debug(">>> CONNECT command detected"); + } + + // Authenticate on CONNECT + if (StompCommand.CONNECT.equals(command)) { + String authToken = accessor.getFirstNativeHeader("Authorization"); + log.debug("CONNECT command - Auth token: {}", authToken != null ? "PRESENT" : "NULL"); + + if (authToken != null && authToken.startsWith("Bearer ")) { + String token = authToken.substring(7); + + if (jwtUtils.validateToken(token)) { + String email = jwtUtils.getEmailFromToken(token); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + + if (userDetails.isEnabled()) { + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + email, + null, + userDetails.getAuthorities() + ); + accessor.setUser(auth); + + // Store token and Principal in session for later use + java.util.Map sessionAttrs = accessor.getSessionAttributes(); + if (sessionAttrs != null) { + sessionAttrs.put("authToken", authToken); + sessionAttrs.put("principal", auth); + log.debug("Stored Principal in session attributes"); + } else { + log.warn("WARNING: Session attributes are null during CONNECT"); + } + + log.info("CONNECT authenticated user: {}", email); + } else { + log.warn("ERROR: User account is disabled: {}", email); + } + } else { + log.warn("ERROR: Invalid JWT token on CONNECT"); + } + } else { + log.warn("ERROR: No auth token found on CONNECT"); + } + } + // For SEND commands, verify Principal exists (should be set from CONNECT) + else if (StompCommand.SEND.equals(command)) { + Principal principal = accessor.getUser(); + if (destination != null && destination.contains("call")) { + log.debug("=== SEND MESSAGE TO: {} ===", destination); + log.debug("Principal: {}", principal != null ? principal.getName() : "NULL"); + } + if (principal == null) { + log.warn("WARNING: Principal is NULL for SEND command to: {}", destination); + + // First, try to get Principal from session attributes (set during CONNECT) + java.util.Map sessionAttrs = accessor.getSessionAttributes(); + if (sessionAttrs != null) { + Object storedPrincipal = sessionAttrs.get("principal"); + if (storedPrincipal instanceof UsernamePasswordAuthenticationToken) { + accessor.setUser((Principal) storedPrincipal); + log.debug("Principal restored from session attributes: {}", ((Principal) storedPrincipal).getName()); + principal = (Principal) storedPrincipal; + } + } + + // If still null, try to authenticate from token + if (principal == null) { + String authToken = accessor.getFirstNativeHeader("Authorization"); + log.debug("Auth token from headers: {}", authToken != null ? "PRESENT" : "NULL"); + + // Also try to get token from session attributes (set during CONNECT) + if (authToken == null && sessionAttrs != null) { + Object sessionToken = sessionAttrs.get("authToken"); + if (sessionToken instanceof String) { + authToken = (String) sessionToken; + log.debug("Found auth token in session attributes"); + } + } + + if (authToken != null && authToken.startsWith("Bearer ")) { + String token = authToken.substring(7); + if (jwtUtils.validateToken(token)) { + String email = jwtUtils.getEmailFromToken(token); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + if (userDetails.isEnabled()) { + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + email, + null, + userDetails.getAuthorities() + ); + accessor.setUser(auth); + // Store in session for future messages + if (sessionAttrs != null) { + sessionAttrs.put("principal", auth); + } + log.debug("Principal set from token: {}", email); + principal = auth; + } else { + log.warn("ERROR: User account is disabled: {}", email); + } + } else { + log.warn("ERROR: Invalid JWT token"); + } + } else { + log.warn("ERROR: No valid auth token found for SEND command"); + } + } + } else { + log.debug("Principal is set: {}", principal.getName()); + } + + // Log Principal AFTER authentication attempt + Principal finalPrincipal = accessor.getUser(); + if (destination != null && destination.contains("call")) { + log.debug(">>> FINAL Principal for call message: {}", finalPrincipal != null ? finalPrincipal.getName() : "NULL"); + } + } + } + return message; + } + }); + } +} + + diff --git a/src/main/java/com/gnx/telemedicine/service/AdminService.java b/src/main/java/com/gnx/telemedicine/service/AdminService.java new file mode 100644 index 0000000..873212c --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/AdminService.java @@ -0,0 +1,337 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.admin.AdminStatsResponseDto; +import com.gnx.telemedicine.dto.admin.MetricsResponseDto; +import com.gnx.telemedicine.dto.admin.PaginatedResponse; +import com.gnx.telemedicine.dto.admin.UserManagementDto; +import com.gnx.telemedicine.dto.appointment.AppointmentResponseDto; +import com.gnx.telemedicine.mappers.UserMapper; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.Role; +import com.gnx.telemedicine.repository.AppointmentRepository; +import com.gnx.telemedicine.repository.BreachNotificationRepository; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.HipaaAuditLogRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.service.AppointmentService; +import com.gnx.telemedicine.util.PaginationUtil; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class AdminService { + private final UserRepository userRepository; + private final AppointmentRepository appointmentRepository; + private final AppointmentService appointmentService; + + private final UserMapper userMapper; + private final DoctorRepository doctorRepository; + private final MeterRegistry meterRegistry; + private final PatientRepository patientRepository; + private final HipaaAuditLogRepository hipaaAuditLogRepository; + private final BreachNotificationRepository breachNotificationRepository; + + + @Transactional(readOnly = true) + @Cacheable(value = "users") + public List getAllUsers() { + return userRepository.findAll().stream() + .map(user -> { + UserManagementDto baseDto = userMapper.toUserManagementDto(user); + // For doctor users, add doctor-specific info + if (user.getRole() == Role.DOCTOR) { + Doctor doctor = doctorRepository.findByUser(user).orElse(null); + if (doctor != null) { + return new UserManagementDto( + baseDto.id(), + baseDto.email(), + baseDto.firstName(), + baseDto.lastName(), + baseDto.phoneNumber(), + baseDto.role(), + baseDto.isActive(), + baseDto.createdAt(), + baseDto.avatarUrl(), + baseDto.isOnline(), + baseDto.status(), + doctor.getMedicalLicenseNumber(), + doctor.getIsVerified() + ); + } + } + return baseDto; + }) + .toList(); + } + + @Transactional(readOnly = true) + public PaginatedResponse getAllUsersPaginated(Integer page, Integer size, String sortBy, String direction) { + Pageable pageable = PaginationUtil.createPageable(page, size, sortBy, direction); + + Page userPage = userRepository.findAll(pageable); + + List users = userPage.getContent().stream() + .map(user -> { + UserManagementDto baseDto = userMapper.toUserManagementDto(user); + // For doctor users, add doctor-specific info + if (user.getRole() == Role.DOCTOR) { + Doctor doctor = doctorRepository.findByUser(user).orElse(null); + if (doctor != null) { + return new UserManagementDto( + baseDto.id(), + baseDto.email(), + baseDto.firstName(), + baseDto.lastName(), + baseDto.phoneNumber(), + baseDto.role(), + baseDto.isActive(), + baseDto.createdAt(), + baseDto.avatarUrl(), + baseDto.isOnline(), + baseDto.status(), + doctor.getMedicalLicenseNumber(), + doctor.getIsVerified() + ); + } + } + return baseDto; + }) + .toList(); + + return PaginatedResponse.of(users, userPage.getNumber(), userPage.getSize(), userPage.getTotalElements()); + } + + @Cacheable(value = "doctors") + public List getAllDoctors() { + return userRepository.findAll().stream() + .filter(user -> user.getRole() == Role.DOCTOR) + .map(user -> { + UserManagementDto baseDto = userMapper.toUserManagementDto(user); + // Get doctor-specific information + Doctor doctor = doctorRepository.findByUser(user).orElse(null); + if (doctor != null) { + return new UserManagementDto( + baseDto.id(), + baseDto.email(), + baseDto.firstName(), + baseDto.lastName(), + baseDto.phoneNumber(), + baseDto.role(), + baseDto.isActive(), + baseDto.createdAt(), + baseDto.avatarUrl(), + baseDto.isOnline(), + baseDto.status(), + doctor.getMedicalLicenseNumber(), + doctor.getIsVerified() + ); + } + return new UserManagementDto( + baseDto.id(), + baseDto.email(), + baseDto.firstName(), + baseDto.lastName(), + baseDto.phoneNumber(), + baseDto.role(), + baseDto.isActive(), + baseDto.createdAt(), + baseDto.avatarUrl(), + baseDto.isOnline(), + baseDto.status(), + null, + null + ); + }) + .toList(); + } + + @Cacheable(value = "patients") + public List getAllPatients() { + return userRepository.findAll().stream() + .filter(user -> user.getRole() == Role.PATIENT) + .map(userMapper::toUserManagementDto) + .toList(); + } + + @Cacheable(value = "adminStats") + public AdminStatsResponseDto getSystemStats() { + long totalUsers = userRepository.count(); + long totalDoctors = userRepository.findAll().stream() + .filter(user -> user.getRole() == Role.DOCTOR) + .count(); + long totalPatients = userRepository.findAll().stream() + .filter(user -> user.getRole() == Role.PATIENT) + .count(); + long totalAppointments = appointmentRepository.count(); + long activeUsers = userRepository.findAll().stream() + .filter(user -> Boolean.TRUE.equals(user.getIsActive())) + .count(); + + return new AdminStatsResponseDto( + totalUsers, + totalDoctors, + totalPatients, + totalAppointments, + activeUsers + ); + } + + @Transactional + public UserManagementDto toggleUserActivity(String email, boolean isActive) { + Optional user = userRepository.findByEmail(email); + if (user.isEmpty()) { + throw new RuntimeException("User with email " + email + " not found"); + } + UserModel actualUser = user.get(); + actualUser.setIsActive(isActive); + userRepository.save(actualUser); + return userMapper.toUserManagementDto(actualUser); + } + + @Transactional + public void verifyDoctor(String medicalLicenseNumber, boolean isVerified) { + Doctor doctor = doctorRepository.findByMedicalLicenseNumber(medicalLicenseNumber) + .orElseThrow(() -> new IllegalArgumentException("Doctor not found with medical license number " + medicalLicenseNumber)); + + doctor.setIsVerified(isVerified); + doctorRepository.save(doctor); + } + + @Transactional + public void deleteUser(String email) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User with email " + email + " not found")); + + // Clean up HIPAA audit logs tied to this user + hipaaAuditLogRepository.deleteByUserId(user.getId()); + + // If the user is (or has) a patient profile, remove logs referencing that patient + if (user.getRole() == Role.PATIENT) { + patientRepository.findByUser(user).ifPresent(patient -> + hipaaAuditLogRepository.deleteByPatientId(patient.getId()) + ); + } + + // Remove breach notifications created by this user to avoid FK conflicts + breachNotificationRepository.deleteByCreatedBy(user); + + userRepository.delete(user); + } + + public List getAllAppointments() { + return appointmentService.getAllAppointments(); + } + + public MetricsResponseDto getMetrics() { + // Get counter values + double loginAttempts = getCounterValue("telemedicine.auth.login.attempts"); + double loginSuccess = getCounterValue("telemedicine.auth.login.success"); + double loginFailure = getCounterValue("telemedicine.auth.login.failure"); + double passwordResetRequests = getCounterValue("telemedicine.auth.password.reset.requests"); + double passwordResetSuccess = getCounterValue("telemedicine.auth.password.reset.success"); + double twoFactorAuthEnabled = getCounterValue("telemedicine.auth.2fa.enabled"); + double twoFactorAuthVerified = getCounterValue("telemedicine.auth.2fa.verified"); + + double appointmentsCreated = getCounterValue("telemedicine.appointments.created"); + double appointmentsCancelled = getCounterValue("telemedicine.appointments.cancelled"); + double appointmentsCompleted = getCounterValue("telemedicine.appointments.completed"); + + double prescriptionsCreated = getCounterValue("telemedicine.prescriptions.created"); + + double messagesSent = getCounterValue("telemedicine.messages.sent"); + + double apiRequests = getCounterValue("telemedicine.api.requests"); + double apiErrors = getCounterValue("telemedicine.api.errors"); + + double phiAccessCount = getCounterValue("telemedicine.phi.access"); + double breachNotifications = getCounterValue("telemedicine.breach.notifications"); + + // Get gauge values + int activeAppointments = (int) getGaugeValue("telemedicine.appointments.active", 0); + long totalAppointments = (long) getGaugeValue("telemedicine.appointments.total", 0); + int activePrescriptions = (int) getGaugeValue("telemedicine.prescriptions.active", 0); + long totalPrescriptions = (long) getGaugeValue("telemedicine.prescriptions.total", 0); + int activeUsers = (int) getGaugeValue("telemedicine.users.active", 0); + long totalUsers = (long) getGaugeValue("telemedicine.users.total", 0); + + // Get timer statistics + Map apiResponseTime = getTimerStats("telemedicine.api.response.time"); + Map databaseQueryTime = getTimerStats("telemedicine.database.query.time"); + + return new MetricsResponseDto( + loginAttempts, + loginSuccess, + loginFailure, + passwordResetRequests, + passwordResetSuccess, + twoFactorAuthEnabled, + twoFactorAuthVerified, + appointmentsCreated, + appointmentsCancelled, + appointmentsCompleted, + activeAppointments, + totalAppointments, + prescriptionsCreated, + activePrescriptions, + totalPrescriptions, + messagesSent, + apiRequests, + apiErrors, + apiResponseTime, + databaseQueryTime, + phiAccessCount, + breachNotifications, + activeUsers, + totalUsers + ); + } + + private double getCounterValue(String name) { + try { + var counter = meterRegistry.find(name).counter(); + return counter != null ? counter.count() : 0.0; + } catch (Exception e) { + return 0.0; + } + } + + private double getGaugeValue(String name, double defaultValue) { + try { + return meterRegistry.find(name).gauge() != null + ? meterRegistry.find(name).gauge().value() + : defaultValue; + } catch (Exception e) { + return defaultValue; + } + } + + private Map getTimerStats(String name) { + Map stats = new HashMap<>(); + try { + var timer = meterRegistry.find(name).timer(); + if (timer != null) { + stats.put("count", (double) timer.count()); + stats.put("mean", timer.mean(TimeUnit.MILLISECONDS)); + stats.put("max", timer.max(TimeUnit.MILLISECONDS)); + stats.put("total", timer.totalTime(TimeUnit.MILLISECONDS)); + } + } catch (Exception e) { + // Return empty map if timer not found + } + return stats; + } +} diff --git a/src/main/java/com/gnx/telemedicine/service/AppointmentService.java b/src/main/java/com/gnx/telemedicine/service/AppointmentService.java new file mode 100644 index 0000000..ac486e8 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/AppointmentService.java @@ -0,0 +1,309 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.appointment.AppointmentRequestDto; +import com.gnx.telemedicine.dto.appointment.AppointmentResponseDto; +import com.gnx.telemedicine.exception.AvailabilityException; +import com.gnx.telemedicine.mappers.AppointmentMapper; +import com.gnx.telemedicine.model.Appointment; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.DoctorAvailability; +import com.gnx.telemedicine.model.enums.AppointmentStatus; +import com.gnx.telemedicine.model.enums.DayOfWeek; +import com.gnx.telemedicine.repository.AppointmentRepository; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AppointmentService { + private final AppointmentRepository appointmentRepository; + private final AppointmentMapper appointmentMapper; + private final EmailNotificationService emailService; + private final PatientRepository patientRepository; + private final DoctorRepository doctorRepository; + private final DoctorAvailabilityService doctorAvailabilityService; + private final com.gnx.telemedicine.metrics.TelemedicineMetrics telemedicineMetrics; + + @Cacheable(value = "appointmentsByPatient", key = "#id") + public List getAppointmentsByPatientId(UUID id) { + return appointmentRepository.findByPatientId(id).stream().map(appointmentMapper::toAppointmentResponseDto).toList(); + } + + @Cacheable(value = "appointmentsByDoctor", key = "#id") + public List getAppointmentsByDoctorId(UUID id) { + return appointmentRepository.findByDoctorId(id).stream().map(appointmentMapper::toAppointmentResponseDto).toList(); + } + + @Cacheable(value = "appointments") + public List getAllAppointments() { + return appointmentRepository.findAllWithRelations().stream().map(appointmentMapper::toAppointmentResponseDto).toList(); + } + + @Cacheable(value = "appointmentById", key = "#id") + public AppointmentResponseDto getAppointmentById(UUID id) { + Appointment appointment = appointmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + return appointmentMapper.toAppointmentResponseDto(appointment); + } + + @Transactional(readOnly = true) + public Appointment getAppointmentEntityById(UUID id) { + Appointment appointment = appointmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + // Force initialization of lazy relationships while session is open + if (appointment.getPatient() != null) { + appointment.getPatient().getId(); // Force initialization + } + if (appointment.getDoctor() != null) { + appointment.getDoctor().getId(); // Force initialization + } + return appointment; + } + + @CacheEvict(value = {"appointments", "appointmentsByPatient", "appointmentsByDoctor"}, allEntries = true) + public AppointmentResponseDto createAppointment(AppointmentRequestDto appointmentRequest) { + // Start appointment creation timer + io.micrometer.core.instrument.Timer.Sample timer = telemedicineMetrics.startAppointmentCreationTimer(); + + try { + // Validate durationMinutes is not null + if (appointmentRequest.durationMinutes() == null) { + throw new IllegalArgumentException("Duration in minutes cannot be null"); + } + + // Check if patient already has an appointment on this date + List existingPatientAppointments = appointmentRepository.findByPatientIdAndDate( + appointmentRequest.patientId(), + appointmentRequest.scheduledDate() + ); + + if (!existingPatientAppointments.isEmpty()) { + throw new AvailabilityException("You already have an appointment scheduled on this date. Each patient can have only one appointment per day."); + } + + DayOfWeek dayOfWeek = DayOfWeek.valueOf(appointmentRequest.scheduledDate().getDayOfWeek().name()); + LocalTime endTime = appointmentRequest.scheduledTime().plusMinutes(appointmentRequest.durationMinutes()); + + // Check if doctor has availability slot for this time + boolean isAvailable = doctorAvailabilityService.isDoctorAvailable( + appointmentRequest.doctorId(), + dayOfWeek, + appointmentRequest.scheduledTime(), + endTime + ); + + if (!isAvailable) { + throw new AvailabilityException("Doctor is not available at the requested time. Please check doctor's availability schedule."); + } + + // Check for overlapping appointments + List overlapping = appointmentRepository.findOverlappingAppointments( + appointmentRequest.doctorId(), + appointmentRequest.scheduledDate(), + appointmentRequest.scheduledTime(), + endTime + ); + + if (!overlapping.isEmpty()) { + throw new AvailabilityException("Time slot is already booked. Please choose a different time."); + } + + Appointment appointment = appointmentMapper.toAppointment(appointmentRequest); + // Don't set ID manually - let Hibernate generate it via @GeneratedValue + appointment.setPatient(patientRepository.findById(appointmentRequest.patientId()).orElseThrow()); + appointment.setDoctor(doctorRepository.findById(appointmentRequest.doctorId()).orElseThrow()); + + // Auto-confirm appointment if slot is available (no overlapping appointments) + // If slot is available and doctor has availability, auto-confirm + if (overlapping.isEmpty() && isAvailable) { + appointment.setStatus(AppointmentStatus.CONFIRMED); + } else { + appointment.setStatus(AppointmentStatus.SCHEDULED); // Request pending doctor confirmation + } + + appointment.setCreatedAt(Instant.now()); + Appointment saved = appointmentRepository.save(appointment); + + // Reload appointment with relationships to avoid LazyInitializationException + Appointment appointmentWithRelations = appointmentRepository.findById(saved.getId()) + .orElseThrow(() -> new RuntimeException("Failed to reload appointment")); + + // Send appropriate email notification based on status + if (saved.getStatus() == AppointmentStatus.CONFIRMED) { + emailService.sendAppointmentConfirmed(appointmentWithRelations); + } else { + emailService.sendAppointmentConfirmation(appointmentWithRelations); + } + + // Record appointment creation metrics + telemedicineMetrics.recordAppointmentCreated(); + telemedicineMetrics.recordAppointmentCreationTime(timer); + + return appointmentMapper.toAppointmentResponseDto(appointmentWithRelations); + } catch (Exception e) { + // Stop timer on exception (record time even on failure) + telemedicineMetrics.recordAppointmentCreationTime(timer); + throw e; + } + } + + @CacheEvict(value = {"appointments", "appointmentsByPatient", "appointmentsByDoctor", "appointmentById"}, allEntries = true) + public void deleteAppointment(UUID id) { + // Hard delete - only used by admins + appointmentRepository.deleteById(id); + } + + @CacheEvict(value = {"appointments", "appointmentsByPatient", "appointmentsByDoctor", "appointmentById"}, allEntries = true) + public void softDeleteAppointmentByPatient(UUID id) { + Appointment appointment = appointmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + appointment.setDeletedByPatient(true); + appointmentRepository.save(appointment); + } + + public void softDeleteAppointmentByDoctor(UUID id) { + Appointment appointment = appointmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + appointment.setDeletedByDoctor(true); + appointmentRepository.save(appointment); + } + + /** + * Remove patient from doctor's history by soft-deleting all appointments between them + * This effectively hides the patient from the doctor's view + */ + @Transactional(rollbackFor = {Exception.class}) + public void removePatientFromDoctorHistory(UUID doctorId, UUID patientId) { + List appointments = appointmentRepository.findByDoctorIdAndPatientId(doctorId, patientId); + for (Appointment appointment : appointments) { + appointment.setDeletedByDoctor(true); + appointmentRepository.save(appointment); + } + } + + /** + * Remove doctor from patient's history by soft-deleting all appointments between them + * This effectively hides the doctor from the patient's view + */ + @Transactional(rollbackFor = {Exception.class}) + public void removeDoctorFromPatientHistory(UUID doctorId, UUID patientId) { + List appointments = appointmentRepository.findByDoctorIdAndPatientId(doctorId, patientId); + for (Appointment appointment : appointments) { + appointment.setDeletedByPatient(true); + appointmentRepository.save(appointment); + } + } + + @CacheEvict(value = {"appointments", "appointmentsByPatient", "appointmentsByDoctor", "appointmentById"}, allEntries = true) + public AppointmentResponseDto changeAppointmentStatus(UUID id, AppointmentStatus status) { + Appointment appointment = appointmentRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("Appointment cannot be null")); + appointment.setStatus(status); + Appointment saved = appointmentRepository.save(appointment); + + // Reload appointment with relationships to avoid LazyInitializationException + Appointment appointmentWithRelations = appointmentRepository.findById(saved.getId()) + .orElseThrow(() -> new RuntimeException("Failed to reload appointment")); + + // Send email notifications based on status change + if (status == AppointmentStatus.CANCELLED) { + emailService.sendAppointmentCancellation(appointmentWithRelations); + } else if (status == AppointmentStatus.CONFIRMED) { + emailService.sendAppointmentConfirmed(appointmentWithRelations); + } else if (status == AppointmentStatus.COMPLETED) { + emailService.sendAppointmentCompleted(appointmentWithRelations); + } + + return appointmentMapper.toAppointmentResponseDto(appointmentWithRelations); + } + + public List getAvailableTimeSlots(UUID doctorId, LocalDate date, Integer durationMinutes) { + DayOfWeek dayOfWeek = DayOfWeek.valueOf(date.getDayOfWeek().name()); + + // Get doctor's default duration if not provided + Doctor doctor = doctorRepository.findById(doctorId).orElse(null); + + // Use provided durationMinutes if present, otherwise use doctor's default, fallback to 30 + int slotDuration; + if (durationMinutes != null && durationMinutes > 0) { + slotDuration = durationMinutes; + } else if (doctor != null && doctor.getDefaultDurationMinutes() != null && doctor.getDefaultDurationMinutes() > 0) { + slotDuration = doctor.getDefaultDurationMinutes(); + } else { + slotDuration = 30; // Default fallback + } + + // Log for debugging + log.debug("getAvailableTimeSlots - doctorId: {}, provided durationMinutes: {}, doctor defaultDurationMinutes: {}, final slotDuration: {}", + doctorId, durationMinutes, doctor != null ? doctor.getDefaultDurationMinutes() : "null", slotDuration); + + // Get doctor's availability slots for this day + List availabilitiesDto = doctorAvailabilityService.getDoctorAvailabilityByDay(doctorId, dayOfWeek); + availabilitiesDto = availabilitiesDto.stream() + .filter(av -> av.isAvailable() != null && av.isAvailable()) + .toList(); + + if (availabilitiesDto.isEmpty()) { + return new ArrayList<>(); + } + + // Get existing appointments for this doctor on this date + List existingAppointments = appointmentRepository.findByDoctorIdAndDate(doctorId, date); + + List availableSlots = new ArrayList<>(); + + // Generate slots based on doctor's duration preference for each availability window + for (com.gnx.telemedicine.dto.availability.AvailabilityResponseDto availability : availabilitiesDto) { + LocalTime start = availability.startTime(); + LocalTime end = availability.endTime(); + + LocalTime current = start; + while (current.isBefore(end)) { + final LocalTime slotStart = current; + LocalTime slotEnd = current.plusMinutes(slotDuration); + if (slotEnd.isAfter(end)) { + break; + } + final LocalTime slotEndFinal = slotEnd; + + // Check if this slot overlaps with any existing appointment + // Two time ranges overlap if one starts before the other ends + // They don't overlap if: slot ends <= appointment starts OR slot starts >= appointment ends + boolean isBooked = existingAppointments.stream().anyMatch(apt -> { + if (apt.getStatus() == AppointmentStatus.CANCELLED) { + return false; // Cancelled appointments don't block slots + } + LocalTime aptStart = apt.getScheduledTime(); + LocalTime aptEnd = aptStart.plusMinutes(apt.getDurationMinutes()); + // Slots don't overlap if one completely ends before the other starts + // This means adjacent slots (9:00-9:30 and 9:30-10:00) are NOT overlapping + boolean noOverlap = slotEndFinal.isBefore(aptStart) || slotStart.isAfter(aptEnd) || + slotEndFinal.equals(aptStart) || slotStart.equals(aptEnd); + return !noOverlap; // If there's overlap, slot is booked + }); + + if (!isBooked) { + availableSlots.add(slotStart.format(DateTimeFormatter.ofPattern("HH:mm"))); + } + + current = current.plusMinutes(slotDuration); + } + } + + return availableSlots; + } +} diff --git a/src/main/java/com/gnx/telemedicine/service/AuthService.java b/src/main/java/com/gnx/telemedicine/service/AuthService.java new file mode 100644 index 0000000..a4a43e0 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/AuthService.java @@ -0,0 +1,376 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.auth.ForgotPasswordRequestDto; +import com.gnx.telemedicine.dto.auth.JwtResponseDto; +import com.gnx.telemedicine.dto.auth.PasswordResetResponseDto; +import com.gnx.telemedicine.dto.auth.RefreshTokenRequestDto; +import com.gnx.telemedicine.dto.auth.RefreshTokenResponseDto; +import com.gnx.telemedicine.dto.auth.ResetPasswordRequestDto; +import com.gnx.telemedicine.dto.auth.UserLoginDto; +import com.gnx.telemedicine.exception.PasswordMismatchException; +import com.gnx.telemedicine.exception.ResourceNotFoundException; +import com.gnx.telemedicine.exception.TwoFactorAuthenticationRequiredException; +import com.gnx.telemedicine.exception.UnauthorizedException; +import com.gnx.telemedicine.model.RefreshToken; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.RefreshTokenRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.security.JwtUtils; +import com.gnx.telemedicine.service.PasswordPolicyService; +import com.gnx.telemedicine.util.PasswordValidator; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + + private final AuthenticationManager authenticationManager; + private final JwtUtils jwtUtils; + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final LoginAttemptService loginAttemptService; + private final TwoFactorAuthService twoFactorAuthService; + private final EmailNotificationService emailNotificationService; + private final PasswordEncoder passwordEncoder; + private final PasswordPolicyService passwordPolicyService; + private final PasswordValidator passwordValidator; + private final com.gnx.telemedicine.metrics.TelemedicineMetrics telemedicineMetrics; + + @Value("${jwt.refresh-expiration:604800}") + private int refreshTokenExpiration; + + @Value("${jwt.refresh-token.max-tokens-per-user:5}") + private int maxTokensPerUser; + + private static final int TOKEN_EXPIRY_HOURS = 1; + private static final SecureRandom secureRandom = new SecureRandom(); + + @Transactional(rollbackFor = {Exception.class}) + public JwtResponseDto login(UserLoginDto userLoginDto, HttpServletRequest request) { + // Record login attempt + telemedicineMetrics.recordLoginAttempt(); + + // Start authentication timer + io.micrometer.core.instrument.Timer.Sample authTimer = telemedicineMetrics.startAuthenticationTimer(); + + try { + // Authenticate credentials first + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + userLoginDto.email(), + userLoginDto.password() + ) + ); + + // Explicitly check if user is active before generating token + UserModel user = userRepository.findByEmail(userLoginDto.email()) + .orElseThrow(() -> new ResourceNotFoundException("User", userLoginDto.email())); + + if (!Boolean.TRUE.equals(user.getIsActive())) { + loginAttemptService.recordFailedLogin(userLoginDto.email(), "Account deactivated", request); + throw new DisabledException("Account is deactivated"); + } + + // Check if password is expired + if (passwordPolicyService.isPasswordExpired(user)) { + loginAttemptService.recordFailedLogin(userLoginDto.email(), "Password expired", request); + throw new DisabledException("Your password has expired. Please reset your password."); + } + + // Check if 2FA is enabled + boolean is2FAEnabled = twoFactorAuthService.is2FAEnabled(userLoginDto.email()); + log.debug("Login attempt for {} - 2FA enabled: {}", userLoginDto.email(), is2FAEnabled); + + if (is2FAEnabled) { + // If 2FA code is not provided, throw specific exception indicating 2FA is required + if (userLoginDto.code() == null || userLoginDto.code().isEmpty()) { + log.debug("2FA enabled but no code provided for user: {}", userLoginDto.email()); + // Don't record as failed - user needs to provide 2FA code + throw new TwoFactorAuthenticationRequiredException("2FA code required. Please provide your authentication code."); + } + + // Verify 2FA code + boolean isValidCode = twoFactorAuthService.verifyCode( + twoFactorAuthService.get2FASetup(userLoginDto.email()) + .orElseThrow(() -> new RuntimeException("2FA not configured")) + .getSecretKey(), + userLoginDto.code() + ) || twoFactorAuthService.verifyBackupCode(userLoginDto.email(), userLoginDto.code()); + + if (!isValidCode) { + loginAttemptService.recordFailedLogin(userLoginDto.email(), "Invalid 2FA code", request); + throw new BadCredentialsException("Invalid 2FA code"); + } + } + + // Record successful login + loginAttemptService.recordSuccessfulLogin(userLoginDto.email(), request); + + // Restore user's last status on login + if (user.getUserStatus() == null || user.getUserStatus() == com.gnx.telemedicine.model.enums.UserStatus.OFFLINE) { + user.setUserStatus(com.gnx.telemedicine.model.enums.UserStatus.ONLINE); + user.setIsOnline(true); + userRepository.save(user); + } else if (user.getUserStatus() == com.gnx.telemedicine.model.enums.UserStatus.ONLINE) { + user.setIsOnline(true); + userRepository.save(user); + } else if (user.getUserStatus() == com.gnx.telemedicine.model.enums.UserStatus.BUSY) { + user.setIsOnline(true); + userRepository.save(user); + } + + // Generate access token + String accessToken = jwtUtils.generateToken(userLoginDto.email()); + + // Generate and save refresh token + String refreshTokenValue = jwtUtils.generateRefreshToken(userLoginDto.email()); + RefreshToken refreshToken = createRefreshToken(user, refreshTokenValue, request); + refreshTokenRepository.save(refreshToken); + + // Record successful login + telemedicineMetrics.recordLoginSuccess(); + telemedicineMetrics.recordAuthenticationTime(authTimer); + telemedicineMetrics.incrementActiveUsers(); + + log.debug("Login successful for user: {} - Access token and refresh token generated", userLoginDto.email()); + + return new JwtResponseDto(accessToken, refreshTokenValue); + + } catch (TwoFactorAuthenticationRequiredException e) { + // Don't record as failed login - this is expected when 2FA is enabled + // Re-throw to be handled by GlobalExceptionHandler + throw e; + } catch (IllegalArgumentException e) { + // Record other IllegalArgumentException errors as failed login + loginAttemptService.recordFailedLogin(userLoginDto.email(), e.getMessage(), request); + throw e; + } catch (BadCredentialsException | DisabledException e) { + loginAttemptService.recordFailedLogin(userLoginDto.email(), e.getMessage(), request); + throw e; + } catch (Exception e) { + log.error("Unexpected error during login for {}: {}", userLoginDto.email(), e.getMessage(), e); + loginAttemptService.recordFailedLogin(userLoginDto.email(), "Authentication failed", request); + throw new BadCredentialsException("Invalid credentials"); + } + } + + @Transactional(rollbackFor = {Exception.class}) + public void logout(String email) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new ResourceNotFoundException("User", email)); + + // Revoke all refresh tokens for the user + refreshTokenRepository.revokeAllTokensForUser(user, Instant.now(), "User logout"); + + // Decrement active users + telemedicineMetrics.decrementActiveUsers(); + + // Set user to OFFLINE when logging out + user.setUserStatus(com.gnx.telemedicine.model.enums.UserStatus.OFFLINE); + user.setIsOnline(false); + user.setLastSeen(java.time.LocalDateTime.now()); + userRepository.save(user); + + log.debug("Logout successful for user: {} - All refresh tokens revoked", email); + } + + /** + * Refresh access token using refresh token. + */ + @Transactional(rollbackFor = {Exception.class}) + public RefreshTokenResponseDto refreshToken(RefreshTokenRequestDto request, HttpServletRequest httpRequest) { + String refreshTokenValue = request.refreshToken(); + + // Validate refresh token JWT + if (!jwtUtils.validateRefreshToken(refreshTokenValue)) { + throw new UnauthorizedException("Invalid or expired refresh token"); + } + + // Get email from refresh token + String email = jwtUtils.getEmailFromToken(refreshTokenValue); + + // Find user + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new ResourceNotFoundException("User", email)); + + // Check if user is active + if (!Boolean.TRUE.equals(user.getIsActive())) { + throw new DisabledException("Account is deactivated"); + } + + // Find refresh token in database + RefreshToken refreshToken = refreshTokenRepository.findByTokenAndUser(refreshTokenValue, user) + .orElseThrow(() -> new UnauthorizedException("Refresh token not found")); + + // Check if refresh token is valid (not revoked and not expired) + if (!refreshToken.isValid()) { + if (refreshToken.isExpired()) { + refreshTokenRepository.delete(refreshToken); + throw new UnauthorizedException("Refresh token has expired"); + } + throw new UnauthorizedException("Refresh token has been revoked"); + } + + // Update last used timestamp + refreshToken.updateLastUsed(); + refreshTokenRepository.save(refreshToken); + + // Generate new access token + String newAccessToken = jwtUtils.generateToken(email); + + log.debug("Token refreshed for user: {}", email); + + return new RefreshTokenResponseDto(newAccessToken, refreshTokenValue); + } + + /** + * Create a new refresh token. + */ + private RefreshToken createRefreshToken(UserModel user, String tokenValue, HttpServletRequest request) { + // Check if user has too many active tokens + long activeTokenCount = refreshTokenRepository.countActiveTokensByUser(user, Instant.now()); + if (activeTokenCount >= maxTokensPerUser) { + // Revoke oldest tokens to make room + List validTokens = refreshTokenRepository.findValidTokensByUser(user, Instant.now()); + int tokensToRevoke = (int) (activeTokenCount - maxTokensPerUser + 1); + for (int i = 0; i < tokensToRevoke && i < validTokens.size(); i++) { + RefreshToken oldToken = validTokens.get(i); + oldToken.revoke("Maximum token limit reached"); + refreshTokenRepository.save(oldToken); + } + log.debug("Revoked {} old refresh tokens for user: {} to maintain limit", tokensToRevoke, user.getEmail()); + } + + // Extract device fingerprint and IP address + String deviceFingerprint = request.getHeader("X-Device-Fingerprint"); + String ipAddress = getClientIpAddress(request); + String userAgent = request.getHeader("User-Agent"); + + // Create new refresh token + Instant expiryDate = Instant.now().plusSeconds(refreshTokenExpiration); + + return RefreshToken.builder() + .token(tokenValue) + .user(user) + .expiryDate(expiryDate) + .deviceFingerprint(deviceFingerprint) + .ipAddress(ipAddress) + .userAgent(userAgent) + .revoked(false) + .build(); + } + + /** + * Get client IP address from request. + */ + private String getClientIpAddress(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + String xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isEmpty()) { + return xRealIp; + } + return request.getRemoteAddr(); + } + + @Transactional + public PasswordResetResponseDto forgotPassword(ForgotPasswordRequestDto request) { + // Always return success message to prevent email enumeration + String successMessage = "If an account with that email exists, a password reset link has been sent."; + + userRepository.findByEmail(request.email()).ifPresent(user -> { + // Generate secure reset token + String resetToken = generateResetToken(); + + // Set token and expiry (1 hour from now) + user.setPasswordResetToken(resetToken); + user.setPasswordResetTokenExpiry(Instant.now().plusSeconds(TOKEN_EXPIRY_HOURS * 3600)); + userRepository.save(user); + + // Send password reset email + emailNotificationService.sendPasswordResetEmail( + user.getEmail(), + resetToken, + user.getFirstName() + ); + + log.info("Password reset token generated for user: {}", user.getEmail()); + }); + + // Record password reset request + telemedicineMetrics.recordPasswordResetRequest(); + + return new PasswordResetResponseDto(successMessage); + } + + @Transactional(rollbackFor = {Exception.class}) + public PasswordResetResponseDto resetPassword(ResetPasswordRequestDto request) { + // Find user by reset token + UserModel user = userRepository.findByPasswordResetToken(request.token()) + .orElseThrow(() -> new BadCredentialsException("Invalid or expired reset token")); + + // Check if token is expired + if (user.getPasswordResetTokenExpiry() == null || + user.getPasswordResetTokenExpiry().isBefore(Instant.now())) { + // Clear expired token + user.setPasswordResetToken(null); + user.setPasswordResetTokenExpiry(null); + userRepository.save(user); + throw new BadCredentialsException("Reset token has expired. Please request a new one."); + } + + // Validate passwords match + if (!request.newPassword().equals(request.confirmPassword())) { + throw new PasswordMismatchException("Passwords do not match"); + } + + // Validate password complexity + PasswordValidator.ValidationResult passwordValidation = passwordValidator.validate(request.newPassword()); + if (!passwordValidation.isValid()) { + throw new IllegalArgumentException("Password does not meet requirements: " + passwordValidation.getMessage()); + } + + // Check password history (prevent reuse) + passwordPolicyService.validatePasswordPolicy(user, request.newPassword()); + + // Update password with history tracking and expiration + passwordPolicyService.updatePassword(user, request.newPassword()); + + // Clear reset token + user.setPasswordResetToken(null); + user.setPasswordResetTokenExpiry(null); + + userRepository.save(user); + + // Record successful password reset + telemedicineMetrics.recordPasswordResetSuccess(); + + log.info("Password reset successful for user: {}", user.getEmail()); + + return new PasswordResetResponseDto("Password has been reset successfully. You can now login with your new password."); + } + + private String generateResetToken() { + byte[] tokenBytes = new byte[32]; + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + } +} diff --git a/src/main/java/com/gnx/telemedicine/service/BreachNotificationService.java b/src/main/java/com/gnx/telemedicine/service/BreachNotificationService.java new file mode 100644 index 0000000..21d63d6 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/BreachNotificationService.java @@ -0,0 +1,106 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.audit.BreachNotificationRequestDto; +import com.gnx.telemedicine.dto.audit.BreachNotificationResponseDto; +import com.gnx.telemedicine.model.BreachNotification; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.BreachStatus; +import com.gnx.telemedicine.repository.BreachNotificationRepository; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BreachNotificationService { + + private final BreachNotificationRepository breachNotificationRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getAllBreachNotifications() { + return breachNotificationRepository.findAllByOrderByIncidentDateDesc() + .stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getBreachNotificationsByStatus(BreachStatus status) { + return breachNotificationRepository.findByStatusOrderByIncidentDateDesc(status) + .stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @Transactional + public BreachNotificationResponseDto createBreachNotification(String userEmail, BreachNotificationRequestDto requestDto) { + UserModel currentUser = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + BreachNotification breach = new BreachNotification(); + breach.setIncidentDate(requestDto.incidentDate()); + breach.setDiscoveryDate(requestDto.discoveryDate()); + breach.setBreachType(requestDto.breachType()); + breach.setAffectedPatientsCount(requestDto.affectedPatientsCount()); + breach.setDescription(requestDto.description()); + breach.setMitigationSteps(requestDto.mitigationSteps()); + breach.setStatus(BreachStatus.INVESTIGATING); + breach.setCreatedBy(currentUser); + breach.setCreatedAt(Instant.now()); + breach.setUpdatedAt(Instant.now()); + + BreachNotification saved = breachNotificationRepository.save(breach); + return toDto(saved); + } + + @Transactional + public BreachNotificationResponseDto updateBreachStatus(UUID id, BreachStatus status) { + BreachNotification breach = breachNotificationRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Breach notification not found")); + + breach.setStatus(status); + + // If resolved or reported, set notified timestamp + if (status == BreachStatus.RESOLVED || status == BreachStatus.REPORTED) { + if (breach.getNotifiedAt() == null) { + breach.setNotifiedAt(Instant.now()); + } + } + + BreachNotification saved = breachNotificationRepository.save(breach); + return toDto(saved); + } + + @Transactional(readOnly = true) + public BreachNotificationResponseDto getBreachNotificationById(UUID id) { + BreachNotification breach = breachNotificationRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Breach notification not found")); + return toDto(breach); + } + + private BreachNotificationResponseDto toDto(BreachNotification breach) { + return new BreachNotificationResponseDto( + breach.getId(), + breach.getIncidentDate(), + breach.getDiscoveryDate(), + breach.getBreachType(), + breach.getAffectedPatientsCount(), + breach.getDescription(), + breach.getMitigationSteps(), + breach.getNotifiedAt(), + breach.getStatus(), + breach.getCreatedBy().getId(), + breach.getCreatedBy().getFirstName() + " " + breach.getCreatedBy().getLastName(), + breach.getCreatedAt(), + breach.getUpdatedAt() + ); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/ClinicalAlertService.java b/src/main/java/com/gnx/telemedicine/service/ClinicalAlertService.java new file mode 100644 index 0000000..df80ddb --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/ClinicalAlertService.java @@ -0,0 +1,341 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.patient_safety.ClinicalAlertRequestDto; +import com.gnx.telemedicine.dto.patient_safety.ClinicalAlertResponseDto; +import com.gnx.telemedicine.mappers.ClinicalAlertMapper; +import com.gnx.telemedicine.model.Appointment; +import com.gnx.telemedicine.model.ClinicalAlert; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.Prescription; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.AlertSeverity; +import com.gnx.telemedicine.model.enums.ClinicalAlertType; +import com.gnx.telemedicine.model.enums.PrescriptionStatus; +import com.gnx.telemedicine.repository.AppointmentRepository; +import com.gnx.telemedicine.repository.ClinicalAlertRepository; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.PrescriptionRepository; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ClinicalAlertService { + + private final ClinicalAlertRepository clinicalAlertRepository; + private final ClinicalAlertMapper clinicalAlertMapper; + private final PatientRepository patientRepository; + private final PrescriptionRepository prescriptionRepository; + private final UserRepository userRepository; + private final DoctorRepository doctorRepository; + private final AppointmentRepository appointmentRepository; + + // Drug interaction database - simplified for demo + private static final Set KNOWN_INTERACTIONS = Set.of( + "WARFARIN-ASPIRIN", "WARFARIN-IBUPROFEN", "WARFARIN-NAPROXEN", + "DIGOXIN-AMIODARONE", "DIGOXIN-VERAPAMIL", + "ACE-BETA_BLOCKER", "ACE-POTASSIUM", + "SSRI-TRAMADOL", "SSRI-MAOI", + "METFORMIN-CONTRAST_DYE" + ); + + @Transactional(readOnly = true) + public List getAlertsByPatientId(UUID patientId) { + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + return clinicalAlertRepository.findByPatientOrderByCreatedAtDesc(patient) + .stream() + .map(clinicalAlertMapper::toClinicalAlertResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getUnacknowledgedAlertsByPatientId(UUID patientId) { + return clinicalAlertRepository.findUnacknowledgedAlertsByPatientId(patientId) + .stream() + .map(clinicalAlertMapper::toClinicalAlertResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getAllUnacknowledgedAlerts() { + // This method should only be used by admins or filtered by doctor in controller + return clinicalAlertRepository.findByAcknowledgedFalseOrderBySeverityDescCreatedAtDesc() + .stream() + .map(clinicalAlertMapper::toClinicalAlertResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getUnacknowledgedAlertsByDoctorId(UUID doctorId) { + return clinicalAlertRepository.findUnacknowledgedAlertsByDoctorId(doctorId) + .stream() + .map(clinicalAlertMapper::toClinicalAlertResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getAlertsByDoctorIdAndPatientId(UUID doctorId, UUID patientId) { + return clinicalAlertRepository.findByDoctorIdAndPatientId(doctorId, patientId) + .stream() + .map(clinicalAlertMapper::toClinicalAlertResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getUnacknowledgedAlertsByDoctorIdAndPatientId(UUID doctorId, UUID patientId) { + return clinicalAlertRepository.findUnacknowledgedAlertsByDoctorIdAndPatientId(doctorId, patientId) + .stream() + .map(clinicalAlertMapper::toClinicalAlertResponseDto) + .toList(); + } + + @Transactional + public ClinicalAlertResponseDto createAlert(String userEmail, ClinicalAlertRequestDto requestDto) { + Patient patient = patientRepository.findById(requestDto.patientId()) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + ClinicalAlert alert = clinicalAlertMapper.toClinicalAlert(requestDto); + alert.setPatient(patient); + + // Determine the treating doctor + Doctor treatingDoctor = findTreatingDoctor(patient.getId(), requestDto.relatedPrescriptionId()); + if (treatingDoctor != null) { + alert.setDoctor(treatingDoctor); + } + + if (requestDto.relatedPrescriptionId() != null) { + Prescription prescription = prescriptionRepository.findById(requestDto.relatedPrescriptionId()) + .orElse(null); + alert.setRelatedPrescription(prescription); + // If prescription exists and alert doesn't have a doctor, use prescription's doctor + if (prescription != null && treatingDoctor == null) { + alert.setDoctor(prescription.getDoctor()); + } + } + + ClinicalAlert saved = clinicalAlertRepository.save(alert); + log.info("Created clinical alert {} for patient {} assigned to doctor {}", + saved.getId(), patient.getId(), + saved.getDoctor() != null ? saved.getDoctor().getId() : "none"); + return clinicalAlertMapper.toClinicalAlertResponseDto(saved); + } + + /** + * Find the treating doctor for a patient. + * Priority: 1. Most recent appointment with CONFIRMED or COMPLETED status + * 2. Most recent prescription's doctor + * 3. Most recent appointment with any status + */ + private Doctor findTreatingDoctor(UUID patientId, UUID prescriptionId) { + // If there's a prescription ID, try to get doctor from that prescription first + if (prescriptionId != null) { + Optional prescription = prescriptionRepository.findById(prescriptionId); + if (prescription.isPresent() && prescription.get().getDoctor() != null) { + return prescription.get().getDoctor(); + } + } + + // Try to find from most recent appointment with CONFIRMED or COMPLETED status + List recentAppointments = appointmentRepository.findByPatientId(patientId); + Optional recentConfirmed = recentAppointments.stream() + .filter(a -> a.getStatus() != null && + (a.getStatus().name().equals("CONFIRMED") || + a.getStatus().name().equals("COMPLETED"))) + .sorted((a1, a2) -> a2.getCreatedAt().compareTo(a1.getCreatedAt())) + .findFirst(); + + if (recentConfirmed.isPresent() && recentConfirmed.get().getDoctor() != null) { + return recentConfirmed.get().getDoctor(); + } + + // Try to find from most recent prescription + List prescriptions = prescriptionRepository.findByPatientIdOrderByCreatedAtDesc(patientId); + Optional recentPrescription = prescriptions.stream().findFirst(); + + if (recentPrescription.isPresent() && recentPrescription.get().getDoctor() != null) { + return recentPrescription.get().getDoctor(); + } + + // Fallback: any recent appointment + Optional anyRecent = recentAppointments.stream() + .filter(a -> a.getDoctor() != null) + .sorted((a1, a2) -> a2.getCreatedAt().compareTo(a1.getCreatedAt())) + .findFirst(); + + if (anyRecent.isPresent()) { + return anyRecent.get().getDoctor(); + } + + return null; + } + + @Transactional + public ClinicalAlertResponseDto acknowledgeAlert(String userEmail, UUID alertId) { + ClinicalAlert alert = clinicalAlertRepository.findById(alertId) + .orElseThrow(() -> new IllegalArgumentException("Alert not found")); + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + alert.setAcknowledged(true); + alert.setAcknowledgedAt(Instant.now()); + alert.setAcknowledgedBy(user); + + ClinicalAlert saved = clinicalAlertRepository.save(alert); + log.info("Alert {} acknowledged by user {}", alertId, userEmail); + return clinicalAlertMapper.toClinicalAlertResponseDto(saved); + } + + @Transactional + public ClinicalAlertResponseDto resolveAlert(String userEmail, UUID alertId) { + ClinicalAlert alert = clinicalAlertRepository.findById(alertId) + .orElseThrow(() -> new IllegalArgumentException("Alert not found")); + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + alert.setResolvedAt(Instant.now()); + alert.setResolvedBy(user); + + ClinicalAlert saved = clinicalAlertRepository.save(alert); + log.info("Alert {} resolved by user {}", alertId, userEmail); + return clinicalAlertMapper.toClinicalAlertResponseDto(saved); + } + + @Transactional + public void checkForDrugInteractions(UUID patientId) { + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + // Get all active prescriptions for patient + List activePrescriptions = prescriptionRepository + .findActivePrescriptionsByPatientId(patientId, LocalDate.now()); + + if (activePrescriptions.size() < 2) { + return; // Need at least 2 medications to have interactions + } + + // Check for drug interactions + for (int i = 0; i < activePrescriptions.size(); i++) { + for (int j = i + 1; j < activePrescriptions.size(); j++) { + Prescription p1 = activePrescriptions.get(i); + Prescription p2 = activePrescriptions.get(j); + + String interactionKey1 = p1.getMedicationName().toUpperCase() + "-" + p2.getMedicationName().toUpperCase(); + String interactionKey2 = p2.getMedicationName().toUpperCase() + "-" + p1.getMedicationName().toUpperCase(); + + if (KNOWN_INTERACTIONS.contains(interactionKey1) || KNOWN_INTERACTIONS.contains(interactionKey2)) { + createDrugInteractionAlert(patient, p1, p2); + } + } + } + } + + @Transactional + public void checkForAllergyConflicts(UUID patientId, String medicationName) { + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + List allergies = patient.getAllergies(); + if (allergies == null || allergies.isEmpty()) { + return; + } + + for (String allergy : allergies) { + if (medicationName.toLowerCase().contains(allergy.toLowerCase()) || + allergy.toLowerCase().contains(medicationName.toLowerCase())) { + createAllergyAlert(patient, medicationName, allergy); + } + } + } + + private void createDrugInteractionAlert(Patient patient, Prescription p1, Prescription p2) { + // Check for duplicate unacknowledged alerts to prevent alert fatigue + List existingAlerts = clinicalAlertRepository.findDuplicateDrugInteractionAlerts( + patient.getId(), + p1.getMedicationName(), + p2.getMedicationName() + ); + + if (!existingAlerts.isEmpty()) { + log.info("Duplicate drug interaction alert already exists for patient {} between {} and {}. Skipping creation.", + patient.getId(), p1.getMedicationName(), p2.getMedicationName()); + return; // Don't create duplicate alert + } + + ClinicalAlert alert = new ClinicalAlert(); + alert.setPatient(patient); + alert.setAlertType(ClinicalAlertType.DRUG_INTERACTION); + alert.setSeverity(AlertSeverity.WARNING); + alert.setTitle("Drug Interaction Detected"); + alert.setDescription(String.format( + "Potential drug interaction between %s and %s. Please review and consult with physician.", + p1.getMedicationName(), p2.getMedicationName() + )); + alert.setRelatedPrescription(p1); + + // Assign to the treating doctor - prefer p1's doctor, fallback to p2's doctor, or find treating doctor + Doctor doctor = p1.getDoctor(); + if (doctor == null && p2.getDoctor() != null) { + doctor = p2.getDoctor(); + } + if (doctor == null) { + doctor = findTreatingDoctor(patient.getId(), null); + } + alert.setDoctor(doctor); + + clinicalAlertRepository.save(alert); + log.info("Created drug interaction alert for patient {} between {} and {} assigned to doctor {}", + patient.getId(), p1.getMedicationName(), p2.getMedicationName(), + doctor != null ? doctor.getId() : "none"); + } + + private void createAllergyAlert(Patient patient, String medication, String allergy) { + // Check for duplicate unacknowledged alerts to prevent alert fatigue + List existingAlerts = clinicalAlertRepository.findDuplicateAllergyAlerts( + patient.getId(), + medication + ); + + if (!existingAlerts.isEmpty()) { + log.info("Duplicate allergy alert already exists for patient {} for medication {}. Skipping creation.", + patient.getId(), medication); + return; // Don't create duplicate alert + } + + ClinicalAlert alert = new ClinicalAlert(); + alert.setPatient(patient); + alert.setAlertType(ClinicalAlertType.ALLERGY); + alert.setSeverity(AlertSeverity.CRITICAL); + alert.setTitle("Allergy Alert"); + alert.setDescription(String.format( + "Medication %s may contain or interact with known allergy: %s", + medication, allergy + )); + alert.setMedicationName(medication); + + // Assign to the treating doctor + Doctor doctor = findTreatingDoctor(patient.getId(), null); + alert.setDoctor(doctor); + + clinicalAlertRepository.save(alert); + log.info("Created allergy alert for patient {} for medication {} with allergy {} assigned to doctor {}", + patient.getId(), medication, allergy, + doctor != null ? doctor.getId() : "none"); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/CriticalResultManagementService.java b/src/main/java/com/gnx/telemedicine/service/CriticalResultManagementService.java new file mode 100644 index 0000000..ba92b56 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/CriticalResultManagementService.java @@ -0,0 +1,225 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.patient_safety.CriticalResultAcknowledgmentRequestDto; +import com.gnx.telemedicine.dto.patient_safety.CriticalResultResponseDto; +import com.gnx.telemedicine.mappers.CriticalResultMapper; +import com.gnx.telemedicine.model.*; +import com.gnx.telemedicine.model.enums.CriticalityLevel; +import com.gnx.telemedicine.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.gnx.telemedicine.model.Appointment; +import com.gnx.telemedicine.model.Prescription; +import com.gnx.telemedicine.repository.AppointmentRepository; +import com.gnx.telemedicine.repository.PrescriptionRepository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CriticalResultManagementService { + + private final CriticalResultRepository criticalResultRepository; + private final CriticalResultMapper criticalResultMapper; + private final LabResultRepository labResultRepository; + private final PatientRepository patientRepository; + private final DoctorRepository doctorRepository; + private final UserRepository userRepository; + private final EmailNotificationService emailNotificationService; + private final AppointmentRepository appointmentRepository; + private final PrescriptionRepository prescriptionRepository; + + @Transactional(readOnly = true) + public List getCriticalResultsByPatientId(UUID patientId) { + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + return criticalResultRepository.findByPatientOrderByCreatedAtDesc(patient) + .stream() + .map(criticalResultMapper::toCriticalResultResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getUnacknowledgedCriticalResultsByPatientId(UUID patientId) { + return criticalResultRepository.findUnacknowledgedCriticalResultsByPatientId(patientId) + .stream() + .map(criticalResultMapper::toCriticalResultResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getUnacknowledgedCriticalResultsByDoctorId(UUID doctorId) { + return criticalResultRepository.findUnacknowledgedCriticalResultsByDoctorId(doctorId) + .stream() + .map(criticalResultMapper::toCriticalResultResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getAllUnacknowledgedCriticalResults() { + return criticalResultRepository.findByAcknowledgedFalseOrderByCriticalityLevelDescCreatedAtDesc() + .stream() + .map(criticalResultMapper::toCriticalResultResponseDto) + .toList(); + } + + @Transactional + public CriticalResultResponseDto createCriticalResult(UUID labResultId, CriticalityLevel criticalityLevel, String clinicalSignificance) { + LabResult labResult = labResultRepository.findById(labResultId) + .orElseThrow(() -> new IllegalArgumentException("Lab result not found")); + + Patient patient = labResult.getPatient(); + + // Find the treating doctor for this patient + // Try to get from most recent appointment or prescription + Doctor doctor = findTreatingDoctorForPatient(patient.getId()); + if (doctor == null) { + throw new IllegalArgumentException("No treating doctor found for patient. Cannot create critical result without a treating doctor."); + } + + CriticalResult criticalResult = new CriticalResult(); + criticalResult.setLabResult(labResult); + criticalResult.setPatient(patient); + criticalResult.setDoctor(doctor); + criticalResult.setCriticalityLevel(criticalityLevel); + criticalResult.setTestName(labResult.getTestName()); + criticalResult.setResultValue(labResult.getResultValue()); + criticalResult.setReferenceRange(labResult.getReferenceRange()); + criticalResult.setClinicalSignificance(clinicalSignificance); + criticalResult.setAcknowledgmentRequired(true); + criticalResult.setFollowUpRequired(true); + criticalResult.setNotifiedAt(Instant.now()); + + CriticalResult saved = criticalResultRepository.save(criticalResult); + log.info("Created critical result {} for lab result {}", saved.getId(), labResultId); + + // Send notification to doctor + sendCriticalResultNotification(saved, doctor); + + return criticalResultMapper.toCriticalResultResponseDto(saved); + } + + @Transactional + public CriticalResultResponseDto acknowledgeCriticalResult(String userEmail, UUID resultId, CriticalResultAcknowledgmentRequestDto requestDto) { + CriticalResult criticalResult = criticalResultRepository.findById(resultId) + .orElseThrow(() -> new IllegalArgumentException("Critical result not found")); + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + criticalResult.setAcknowledged(true); + criticalResult.setAcknowledgedAt(Instant.now()); + criticalResult.setAcknowledgedBy(user); + + if (requestDto.acknowledgmentMethod() != null) { + criticalResult.setAcknowledgmentMethod(requestDto.acknowledgmentMethod()); + } + + if (requestDto.followUpStatus() != null) { + criticalResult.setFollowUpStatus(requestDto.followUpStatus()); + } + + CriticalResult saved = criticalResultRepository.save(criticalResult); + log.info("Critical result {} acknowledged by user {}", resultId, userEmail); + return criticalResultMapper.toCriticalResultResponseDto(saved); + } + + @Transactional(readOnly = true) + public CriticalResultResponseDto getCriticalResultById(UUID id) { + CriticalResult result = criticalResultRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Critical result not found")); + return criticalResultMapper.toCriticalResultResponseDto(result); + } + + public long countUnacknowledgedByPatientId(UUID patientId) { + return criticalResultRepository.countByPatientAndAcknowledgedFalse( + patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")) + ); + } + + public long countUnacknowledgedByDoctorId(UUID doctorId) { + return criticalResultRepository.countByDoctorAndAcknowledgedFalse( + doctorRepository.findById(doctorId) + .orElseThrow(() -> new IllegalArgumentException("Doctor not found")) + ); + } + + private void sendCriticalResultNotification(CriticalResult criticalResult, Doctor doctor) { + try { + String subject = "CRITICAL LAB RESULT ALERT"; + String message = String.format( + "CRITICAL LAB RESULT ALERT\n\n" + + "Patient: %s %s\n" + + "Test: %s\n" + + "Result: %s %s\n" + + "Criticality Level: %s\n" + + "Clinical Significance: %s\n" + + "\nImmediate attention required!", + criticalResult.getPatient().getUser().getFirstName(), + criticalResult.getPatient().getUser().getLastName(), + criticalResult.getTestName(), + criticalResult.getResultValue(), + criticalResult.getReferenceRange(), + criticalResult.getCriticalityLevel(), + criticalResult.getClinicalSignificance() + ); + + emailNotificationService.sendEmail(doctor.getUser().getEmail(), subject, message); + log.info("Critical result notification sent to doctor {}", doctor.getUser().getEmail()); + } catch (Exception e) { + log.error("Failed to send critical result notification", e); + } + } + + /** + * Find the treating doctor for a patient. + * Priority: 1. Most recent appointment with CONFIRMED or COMPLETED status + * 2. Most recent prescription's doctor + * 3. Most recent appointment with any status + */ + private Doctor findTreatingDoctorForPatient(UUID patientId) { + // Try to find from most recent appointment with CONFIRMED or COMPLETED status + List recentAppointments = appointmentRepository.findByPatientId(patientId); + Optional recentConfirmed = recentAppointments.stream() + .filter(a -> a.getStatus() != null && + (a.getStatus().name().equals("CONFIRMED") || + a.getStatus().name().equals("COMPLETED"))) + .sorted((a1, a2) -> a2.getCreatedAt().compareTo(a1.getCreatedAt())) + .findFirst(); + + if (recentConfirmed.isPresent() && recentConfirmed.get().getDoctor() != null) { + return recentConfirmed.get().getDoctor(); + } + + // Try to find from most recent prescription + List prescriptions = prescriptionRepository.findByPatientIdOrderByCreatedAtDesc(patientId); + Optional recentPrescription = prescriptions.stream() + .filter(p -> p.getDoctor() != null) + .findFirst(); + + if (recentPrescription.isPresent()) { + return recentPrescription.get().getDoctor(); + } + + // Fallback: any recent appointment + Optional anyRecent = recentAppointments.stream() + .filter(a -> a.getDoctor() != null) + .sorted((a1, a2) -> a2.getCreatedAt().compareTo(a1.getCreatedAt())) + .findFirst(); + + if (anyRecent.isPresent()) { + return anyRecent.get().getDoctor(); + } + + return null; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/DataRetentionService.java b/src/main/java/com/gnx/telemedicine/service/DataRetentionService.java new file mode 100644 index 0000000..9bd4ffd --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/DataRetentionService.java @@ -0,0 +1,115 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.DataRetentionPolicy; +import com.gnx.telemedicine.repository.DataRetentionPolicyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DataRetentionService { + + private final DataRetentionPolicyRepository policyRepository; + + @Transactional(readOnly = true) + public List getAllPolicies() { + return policyRepository.findAll(); + } + + @Transactional(readOnly = true) + public Optional getPolicyByDataType(String dataType) { + return policyRepository.findByDataType(dataType); + } + + @Transactional + public DataRetentionPolicy createOrUpdatePolicy( + String dataType, + Integer retentionPeriodDays, + Boolean autoDeleteEnabled, + String legalRequirement) { + + Optional existingPolicy = policyRepository.findByDataType(dataType); + + DataRetentionPolicy policy; + if (existingPolicy.isPresent()) { + policy = existingPolicy.get(); + } else { + policy = new DataRetentionPolicy(); + policy.setDataType(dataType); + } + + policy.setRetentionPeriodDays(retentionPeriodDays); + policy.setAutoDeleteEnabled(autoDeleteEnabled != null ? autoDeleteEnabled : false); + policy.setLegalRequirement(legalRequirement); + + return policyRepository.save(policy); + } + + @Transactional + @Scheduled(cron = "0 0 2 * * ?") // Run daily at 2 AM + public void cleanupExpiredData() { + log.info("Starting data retention cleanup job"); + + List policies = policyRepository.findAll(); + + for (DataRetentionPolicy policy : policies) { + if (Boolean.TRUE.equals(policy.getAutoDeleteEnabled())) { + cleanupDataByType(policy); + } + } + + log.info("Data retention cleanup job completed"); + } + + private void cleanupDataByType(DataRetentionPolicy policy) { + Instant cutoffDate = Instant.now().minus(policy.getRetentionPeriodDays(), ChronoUnit.DAYS); + + try { + switch (policy.getDataType()) { + case "AUDIT_LOG": + // Cleanup would be implemented based on specific audit log repository + log.info("Cleaning up audit logs older than {}", cutoffDate); + // Example: auditLogRepository.deleteByTimestampBefore(cutoffDate); + break; + case "PHI_ACCESS_LOG": + log.info("Cleaning up PHI access logs older than {}", cutoffDate); + // Example: phiAccessLogRepository.deleteByTimestampBefore(cutoffDate); + break; + case "HIPAA_AUDIT_LOG": + log.info("Cleaning up HIPAA audit logs older than {}", cutoffDate); + // Example: hipaaAuditLogRepository.deleteByTimestampBefore(cutoffDate); + break; + case "MESSAGE": + log.info("Cleaning up messages older than {}", cutoffDate); + // Example: messageRepository.deleteByCreatedAtBefore(cutoffDate); + break; + default: + log.warn("Unknown data type for cleanup: {}", policy.getDataType()); + } + + policy.setLastCleanupAt(Instant.now()); + policyRepository.save(policy); + + } catch (Exception e) { + log.error("Error cleaning up data for type {}: {}", policy.getDataType(), e.getMessage(), e); + } + } + + @Transactional(readOnly = true) + public Instant getRetentionCutoffDate(String dataType) { + return policyRepository.findByDataType(dataType) + .map(policy -> Instant.now().minus(policy.getRetentionPeriodDays(), ChronoUnit.DAYS)) + .orElse(Instant.now().minus(365, ChronoUnit.DAYS)); // Default 1 year + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/DoctorAvailabilityService.java b/src/main/java/com/gnx/telemedicine/service/DoctorAvailabilityService.java new file mode 100644 index 0000000..7d22f10 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/DoctorAvailabilityService.java @@ -0,0 +1,207 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.availability.AvailabilityRequestDto; +import com.gnx.telemedicine.dto.availability.AvailabilityResponseDto; +import com.gnx.telemedicine.dto.availability.AvailabilityUpdateDto; +import com.gnx.telemedicine.dto.availability.BulkAvailabilityRequestDto; +import com.gnx.telemedicine.exception.AvailabilityException; +import com.gnx.telemedicine.mappers.AvailabilityMapper; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.DoctorAvailability; +import com.gnx.telemedicine.model.enums.DayOfWeek; +import com.gnx.telemedicine.repository.DoctorAvailabilityRepository; +import com.gnx.telemedicine.repository.DoctorRepository; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DoctorAvailabilityService { + private final DoctorAvailabilityRepository availabilityRepository; + private final DoctorRepository doctorRepository; + private final AvailabilityMapper availabilityMapper; + + @Transactional + public AvailabilityResponseDto createAvailability(@Valid AvailabilityRequestDto request) { + log.info("Creating availability for doctor: {}", request.doctorId()); + + // Validate doctor exists + Doctor doctor = doctorRepository.findById(request.doctorId()) + .orElseThrow(() -> new IllegalArgumentException("Doctor not found")); + + // Validate time range + validateTimeRange(request.startTime(), request.endTime()); + + // Check for overlapping availability + List overlapping = availabilityRepository.findOverlappingAvailabilities( + request.doctorId(), + request.dayOfWeek(), + request.startTime(), + request.endTime() + ); + + if (!overlapping.isEmpty()) { + throw new AvailabilityException("Availability slot overlaps with existing slot"); + } + + DoctorAvailability availability = availabilityMapper.toEntity(request); + availability.setDoctor(doctor); + + DoctorAvailability saved = availabilityRepository.save(availability); + log.info("Availability created successfully: {}", saved.getId()); + + return availabilityMapper.toResponseDto(saved); + } + + @Transactional + public List createBulkAvailability(@Valid BulkAvailabilityRequestDto request) { + log.info("Creating bulk availability for doctor: {}", request.doctorId()); + + Doctor doctor = doctorRepository.findById(request.doctorId()) + .orElseThrow(() -> new IllegalArgumentException("Doctor not found")); + + List availabilities = new ArrayList<>(); + + for (BulkAvailabilityRequestDto.AvailabilitySlot slot : request.slots()) { + validateTimeRange(slot.startTime(), slot.endTime()); + + // Check for overlapping availability + List overlapping = availabilityRepository.findOverlappingAvailabilities( + request.doctorId(), + slot.dayOfWeek(), + slot.startTime(), + slot.endTime() + ); + + if (!overlapping.isEmpty()) { + throw new AvailabilityException("Availability slot for " + slot.dayOfWeek() + + " " + slot.startTime() + "-" + slot.endTime() + " overlaps with existing slot"); + } + + DoctorAvailability availability = new DoctorAvailability(); + availability.setDoctor(doctor); + availability.setDayOfWeek(slot.dayOfWeek()); + availability.setStartTime(slot.startTime()); + availability.setEndTime(slot.endTime()); + availability.setIsAvailable(true); + + availabilities.add(availability); + } + + List saved = availabilityRepository.saveAll(availabilities); + log.info("Created {} availability slots for doctor: {}", saved.size(), request.doctorId()); + + return saved.stream() + .map(availabilityMapper::toResponseDto) + .toList(); + } + + public List getDoctorAvailability(UUID doctorId) { + log.info("Fetching availability for doctor: {}", doctorId); + + List availabilities = availabilityRepository.findByDoctorId(doctorId); + + return availabilities.stream() + .map(availabilityMapper::toResponseDto) + .toList(); + } + + public List getDoctorAvailabilityByDay(UUID doctorId, DayOfWeek dayOfWeek) { + log.info("Fetching availability for doctor: {} on {}", doctorId, dayOfWeek); + + List availabilities = availabilityRepository + .findByDoctorIdAndDayOfWeek(doctorId, dayOfWeek); + + return availabilities.stream() + .map(availabilityMapper::toResponseDto) + .toList(); + } + + public List getActiveAvailability(UUID doctorId) { + log.info("Fetching active availability for doctor: {}", doctorId); + + List availabilities = availabilityRepository + .findByDoctorIdAndIsAvailable(doctorId, true); + + return availabilities.stream() + .map(availabilityMapper::toResponseDto) + .toList(); + } + + @Transactional + public AvailabilityResponseDto updateAvailability(UUID availabilityId, @Valid AvailabilityUpdateDto updateDto) { + log.info("Updating availability: {}", availabilityId); + + DoctorAvailability availability = availabilityRepository.findById(availabilityId) + .orElseThrow(() -> new IllegalArgumentException("Availability slot not found")); + + if (updateDto.startTime() != null && updateDto.endTime() != null) { + validateTimeRange(updateDto.startTime(), updateDto.endTime()); + + // Check for overlaps (excluding current slot) + List overlapping = availabilityRepository.findOverlappingAvailabilities( + availability.getDoctor().getId(), + availability.getDayOfWeek(), + updateDto.startTime(), + updateDto.endTime() + ); + + overlapping.removeIf(a -> a.getId().equals(availabilityId)); + + if (!overlapping.isEmpty()) { + throw new AvailabilityException("Updated time slot overlaps with existing availability"); + } + + availability.setStartTime(updateDto.startTime()); + availability.setEndTime(updateDto.endTime()); + } + + if (updateDto.isAvailable() != null) { + availability.setIsAvailable(updateDto.isAvailable()); + } + + DoctorAvailability updated = availabilityRepository.save(availability); + log.info("Availability updated successfully: {}", availabilityId); + + return availabilityMapper.toResponseDto(updated); + } + + @Transactional + public void deleteAvailability(UUID availabilityId) { + log.info("Deleting availability: {}", availabilityId); + + if (!availabilityRepository.existsById(availabilityId)) { + throw new IllegalArgumentException("Availability slot not found"); + } + + availabilityRepository.deleteById(availabilityId); + log.info("Availability deleted successfully: {}", availabilityId); + } + + @Transactional + public void deleteAllDoctorAvailability(UUID doctorId) { + log.info("Deleting all availability for doctor: {}", doctorId); + availabilityRepository.deleteByDoctorId(doctorId); + log.info("All availability deleted for doctor: {}", doctorId); + } + + public boolean isDoctorAvailable(UUID doctorId, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) { + return availabilityRepository.isDoctorAvailableForSlot(doctorId, dayOfWeek, startTime, endTime); + } + + private void validateTimeRange(LocalTime startTime, LocalTime endTime) { + if (startTime.isAfter(endTime) || startTime.equals(endTime)) { + throw new AvailabilityException("Start time must be before end time"); + } + } + +} diff --git a/src/main/java/com/gnx/telemedicine/service/DuplicatePatientDetectionService.java b/src/main/java/com/gnx/telemedicine/service/DuplicatePatientDetectionService.java new file mode 100644 index 0000000..01aaec4 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/DuplicatePatientDetectionService.java @@ -0,0 +1,215 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.patient_safety.DuplicatePatientRecordResponseDto; +import com.gnx.telemedicine.dto.patient_safety.DuplicatePatientReviewRequestDto; +import com.gnx.telemedicine.mappers.DuplicatePatientRecordMapper; +import com.gnx.telemedicine.model.DuplicateMatchReason; +import com.gnx.telemedicine.model.DuplicatePatientRecord; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.DuplicateStatus; +import com.gnx.telemedicine.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DuplicatePatientDetectionService { + + private final DuplicatePatientRecordRepository duplicatePatientRecordRepository; + private final DuplicatePatientRecordMapper duplicatePatientRecordMapper; + private final PatientRepository patientRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getAllDuplicateRecords() { + return duplicatePatientRecordRepository.findAll() + .stream() + .map(duplicatePatientRecordMapper::toDuplicatePatientRecordResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getDuplicateRecordsByStatus(DuplicateStatus status) { + return duplicatePatientRecordRepository.findByStatusOrderByCreatedAtDesc(status) + .stream() + .map(duplicatePatientRecordMapper::toDuplicatePatientRecordResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getPendingDuplicateRecords() { + return duplicatePatientRecordRepository.findAllPendingOrderedByMatchScore() + .stream() + .map(duplicatePatientRecordMapper::toDuplicatePatientRecordResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getDuplicatesForPatient(UUID patientId) { + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + List results = new ArrayList<>(); + + // Check if patient is primary + results.addAll(duplicatePatientRecordRepository.findByPrimaryPatientOrderByCreatedAtDesc(patient) + .stream() + .map(duplicatePatientRecordMapper::toDuplicatePatientRecordResponseDto) + .toList()); + + // Check if patient is duplicate + results.addAll(duplicatePatientRecordRepository.findByDuplicatePatientOrderByCreatedAtDesc(patient) + .stream() + .map(duplicatePatientRecordMapper::toDuplicatePatientRecordResponseDto) + .toList()); + + return results; + } + + @Transactional + public DuplicatePatientRecordResponseDto reviewDuplicate(String userEmail, UUID duplicateId, DuplicatePatientReviewRequestDto requestDto) { + DuplicatePatientRecord record = duplicatePatientRecordRepository.findById(duplicateId) + .orElseThrow(() -> new IllegalArgumentException("Duplicate record not found")); + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + record.setStatus(requestDto.status()); + record.setReviewNotes(requestDto.reviewNotes()); + record.setReviewedBy(user); + record.setReviewedAt(java.time.Instant.now()); + + DuplicatePatientRecord saved = duplicatePatientRecordRepository.save(record); + log.info("Duplicate record {} reviewed by user {} with status {}", duplicateId, userEmail, requestDto.status()); + return duplicatePatientRecordMapper.toDuplicatePatientRecordResponseDto(saved); + } + + @Transactional(readOnly = true) + public DuplicatePatientRecordResponseDto getDuplicateRecordById(UUID id) { + DuplicatePatientRecord record = duplicatePatientRecordRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Duplicate record not found")); + return duplicatePatientRecordMapper.toDuplicatePatientRecordResponseDto(record); + } + + public long countPendingDuplicates() { + return duplicatePatientRecordRepository.countPendingDuplicates(); + } + + @Transactional + public void scanForDuplicates() { + log.info("Starting duplicate patient detection scan"); + List allPatients = patientRepository.findAll(); + int duplicateCount = 0; + + for (int i = 0; i < allPatients.size(); i++) { + for (int j = i + 1; j < allPatients.size(); j++) { + Patient p1 = allPatients.get(i); + Patient p2 = allPatients.get(j); + + BigDecimal matchScore = calculateMatchScore(p1, p2); + + // If match score is above threshold, create duplicate record + if (matchScore.compareTo(new BigDecimal("70.0")) >= 0) { + createDuplicateRecordIfNotExists(p1, p2, matchScore); + duplicateCount++; + } + } + } + + log.info("Duplicate scan completed. Found {} potential duplicates", duplicateCount); + } + + private BigDecimal calculateMatchScore(Patient p1, Patient p2) { + UserModel u1 = p1.getUser(); + UserModel u2 = p2.getUser(); + + BigDecimal score = BigDecimal.ZERO; + List reasons = new ArrayList<>(); + + // Name match + if (u1.getFirstName().equalsIgnoreCase(u2.getFirstName())) { + score = score.add(new BigDecimal("20")); + reasons.add("Matching first name"); + } + if (u1.getLastName().equalsIgnoreCase(u2.getLastName())) { + score = score.add(new BigDecimal("20")); + reasons.add("Matching last name"); + } + + // Phone match + if (u1.getPhoneNumber() != null && u2.getPhoneNumber() != null && + u1.getPhoneNumber().equals(u2.getPhoneNumber())) { + score = score.add(new BigDecimal("30")); + reasons.add("Matching phone number"); + } + + // Blood type match + if (p1.getBloodType() != null && p2.getBloodType() != null && + p1.getBloodType().equals(p2.getBloodType())) { + score = score.add(new BigDecimal("15")); + reasons.add("Matching blood type"); + } + + // Allergy match + if (p1.getAllergies() != null && p2.getAllergies() != null && + !p1.getAllergies().isEmpty() && !p2.getAllergies().isEmpty()) { + List commonAllergies = new ArrayList<>(p1.getAllergies()); + commonAllergies.retainAll(p2.getAllergies()); + if (!commonAllergies.isEmpty()) { + score = score.add(new BigDecimal("15")); + reasons.add("Common allergies: " + String.join(", ", commonAllergies)); + } + } + + return score; + } + + private void createDuplicateRecordIfNotExists(Patient p1, Patient p2, BigDecimal matchScore) { + // Check if record already exists + if (duplicatePatientRecordRepository.findByPrimaryPatientAndDuplicatePatient(p1, p2).isPresent() || + duplicatePatientRecordRepository.findByPrimaryPatientAndDuplicatePatient(p2, p1).isPresent()) { + return; // Already exists + } + + DuplicatePatientRecord record = new DuplicatePatientRecord(); + record.setPrimaryPatient(p1); + record.setDuplicatePatient(p2); + record.setMatchScore(matchScore); + record.setStatus(DuplicateStatus.PENDING); + + List reasons = new ArrayList<>(); + if (p1.getUser().getFirstName().equalsIgnoreCase(p2.getUser().getFirstName())) { + DuplicateMatchReason reason = new DuplicateMatchReason(); + reason.setDuplicateRecord(record); + reason.setReason("Matching first name"); + reasons.add(reason); + } + if (p1.getUser().getLastName().equalsIgnoreCase(p2.getUser().getLastName())) { + DuplicateMatchReason reason = new DuplicateMatchReason(); + reason.setDuplicateRecord(record); + reason.setReason("Matching last name"); + reasons.add(reason); + } + if (p1.getUser().getPhoneNumber() != null && p1.getUser().getPhoneNumber().equals(p2.getUser().getPhoneNumber())) { + DuplicateMatchReason reason = new DuplicateMatchReason(); + reason.setDuplicateRecord(record); + reason.setReason("Matching phone"); + reasons.add(reason); + } + record.setMatchReasons(reasons); + + duplicatePatientRecordRepository.save(record); + log.info("Created duplicate record for patients {} and {} with match score {}", + p1.getId(), p2.getId(), matchScore); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/EmailNotificationService.java b/src/main/java/com/gnx/telemedicine/service/EmailNotificationService.java new file mode 100644 index 0000000..7a55948 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/EmailNotificationService.java @@ -0,0 +1,430 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.Appointment; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.format.DateTimeFormatter; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EmailNotificationService { + + private final JavaMailSender mailSender; + + @Async + public void sendAppointmentConfirmation(Appointment appointment) { + try { + // Email to patient + sendEmail( + appointment.getPatient().getUser().getEmail(), + "Appointment Confirmation", + buildPatientConfirmationMessage(appointment) + ); + + // Email to doctor + sendEmail( + appointment.getDoctor().getUser().getEmail(), + "New Appointment Scheduled", + buildDoctorNotificationMessage(appointment) + ); + + log.info("Appointment confirmation emails sent for appointment: {}", appointment.getId()); + } catch (Exception e) { + log.error("Failed to send appointment confirmation emails", e); + } + } + + @Async + public void sendAppointmentCancellation(Appointment appointment) { + try { + sendEmail( + appointment.getPatient().getUser().getEmail(), + "Appointment Cancelled", + buildCancellationMessage(appointment) + ); + + sendEmail( + appointment.getDoctor().getUser().getEmail(), + "Appointment Cancelled", + buildCancellationMessage(appointment) + ); + + log.info("Appointment cancellation emails sent for appointment: {}", appointment.getId()); + } catch (Exception e) { + log.error("Failed to send cancellation emails", e); + } + } + + @Async + public void sendAppointmentConfirmed(Appointment appointment) { + try { + // Email to patient + sendEmail( + appointment.getPatient().getUser().getEmail(), + "Appointment Confirmed", + buildPatientConfirmedMessage(appointment) + ); + + // Email to doctor + sendEmail( + appointment.getDoctor().getUser().getEmail(), + "Appointment Confirmed", + buildDoctorConfirmedMessage(appointment) + ); + + log.info("Appointment confirmed emails sent for appointment: {}", appointment.getId()); + } catch (Exception e) { + log.error("Failed to send confirmed emails", e); + } + } + + @Async + public void sendAppointmentCompleted(Appointment appointment) { + try { + // Email to patient + sendEmail( + appointment.getPatient().getUser().getEmail(), + "Appointment Completed", + buildPatientCompletedMessage(appointment) + ); + + // Email to doctor + sendEmail( + appointment.getDoctor().getUser().getEmail(), + "Appointment Completed", + buildDoctorCompletedMessage(appointment) + ); + + log.info("Appointment completed emails sent for appointment: {}", appointment.getId()); + } catch (Exception e) { + log.error("Failed to send completed emails", e); + } + } + + public void sendEmail(String to, String subject, String text) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject(subject); + message.setText(text); + message.setFrom("support@gnxsoft.com"); + + mailSender.send(message); + } + + private String buildPatientConfirmationMessage(Appointment appointment) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); + DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("hh:mm a"); + + return String.format(""" + Dear %s %s, + + Your appointment has been confirmed! + + Doctor: Dr. %s %s + Specialization: %s + Date: %s + Time: %s + Duration: %d minutes + + Please arrive 10 minutes early for check-in. + + If you need to cancel or reschedule, please contact us at least 24 hours in advance. + + Best regards, + GNX Soft LTD + """, + appointment.getPatient().getUser().getFirstName(), + appointment.getPatient().getUser().getLastName(), + appointment.getDoctor().getUser().getFirstName(), + appointment.getDoctor().getUser().getLastName(), + appointment.getDoctor().getSpecialization(), + appointment.getScheduledDate().format(dateFormatter), + appointment.getScheduledTime().format(timeFormatter), + appointment.getDurationMinutes() + ); + } + + private String buildDoctorNotificationMessage(Appointment appointment) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); + DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("hh:mm a"); + + return String.format(""" + Dear Dr. %s %s, + + A new appointment has been scheduled. + + Patient: %s %s + Date: %s + Time: %s + Duration: %d minutes + + Please review your schedule accordingly. + + Best regards, + GNX Soft LTD + """, + appointment.getDoctor().getUser().getFirstName(), + appointment.getDoctor().getUser().getLastName(), + appointment.getPatient().getUser().getFirstName(), + appointment.getPatient().getUser().getLastName(), + appointment.getScheduledDate().format(dateFormatter), + appointment.getScheduledTime().format(timeFormatter), + appointment.getDurationMinutes() + ); + } + + private String buildCancellationMessage(Appointment appointment) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); + DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("hh:mm a"); + + return String.format(""" + Dear User, + + The following appointment has been cancelled: + + Date: %s + Time: %s + + If you have any questions, please contact support. + + Best regards, + GNX Soft LTD + """, + appointment.getScheduledDate().format(dateFormatter), + appointment.getScheduledTime().format(timeFormatter) + ); + } + + private String buildPatientConfirmedMessage(Appointment appointment) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); + DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("hh:mm a"); + + return String.format(""" + Dear %s %s, + + Your appointment has been confirmed! + + Doctor: Dr. %s %s + Specialization: %s + Date: %s + Time: %s + Duration: %d minutes + + Your appointment is now confirmed. Please prepare for your consultation and arrive on time. + + If you need to cancel or reschedule, please contact us at least 24 hours in advance. + + Best regards, + GNX Soft LTD + """, + appointment.getPatient().getUser().getFirstName(), + appointment.getPatient().getUser().getLastName(), + appointment.getDoctor().getUser().getFirstName(), + appointment.getDoctor().getUser().getLastName(), + appointment.getDoctor().getSpecialization(), + appointment.getScheduledDate().format(dateFormatter), + appointment.getScheduledTime().format(timeFormatter), + appointment.getDurationMinutes() + ); + } + + private String buildDoctorConfirmedMessage(Appointment appointment) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); + DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("hh:mm a"); + + return String.format(""" + Dear Dr. %s %s, + + An appointment has been confirmed. + + Patient: %s %s + Date: %s + Time: %s + Duration: %d minutes + + Please ensure you are prepared for this consultation. + + Best regards, + GNX Soft LTD + """, + appointment.getDoctor().getUser().getFirstName(), + appointment.getDoctor().getUser().getLastName(), + appointment.getPatient().getUser().getFirstName(), + appointment.getPatient().getUser().getLastName(), + appointment.getScheduledDate().format(dateFormatter), + appointment.getScheduledTime().format(timeFormatter), + appointment.getDurationMinutes() + ); + } + + private String buildPatientCompletedMessage(Appointment appointment) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); + DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("hh:mm a"); + + return String.format(""" + Dear %s %s, + + Your appointment has been marked as completed. + + Doctor: Dr. %s %s + Specialization: %s + Date: %s + Time: %s + + Thank you for using our telemedicine platform. We hope your consultation was helpful. + + If you have any follow-up questions or need to schedule another appointment, please don't hesitate to contact us. + + Best regards, + GNX Soft LTD + """, + appointment.getPatient().getUser().getFirstName(), + appointment.getPatient().getUser().getLastName(), + appointment.getDoctor().getUser().getFirstName(), + appointment.getDoctor().getUser().getLastName(), + appointment.getDoctor().getSpecialization(), + appointment.getScheduledDate().format(dateFormatter), + appointment.getScheduledTime().format(timeFormatter) + ); + } + + private String buildDoctorCompletedMessage(Appointment appointment) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); + DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("hh:mm a"); + + return String.format(""" + Dear Dr. %s %s, + + The following appointment has been marked as completed. + + Patient: %s %s + Date: %s + Time: %s + + Thank you for your service. Please ensure all documentation and notes are complete. + + Best regards, + GNX Soft LTD + """, + appointment.getDoctor().getUser().getFirstName(), + appointment.getDoctor().getUser().getLastName(), + appointment.getPatient().getUser().getFirstName(), + appointment.getPatient().getUser().getLastName(), + appointment.getScheduledDate().format(dateFormatter), + appointment.getScheduledTime().format(timeFormatter) + ); + } + + @Async + public void sendDataSubjectRequestCompletionEmail(String to, String requestType) { + try { + sendEmail( + to, + "Data Subject Request Completed", + buildDataSubjectRequestCompletionMessage(requestType) + ); + log.info("Data subject request completion email sent to: {}", to); + } catch (Exception e) { + log.error("Failed to send data subject request completion email", e); + } + } + + @Async + public void sendDataPortabilityEmail(String to, java.util.Map data) { + try { + // In production, you would attach the data as a JSON file + sendEmail( + to, + "Your Data Export - GDPR Data Portability", + buildDataPortabilityMessage(data) + ); + log.info("Data portability email sent to: {}", to); + } catch (Exception e) { + log.error("Failed to send data portability email", e); + } + } + + private String buildDataSubjectRequestCompletionMessage(String requestType) { + return String.format(""" + Dear User, + + Your data subject request (%s) has been completed. + + You can access your data through the platform or download it from the link provided in your account. + + If you have any questions or concerns, please contact our support team. + + Best regards, + GNX Soft LTD - Privacy Team + """, requestType); + } + + private String buildDataPortabilityMessage(java.util.Map data) { + return """ + Dear User, + + Your data export is ready for download. + + This export contains all your personal data stored in our system, including: + - User profile information + - Patient records (if applicable) + - Appointments + - Medical records + - Prescriptions + + You can download your data from your account dashboard. + + The data is provided in JSON format, which is machine-readable and portable. + + If you have any questions about your data export, please contact our support team. + + Best regards, + GNX Soft LTD - Privacy Team + """; + } + + @Async + public void sendPasswordResetEmail(String to, String resetToken, String firstName) { + try { + sendEmail( + to, + "Password Reset Request", + buildPasswordResetMessage(firstName, resetToken) + ); + log.info("Password reset email sent to: {}", to); + } catch (Exception e) { + log.error("Failed to send password reset email", e); + } + } + + private String buildPasswordResetMessage(String firstName, String resetToken) { + // In production, this would be a proper URL to your frontend reset password page + String resetUrl = "http://localhost:4200/reset-password?token=" + resetToken; + + return String.format(""" + Dear %s, + + We received a request to reset your password for your telemedicine account. + + To reset your password, please click on the following link: + %s + + This link will expire in 1 hour for security reasons. + + If you did not request a password reset, please ignore this email. Your password will remain unchanged. + + For security reasons, never share this link with anyone. + + If you have any questions or concerns, please contact our support team at support@gnxsoft.com. + + Best regards, + GNX Soft LTD - Security Team + """, firstName != null ? firstName : "User", resetUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/gnx/telemedicine/service/FileUploadService.java b/src/main/java/com/gnx/telemedicine/service/FileUploadService.java new file mode 100644 index 0000000..117d482 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/FileUploadService.java @@ -0,0 +1,185 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Base64; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FileUploadService { + + private final UserRepository userRepository; + + @Value("${file.upload-dir:uploads/avatars}") + private String uploadDir; + + @Transactional + public String uploadAvatar(String userEmail, MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("File is empty"); + } + + // Validate file type - check both content type and extension + String contentType = file.getContentType(); + String originalFilename = file.getOriginalFilename(); + String extension = originalFilename != null && originalFilename.contains(".") + ? originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase() + : ""; + + // Allowed image types + String[] allowedExtensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}; + boolean isAllowedExtension = false; + for (String allowedExt : allowedExtensions) { + if (extension.equals(allowedExt)) { + isAllowedExtension = true; + break; + } + } + + if (!isAllowedExtension) { + throw new IllegalArgumentException("Only image files are allowed (JPG, PNG, GIF, WEBP)"); + } + + if (contentType == null || !contentType.startsWith("image/")) { + throw new IllegalArgumentException("Invalid file type. Only image files are allowed"); + } + + // Validate specific content types + String[] allowedContentTypes = { + "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp" + }; + boolean isAllowedContentType = false; + for (String allowedType : allowedContentTypes) { + if (contentType.equalsIgnoreCase(allowedType)) { + isAllowedContentType = true; + break; + } + } + + if (!isAllowedContentType) { + throw new IllegalArgumentException("Invalid image format. Allowed: JPG, PNG, GIF, WEBP"); + } + + // Validate file size (max 5MB) + long maxSize = 5 * 1024 * 1024; // 5MB + if (file.getSize() > maxSize) { + throw new IllegalArgumentException("File size exceeds 5MB limit. Current size: " + + String.format("%.2f", file.getSize() / 1024.0 / 1024.0) + "MB"); + } + + // Validate minimum file size (must be > 0) + if (file.getSize() == 0) { + throw new IllegalArgumentException("File is empty"); + } + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Create upload directory if it doesn't exist + Path uploadPath = Paths.get(uploadDir); + if (!Files.exists(uploadPath)) { + Files.createDirectories(uploadPath); + } + + // Generate unique filename (reuse extension from validation above) + String filename = UUID.randomUUID().toString() + extension; + + // Save file + Path filePath = uploadPath.resolve(filename); + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + + // Generate URL (relative path that frontend can access) + String avatarUrl = "/api/v3/files/avatars/" + filename; + + // Update user's avatar URL + user.setAvatarUrl(avatarUrl); + userRepository.save(user); + + log.info("Avatar uploaded for user {}: {}", userEmail, avatarUrl); + return avatarUrl; + } + + @Transactional + public String uploadAvatarBase64(String userEmail, String base64Image) throws IOException { + if (base64Image == null || base64Image.isEmpty()) { + throw new IllegalArgumentException("Base64 image is empty"); + } + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Remove data URL prefix if present (e.g., "data:image/png;base64,") + String base64Data = base64Image; + if (base64Image.contains(",")) { + base64Data = base64Image.substring(base64Image.indexOf(",") + 1); + } + + // Decode base64 + byte[] imageBytes = Base64.getDecoder().decode(base64Data); + + // Validate file size (max 5MB) + if (imageBytes.length > 5 * 1024 * 1024) { + throw new IllegalArgumentException("Image size exceeds 5MB limit"); + } + + // Create upload directory if it doesn't exist + Path uploadPath = Paths.get(uploadDir); + if (!Files.exists(uploadPath)) { + Files.createDirectories(uploadPath); + } + + // Generate unique filename + String filename = UUID.randomUUID().toString() + ".jpg"; + + // Save file + Path filePath = uploadPath.resolve(filename); + Files.write(filePath, imageBytes); + + // Generate URL (relative path that frontend can access) + String avatarUrl = "/api/v3/files/avatars/" + filename; + + // Update user's avatar URL + user.setAvatarUrl(avatarUrl); + userRepository.save(user); + + log.info("Avatar uploaded (base64) for user {}: {}", userEmail, avatarUrl); + return avatarUrl; + } + + public void deleteAvatar(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + if (user.getAvatarUrl() != null && !user.getAvatarUrl().isEmpty()) { + try { + // Extract filename from URL + String filename = user.getAvatarUrl().substring(user.getAvatarUrl().lastIndexOf("/") + 1); + Path filePath = Paths.get(uploadDir).resolve(filename); + + if (Files.exists(filePath)) { + Files.delete(filePath); + } + } catch (IOException e) { + log.error("Error deleting avatar file for user {}: {}", userEmail, e.getMessage()); + } + + user.setAvatarUrl(null); + userRepository.save(user); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/GdprConsentService.java b/src/main/java/com/gnx/telemedicine/service/GdprConsentService.java new file mode 100644 index 0000000..614bc30 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/GdprConsentService.java @@ -0,0 +1,126 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.DataSubjectConsent; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.ConsentStatus; +import com.gnx.telemedicine.model.enums.ConsentType; +import com.gnx.telemedicine.repository.DataSubjectConsentRepository; +import com.gnx.telemedicine.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GdprConsentService { + + private final DataSubjectConsentRepository consentRepository; + private final UserRepository userRepository; + + @Transactional + public DataSubjectConsent grantConsent(String userEmail, ConsentType consentType, String consentVersion, + String consentMethod, HttpServletRequest request) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + Optional existingConsent = consentRepository.findByUserAndConsentType(user, consentType); + + DataSubjectConsent consent; + if (existingConsent.isPresent()) { + consent = existingConsent.get(); + // Update existing consent + if (consent.getConsentStatus() == ConsentStatus.WITHDRAWN) { + consent.setWithdrawnAt(null); + } + } else { + consent = new DataSubjectConsent(); + consent.setUser(user); + consent.setConsentType(consentType); + } + + consent.setConsentStatus(ConsentStatus.GRANTED); + consent.setConsentVersion(consentVersion); + consent.setConsentMethod(consentMethod); + consent.setGrantedAt(Instant.now()); + + if (request != null) { + consent.setIpAddress(getClientIpAddress(request)); + consent.setUserAgent(request.getHeader("User-Agent")); + } + + return consentRepository.save(consent); + } + + @Transactional + public DataSubjectConsent withdrawConsent(String userEmail, ConsentType consentType, HttpServletRequest request) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + DataSubjectConsent consent = consentRepository.findByUserAndConsentType(user, consentType) + .orElseThrow(() -> new IllegalArgumentException("Consent not found")); + + consent.setConsentStatus(ConsentStatus.WITHDRAWN); + consent.setWithdrawnAt(Instant.now()); + + if (request != null) { + consent.setIpAddress(getClientIpAddress(request)); + consent.setUserAgent(request.getHeader("User-Agent")); + } + + return consentRepository.save(consent); + } + + @Transactional(readOnly = true) + public List getUserConsents(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + return consentRepository.findByUser(user); + } + + @Transactional(readOnly = true) + public Optional getConsent(String userEmail, ConsentType consentType) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + return consentRepository.findByUserAndConsentType(user, consentType); + } + + @Transactional(readOnly = true) + public boolean hasConsent(String userEmail, ConsentType consentType) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + return consentRepository.existsByUserAndConsentTypeAndConsentStatus( + user, consentType, ConsentStatus.GRANTED); + } + + @Transactional(readOnly = true) + public List getUserConsentsByStatus(String userEmail, ConsentStatus status) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + return consentRepository.findByUserAndConsentStatus(user, status); + } + + private String getClientIpAddress(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + String xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isEmpty()) { + return xRealIp; + } + return request.getRemoteAddr(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/GdprDataSubjectRequestService.java b/src/main/java/com/gnx/telemedicine/service/GdprDataSubjectRequestService.java new file mode 100644 index 0000000..823154e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/GdprDataSubjectRequestService.java @@ -0,0 +1,302 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.*; +import com.gnx.telemedicine.model.enums.DataSubjectRequestStatus; +import com.gnx.telemedicine.model.enums.DataSubjectRequestType; +import com.gnx.telemedicine.repository.*; +import com.gnx.telemedicine.service.HipaaAuditService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GdprDataSubjectRequestService { + + private final DataSubjectRequestRepository requestRepository; + private final UserRepository userRepository; + private final PatientRepository patientRepository; + private final AppointmentRepository appointmentRepository; + private final MedicalRecordRepository medicalRecordRepository; + private final PrescriptionRepository prescriptionRepository; + private final HipaaAuditService hipaaAuditService; + private final EmailNotificationService emailNotificationService; + + @Transactional + public DataSubjectRequest createRequest(String userEmail, DataSubjectRequestType requestType, + String description, HttpServletRequest request) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + DataSubjectRequest dataSubjectRequest = new DataSubjectRequest(); + dataSubjectRequest.setUser(user); + dataSubjectRequest.setRequestType(requestType); + dataSubjectRequest.setRequestStatus(DataSubjectRequestStatus.PENDING); + dataSubjectRequest.setDescription(description); + dataSubjectRequest.setRequestedAt(Instant.now()); + + // Generate verification token for identity verification + dataSubjectRequest.setVerificationToken(UUID.randomUUID().toString()); + + // Log HIPAA audit + try { + hipaaAuditService.logAccess( + userEmail, + com.gnx.telemedicine.model.enums.ActionType.CREATE, + "DATA_SUBJECT_REQUEST", + dataSubjectRequest.getId(), + null, + Map.of("requestType", requestType.name(), "description", description != null ? description : ""), + request + ); + } catch (Exception e) { + log.error("Error logging HIPAA audit for data subject request: {}", e.getMessage()); + } + + return requestRepository.save(dataSubjectRequest); + } + + @Transactional(readOnly = true) + public List getUserRequests(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + return requestRepository.findByUser(user); + } + + @Transactional + public DataSubjectRequest processAccessRequest(UUID requestId, String processedByEmail) { + DataSubjectRequest request = requestRepository.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("Request not found")); + + if (request.getRequestType() != DataSubjectRequestType.ACCESS) { + throw new IllegalArgumentException("Request is not an access request"); + } + + UserModel processedBy = userRepository.findByEmail(processedByEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + // Collect all user data + Map userData = collectUserData(request.getUser()); + + request.setResponseData(userData); + request.setRequestStatus(DataSubjectRequestStatus.COMPLETED); + request.setCompletedAt(Instant.now()); + request.setProcessedBy(processedBy); + + // Send notification email + try { + emailNotificationService.sendDataSubjectRequestCompletionEmail( + request.getUser().getEmail(), + request.getRequestType().name() + ); + } catch (Exception e) { + log.error("Error sending completion email: {}", e.getMessage()); + } + + return requestRepository.save(request); + } + + @Transactional + public DataSubjectRequest processErasureRequest(UUID requestId, String processedByEmail, String notes) { + DataSubjectRequest request = requestRepository.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("Request not found")); + + if (request.getRequestType() != DataSubjectRequestType.ERASURE) { + throw new IllegalArgumentException("Request is not an erasure request"); + } + + UserModel processedBy = userRepository.findByEmail(processedByEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + // Check for legal retention requirements (HIPAA requires 6 years retention) + // For erasure requests, we may need to anonymize instead of delete + // This is a simplified version - in production, you'd need more sophisticated logic + + UserModel user = request.getUser(); + + // Anonymize user data instead of deleting (to comply with HIPAA) + user.setFirstName("ANONYMIZED"); + user.setLastName("USER"); + user.setEmail("anonymized_" + UUID.randomUUID() + "@deleted.local"); + user.setPhoneNumber(null); + user.setIsActive(false); + userRepository.save(user); + + // Anonymize associated patient data if exists + patientRepository.findByUser(user).ifPresent(patient -> { + patient.setEmergencyContactName(null); + patient.setEmergencyContactPhone(null); + patient.setBloodType(null); + patient.setAllergies(null); + patientRepository.save(patient); + }); + + request.setRequestStatus(DataSubjectRequestStatus.COMPLETED); + request.setCompletedAt(Instant.now()); + request.setProcessedBy(processedBy); + request.setNotes(notes); + + return requestRepository.save(request); + } + + @Transactional + public DataSubjectRequest processPortabilityRequest(UUID requestId, String processedByEmail) { + DataSubjectRequest request = requestRepository.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("Request not found")); + + if (request.getRequestType() != DataSubjectRequestType.PORTABILITY) { + throw new IllegalArgumentException("Request is not a portability request"); + } + + UserModel processedBy = userRepository.findByEmail(processedByEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + // Collect all user data in a portable format (JSON) + Map userData = collectUserData(request.getUser()); + + request.setResponseData(userData); + request.setRequestStatus(DataSubjectRequestStatus.COMPLETED); + request.setCompletedAt(Instant.now()); + request.setProcessedBy(processedBy); + + // Send notification email with data export + try { + emailNotificationService.sendDataPortabilityEmail( + request.getUser().getEmail(), + userData + ); + } catch (Exception e) { + log.error("Error sending portability email: {}", e.getMessage()); + } + + return requestRepository.save(request); + } + + @Transactional + public DataSubjectRequest rejectRequest(UUID requestId, String processedByEmail, String rejectionReason) { + DataSubjectRequest request = requestRepository.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("Request not found")); + + UserModel processedBy = userRepository.findByEmail(processedByEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + request.setRequestStatus(DataSubjectRequestStatus.REJECTED); + request.setRejectedAt(Instant.now()); + request.setRejectionReason(rejectionReason); + request.setProcessedBy(processedBy); + + return requestRepository.save(request); + } + + @Transactional + public DataSubjectRequest verifyRequest(String verificationToken) { + DataSubjectRequest request = requestRepository.findByVerificationToken(verificationToken) + .orElseThrow(() -> new IllegalArgumentException("Invalid verification token")); + + request.setVerifiedAt(Instant.now()); + return requestRepository.save(request); + } + + @Transactional(readOnly = true) + public List getPendingRequests() { + return requestRepository.findByRequestStatus(DataSubjectRequestStatus.PENDING); + } + + private Map collectUserData(UserModel user) { + Map data = new HashMap<>(); + + // Basic user information + data.put("user", Map.of( + "id", user.getId().toString(), + "email", user.getEmail(), + "firstName", user.getFirstName(), + "lastName", user.getLastName(), + "phoneNumber", user.getPhoneNumber() != null ? user.getPhoneNumber() : "", + "role", user.getRole().name(), + "createdAt", user.getCreatedAt().toString(), + "updatedAt", user.getUpdatedAt().toString() + )); + + // Patient data if exists + patientRepository.findByUser(user).ifPresent(patient -> { + Map patientData = new HashMap<>(); + patientData.put("id", patient.getId().toString()); + patientData.put("emergencyContactName", patient.getEmergencyContactName() != null ? patient.getEmergencyContactName() : ""); + patientData.put("emergencyContactPhone", patient.getEmergencyContactPhone() != null ? patient.getEmergencyContactPhone() : ""); + patientData.put("bloodType", patient.getBloodType() != null ? patient.getBloodType() : ""); + patientData.put("allergies", patient.getAllergies() != null ? patient.getAllergies() : List.of()); + patientData.put("createdAt", patient.getCreatedAt().toString()); + data.put("patient", patientData); + }); + + // Appointments - get via patient if exists + Optional patientOpt = patientRepository.findByUser(user); + if (patientOpt.isPresent()) { + Patient patient = patientOpt.get(); + List> appointments = appointmentRepository.findByPatientId(patient.getId()) + .stream() + .map(apt -> { + Map appointmentData = new HashMap<>(); + appointmentData.put("id", apt.getId().toString()); + appointmentData.put("scheduledDate", apt.getScheduledDate() != null ? apt.getScheduledDate().toString() : ""); + appointmentData.put("scheduledTime", apt.getScheduledTime() != null ? apt.getScheduledTime().toString() : ""); + appointmentData.put("status", apt.getStatus() != null ? apt.getStatus().name() : ""); + appointmentData.put("durationMinutes", apt.getDurationMinutes() != null ? apt.getDurationMinutes() : ""); + appointmentData.put("createdAt", apt.getCreatedAt() != null ? apt.getCreatedAt().toString() : ""); + return appointmentData; + }) + .collect(Collectors.toList()); + data.put("appointments", appointments); + + // Medical records + List> medicalRecords = medicalRecordRepository.findByPatientIdOrderByCreatedAtDesc(patient.getId()) + .stream() + .map(mr -> { + Map recordData = new HashMap<>(); + recordData.put("id", mr.getId().toString()); + recordData.put("title", mr.getTitle() != null ? mr.getTitle() : ""); + recordData.put("content", mr.getContent() != null ? mr.getContent() : ""); + recordData.put("diagnosisCode", mr.getDiagnosisCode() != null ? mr.getDiagnosisCode() : ""); + recordData.put("recordType", mr.getRecordType() != null ? mr.getRecordType().name() : ""); + recordData.put("createdAt", mr.getCreatedAt() != null ? mr.getCreatedAt().toString() : ""); + recordData.put("updatedAt", mr.getUpdatedAt() != null ? mr.getUpdatedAt().toString() : ""); + return recordData; + }) + .collect(Collectors.toList()); + data.put("medicalRecords", medicalRecords); + + // Prescriptions + List> prescriptions = prescriptionRepository.findByPatientIdOrderByCreatedAtDesc(patient.getId()) + .stream() + .map(p -> { + Map prescriptionData = new HashMap<>(); + prescriptionData.put("id", p.getId().toString()); + prescriptionData.put("medicationName", p.getMedicationName() != null ? p.getMedicationName() : ""); + prescriptionData.put("dosage", p.getDosage() != null ? p.getDosage() : ""); + prescriptionData.put("frequency", p.getFrequency() != null ? p.getFrequency() : ""); + prescriptionData.put("startDate", p.getStartDate() != null ? p.getStartDate().toString() : ""); + prescriptionData.put("endDate", p.getEndDate() != null ? p.getEndDate().toString() : ""); + prescriptionData.put("status", p.getStatus() != null ? p.getStatus().name() : ""); + prescriptionData.put("createdAt", p.getCreatedAt() != null ? p.getCreatedAt().toString() : ""); + return prescriptionData; + }) + .collect(Collectors.toList()); + data.put("prescriptions", prescriptions); + } else { + data.put("appointments", List.of()); + data.put("medicalRecords", List.of()); + data.put("prescriptions", List.of()); + } + + return data; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/HipaaAccountingOfDisclosureService.java b/src/main/java/com/gnx/telemedicine/service/HipaaAccountingOfDisclosureService.java new file mode 100644 index 0000000..0bc9ced --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/HipaaAccountingOfDisclosureService.java @@ -0,0 +1,92 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.AccountingOfDisclosure; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.DisclosureType; +import com.gnx.telemedicine.repository.AccountingOfDisclosureRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class HipaaAccountingOfDisclosureService { + + private final AccountingOfDisclosureRepository disclosureRepository; + private final PatientRepository patientRepository; + private final UserRepository userRepository; + + @Transactional + public AccountingOfDisclosure logDisclosure( + UUID patientId, + String userEmail, + DisclosureType disclosureType, + String purpose, + String recipientName, + String recipientAddress, + String informationDisclosed, + UUID authorizedByUserId, + HttpServletRequest request) { + + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + AccountingOfDisclosure disclosure = new AccountingOfDisclosure(); + disclosure.setPatient(patient); + disclosure.setUser(user); + disclosure.setDisclosureType(disclosureType); + disclosure.setPurpose(purpose); + disclosure.setRecipientName(recipientName); + disclosure.setRecipientAddress(recipientAddress); + disclosure.setInformationDisclosed(informationDisclosed); + disclosure.setDisclosureDate(Instant.now()); + + if (authorizedByUserId != null) { + UserModel authorizedBy = userRepository.findById(authorizedByUserId) + .orElseThrow(() -> new IllegalArgumentException("Authorized user not found")); + disclosure.setAuthorizedBy(authorizedBy); + disclosure.setAuthorizationDate(Instant.now()); + } + + return disclosureRepository.save(disclosure); + } + + @Transactional(readOnly = true) + public List getDisclosuresByPatient(UUID patientId) { + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + return disclosureRepository.findByPatient(patient); + } + + @Transactional(readOnly = true) + public List getDisclosuresByPatientAndDateRange( + UUID patientId, Instant startDate, Instant endDate) { + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + return disclosureRepository.findByPatientAndDisclosureDateBetween(patient, startDate, endDate); + } + + @Transactional(readOnly = true) + public List getDisclosuresByUser(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + return disclosureRepository.findByUser(user); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/HipaaAuditService.java b/src/main/java/com/gnx/telemedicine/service/HipaaAuditService.java new file mode 100644 index 0000000..cb503b0 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/HipaaAuditService.java @@ -0,0 +1,114 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.HipaaAuditLog; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.ActionType; +import com.gnx.telemedicine.repository.HipaaAuditLogRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class HipaaAuditService { + + private final HipaaAuditLogRepository hipaaAuditLogRepository; + private final UserRepository userRepository; + private final PatientRepository patientRepository; + + @Transactional + public void logAccess(String userEmail, ActionType actionType, String resourceType, UUID resourceId, UUID patientId, Map details, HttpServletRequest request) { + logAccess(userEmail, actionType, resourceType, resourceId, patientId, details, true, null, request); + } + + @Transactional + public void logAccess(String userEmail, ActionType actionType, String resourceType, UUID resourceId, UUID patientId, Map details, boolean success, String errorMessage, HttpServletRequest request) { + try { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + HipaaAuditLog auditLog = new HipaaAuditLog(); + auditLog.setUser(user); + auditLog.setActionType(actionType); + auditLog.setResourceType(resourceType); + auditLog.setResourceId(resourceId); + auditLog.setSuccess(success); + auditLog.setErrorMessage(errorMessage); + auditLog.setDetails(details); + auditLog.setTimestamp(Instant.now()); + + // Set patient if provided + if (patientId != null) { + Patient patient = patientRepository.findById(patientId).orElse(null); + auditLog.setPatient(patient); + } + + // Extract IP address and user agent from request + if (request != null) { + String ipAddress = getClientIpAddress(request); + auditLog.setIpAddress(ipAddress); + auditLog.setUserAgent(request.getHeader("User-Agent")); + } + + hipaaAuditLogRepository.save(auditLog); + } catch (Exception e) { + // Log error but don't throw - audit logging should not break the main flow + log.error("Failed to create HIPAA audit log: {}", e.getMessage(), e); + } + } + + @Transactional(readOnly = true) + public List getAuditLogsByPatientId(UUID patientId) { + return hipaaAuditLogRepository.findByPatientIdOrderByTimestampDesc(patientId); + } + + @Transactional(readOnly = true) + public List getAuditLogsByUserId(UUID userId) { + return hipaaAuditLogRepository.findByUserIdOrderByTimestampDesc(userId); + } + + @Transactional(readOnly = true) + public List getAuditLogsByResource(String resourceType, UUID resourceId) { + return hipaaAuditLogRepository.findByResourceTypeAndResourceIdOrderByTimestampDesc(resourceType, resourceId); + } + + @Transactional(readOnly = true) + public List getAuditLogsByPatientIdAndDateRange(UUID patientId, Instant startDate, Instant endDate) { + return hipaaAuditLogRepository.findByPatientIdAndDateRange(patientId, startDate, endDate); + } + + @Transactional(readOnly = true) + public Long countAccessesByPatientIdSince(UUID patientId, Instant startDate) { + return hipaaAuditLogRepository.countAccessesByPatientIdSince(patientId, startDate); + } + + private String getClientIpAddress(HttpServletRequest request) { + String ipAddress = request.getHeader("X-Forwarded-For"); + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getHeader("Proxy-Client-IP"); + } + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getHeader("WL-Proxy-Client-IP"); + } + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getRemoteAddr(); + } + // Handle multiple IPs (first one is the original client) + if (ipAddress != null && ipAddress.contains(",")) { + ipAddress = ipAddress.split(",")[0].trim(); + } + return ipAddress; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/LabResultService.java b/src/main/java/com/gnx/telemedicine/service/LabResultService.java new file mode 100644 index 0000000..0e6a8ae --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/LabResultService.java @@ -0,0 +1,136 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.medical.LabResultRequestDto; +import com.gnx.telemedicine.dto.medical.LabResultResponseDto; +import com.gnx.telemedicine.mappers.LabResultMapper; +import com.gnx.telemedicine.model.LabResult; +import com.gnx.telemedicine.model.MedicalRecord; +import com.gnx.telemedicine.model.enums.LabResultStatus; +import com.gnx.telemedicine.repository.LabResultRepository; +import com.gnx.telemedicine.repository.MedicalRecordRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class LabResultService { + + private final LabResultRepository labResultRepository; + private final LabResultMapper labResultMapper; + private final MedicalRecordRepository medicalRecordRepository; + private final PatientRepository patientRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getLabResultsByPatientId(UUID patientId) { + return labResultRepository.findByPatientIdOrderByCreatedAtDesc(patientId) + .stream() + .map(labResultMapper::toLabResultResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getLabResultsByPatientIdAndStatus(UUID patientId, LabResultStatus status) { + return labResultRepository.findByPatientIdAndStatusOrderByCreatedAtDesc(patientId, status) + .stream() + .map(labResultMapper::toLabResultResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getAbnormalLabResultsByPatientId(UUID patientId) { + return labResultRepository.findAbnormalResultsByPatientId(patientId) + .stream() + .map(labResultMapper::toLabResultResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getLabResultsByMedicalRecordId(UUID medicalRecordId) { + return labResultRepository.findByMedicalRecordId(medicalRecordId) + .stream() + .map(labResultMapper::toLabResultResponseDto) + .toList(); + } + + @Transactional + public LabResultResponseDto createLabResult(String userEmail, LabResultRequestDto requestDto) { + // Get current user (orderer) + var currentUser = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Validate and get medical record if provided (optional) + MedicalRecord medicalRecord = null; + if (requestDto.medicalRecordId() != null) { + medicalRecord = medicalRecordRepository.findById(requestDto.medicalRecordId()) + .orElseThrow(() -> new IllegalArgumentException("Medical record not found")); + } + + // Validate patient + var patient = patientRepository.findById(requestDto.patientId()) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + // Create lab result + LabResult labResult = labResultMapper.toLabResult(requestDto); + // Don't set ID manually - let Hibernate generate it via @GeneratedValue + labResult.setMedicalRecord(medicalRecord); // Can be null + labResult.setPatient(patient); + labResult.setOrderedBy(currentUser); + + // Set default status if not provided + if (labResult.getStatus() == null) { + labResult.setStatus(LabResultStatus.PENDING); + } + + // Set performed at if not provided + if (labResult.getPerformedAt() == null) { + labResult.setPerformedAt(Instant.now()); + } + + LabResult saved = labResultRepository.save(labResult); + return labResultMapper.toLabResultResponseDto(saved); + } + + @Transactional(readOnly = true) + public LabResultResponseDto getLabResultById(UUID id) { + LabResult labResult = labResultRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Lab result not found")); + return labResultMapper.toLabResultResponseDto(labResult); + } + + @Transactional + public LabResultResponseDto updateLabResult(String userEmail, UUID id, LabResultRequestDto requestDto) { + LabResult labResult = labResultRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Lab result not found")); + + // Update fields + labResult.setTestName(requestDto.testName()); + labResult.setTestCode(requestDto.testCode()); + labResult.setResultValue(requestDto.resultValue()); + labResult.setUnit(requestDto.unit()); + labResult.setReferenceRange(requestDto.referenceRange()); + if (requestDto.status() != null) { + labResult.setStatus(requestDto.status()); + } + if (requestDto.performedAt() != null) { + labResult.setPerformedAt(requestDto.performedAt()); + } + labResult.setResultFileUrl(requestDto.resultFileUrl()); + + LabResult saved = labResultRepository.save(labResult); + return labResultMapper.toLabResultResponseDto(saved); + } + + @Transactional + public void deleteLabResult(UUID id) { + labResultRepository.deleteById(id); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/LoginAttemptService.java b/src/main/java/com/gnx/telemedicine/service/LoginAttemptService.java new file mode 100644 index 0000000..6bfb713 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/LoginAttemptService.java @@ -0,0 +1,129 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.LoginAttempt; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.LoginAttemptRepository; +import com.gnx.telemedicine.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class LoginAttemptService { + + private final LoginAttemptRepository loginAttemptRepository; + private final UserRepository userRepository; + + @Value("${security.login.max-attempts:5}") + private int maxFailedAttempts; + + @Value("${security.login.lockout-duration-minutes:30}") + private int lockoutDurationMinutes; + + @Transactional + public void recordSuccessfulLogin(String email, HttpServletRequest request) { + recordLoginAttempt(email, true, null, request); + + // Reset failed attempts counter + UserModel user = userRepository.findByEmail(email).orElse(null); + if (user != null) { + user.setFailedLoginAttempts(0); + user.setAccountLockedUntil(null); + userRepository.save(user); + } + } + + @Transactional + public void recordFailedLogin(String email, String reason, HttpServletRequest request) { + recordLoginAttempt(email, false, reason, request); + + // Increment failed attempts counter + UserModel user = userRepository.findByEmail(email).orElse(null); + if (user != null) { + int failedAttempts = (user.getFailedLoginAttempts() != null ? user.getFailedLoginAttempts() : 0) + 1; + user.setFailedLoginAttempts(failedAttempts); + user.setLastFailedLogin(Instant.now()); + + // Lock account if max attempts reached + if (failedAttempts >= maxFailedAttempts) { + Instant lockoutUntil = Instant.now().plus(lockoutDurationMinutes, ChronoUnit.MINUTES); + user.setAccountLockedUntil(lockoutUntil); + } + + userRepository.save(user); + } + } + + @Transactional(readOnly = true) + public boolean isAccountLocked(String email) { + UserModel user = userRepository.findByEmail(email).orElse(null); + if (user == null || user.getAccountLockedUntil() == null) { + return false; + } + + return Instant.now().isBefore(user.getAccountLockedUntil()); + } + + @Transactional(readOnly = true) + public int getRemainingAttempts(String email) { + UserModel user = userRepository.findByEmail(email).orElse(null); + if (user == null) { + return maxFailedAttempts; + } + + int failedAttempts = user.getFailedLoginAttempts() != null ? user.getFailedLoginAttempts() : 0; + return Math.max(0, maxFailedAttempts - failedAttempts); + } + + @Transactional(readOnly = true) + public Long getFailedAttemptsCount(String email, Instant since) { + return loginAttemptRepository.countFailedAttemptsSince(email, since); + } + + @Transactional(readOnly = true) + public List getRecentAttempts(String email, int hours) { + Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); + return loginAttemptRepository.findRecentAttemptsByEmail(email, since); + } + + private void recordLoginAttempt(String email, boolean success, String failureReason, HttpServletRequest request) { + LoginAttempt attempt = new LoginAttempt(); + // Don't set ID manually - let Hibernate generate it via @GeneratedValue + attempt.setEmail(email); + attempt.setSuccess(success); + attempt.setFailureReason(failureReason); + attempt.setTimestamp(Instant.now()); + + if (request != null) { + attempt.setIpAddress(getClientIpAddress(request)); + } + + loginAttemptRepository.save(attempt); + } + + private String getClientIpAddress(HttpServletRequest request) { + String ipAddress = request.getHeader("X-Forwarded-For"); + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getHeader("Proxy-Client-IP"); + } + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getHeader("WL-Proxy-Client-IP"); + } + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getRemoteAddr(); + } + if (ipAddress != null && ipAddress.contains(",")) { + ipAddress = ipAddress.split(",")[0].trim(); + } + return ipAddress; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/MedicalRecordService.java b/src/main/java/com/gnx/telemedicine/service/MedicalRecordService.java new file mode 100644 index 0000000..546d73c --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/MedicalRecordService.java @@ -0,0 +1,173 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.medical.MedicalRecordRequestDto; +import com.gnx.telemedicine.dto.medical.MedicalRecordResponseDto; +import com.gnx.telemedicine.mappers.MedicalRecordMapper; +import com.gnx.telemedicine.model.Appointment; +import com.gnx.telemedicine.model.MedicalRecord; +import com.gnx.telemedicine.model.enums.RecordType; +import com.gnx.telemedicine.repository.AppointmentRepository; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.MedicalRecordRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class MedicalRecordService { + + private final MedicalRecordRepository medicalRecordRepository; + private final MedicalRecordMapper medicalRecordMapper; + private final PatientRepository patientRepository; + private final DoctorRepository doctorRepository; + private final AppointmentRepository appointmentRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getMedicalRecordsByPatientId(UUID patientId) { + return medicalRecordRepository.findByPatientIdOrderByCreatedAtDesc(patientId) + .stream() + .map(medicalRecordMapper::toMedicalRecordResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getMedicalRecordsByPatientIdAndType(UUID patientId, RecordType recordType) { + return medicalRecordRepository.findByPatientIdAndRecordTypeOrderByCreatedAtDesc(patientId, recordType) + .stream() + .map(medicalRecordMapper::toMedicalRecordResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getMedicalRecordsByDoctorId(UUID doctorId) { + return medicalRecordRepository.findByDoctorIdOrderByCreatedAtDesc(doctorId) + .stream() + .map(medicalRecordMapper::toMedicalRecordResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getMedicalRecordsByAppointmentId(UUID appointmentId) { + return medicalRecordRepository.findByAppointmentId(appointmentId) + .stream() + .map(medicalRecordMapper::toMedicalRecordResponseDto) + .toList(); + } + + @Transactional + public MedicalRecordResponseDto createMedicalRecord(String userEmail, MedicalRecordRequestDto requestDto) { + // Get current user (creator) + var currentUser = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Validate user is a doctor + if (!currentUser.getRole().name().equals("DOCTOR")) { + throw new IllegalArgumentException("Only doctors can create medical records"); + } + + // Get the doctor profile for current user + var currentDoctor = doctorRepository.findByUser(currentUser) + .orElseThrow(() -> new IllegalArgumentException("Doctor profile not found for current user")); + + // Validate and get patient + var patient = patientRepository.findById(requestDto.patientId()) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + // Validate that the doctor in request matches the authenticated doctor + // Doctors can only create records for themselves + if (!currentDoctor.getId().equals(requestDto.doctorId())) { + throw new IllegalArgumentException("Doctors can only create medical records for themselves"); + } + + var doctor = currentDoctor; + + // Validate appointment if provided + Appointment appointment = null; + if (requestDto.appointmentId() != null) { + appointment = appointmentRepository.findById(requestDto.appointmentId()) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + } + + // Create medical record + MedicalRecord medicalRecord = medicalRecordMapper.toMedicalRecord(requestDto); + // Don't set ID manually - let Hibernate generate it via @GeneratedValue + medicalRecord.setPatient(patient); + medicalRecord.setDoctor(doctor); + medicalRecord.setAppointment(appointment); + medicalRecord.setCreatedBy(currentUser); + medicalRecord.setCreatedAt(Instant.now()); + medicalRecord.setUpdatedAt(Instant.now()); + + MedicalRecord saved = medicalRecordRepository.save(medicalRecord); + return medicalRecordMapper.toMedicalRecordResponseDto(saved); + } + + @Transactional(readOnly = true) + public MedicalRecordResponseDto getMedicalRecordById(UUID id) { + MedicalRecord medicalRecord = medicalRecordRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Medical record not found")); + + // Log PHI access (if aspect doesn't catch it) + // The aspect should handle this, but we'll also log at service level as backup + + return medicalRecordMapper.toMedicalRecordResponseDto(medicalRecord); + } + + @Transactional(readOnly = true) + public List getMedicalRecordsByPatientIdAndDoctorId(UUID patientId, UUID doctorId) { + return medicalRecordRepository.findByPatientIdAndDoctorId(patientId, doctorId) + .stream() + .map(medicalRecordMapper::toMedicalRecordResponseDto) + .toList(); + } + + @Transactional + public MedicalRecordResponseDto updateMedicalRecord(String userEmail, UUID id, MedicalRecordRequestDto requestDto) { + MedicalRecord medicalRecord = medicalRecordRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Medical record not found")); + + // Update fields + medicalRecord.setTitle(requestDto.title()); + medicalRecord.setContent(requestDto.content()); + medicalRecord.setRecordType(requestDto.recordType()); + medicalRecord.setDiagnosisCode(requestDto.diagnosisCode()); + medicalRecord.setUpdatedAt(Instant.now()); + + // Update patient/doctor/appointment if changed + if (!medicalRecord.getPatient().getId().equals(requestDto.patientId())) { + var patient = patientRepository.findById(requestDto.patientId()) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + medicalRecord.setPatient(patient); + } + + if (!medicalRecord.getDoctor().getId().equals(requestDto.doctorId())) { + var doctor = doctorRepository.findById(requestDto.doctorId()) + .orElseThrow(() -> new IllegalArgumentException("Doctor not found")); + medicalRecord.setDoctor(doctor); + } + + if (requestDto.appointmentId() != null && + (medicalRecord.getAppointment() == null || !medicalRecord.getAppointment().getId().equals(requestDto.appointmentId()))) { + var appointment = appointmentRepository.findById(requestDto.appointmentId()) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + medicalRecord.setAppointment(appointment); + } + + MedicalRecord saved = medicalRecordRepository.save(medicalRecord); + return medicalRecordMapper.toMedicalRecordResponseDto(saved); + } + + @Transactional + public void deleteMedicalRecord(UUID id) { + medicalRecordRepository.deleteById(id); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/MedicationIntakeLogService.java b/src/main/java/com/gnx/telemedicine/service/MedicationIntakeLogService.java new file mode 100644 index 0000000..f5581d7 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/MedicationIntakeLogService.java @@ -0,0 +1,100 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.prescription.MedicationIntakeLogRequestDto; +import com.gnx.telemedicine.dto.prescription.MedicationIntakeLogResponseDto; +import com.gnx.telemedicine.mappers.MedicationIntakeLogMapper; +import com.gnx.telemedicine.model.MedicationIntakeLog; +import com.gnx.telemedicine.model.Prescription; +import com.gnx.telemedicine.repository.MedicationIntakeLogRepository; +import com.gnx.telemedicine.repository.PrescriptionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class MedicationIntakeLogService { + + private final MedicationIntakeLogRepository medicationIntakeLogRepository; + private final MedicationIntakeLogMapper medicationIntakeLogMapper; + private final PrescriptionRepository prescriptionRepository; + + @Transactional(readOnly = true) + public List getLogsByPrescriptionId(UUID prescriptionId) { + return medicationIntakeLogRepository.findByPrescriptionIdOrderByScheduledTimeDesc(prescriptionId) + .stream() + .map(medicationIntakeLogMapper::toMedicationIntakeLogResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getMissedDoses(UUID prescriptionId) { + List missedDoses = medicationIntakeLogRepository.findMissedDoses(prescriptionId, Instant.now()); + return missedDoses.stream() + .map(medicationIntakeLogMapper::toMedicationIntakeLogResponseDto) + .toList(); + } + + @Transactional + public MedicationIntakeLogResponseDto createIntakeLog(MedicationIntakeLogRequestDto requestDto) { + // Validate prescription exists + Prescription prescription = prescriptionRepository.findById(requestDto.prescriptionId()) + .orElseThrow(() -> new IllegalArgumentException("Prescription not found")); + + // Create intake log + MedicationIntakeLog log = medicationIntakeLogMapper.toMedicationIntakeLog(requestDto); + log.setPrescription(prescription); + log.setScheduledTime(requestDto.scheduledTime()); + + if (requestDto.taken() != null && requestDto.taken()) { + log.setTaken(true); + log.setTakenAt(Instant.now()); + } else { + log.setTaken(false); + } + + log.setNotes(requestDto.notes()); + + MedicationIntakeLog saved = medicationIntakeLogRepository.save(log); + return medicationIntakeLogMapper.toMedicationIntakeLogResponseDto(saved); + } + + @Transactional + public MedicationIntakeLogResponseDto markDoseAsTaken(UUID id) { + MedicationIntakeLog log = medicationIntakeLogRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Medication intake log not found")); + + log.setTaken(true); + log.setTakenAt(Instant.now()); + + MedicationIntakeLog saved = medicationIntakeLogRepository.save(log); + return medicationIntakeLogMapper.toMedicationIntakeLogResponseDto(saved); + } + + @Transactional(readOnly = true) + public MedicationIntakeLogResponseDto getIntakeLogById(UUID id) { + MedicationIntakeLog log = medicationIntakeLogRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Medication intake log not found")); + return medicationIntakeLogMapper.toMedicationIntakeLogResponseDto(log); + } + + @Transactional(readOnly = true) + public Long getAdherenceRate(UUID prescriptionId) { + Long totalDoses = medicationIntakeLogRepository.countTotalDosesByPrescriptionId(prescriptionId); + if (totalDoses == 0) { + return 0L; + } + Long takenDoses = medicationIntakeLogRepository.countTakenDosesByPrescriptionId(prescriptionId); + return (takenDoses * 100) / totalDoses; + } + + @Transactional + public void deleteIntakeLog(UUID id) { + medicationIntakeLogRepository.deleteById(id); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/MessageService.java b/src/main/java/com/gnx/telemedicine/service/MessageService.java new file mode 100644 index 0000000..e921d18 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/MessageService.java @@ -0,0 +1,444 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.message.ChatUserDto; +import com.gnx.telemedicine.dto.message.ConversationDto; +import com.gnx.telemedicine.dto.message.MessageRequestDto; +import com.gnx.telemedicine.dto.message.MessageResponseDto; +import com.gnx.telemedicine.mappers.MessageMapper; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Message; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.UserBlock; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.MessageRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserBlockRepository; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MessageService { + + private final MessageRepository messageRepository; + private final UserRepository userRepository; + private final DoctorRepository doctorRepository; + private final PatientRepository patientRepository; + private final UserBlockRepository userBlockRepository; + private final MessageMapper messageMapper; + private final SimpMessagingTemplate messagingTemplate; + + @Transactional + public MessageResponseDto sendMessage(String senderEmail, MessageRequestDto requestDto) { + UserModel sender = userRepository.findByEmail(senderEmail) + .orElseThrow(() -> new RuntimeException("Sender not found")); + + UserModel receiver = userRepository.findById(requestDto.getReceiverId()) + .orElseThrow(() -> new RuntimeException("Receiver not found")); + + // Validate that sender and receiver are different + if (sender.getId().equals(receiver.getId())) { + throw new IllegalArgumentException("Cannot send message to yourself"); + } + + // Validate that sender is either doctor or patient, and receiver is the opposite role + if (!((sender.getRole().name().equals("DOCTOR") && receiver.getRole().name().equals("PATIENT")) || + (sender.getRole().name().equals("PATIENT") && receiver.getRole().name().equals("DOCTOR")))) { + throw new IllegalArgumentException("Messages can only be sent between doctors and patients"); + } + + // Check if sender is blocked by receiver or receiver is blocked by sender + if (userBlockRepository.isBlocked(sender.getId(), receiver.getId())) { + throw new IllegalArgumentException("You cannot send messages to this user. You have been blocked or you have blocked them."); + } + + Message message = new Message(); + message.setSender(sender); + message.setReceiver(receiver); + message.setContent(requestDto.getContent()); + message.setIsRead(false); + + Message savedMessage = messageRepository.save(message); + return messageMapper.toDto(savedMessage); + } + + @Transactional(readOnly = true) + public List getConversation(String userEmail, UUID otherUserId) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + List messages = messageRepository.findConversationBetweenUsers(user.getId(), otherUserId, user.getId()); + return messageMapper.toDtoList(messages); + } + + @Transactional(readOnly = true) + public List getConversations(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + List partnerIds = messageRepository.findConversationPartners(user.getId()); + + // Remove duplicates and sort partner IDs by last message time (most recent first) + List sortedPartnerIds = partnerIds.stream() + .distinct() // Ensure no duplicates + .sorted((id1, id2) -> { + java.time.LocalDateTime time1 = messageRepository.getLastMessageTime(user.getId(), id1, user.getId()); + java.time.LocalDateTime time2 = messageRepository.getLastMessageTime(user.getId(), id2, user.getId()); + if (time1 == null && time2 == null) return 0; + if (time1 == null) return 1; + if (time2 == null) return -1; + return time2.compareTo(time1); // Descending order + }) + .collect(Collectors.toList()); + + return sortedPartnerIds.stream().map(partnerId -> { + UserModel partner = userRepository.findById(partnerId) + .orElseThrow(() -> new RuntimeException("Partner not found")); + + List messages = messageRepository.findConversationBetweenUsers(user.getId(), partnerId, user.getId()); + // Count unread messages from this specific partner + Long unreadCount = messageRepository.countUnreadMessagesFromSender(user.getId(), partnerId); + + MessageResponseDto lastMessage = messages.isEmpty() ? null : messageMapper.toDto(messages.get(messages.size() - 1)); + + return ConversationDto.builder() + .otherUserId(partner.getId()) + .otherUserName(partner.getFirstName() + " " + partner.getLastName()) + .otherUserRole(partner.getRole().name()) + .isOnline(partner.getIsOnline() != null ? partner.getIsOnline() : false) + .otherUserStatus(partner.getUserStatus() != null ? partner.getUserStatus().name() : "OFFLINE") + .lastSeen(partner.getLastSeen()) + .unreadCount(unreadCount) + .lastMessage(lastMessage) + .messages(messageMapper.toDtoList(messages)) + .otherUserAvatarUrl(partner.getAvatarUrl()) + .build(); + }).collect(Collectors.toList()); + } + + @Transactional + public void markMessagesAsRead(String userEmail, UUID senderId) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + messageRepository.markMessagesAsRead(user.getId(), senderId); + } + + @Transactional(readOnly = true) + public Long getUnreadMessageCount(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + return messageRepository.countUnreadMessages(user.getId()); + } + + @Transactional + public void updateOnlineStatus(String userEmail, boolean isOnline) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + if (isOnline) { + // When WebSocket connects, restore user's last status + // If status is null, set to ONLINE as default + // If status is OFFLINE (from logout), restore to ONLINE + // If status is ONLINE or BUSY, restore it + if (user.getUserStatus() == null || user.getUserStatus() == com.gnx.telemedicine.model.enums.UserStatus.OFFLINE) { + // New user, never set status, or was OFFLINE -> set to ONLINE on reconnect + user.setUserStatus(com.gnx.telemedicine.model.enums.UserStatus.ONLINE); + user.setIsOnline(true); + } else if (user.getUserStatus() == com.gnx.telemedicine.model.enums.UserStatus.ONLINE) { + // User was ONLINE -> restore ONLINE status + user.setIsOnline(true); + } else if (user.getUserStatus() == com.gnx.telemedicine.model.enums.UserStatus.BUSY) { + // User manually set to BUSY -> restore BUSY status + user.setIsOnline(true); + } + } else { + // If disconnecting (page refresh, network issue), only set isOnline=false + // Don't change userStatus to preserve it for reconnection + // Only set OFFLINE status when explicitly called from logout + user.setIsOnline(false); + // Don't change userStatus here - preserve ONLINE or BUSY for reconnect + } + user.setLastSeen(LocalDateTime.now()); + userRepository.save(user); + + // Broadcast online status change to all connected clients + Map statusUpdate = new HashMap<>(); + statusUpdate.put("userId", user.getId().toString()); + statusUpdate.put("isOnline", user.getIsOnline()); + statusUpdate.put("status", user.getUserStatus().name()); + statusUpdate.put("lastSeen", user.getLastSeen()); + messagingTemplate.convertAndSend("/topic/online-status", statusUpdate); + } + + @Transactional + public void updateUserStatus(String userEmail, String status) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + try { + com.gnx.telemedicine.model.enums.UserStatus userStatus = + com.gnx.telemedicine.model.enums.UserStatus.valueOf(status.toUpperCase()); + + user.setUserStatus(userStatus); + // Update isOnline based on status + user.setIsOnline(userStatus == com.gnx.telemedicine.model.enums.UserStatus.ONLINE || + userStatus == com.gnx.telemedicine.model.enums.UserStatus.BUSY); + user.setLastSeen(LocalDateTime.now()); + userRepository.save(user); + + // Broadcast status change to all connected clients + Map statusUpdate = new HashMap<>(); + statusUpdate.put("userId", user.getId().toString()); + statusUpdate.put("isOnline", user.getIsOnline()); + statusUpdate.put("status", userStatus.name()); + statusUpdate.put("lastSeen", user.getLastSeen()); + messagingTemplate.convertAndSend("/topic/online-status", statusUpdate); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid status: " + status + ". Valid values are: ONLINE, OFFLINE, BUSY"); + } + } + + @Transactional(readOnly = true) + public List searchDoctors(String userEmail, String query) { + UserModel currentUser = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Only patients can search for doctors + if (!currentUser.getRole().name().equals("PATIENT")) { + throw new IllegalArgumentException("Only patients can search for doctors"); + } + + String searchQuery = query == null || query.trim().isEmpty() ? "" : query.trim(); + // Use more lenient queries for chat search + List doctors = searchQuery.isEmpty() + ? doctorRepository.findAllForChat() + : doctorRepository.searchDoctorsForChat(searchQuery); + + // Filter out the current user if they happen to be a doctor (shouldn't happen, but safety check) + // Also filter out blocked users + return doctors.stream() + .filter(doctor -> !doctor.getUser().getId().equals(currentUser.getId())) // Exclude self + .filter(doctor -> doctor.getUser() != null && doctor.getUser().getFirstName() != null) // Safety checks + .filter(doctor -> !userBlockRepository.isBlocked(currentUser.getId(), doctor.getUser().getId())) // Exclude blocked users + .map(doctor -> ChatUserDto.builder() + .userId(doctor.getUser().getId()) + .firstName(doctor.getUser().getFirstName()) + .lastName(doctor.getUser().getLastName()) + .specialization(doctor.getSpecialization()) + .isVerified(doctor.getIsVerified()) + .isOnline(doctor.getUser().getIsOnline() != null ? doctor.getUser().getIsOnline() : false) + .status(doctor.getUser().getUserStatus() != null ? doctor.getUser().getUserStatus().name() : "OFFLINE") + .avatarUrl(doctor.getUser().getAvatarUrl()) + .build()) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List searchPatients(String userEmail, String query) { + UserModel currentUser = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Only doctors can search for patients + if (!currentUser.getRole().name().equals("DOCTOR")) { + throw new IllegalArgumentException("Only doctors can search for patients"); + } + + String searchQuery = query == null || query.trim().isEmpty() ? "" : query.trim(); + // Use more lenient queries for chat search + List patients = searchQuery.isEmpty() + ? patientRepository.findAllForChat() + : patientRepository.searchPatientsForChat(searchQuery); + + // Filter out the current user and blocked users + return patients.stream() + .filter(patient -> !patient.getUser().getId().equals(currentUser.getId())) // Exclude self + .filter(patient -> !userBlockRepository.isBlocked(currentUser.getId(), patient.getUser().getId())) // Exclude blocked users + .map(patient -> ChatUserDto.builder() + .userId(patient.getUser().getId()) + .firstName(patient.getUser().getFirstName()) + .lastName(patient.getUser().getLastName()) + .isOnline(patient.getUser().getIsOnline() != null ? patient.getUser().getIsOnline() : false) + .status(patient.getUser().getUserStatus() != null ? patient.getUser().getUserStatus().name() : "OFFLINE") + .avatarUrl(patient.getUser().getAvatarUrl()) + .build()) + .collect(Collectors.toList()); + } + + @Transactional + public void deleteMessage(String userEmail, UUID messageId) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Verify message exists and user is either sender or receiver + Message message = messageRepository.findById(messageId) + .orElseThrow(() -> new RuntimeException("Message not found")); + + // Check if user is the sender or receiver + boolean isSender = message.getSender().getId().equals(user.getId()); + boolean isReceiver = message.getReceiver().getId().equals(user.getId()); + + if (!isSender && !isReceiver) { + throw new IllegalArgumentException("You can only delete messages that you sent or received"); + } + + // Soft delete: set the appropriate flag based on whether user is sender or receiver + if (isSender) { + messageRepository.softDeleteMessageBySender(messageId, user.getId()); + } + if (isReceiver) { + messageRepository.softDeleteMessageByReceiver(messageId, user.getId()); + } + } + + @Transactional + public void deleteConversation(String userEmail, UUID otherUserId) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + UserModel otherUser = userRepository.findById(otherUserId) + .orElseThrow(() -> new RuntimeException("Other user not found")); + + // Soft delete all messages in the conversation for the current user + // If user is sender: mark deleted_by_sender = true for messages they sent + // If user is receiver: mark deleted_by_receiver = true for messages they received + messageRepository.softDeleteConversationBySender(user.getId(), otherUser.getId()); + messageRepository.softDeleteConversationByReceiver(user.getId(), otherUser.getId()); + } + + @Transactional + public void blockUser(String userEmail, UUID userIdToBlock) { + UserModel blocker = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + UserModel blocked = userRepository.findById(userIdToBlock) + .orElseThrow(() -> new RuntimeException("User to block not found")); + + // Validate that blocker and blocked are different + if (blocker.getId().equals(blocked.getId())) { + throw new IllegalArgumentException("Cannot block yourself"); + } + + // Validate that blocker and blocked are opposite roles (doctor/patient) + if (!((blocker.getRole().name().equals("DOCTOR") && blocked.getRole().name().equals("PATIENT")) || + (blocker.getRole().name().equals("PATIENT") && blocked.getRole().name().equals("DOCTOR")))) { + throw new IllegalArgumentException("Can only block users with opposite role (doctor/patient)"); + } + + // Check if already blocked + if (userBlockRepository.findByBlockerIdAndBlockedId(blocker.getId(), blocked.getId()).isPresent()) { + throw new IllegalArgumentException("User is already blocked"); + } + + // Create block relationship + UserBlock userBlock = new UserBlock(); + userBlock.setBlocker(blocker); + userBlock.setBlocked(blocked); + userBlockRepository.save(userBlock); + } + + @Transactional + public void unblockUser(String userEmail, UUID userIdToUnblock) { + UserModel blocker = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + UserModel blocked = userRepository.findById(userIdToUnblock) + .orElseThrow(() -> new RuntimeException("User to unblock not found")); + + UserBlock userBlock = userBlockRepository.findByBlockerIdAndBlockedId(blocker.getId(), blocked.getId()) + .orElseThrow(() -> new IllegalArgumentException("User is not blocked")); + + userBlockRepository.delete(userBlock); + } + + @Transactional(readOnly = true) + public boolean isBlocked(String userEmail, UUID otherUserId) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + return userBlockRepository.isBlocked(user.getId(), otherUserId); + } + + @Transactional(readOnly = true) + public List getBlockedUsers(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + return userBlockRepository.findAll().stream() + .filter(block -> block.getBlocker().getId().equals(user.getId())) + .map(block -> block.getBlocked().getId()) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getBlockedUsersWithDetails(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Get all blocked user IDs - use repository query to avoid lazy loading issues + List blockedUserIds = userBlockRepository.findBlockedUserIdsByBlockerId(user.getId()); + + log.debug("Found {} blocked user IDs for user {}", blockedUserIds.size(), userEmail); + + if (blockedUserIds.isEmpty()) { + return new ArrayList<>(); + } + + // Get blocked users' details based on current user's role + if (user.getRole().name().equals("PATIENT")) { + // If current user is a patient, blocked users are doctors + // Find doctors by their user.id directly using repository query + List doctors = doctorRepository.findByUserIds(blockedUserIds); + + log.debug("Found {} doctors for {} blocked user IDs", doctors.size(), blockedUserIds.size()); + + return doctors.stream() + .filter(doctor -> doctor.getUser() != null && doctor.getUser().getFirstName() != null) + .map(doctor -> ChatUserDto.builder() + .userId(doctor.getUser().getId()) + .firstName(doctor.getUser().getFirstName()) + .lastName(doctor.getUser().getLastName()) + .specialization(doctor.getSpecialization()) + .isVerified(doctor.getIsVerified() != null ? doctor.getIsVerified() : false) + .isOnline(doctor.getUser().getIsOnline() != null ? doctor.getUser().getIsOnline() : false) + .status(doctor.getUser().getUserStatus() != null ? doctor.getUser().getUserStatus().name() : "OFFLINE") + .avatarUrl(doctor.getUser().getAvatarUrl()) + .build()) + .collect(Collectors.toList()); + } else if (user.getRole().name().equals("DOCTOR")) { + // If current user is a doctor, blocked users are patients + // Find patients by their user.id directly using repository query + List patients = patientRepository.findByUserIds(blockedUserIds); + + log.debug("Found {} patients for {} blocked user IDs", patients.size(), blockedUserIds.size()); + + return patients.stream() + .filter(patient -> patient.getUser() != null && patient.getUser().getFirstName() != null) + .map(patient -> ChatUserDto.builder() + .userId(patient.getUser().getId()) + .firstName(patient.getUser().getFirstName()) + .lastName(patient.getUser().getLastName()) + .isOnline(patient.getUser().getIsOnline() != null ? patient.getUser().getIsOnline() : false) + .status(patient.getUser().getUserStatus() != null ? patient.getUser().getUserStatus().name() : "OFFLINE") + .avatarUrl(patient.getUser().getAvatarUrl()) + .build()) + .collect(Collectors.toList()); + } + + return new ArrayList<>(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/MinimumNecessaryService.java b/src/main/java/com/gnx/telemedicine/service/MinimumNecessaryService.java new file mode 100644 index 0000000..9a7da07 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/MinimumNecessaryService.java @@ -0,0 +1,504 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.ActionType; +import com.gnx.telemedicine.model.enums.Role; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Comprehensive service to enforce HIPAA Minimum Necessary Standard (45 CFR §164.502(b)) + * + * This service provides full enterprise-grade enforcement of the minimum necessary standard, + * ensuring that only the minimum necessary PHI is accessed or disclosed based on: + * - User role and permissions + * - Access purpose (Treatment, Payment, Operations, Authorization) + * - Field-level granularity + * - Patient relationship (own patient vs. other patients) + * - Time-based access restrictions + * + * Features: + * - Granular field-level access control + * - Purpose-based access validation + * - Automatic audit logging of violations + * - Relationship-based access (doctor-patient assignments) + * - Emergency override capabilities + * - Comprehensive access reporting + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MinimumNecessaryService { + + private final UserRepository userRepository; + private final PatientRepository patientRepository; + private final HipaaAuditService hipaaAuditService; + + @Value("${compliance.hipaa.minimum-necessary:true}") + private boolean minimumNecessaryEnabled; + + @Value("${compliance.hipaa.emergency-override:true}") + private boolean emergencyOverrideEnabled; + + // Comprehensive PHI field definitions + public enum PhiField { + // Basic Patient Information + FIRST_NAME, LAST_NAME, EMAIL, PHONE_NUMBER, DATE_OF_BIRTH, GENDER, + // Address Information + STREET_ADDRESS, CITY, STATE, ZIP_CODE, COUNTRY, + // Medical Information + BLOOD_TYPE, ALLERGIES, MEDICAL_HISTORY, CURRENT_MEDICATIONS, + // Emergency Contact + EMERGENCY_CONTACT_NAME, EMERGENCY_CONTACT_PHONE, + // Insurance Information + INSURANCE_PROVIDER, INSURANCE_POLICY_NUMBER, + // Medical Records + MEDICAL_RECORD_ID, CHIEF_COMPLAINT, DIAGNOSIS, TREATMENT_PLAN, NOTES, + // Vital Signs + VITAL_SIGNS, BLOOD_PRESSURE, HEART_RATE, TEMPERATURE, WEIGHT, HEIGHT, + // Lab Results + LAB_RESULT_ID, LAB_TEST_NAME, LAB_RESULT_VALUE, LAB_RESULT_DATE, + // Prescriptions + PRESCRIPTION_ID, MEDICATION_NAME, DOSAGE, FREQUENCY, START_DATE, END_DATE, + // Appointments + APPOINTMENT_ID, APPOINTMENT_DATE, APPOINTMENT_TIME, APPOINTMENT_STATUS, + // Administrative + CREATED_AT, UPDATED_AT, CREATED_BY, UPDATED_BY + } + + // Access purposes per HIPAA + public enum AccessPurpose { + TREATMENT, // For providing healthcare services + PAYMENT, // For payment processing + OPERATIONS, // For healthcare operations + AUTHORIZATION, // Patient-authorized disclosure + PUBLIC_HEALTH, // Public health purposes + RESEARCH, // Research purposes (with proper authorization) + LAW_ENFORCEMENT // Law enforcement (with proper authorization) + } + + /** + * Comprehensive access validation with purpose and field-level granularity + * + * @param userEmail The email of the user requesting access + * @param patientId The patient ID whose data is being accessed + * @param phiFields The PHI fields being accessed + * @param purpose The purpose of access + * @param request HTTP request for audit logging + * @return AccessResult containing access decision and details + */ + @Transactional(readOnly = true) + public AccessResult validateAccess(String userEmail, UUID patientId, Set phiFields, + AccessPurpose purpose, HttpServletRequest request) { + if (!minimumNecessaryEnabled) { + return AccessResult.allowed("Minimum necessary check disabled"); + } + + try { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userEmail)); + + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found: " + patientId)); + + Role role = user.getRole(); + + // Check basic access rights + AccessResult basicAccess = checkBasicAccess(user, patient, role); + if (!basicAccess.isAllowed()) { + logViolationAndAudit(userEmail, patientId, phiFields, purpose, + "Basic access denied: " + basicAccess.getReason(), request); + return basicAccess; + } + + // Get allowed fields based on role and purpose + Set allowedFields = getAllowedPhiFieldsForRoleAndPurpose(role, purpose); + + // Check if all requested fields are allowed + Set deniedFields = new HashSet<>(); + Set allowedRequestedFields = new HashSet<>(); + + // Check if user has full access (all fields allowed - e.g., ADMIN role) + boolean hasFullAccess = allowedFields.size() == PhiField.values().length; + + for (PhiField field : phiFields) { + if (hasFullAccess || allowedFields.contains(field)) { + allowedRequestedFields.add(field); + } else { + deniedFields.add(field); + } + } + + if (!deniedFields.isEmpty()) { + String reason = String.format("Access denied to fields: %s for role %s and purpose %s", + deniedFields, role, purpose); + logViolationAndAudit(userEmail, patientId, deniedFields, purpose, reason, request); + return AccessResult.denied(reason, allowedRequestedFields, deniedFields); + } + + // Check purpose-specific restrictions + AccessResult purposeCheck = validatePurposeAccess(role, purpose, patientId); + if (!purposeCheck.isAllowed()) { + logViolationAndAudit(userEmail, patientId, phiFields, purpose, + "Purpose access denied: " + purposeCheck.getReason(), request); + return purposeCheck; + } + + // Log successful access + logAccess(userEmail, patientId, allowedRequestedFields, purpose, request); + + return AccessResult.allowed("Access granted", allowedRequestedFields); + + } catch (Exception e) { + log.error("Error validating minimum necessary access: {}", e.getMessage(), e); + return AccessResult.denied("Error validating access: " + e.getMessage()); + } + } + + /** + * Check basic access rights (can user access this patient's data at all?) + */ + private AccessResult checkBasicAccess(UserModel user, Patient patient, Role role) { + // Patients can only access their own data + if (role == Role.PATIENT) { + if (!user.getId().equals(patient.getUser().getId())) { + return AccessResult.denied("Patients can only access their own data"); + } + return AccessResult.allowed("Patient accessing own data"); + } + + // Doctors need relationship check (can be enhanced with appointment/assignment records) + if (role == Role.DOCTOR) { + // For now, doctors can access any patient data for treatment purposes + // In production, you might want to check doctor-patient relationships + return AccessResult.allowed("Doctor access for treatment"); + } + + // Admins have full access for system administration + if (role == Role.ADMIN) { + return AccessResult.allowed("Admin full access"); + } + + return AccessResult.denied("Unknown role: " + role); + } + + /** + * Get allowed PHI fields based on role and access purpose + */ + private Set getAllowedPhiFieldsForRoleAndPurpose(Role role, AccessPurpose purpose) { + // Base allowed fields per role + Map> roleFields = Map.of( + Role.PATIENT, Set.of( + PhiField.FIRST_NAME, PhiField.LAST_NAME, PhiField.EMAIL, PhiField.PHONE_NUMBER, + PhiField.DATE_OF_BIRTH, PhiField.GENDER, PhiField.STREET_ADDRESS, PhiField.CITY, + PhiField.STATE, PhiField.ZIP_CODE, PhiField.COUNTRY, PhiField.BLOOD_TYPE, + PhiField.ALLERGIES, PhiField.MEDICAL_HISTORY, PhiField.CURRENT_MEDICATIONS, + PhiField.EMERGENCY_CONTACT_NAME, PhiField.EMERGENCY_CONTACT_PHONE, + PhiField.INSURANCE_PROVIDER, PhiField.INSURANCE_POLICY_NUMBER, + PhiField.MEDICAL_RECORD_ID, PhiField.CHIEF_COMPLAINT, PhiField.DIAGNOSIS, + PhiField.TREATMENT_PLAN, PhiField.NOTES, PhiField.VITAL_SIGNS, + PhiField.BLOOD_PRESSURE, PhiField.HEART_RATE, PhiField.TEMPERATURE, + PhiField.WEIGHT, PhiField.HEIGHT, PhiField.LAB_RESULT_ID, PhiField.LAB_TEST_NAME, + PhiField.LAB_RESULT_VALUE, PhiField.LAB_RESULT_DATE, PhiField.PRESCRIPTION_ID, + PhiField.MEDICATION_NAME, PhiField.DOSAGE, PhiField.FREQUENCY, + PhiField.START_DATE, PhiField.END_DATE, PhiField.APPOINTMENT_ID, + PhiField.APPOINTMENT_DATE, PhiField.APPOINTMENT_TIME, PhiField.APPOINTMENT_STATUS, + PhiField.CREATED_AT, PhiField.UPDATED_AT + ), + Role.DOCTOR, Set.of( + // Doctors need comprehensive access for treatment + PhiField.FIRST_NAME, PhiField.LAST_NAME, PhiField.EMAIL, PhiField.PHONE_NUMBER, + PhiField.DATE_OF_BIRTH, PhiField.GENDER, PhiField.STREET_ADDRESS, PhiField.CITY, + PhiField.STATE, PhiField.ZIP_CODE, PhiField.COUNTRY, PhiField.BLOOD_TYPE, + PhiField.ALLERGIES, PhiField.MEDICAL_HISTORY, PhiField.CURRENT_MEDICATIONS, + PhiField.EMERGENCY_CONTACT_NAME, PhiField.EMERGENCY_CONTACT_PHONE, + PhiField.INSURANCE_PROVIDER, PhiField.INSURANCE_POLICY_NUMBER, + PhiField.MEDICAL_RECORD_ID, PhiField.CHIEF_COMPLAINT, PhiField.DIAGNOSIS, + PhiField.TREATMENT_PLAN, PhiField.NOTES, PhiField.VITAL_SIGNS, + PhiField.BLOOD_PRESSURE, PhiField.HEART_RATE, PhiField.TEMPERATURE, + PhiField.WEIGHT, PhiField.HEIGHT, PhiField.LAB_RESULT_ID, PhiField.LAB_TEST_NAME, + PhiField.LAB_RESULT_VALUE, PhiField.LAB_RESULT_DATE, PhiField.PRESCRIPTION_ID, + PhiField.MEDICATION_NAME, PhiField.DOSAGE, PhiField.FREQUENCY, + PhiField.START_DATE, PhiField.END_DATE, PhiField.APPOINTMENT_ID, + PhiField.APPOINTMENT_DATE, PhiField.APPOINTMENT_TIME, PhiField.APPOINTMENT_STATUS, + PhiField.CREATED_AT, PhiField.UPDATED_AT + ), + Role.ADMIN, Set.of( + // Admins have full access for system administration + // Include all fields for admin access + PhiField.values() // All fields + ) + ); + + Set baseFields = roleFields.getOrDefault(role, Set.of()); + + // Apply purpose-based restrictions + return applyPurposeRestrictions(baseFields, role, purpose); + } + + /** + * Apply purpose-based field restrictions + */ + private Set applyPurposeRestrictions(Set baseFields, Role role, AccessPurpose purpose) { + if (purpose == AccessPurpose.TREATMENT) { + // For treatment, return all fields (no restrictions) + return baseFields; + } + + if (purpose == AccessPurpose.PAYMENT) { + // For payment, only return billing-related fields + return baseFields.stream() + .filter(field -> isPaymentField(field)) + .collect(Collectors.toSet()); + } + + if (purpose == AccessPurpose.OPERATIONS) { + // For operations, return operational fields (exclude sensitive clinical data) + return baseFields.stream() + .filter(field -> isOperationalField(field)) + .collect(Collectors.toSet()); + } + + // For other purposes, return base fields + return baseFields; + } + + private boolean isPaymentField(PhiField field) { + return field == PhiField.INSURANCE_PROVIDER || + field == PhiField.INSURANCE_POLICY_NUMBER || + field == PhiField.FIRST_NAME || + field == PhiField.LAST_NAME || + field == PhiField.DATE_OF_BIRTH || + field == PhiField.APPOINTMENT_ID || + field == PhiField.APPOINTMENT_DATE; + } + + private boolean isOperationalField(PhiField field) { + return field != PhiField.DIAGNOSIS && + field != PhiField.TREATMENT_PLAN && + field != PhiField.NOTES && + field != PhiField.CHIEF_COMPLAINT; + } + + /** + * Validate purpose-based access + */ + private AccessResult validatePurposeAccess(Role role, AccessPurpose purpose, UUID patientId) { + // Treatment access is always allowed for doctors + if (purpose == AccessPurpose.TREATMENT && (role == Role.DOCTOR || role == Role.ADMIN)) { + return AccessResult.allowed("Treatment access granted"); + } + + // Payment access requires proper authorization + if (purpose == AccessPurpose.PAYMENT) { + // In production, check if user has payment authorization + return AccessResult.allowed("Payment access granted"); + } + + // Operations access for admins and doctors + if (purpose == AccessPurpose.OPERATIONS && (role == Role.ADMIN || role == Role.DOCTOR)) { + return AccessResult.allowed("Operations access granted"); + } + + // Authorization access requires explicit patient authorization + if (purpose == AccessPurpose.AUTHORIZATION) { + // In production, check for explicit authorization + return AccessResult.allowed("Authorization access granted"); + } + + return AccessResult.allowed("Purpose access granted"); + } + + /** + * Check if a user can access a specific patient's data (simplified version) + */ + @Transactional(readOnly = true) + public boolean canAccessPatientData(String userEmail, UUID patientUserId) { + if (!minimumNecessaryEnabled) { + return true; + } + + try { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + Role role = user.getRole(); + + // Patients can only access their own data + if (role == Role.PATIENT) { + return user.getId().equals(patientUserId); + } + + // Doctors and Admins can access patient data + return role == Role.DOCTOR || role == Role.ADMIN; + } catch (Exception e) { + log.error("Error checking patient data access: {}", e.getMessage(), e); + return false; + } + } + + /** + * Check if user has access to specific PHI fields (simplified version for backward compatibility) + */ + @Transactional(readOnly = true) + public boolean hasAccessToPhiFields(String userEmail, Set phiFields) { + if (!minimumNecessaryEnabled) { + return true; + } + + try { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + Role role = user.getRole(); + Set allowedFields = getAllowedPhiFieldsForRole(role); + + for (String field : phiFields) { + if (!allowedFields.contains(field) && !allowedFields.contains("ALL")) { + log.warn("User {} with role {} attempted to access restricted PHI field: {}", + userEmail, role, field); + return false; + } + } + + return true; + } catch (Exception e) { + log.error("Error checking PHI field access: {}", e.getMessage(), e); + return false; + } + } + + private Set getAllowedPhiFieldsForRole(Role role) { + return switch (role) { + case PATIENT -> Set.of("ALL"); + case DOCTOR -> Set.of("ALL", "MEDICAL_RECORD", "VITAL_SIGNS", "LAB_RESULT", + "PRESCRIPTION", "APPOINTMENT", "PATIENT_INFO", "EMERGENCY_CONTACT", + "ALLERGIES", "MEDICAL_HISTORY"); + case ADMIN -> Set.of("ALL"); + }; + } + + /** + * Log violation and audit + */ + private void logViolationAndAudit(String userEmail, UUID patientId, Set deniedFields, + AccessPurpose purpose, String reason, HttpServletRequest request) { + log.warn("HIPAA Minimum Necessary Violation - User: {}, Patient: {}, Denied Fields: {}, Purpose: {}, Reason: {}", + userEmail, patientId, deniedFields, purpose, reason); + + try { + hipaaAuditService.logAccess( + userEmail, + ActionType.VIEW, + "MINIMUM_NECESSARY_VIOLATION", + UUID.randomUUID(), + patientId, + Map.of( + "violation", true, + "deniedFields", deniedFields.stream().map(Enum::name).collect(Collectors.toList()), + "purpose", purpose.name(), + "reason", reason + ), + false, + reason, + request + ); + } catch (Exception e) { + log.error("Failed to log minimum necessary violation: {}", e.getMessage()); + } + } + + /** + * Log successful access + */ + private void logAccess(String userEmail, UUID patientId, Set accessedFields, + AccessPurpose purpose, HttpServletRequest request) { + try { + hipaaAuditService.logAccess( + userEmail, + ActionType.VIEW, + "PHI_ACCESS", + UUID.randomUUID(), + patientId, + Map.of( + "accessedFields", accessedFields.stream().map(Enum::name).collect(Collectors.toList()), + "purpose", purpose.name(), + "minimumNecessary", true + ), + true, + null, + request + ); + } catch (Exception e) { + log.error("Failed to log PHI access: {}", e.getMessage()); + } + } + + /** + * Log a minimum necessary violation (backward compatibility) + */ + public void logViolation(String userEmail, Set attemptedFields, String reason) { + log.warn("HIPAA Minimum Necessary Violation - User: {}, Attempted Fields: {}, Reason: {}", + userEmail, attemptedFields, reason); + } + + /** + * Access result class + */ + public static class AccessResult { + private final boolean allowed; + private final String reason; + private final Set allowedFields; + private final Set deniedFields; + + private AccessResult(boolean allowed, String reason, Set allowedFields, Set deniedFields) { + this.allowed = allowed; + this.reason = reason; + this.allowedFields = allowedFields != null ? new HashSet<>(allowedFields) : new HashSet<>(); + this.deniedFields = deniedFields != null ? new HashSet<>(deniedFields) : new HashSet<>(); + } + + public static AccessResult allowed(String reason) { + return new AccessResult(true, reason, null, null); + } + + public static AccessResult allowed(String reason, Set allowedFields) { + return new AccessResult(true, reason, allowedFields, null); + } + + public static AccessResult denied(String reason) { + return new AccessResult(false, reason, null, null); + } + + public static AccessResult denied(String reason, Set allowedFields, Set deniedFields) { + return new AccessResult(false, reason, allowedFields, deniedFields); + } + + public boolean isAllowed() { + return allowed; + } + + public String getReason() { + return reason; + } + + public Set getAllowedFields() { + return Collections.unmodifiableSet(allowedFields); + } + + public Set getDeniedFields() { + return Collections.unmodifiableSet(deniedFields); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/PasswordPolicyService.java b/src/main/java/com/gnx/telemedicine/service/PasswordPolicyService.java new file mode 100644 index 0000000..4392db5 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/PasswordPolicyService.java @@ -0,0 +1,186 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.exception.ConflictException; +import com.gnx.telemedicine.exception.ResourceNotFoundException; +import com.gnx.telemedicine.model.PasswordHistory; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.PasswordHistoryRepository; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +/** + * Service for managing password policies including history and expiration. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class PasswordPolicyService { + + private final PasswordHistoryRepository passwordHistoryRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Value("${security.password.history-count:5}") + private int defaultHistoryCount; + + @Value("${security.password.expiration-days:90}") + private int defaultExpirationDays; + + @Value("${security.password.enforce-history:true}") + private boolean enforceHistory; + + @Value("${security.password.enforce-expiration:true}") + private boolean enforceExpiration; + + /** + * Check if password has been used recently (in password history). + */ + public boolean isPasswordInHistory(UserModel user, String newPassword) { + if (!enforceHistory) { + return false; + } + + // Check if new password matches current password + if (user.getPassword() != null && passwordEncoder.matches(newPassword, user.getPassword())) { + return true; // New password matches current password + } + + // Check against password history + List history = passwordHistoryRepository.findRecentPasswordsByUser(user); + int historyCount = user.getPasswordHistoryCount() != null ? user.getPasswordHistoryCount() : defaultHistoryCount; + + // Only check the last N passwords + int checkCount = Math.min(historyCount, history.size()); + for (int i = 0; i < checkCount; i++) { + PasswordHistory entry = history.get(i); + if (passwordEncoder.matches(newPassword, entry.getPasswordHash())) { + return true; + } + } + + return false; + } + + /** + * Save password to history. + */ + @Transactional(rollbackFor = {Exception.class}) + public void savePasswordToHistory(UserModel user, String oldPasswordHash) { + // Save current password to history + PasswordHistory historyEntry = PasswordHistory.builder() + .user(user) + .passwordHash(oldPasswordHash) + .createdAt(Instant.now()) + .build(); + + passwordHistoryRepository.save(historyEntry); + + // Clean up old history entries (keep only last N passwords) + int historyCount = user.getPasswordHistoryCount() != null ? user.getPasswordHistoryCount() : defaultHistoryCount; + List allHistory = passwordHistoryRepository.findRecentPasswordsByUser(user); + + if (allHistory.size() > historyCount) { + // Delete oldest entries + List toDelete = allHistory.subList(historyCount, allHistory.size()); + passwordHistoryRepository.deleteAll(toDelete); + log.debug("Cleaned up {} old password history entries for user: {}", toDelete.size(), user.getEmail()); + } + } + + /** + * Update password with history tracking and expiration. + */ + @Transactional(rollbackFor = {Exception.class}) + public void updatePassword(UserModel user, String newPassword) { + // Save old password to history + if (user.getPassword() != null) { + savePasswordToHistory(user, user.getPassword()); + } + + // Update password + String newPasswordHash = passwordEncoder.encode(newPassword); + user.setPassword(newPasswordHash); + + // Update password change timestamp + Instant now = Instant.now(); + user.setPasswordChangedAt(now); + + // Calculate expiration date + int expirationDays = user.getPasswordExpirationDays() != null ? user.getPasswordExpirationDays() : defaultExpirationDays; + user.setPasswordExpiresAt(now.plusSeconds(expirationDays * 24L * 60L * 60L)); + + userRepository.save(user); + + log.debug("Password updated for user: {} - Expires at: {}", user.getEmail(), user.getPasswordExpiresAt()); + } + + /** + * Check if password is expired. + */ + public boolean isPasswordExpired(UserModel user) { + if (!enforceExpiration) { + return false; + } + + if (user.getPasswordExpiresAt() == null) { + // If expiration is not set, calculate it from password_changed_at + if (user.getPasswordChangedAt() != null) { + int expirationDays = user.getPasswordExpirationDays() != null ? user.getPasswordExpirationDays() : defaultExpirationDays; + Instant expirationDate = user.getPasswordChangedAt().plusSeconds(expirationDays * 24L * 60L * 60L); + return expirationDate.isBefore(Instant.now()); + } + return false; // No expiration set and no change date + } + + return user.getPasswordExpiresAt().isBefore(Instant.now()); + } + + /** + * Get days until password expires. + */ + public long getDaysUntilExpiration(UserModel user) { + if (user.getPasswordExpiresAt() == null) { + return -1; // No expiration set + } + + Instant now = Instant.now(); + if (user.getPasswordExpiresAt().isBefore(now)) { + return 0; // Already expired + } + + long secondsUntilExpiration = user.getPasswordExpiresAt().getEpochSecond() - now.getEpochSecond(); + return secondsUntilExpiration / (24 * 60 * 60); + } + + /** + * Validate password against policy (history and complexity). + */ + public void validatePasswordPolicy(UserModel user, String newPassword) { + // Check if password is in history + if (isPasswordInHistory(user, newPassword)) { + int historyCount = user.getPasswordHistoryCount() != null ? user.getPasswordHistoryCount() : defaultHistoryCount; + throw new ConflictException( + String.format("Password has been used recently. Please choose a different password. You cannot reuse your last %d passwords.", historyCount) + ); + } + } + + /** + * Clean up old password history entries (older than specified days). + */ + @Transactional(rollbackFor = {Exception.class}) + public void cleanupOldPasswordHistory(int daysToKeep) { + Instant cutoffDate = Instant.now().minusSeconds(daysToKeep * 24L * 60L * 60L); + passwordHistoryRepository.deleteOldPasswordHistory(cutoffDate); + log.info("Cleaned up password history entries older than {} days", daysToKeep); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/PhiAccessLogService.java b/src/main/java/com/gnx/telemedicine/service/PhiAccessLogService.java new file mode 100644 index 0000000..6fcebf8 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/PhiAccessLogService.java @@ -0,0 +1,96 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.PhiAccessLog; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.PhiAccessLogRepository; +import com.gnx.telemedicine.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PhiAccessLogService { + + private final PhiAccessLogRepository phiAccessLogRepository; + private final UserRepository userRepository; + private final PatientRepository patientRepository; + + @Transactional + public void logPhiAccess(String userEmail, UUID patientId, String accessType, List accessedFields, String purpose, HttpServletRequest request) { + try { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + PhiAccessLog accessLog = new PhiAccessLog(); + accessLog.setUser(user); + accessLog.setPatient(patient); + accessLog.setAccessType(accessType); + accessLog.setAccessedFields(accessedFields); + accessLog.setPurpose(purpose); + accessLog.setTimestamp(Instant.now()); + + // Extract IP address and user agent from request + if (request != null) { + String ipAddress = getClientIpAddress(request); + accessLog.setIpAddress(ipAddress); + accessLog.setUserAgent(request.getHeader("User-Agent")); + } + + phiAccessLogRepository.save(accessLog); + } catch (Exception e) { + // Log error but don't throw - audit logging should not break the main flow + log.error("Failed to create PHI access log: {}", e.getMessage(), e); + } + } + + @Transactional(readOnly = true) + public List getPhiAccessLogsByPatientId(UUID patientId) { + return phiAccessLogRepository.findByPatientIdOrderByTimestampDesc(patientId); + } + + @Transactional(readOnly = true) + public List getPhiAccessLogsByUserId(UUID userId) { + return phiAccessLogRepository.findByUserIdOrderByTimestampDesc(userId); + } + + @Transactional(readOnly = true) + public List getPhiAccessLogsByPatientIdAndDateRange(UUID patientId, Instant startDate, Instant endDate) { + return phiAccessLogRepository.findByPatientIdAndDateRange(patientId, startDate, endDate); + } + + @Transactional(readOnly = true) + public Long countDistinctUsersAccessingPatient(UUID patientId, Instant startDate) { + return phiAccessLogRepository.countDistinctUsersAccessingPatient(patientId, startDate); + } + + private String getClientIpAddress(HttpServletRequest request) { + String ipAddress = request.getHeader("X-Forwarded-For"); + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getHeader("Proxy-Client-IP"); + } + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getHeader("WL-Proxy-Client-IP"); + } + if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) { + ipAddress = request.getRemoteAddr(); + } + if (ipAddress != null && ipAddress.contains(",")) { + ipAddress = ipAddress.split(",")[0].trim(); + } + return ipAddress; + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/PrescriptionService.java b/src/main/java/com/gnx/telemedicine/service/PrescriptionService.java new file mode 100644 index 0000000..6265dbb --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/PrescriptionService.java @@ -0,0 +1,266 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.prescription.PrescriptionRequestDto; +import com.gnx.telemedicine.dto.prescription.PrescriptionResponseDto; +import com.gnx.telemedicine.mappers.PrescriptionMapper; +import com.gnx.telemedicine.model.Appointment; +import com.gnx.telemedicine.model.Prescription; +import com.gnx.telemedicine.model.enums.PrescriptionStatus; +import com.gnx.telemedicine.repository.AppointmentRepository; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.PrescriptionRepository; +import com.gnx.telemedicine.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PrescriptionService { + + private final PrescriptionRepository prescriptionRepository; + private final PrescriptionMapper prescriptionMapper; + private final PatientRepository patientRepository; + private final DoctorRepository doctorRepository; + private final AppointmentRepository appointmentRepository; + private final UserRepository userRepository; + private final ClinicalAlertService clinicalAlertService; + + @Transactional(readOnly = true) + public List getPrescriptionsByPatientId(UUID patientId) { + return prescriptionRepository.findByPatientIdOrderByCreatedAtDesc(patientId) + .stream() + .map(prescriptionMapper::toPrescriptionResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getPrescriptionsByPatientIdAndStatus(UUID patientId, PrescriptionStatus status) { + return prescriptionRepository.findByPatientIdAndStatusOrderByCreatedAtDesc(patientId, status) + .stream() + .map(prescriptionMapper::toPrescriptionResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getActivePrescriptionsByPatientId(UUID patientId) { + return prescriptionRepository.findActivePrescriptionsByPatientId(patientId, LocalDate.now()) + .stream() + .map(prescriptionMapper::toPrescriptionResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getPrescriptionsByDoctorId(UUID doctorId) { + return prescriptionRepository.findByDoctorIdOrderByCreatedAtDesc(doctorId) + .stream() + .map(prescriptionMapper::toPrescriptionResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getPrescriptionsByAppointmentId(UUID appointmentId) { + return prescriptionRepository.findByAppointmentId(appointmentId) + .stream() + .map(prescriptionMapper::toPrescriptionResponseDto) + .toList(); + } + + @Transactional(rollbackFor = {Exception.class}) + public PrescriptionResponseDto createPrescription(String userEmail, PrescriptionRequestDto requestDto) { + // Get current user (creator) + var currentUser = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Validate user is a doctor + if (!currentUser.getRole().name().equals("DOCTOR")) { + throw new IllegalArgumentException("Only doctors can create prescriptions"); + } + + // Get the doctor profile for current user + var currentDoctor = doctorRepository.findByUser(currentUser) + .orElseThrow(() -> new IllegalArgumentException("Doctor profile not found for current user")); + + // Validate and get patient + var patient = patientRepository.findById(requestDto.patientId()) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + // Validate that the doctor in request matches the authenticated doctor + // Doctors can only create prescriptions for themselves + if (!currentDoctor.getId().equals(requestDto.doctorId())) { + throw new IllegalArgumentException("Doctors can only create prescriptions for themselves"); + } + + var doctor = currentDoctor; + + // Validate appointment if provided + Appointment appointment = null; + if (requestDto.appointmentId() != null) { + appointment = appointmentRepository.findById(requestDto.appointmentId()) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + } + + // Create prescription + Prescription prescription = prescriptionMapper.toPrescription(requestDto); + // Don't set ID manually - let Hibernate generate it via @GeneratedValue + prescription.setPatient(patient); + prescription.setDoctor(doctor); + prescription.setAppointment(appointment); + prescription.setCreatedBy(currentUser); + + // Generate unique prescription number + prescription.setPrescriptionNumber(generatePrescriptionNumber()); + + // Set default values + if (prescription.getRefills() == null) { + prescription.setRefills(0); + } + if (prescription.getStatus() == null) { + prescription.setStatus(PrescriptionStatus.ACTIVE); + } + if (prescription.getEPrescriptionSent() == null) { + prescription.setEPrescriptionSent(false); + } + + Prescription saved = prescriptionRepository.save(prescription); + + // Automatically check for safety issues after prescription creation + try { + // Check for drug interactions with existing active prescriptions + clinicalAlertService.checkForDrugInteractions(patient.getId()); + + // Check for allergy conflicts with the new medication + if (requestDto.medicationName() != null && !requestDto.medicationName().trim().isEmpty()) { + clinicalAlertService.checkForAllergyConflicts(patient.getId(), requestDto.medicationName()); + } + } catch (Exception e) { + // Log error but don't fail prescription creation if safety check fails + // This ensures prescription creation succeeds even if safety checks have issues + log.warn("Warning: Safety check failed for prescription: {}", e.getMessage(), e); + } + + return prescriptionMapper.toPrescriptionResponseDto(saved); + } + + @Transactional(readOnly = true) + public PrescriptionResponseDto getPrescriptionById(UUID id) { + Prescription prescription = prescriptionRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Prescription not found")); + return prescriptionMapper.toPrescriptionResponseDto(prescription); + } + + @Transactional(readOnly = true) + public List getPrescriptionsByPatientIdAndDoctorId(UUID patientId, UUID doctorId) { + return prescriptionRepository.findByPatientIdAndDoctorId(patientId, doctorId) + .stream() + .map(prescriptionMapper::toPrescriptionResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public PrescriptionResponseDto getPrescriptionByNumber(String prescriptionNumber) { + Prescription prescription = prescriptionRepository.findByPrescriptionNumber(prescriptionNumber) + .orElseThrow(() -> new IllegalArgumentException("Prescription not found")); + return prescriptionMapper.toPrescriptionResponseDto(prescription); + } + + @Transactional(rollbackFor = {Exception.class}) + public PrescriptionResponseDto updatePrescriptionStatus(String userEmail, UUID id, PrescriptionStatus status) { + Prescription prescription = prescriptionRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Prescription not found")); + + prescription.setStatus(status); + + // If discontinuing, set end date to today if not already set + if (status == PrescriptionStatus.DISCONTINUED && prescription.getEndDate() == null) { + prescription.setEndDate(LocalDate.now()); + } + + Prescription saved = prescriptionRepository.save(prescription); + return prescriptionMapper.toPrescriptionResponseDto(saved); + } + + @Transactional + public PrescriptionResponseDto markEPrescriptionSent(String userEmail, UUID id) { + Prescription prescription = prescriptionRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Prescription not found")); + + prescription.setEPrescriptionSent(true); + prescription.setEPrescriptionSentAt(Instant.now()); + + Prescription saved = prescriptionRepository.save(prescription); + return prescriptionMapper.toPrescriptionResponseDto(saved); + } + + @Transactional(rollbackFor = {Exception.class}) + public PrescriptionResponseDto updatePrescription(String userEmail, UUID id, PrescriptionRequestDto requestDto) { + Prescription prescription = prescriptionRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Prescription not found")); + + // Update fields + prescription.setMedicationName(requestDto.medicationName()); + prescription.setMedicationCode(requestDto.medicationCode()); + prescription.setDosage(requestDto.dosage()); + prescription.setFrequency(requestDto.frequency()); + prescription.setQuantity(requestDto.quantity()); + if (requestDto.refills() != null) { + prescription.setRefills(requestDto.refills()); + } + prescription.setInstructions(requestDto.instructions()); + prescription.setStartDate(requestDto.startDate()); + prescription.setEndDate(requestDto.endDate()); + if (requestDto.status() != null) { + prescription.setStatus(requestDto.status()); + } + prescription.setPharmacyName(requestDto.pharmacyName()); + prescription.setPharmacyAddress(requestDto.pharmacyAddress()); + prescription.setPharmacyPhone(requestDto.pharmacyPhone()); + + Prescription saved = prescriptionRepository.save(prescription); + + // Automatically check for safety issues after prescription update + // Check if medication name changed or status is ACTIVE + String oldMedicationName = prescription.getMedicationName(); + String newMedicationName = requestDto.medicationName(); + boolean medicationChanged = newMedicationName != null && + !newMedicationName.trim().isEmpty() && + !newMedicationName.equals(oldMedicationName); + boolean isActive = requestDto.status() == null || requestDto.status() == PrescriptionStatus.ACTIVE; + + if (isActive && (medicationChanged || newMedicationName != null)) { + try { + // Check for drug interactions with existing active prescriptions + clinicalAlertService.checkForDrugInteractions(saved.getPatient().getId()); + + // Check for allergy conflicts if medication name was provided + if (newMedicationName != null && !newMedicationName.trim().isEmpty()) { + clinicalAlertService.checkForAllergyConflicts(saved.getPatient().getId(), newMedicationName); + } + } catch (Exception e) { + // Log error but don't fail prescription update if safety check fails + log.warn("Warning: Safety check failed for prescription update: {}", e.getMessage(), e); + } + } + + return prescriptionMapper.toPrescriptionResponseDto(saved); + } + + @Transactional(rollbackFor = {Exception.class}) + public void deletePrescription(UUID id) { + prescriptionRepository.deleteById(id); + } + + private String generatePrescriptionNumber() { + // Generate unique prescription number: PRE + timestamp + random + return "PRE-" + System.currentTimeMillis() + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/RateLimitingService.java b/src/main/java/com/gnx/telemedicine/service/RateLimitingService.java new file mode 100644 index 0000000..5fbe396 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/RateLimitingService.java @@ -0,0 +1,198 @@ +package com.gnx.telemedicine.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@Slf4j +public class RateLimitingService { + + @Value("${rate-limit.requests-per-minute:60}") + private int requestsPerMinute = 60; + + @Value("${rate-limit.requests-per-hour:1000}") + private int requestsPerHour = 1000; + + @Value("${rate-limit.login-attempts-per-minute:5}") + private int loginAttemptsPerMinute = 5; + + // Store request counts: key = "userIdentifier:endpoint", value = RequestWindow + private final Map requestCounts = new ConcurrentHashMap<>(); + + public boolean isAllowed(String userIdentifier, String endpoint, String method) { + return checkRateLimit(userIdentifier, endpoint, method).isAllowed(); + } + + public RateLimitResult checkRateLimit(String userIdentifier, String endpoint, String method) { + String key = userIdentifier + ":" + endpoint + ":" + method; + Instant now = Instant.now(); + + // Special rate limiting for login endpoint + if (endpoint.contains("/auth/login") || endpoint.contains("/login")) { + return checkLoginRateLimit(key, now); + } + + return checkGeneralRateLimit(key, now); + } + + private RateLimitResult checkLoginRateLimit(String key, Instant now) { + RequestWindow window = requestCounts.computeIfAbsent(key, k -> new RequestWindow()); + + int currentCount = window.getMinuteCount(now); + int limit = loginAttemptsPerMinute; + int remaining = Math.max(0, limit - currentCount); + long resetTime = ((now.getEpochSecond() / 60) + 1) * 60; // Next minute + + // Check per-minute limit for login + if (currentCount >= limit) { + log.warn("Rate limit exceeded for login: {}", key); + return new RateLimitResult(false, limit, 0, resetTime); + } + + window.recordRequest(now); + return new RateLimitResult(true, limit, remaining - 1, resetTime); + } + + private RateLimitResult checkGeneralRateLimit(String key, Instant now) { + RequestWindow window = requestCounts.computeIfAbsent(key, k -> new RequestWindow()); + + int minuteCount = window.getMinuteCount(now); + int hourCount = window.getHourCount(now); + + // Determine which limit applies (the stricter one) + int limit; + int currentCount; + long resetTime; + + if (minuteCount >= requestsPerMinute) { + limit = requestsPerMinute; + currentCount = minuteCount; + resetTime = ((now.getEpochSecond() / 60) + 1) * 60; // Next minute + } else if (hourCount >= requestsPerHour) { + limit = requestsPerHour; + currentCount = hourCount; + resetTime = ((now.getEpochSecond() / 3600) + 1) * 3600; // Next hour + } else { + // Use per-minute limit as primary + limit = requestsPerMinute; + currentCount = minuteCount; + resetTime = ((now.getEpochSecond() / 60) + 1) * 60; // Next minute + } + + int remaining = Math.max(0, limit - currentCount); + + // Check per-minute limit + if (minuteCount >= requestsPerMinute) { + log.warn("Rate limit exceeded (per minute): {}", key); + return new RateLimitResult(false, requestsPerMinute, 0, resetTime); + } + + // Check per-hour limit + if (hourCount >= requestsPerHour) { + log.warn("Rate limit exceeded (per hour): {}", key); + resetTime = ((now.getEpochSecond() / 3600) + 1) * 3600; + return new RateLimitResult(false, requestsPerHour, 0, resetTime); + } + + window.recordRequest(now); + // Recalculate remaining after recording + remaining = Math.max(0, limit - currentCount - 1); + return new RateLimitResult(true, limit, remaining, resetTime); + } + + public void clearRateLimit(String userIdentifier, String endpoint) { + String key = userIdentifier + ":" + endpoint; + requestCounts.remove(key); + } + + // Inner class to track request windows + private static class RequestWindow { + private long currentMinuteWindow; + private long currentHourWindow; + private int minuteCount; + private int hourCount; + + public RequestWindow() { + Instant now = Instant.now(); + currentMinuteWindow = now.getEpochSecond() / 60; + currentHourWindow = now.getEpochSecond() / 3600; + minuteCount = 0; + hourCount = 0; + } + + public void recordRequest(Instant now) { + long minuteWindow = now.getEpochSecond() / 60; + long hourWindow = now.getEpochSecond() / 3600; + + // Reset minute count if window changed + if (minuteWindow != currentMinuteWindow) { + currentMinuteWindow = minuteWindow; + minuteCount = 0; + } + + // Reset hour count if window changed + if (hourWindow != currentHourWindow) { + currentHourWindow = hourWindow; + hourCount = 0; + } + + minuteCount++; + hourCount++; + } + + public int getMinuteCount(Instant now) { + long minuteWindow = now.getEpochSecond() / 60; + if (minuteWindow != currentMinuteWindow) { + return 0; // New window, count is 0 + } + return minuteCount; + } + + public int getHourCount(Instant now) { + long hourWindow = now.getEpochSecond() / 3600; + if (hourWindow != currentHourWindow) { + return 0; // New window, count is 0 + } + return hourCount; + } + } + + /** + * Result of rate limit check. + */ + public static class RateLimitResult { + private final boolean allowed; + private final int limit; + private final int remaining; + private final long resetTime; + + public RateLimitResult(boolean allowed, int limit, int remaining, long resetTime) { + this.allowed = allowed; + this.limit = limit; + this.remaining = remaining; + this.resetTime = resetTime; + } + + public boolean isAllowed() { + return allowed; + } + + public int getLimit() { + return limit; + } + + public int getRemaining() { + return remaining; + } + + public long getResetTime() { + return resetTime; + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/SentinelEventReportingService.java b/src/main/java/com/gnx/telemedicine/service/SentinelEventReportingService.java new file mode 100644 index 0000000..50a0153 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/SentinelEventReportingService.java @@ -0,0 +1,178 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.patient_safety.SentinelEventRequestDto; +import com.gnx.telemedicine.dto.patient_safety.SentinelEventResponseDto; +import com.gnx.telemedicine.dto.patient_safety.SentinelEventUpdateRequestDto; +import com.gnx.telemedicine.mappers.SentinelEventMapper; +import com.gnx.telemedicine.model.*; +import com.gnx.telemedicine.model.enums.SentinelEventStatus; +import com.gnx.telemedicine.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SentinelEventReportingService { + + private final SentinelEventRepository sentinelEventRepository; + private final SentinelEventMapper sentinelEventMapper; + private final PatientRepository patientRepository; + private final DoctorRepository doctorRepository; + private final AppointmentRepository appointmentRepository; + private final UserRepository userRepository; + private final EmailNotificationService emailNotificationService; + + @Transactional(readOnly = true) + public List getAllSentinelEvents() { + return sentinelEventRepository.findAll() + .stream() + .map(sentinelEventMapper::toSentinelEventResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getSentinelEventsByStatus(SentinelEventStatus status) { + return sentinelEventRepository.findByStatusOrderByOccurredAtDesc(status) + .stream() + .map(sentinelEventMapper::toSentinelEventResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getActiveSentinelEvents() { + return sentinelEventRepository.findAllActiveIncidents() + .stream() + .map(sentinelEventMapper::toSentinelEventResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public List getSentinelEventsByTimeRange(Instant startDate, Instant endDate) { + return sentinelEventRepository.findByOccurredAtBetween(startDate, endDate) + .stream() + .map(sentinelEventMapper::toSentinelEventResponseDto) + .toList(); + } + + @Transactional + public SentinelEventResponseDto reportSentinelEvent(String userEmail, SentinelEventRequestDto requestDto) { + UserModel reportingUser = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + SentinelEvent event = sentinelEventMapper.toSentinelEvent(requestDto); + event.setReportedBy(reportingUser); + event.setStatus(SentinelEventStatus.REPORTED); + + if (requestDto.patientId() != null) { + Patient patient = patientRepository.findById(requestDto.patientId()) + .orElse(null); + event.setPatient(patient); + } + + if (requestDto.doctorId() != null) { + Doctor doctor = doctorRepository.findById(requestDto.doctorId()) + .orElse(null); + event.setDoctor(doctor); + } + + if (requestDto.appointmentId() != null) { + Appointment appointment = appointmentRepository.findById(requestDto.appointmentId()) + .orElse(null); + event.setAppointment(appointment); + } + + SentinelEvent saved = sentinelEventRepository.save(event); + log.info("Sentinel event {} reported by user {}", saved.getId(), userEmail); + + // Notify administrators + notifyAdministrators(saved); + + return sentinelEventMapper.toSentinelEventResponseDto(saved); + } + + @Transactional + public SentinelEventResponseDto updateSentinelEvent(String userEmail, UUID eventId, SentinelEventUpdateRequestDto requestDto) { + SentinelEvent event = sentinelEventRepository.findById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Sentinel event not found")); + + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + if (requestDto.status() != null) { + event.setStatus(requestDto.status()); + + if (requestDto.status() == SentinelEventStatus.RESOLVED || requestDto.status() == SentinelEventStatus.CLOSED) { + event.setResolvedAt(Instant.now()); + event.setResolvedBy(user); + } + } + + if (requestDto.investigationNotes() != null) { + event.setInvestigationNotes(requestDto.investigationNotes()); + } + + if (requestDto.rootCauseAnalysis() != null) { + event.setRootCauseAnalysis(requestDto.rootCauseAnalysis()); + } + + if (requestDto.correctiveAction() != null) { + event.setCorrectiveAction(requestDto.correctiveAction()); + } + + SentinelEvent saved = sentinelEventRepository.save(event); + log.info("Sentinel event {} updated by user {}", eventId, userEmail); + return sentinelEventMapper.toSentinelEventResponseDto(saved); + } + + @Transactional(readOnly = true) + public SentinelEventResponseDto getSentinelEventById(UUID id) { + SentinelEvent event = sentinelEventRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Sentinel event not found")); + return sentinelEventMapper.toSentinelEventResponseDto(event); + } + + public long countActiveIncidents() { + return sentinelEventRepository.countActiveIncidents(); + } + + private void notifyAdministrators(SentinelEvent event) { + try { + List admins = userRepository.findByRole(com.gnx.telemedicine.model.enums.Role.ADMIN); + + String subject = "SENTINEL EVENT REPORTED - " + event.getEventType(); + String message = String.format( + "A sentinel event has been reported and requires immediate attention.\n\n" + + "Event Type: %s\n" + + "Severity: %s\n" + + "Description: %s\n" + + "Location: %s\n" + + "Occurred: %s\n" + + "Reported By: %s %s\n\n" + + "Please log in to review and investigate this event.", + event.getEventType(), + event.getSeverity(), + event.getDescription(), + event.getLocation(), + event.getOccurredAt(), + event.getReportedBy().getFirstName(), + event.getReportedBy().getLastName() + ); + + for (UserModel admin : admins) { + emailNotificationService.sendEmail(admin.getEmail(), subject, message); + } + + log.info("Sentinel event notifications sent to {} administrators", admins.size()); + } catch (Exception e) { + log.error("Failed to send sentinel event notifications", e); + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/TwoFactorAuthService.java b/src/main/java/com/gnx/telemedicine/service/TwoFactorAuthService.java new file mode 100644 index 0000000..6b49724 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/TwoFactorAuthService.java @@ -0,0 +1,216 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.model.TwoFactorAuth; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.repository.TwoFactorAuthRepository; +import com.gnx.telemedicine.repository.UserRepository; +import dev.samstevens.totp.code.*; +import dev.samstevens.totp.exceptions.QrGenerationException; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.qr.QrGenerator; +import dev.samstevens.totp.qr.ZxingPngQrGenerator; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TwoFactorAuthService { + + private final TwoFactorAuthRepository twoFactorAuthRepository; + private final UserRepository userRepository; + private final SecretGenerator secretGenerator = new DefaultSecretGenerator(); + private final QrGenerator qrGenerator = new ZxingPngQrGenerator(); + private final TimeProvider timeProvider = new SystemTimeProvider(); + private final CodeGenerator codeGenerator = new DefaultCodeGenerator(); + private final CodeVerifier codeVerifier = new DefaultCodeVerifier(codeGenerator, timeProvider); + + @Transactional + public TwoFactorAuth setup2FA(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + TwoFactorAuth twoFactorAuth = twoFactorAuthRepository.findByUserId(user.getId()) + .orElse(new TwoFactorAuth()); + + if (twoFactorAuth.getSecretKey() == null) { + // Generate new secret key + String secret = secretGenerator.generate(); + twoFactorAuth.setSecretKey(secret); + + // Generate backup codes + List backupCodes = generateBackupCodes(); + twoFactorAuth.setBackupCodes(backupCodes); + + twoFactorAuth.setUser(user); + twoFactorAuth.setEnabled(false); + } + + twoFactorAuth = twoFactorAuthRepository.save(twoFactorAuth); + return twoFactorAuth; + } + + @Transactional + public void enable2FA(String userEmail, String code) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + TwoFactorAuth twoFactorAuth = twoFactorAuthRepository.findByUserId(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("2FA not set up. Please set up first.")); + + if (!verifyCode(twoFactorAuth.getSecretKey(), code)) { + throw new IllegalArgumentException("Invalid verification code"); + } + + twoFactorAuth.setEnabled(true); + twoFactorAuthRepository.save(twoFactorAuth); + log.info("2FA enabled for user: {}", userEmail); + } + + @Transactional + public void disable2FA(String userEmail, String code) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + TwoFactorAuth twoFactorAuth = twoFactorAuthRepository.findByUserId(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("2FA not enabled")); + + // Try TOTP code first, then backup code + boolean isValidCode = verifyCode(twoFactorAuth.getSecretKey(), code); + if (!isValidCode) { + // If TOTP code fails, try backup code + isValidCode = verifyBackupCode(userEmail, code); + } + + if (!isValidCode) { + throw new IllegalArgumentException("Invalid verification code"); + } + + twoFactorAuth.setEnabled(false); + twoFactorAuth.setBackupCodes(null); + twoFactorAuthRepository.save(twoFactorAuth); + log.info("2FA disabled for user: {}", userEmail); + } + + public boolean verifyCode(String secretKey, String code) { + if (code == null || code.isEmpty()) { + return false; + } + + // Try TOTP code + try { + if (codeVerifier.isValidCode(secretKey, code)) { + return true; + } + } catch (Exception e) { + log.debug("Error verifying TOTP code: {}", e.getMessage()); + } + + return false; + } + + public boolean verifyBackupCode(String userEmail, String code) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + TwoFactorAuth twoFactorAuth = twoFactorAuthRepository.findByUserId(user.getId()) + .orElse(null); + + if (twoFactorAuth == null || twoFactorAuth.getBackupCodes() == null) { + return false; + } + + List backupCodes = twoFactorAuth.getBackupCodes(); + if (backupCodes.contains(code)) { + // Remove used backup code + backupCodes.remove(code); + twoFactorAuth.setBackupCodes(backupCodes); + twoFactorAuthRepository.save(twoFactorAuth); + return true; + } + + return false; + } + + @Transactional + public List regenerateBackupCodes(String userEmail, String code) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + TwoFactorAuth twoFactorAuth = twoFactorAuthRepository.findByUserId(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("2FA not set up")); + + if (!verifyCode(twoFactorAuth.getSecretKey(), code)) { + throw new IllegalArgumentException("Invalid verification code"); + } + + List backupCodes = generateBackupCodes(); + twoFactorAuth.setBackupCodes(backupCodes); + twoFactorAuthRepository.save(twoFactorAuth); + return backupCodes; + } + + public boolean is2FAEnabled(String userEmail) { + return userRepository.findByEmail(userEmail) + .map(user -> twoFactorAuthRepository.findByUserId(user.getId()) + .map(TwoFactorAuth::getEnabled) + .orElse(false)) + .orElse(false); + } + + public String generateQrCodeUrl(String userEmail) { + UserModel user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + TwoFactorAuth twoFactorAuth = twoFactorAuthRepository.findByUserId(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("2FA not set up")); + + String issuer = "Telemedicine API"; + String accountName = user.getEmail(); + + QrData data = new QrData.Builder() + .label(accountName) + .secret(twoFactorAuth.getSecretKey()) + .issuer(issuer) + .algorithm(HashingAlgorithm.SHA256) + .digits(6) + .period(30) + .build(); + + try { + byte[] qrCodeBytes = qrGenerator.generate(data); + String base64 = java.util.Base64.getEncoder().encodeToString(qrCodeBytes); + return "data:image/png;base64," + base64; + } catch (QrGenerationException e) { + throw new RuntimeException("Failed to generate QR code", e); + } + } + + private List generateBackupCodes() { + List codes = new ArrayList<>(); + Random random = new Random(); + + for (int i = 0; i < 10; i++) { + // Generate 8-digit backup code + int code = 10000000 + random.nextInt(90000000); + codes.add(String.valueOf(code)); + } + + return codes; + } + + public Optional get2FASetup(String userEmail) { + return userRepository.findByEmail(userEmail) + .flatMap(user -> twoFactorAuthRepository.findByUserId(user.getId())); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/service/UserService.java b/src/main/java/com/gnx/telemedicine/service/UserService.java new file mode 100644 index 0000000..dbc5f4e --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/UserService.java @@ -0,0 +1,420 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.user.*; +import com.gnx.telemedicine.dto.admin.UserManagementDto; +import com.gnx.telemedicine.exception.DuplicateUserException; +import com.gnx.telemedicine.exception.PasswordMismatchException; +import com.gnx.telemedicine.mappers.UserMapper; +import com.gnx.telemedicine.model.Doctor; +import com.gnx.telemedicine.model.Patient; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.Role; +import com.gnx.telemedicine.repository.DoctorRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.util.InputSanitizer; +import com.gnx.telemedicine.util.PasswordValidator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserService { + + private final UserMapper userMapper; + + private final UserRepository userRepository; + private final PatientRepository patientRepository; + private final DoctorRepository doctorRepository; + + private final PasswordEncoder passwordEncoder; + private final PasswordValidator passwordValidator; + private final PasswordPolicyService passwordPolicyService; + + @Value("${spring.mail.username}") + private String adminEmail; + + + + public DoctorResponseDto registerDoctor(DoctorRegistrationDto doctorRegistrationDto) { + Doctor doctor = userMapper.toDoctor(doctorRegistrationDto); + UserModel savedUser = registerUser(doctorRegistrationDto.user(), Role.DOCTOR); + doctor.setUser(savedUser); + Doctor savedDoctor = doctorRepository.save(doctor); + return userMapper.toDoctorResponse(savedDoctor); + } + + public PatientResponseDto registerPatient(PatientRegistrationDto patientRegistrationDto) { + Patient patient = userMapper.toPatient(patientRegistrationDto); + UserModel savedUser = registerUser(patientRegistrationDto.user(), Role.PATIENT); + patient.setUser(savedUser); + Patient savedPatieht = patientRepository.save(patient); + return userMapper.toPatientResponse(savedPatieht); + } + + public void registerAdmin(UserRegistrationDto userRegistrationDto) { + if (!userRegistrationDto.email().equals(adminEmail)) { + throw new BadCredentialsException("Only admin can register"); + } + registerUser(userRegistrationDto, Role.ADMIN); + } + + //Helper methods + + @Transactional(rollbackFor = {Exception.class}) + private UserModel registerUser(UserRegistrationDto userRegistrationDto, Role role) { + UserModel userModel = userMapper.toUser(userRegistrationDto); + validateUser(userRegistrationDto); + userModel.setRole(role); + userModel.setIsActive(true); + + String plainPassword = userModel.getPassword(); + if (plainPassword == null) { + throw new IllegalArgumentException("Password cannot be null"); + } + + // Persist the user to obtain an ID while avoiding storing the raw password + String placeholderPassword = passwordEncoder.encode(java.util.UUID.randomUUID().toString()); + userModel.setPassword(placeholderPassword); + userModel = userRepository.save(userModel); + + // Clear placeholder so password history logic does not treat it as a real password + userModel.setPassword(null); + + passwordPolicyService.updatePassword(userModel, plainPassword); + + return userModel; + } + + private void validateUser(UserRegistrationDto userRegistrationDto) { + // Sanitize email + String email = InputSanitizer.sanitizeEmail(userRegistrationDto.email()); + if (email == null) { + throw new IllegalArgumentException("Invalid email format"); + } + + // Check for duplicate email + if (userRepository.existsByEmail(email)) { + throw new DuplicateUserException("User with email " + email + " already exists"); + } + + // Validate password + Optional password = Optional.ofNullable(userRegistrationDto.password().password()); + Optional confirmPassword = Optional.ofNullable(userRegistrationDto.password().confirmPassword()); + if (password.isEmpty() || confirmPassword.isEmpty()) { + throw new IllegalArgumentException("Password cannot be null"); + } + + String passwordValue = password.get(); + String confirmPasswordValue = confirmPassword.get(); + + // Check password match + if (!passwordValue.equals(confirmPasswordValue)) { + throw new PasswordMismatchException("Passwords do not match"); + } + + // Validate password complexity + PasswordValidator.ValidationResult passwordValidation = passwordValidator.validate(passwordValue); + if (!passwordValidation.isValid()) { + log.warn("Password validation failed for user {}: {}", email, passwordValidation.getMessage()); + throw new IllegalArgumentException("Password does not meet requirements: " + passwordValidation.getMessage()); + } + + // Check for SQL injection and XSS in email + if (InputSanitizer.containsSqlInjection(email) || InputSanitizer.containsXss(email)) { + log.warn("Potential security threat detected in email: {}", email); + throw new IllegalArgumentException("Invalid input detected"); + } + } + + @Cacheable(value = "userByEmail", key = "#email") + public UserManagementDto getCurrentUser(String email) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + return userMapper.toUserManagementDto(user); + } + + @Transactional + @CacheEvict(value = {"userByEmail", "userProfile"}, key = "#email") + public UserManagementDto updateUserProfile(String email, UserUpdateDto updateDto) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Sanitize and validate input + if (updateDto.firstName() != null && !updateDto.firstName().isBlank()) { + String sanitized = InputSanitizer.sanitizeText(updateDto.firstName()); + if (sanitized == null || sanitized.length() > 100) { + throw new IllegalArgumentException("Invalid first name"); + } + if (InputSanitizer.containsSqlInjection(sanitized) || InputSanitizer.containsXss(sanitized)) { + log.warn("Potential security threat detected in firstName for user: {}", email); + throw new IllegalArgumentException("Invalid input detected"); + } + user.setFirstName(sanitized); + } + if (updateDto.lastName() != null && !updateDto.lastName().isBlank()) { + String sanitized = InputSanitizer.sanitizeText(updateDto.lastName()); + if (sanitized == null || sanitized.length() > 100) { + throw new IllegalArgumentException("Invalid last name"); + } + if (InputSanitizer.containsSqlInjection(sanitized) || InputSanitizer.containsXss(sanitized)) { + log.warn("Potential security threat detected in lastName for user: {}", email); + throw new IllegalArgumentException("Invalid input detected"); + } + user.setLastName(sanitized); + } + if (updateDto.phoneNumber() != null && !updateDto.phoneNumber().isBlank()) { + String sanitized = InputSanitizer.sanitizePhone(updateDto.phoneNumber()); + if (sanitized == null || sanitized.length() > 20) { + throw new IllegalArgumentException("Invalid phone number"); + } + user.setPhoneNumber(sanitized); + } + + UserModel saved = userRepository.save(user); + return userMapper.toUserManagementDto(saved); + } + + @Transactional + public DoctorResponseDto updateDoctorProfile(String email, DoctorUpdateDto updateDto) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + Doctor doctor = doctorRepository.findByUser(user) + .orElseThrow(() -> new RuntimeException("Doctor profile not found")); + + if (updateDto.medicalLicenseNumber() != null && !updateDto.medicalLicenseNumber().isBlank()) { + doctor.setMedicalLicenseNumber(updateDto.medicalLicenseNumber()); + } + if (updateDto.specialization() != null && !updateDto.specialization().isBlank()) { + doctor.setSpecialization(updateDto.specialization()); + } + if (updateDto.yearsOfExperience() != null) { + doctor.setYearsOfExperience(updateDto.yearsOfExperience()); + } + if (updateDto.biography() != null) { + doctor.setBiography(updateDto.biography()); + } + if (updateDto.consultationFee() != null) { + doctor.setConsultationFee(updateDto.consultationFee()); + } + if (updateDto.defaultDurationMinutes() != null) { + doctor.setDefaultDurationMinutes(updateDto.defaultDurationMinutes()); + } + // Enterprise fields + if (updateDto.streetAddress() != null) { + doctor.setStreetAddress(updateDto.streetAddress()); + } + if (updateDto.city() != null) { + doctor.setCity(updateDto.city()); + } + if (updateDto.state() != null) { + doctor.setState(updateDto.state()); + } + if (updateDto.zipCode() != null) { + doctor.setZipCode(updateDto.zipCode()); + } + if (updateDto.country() != null) { + doctor.setCountry(updateDto.country()); + } + if (updateDto.educationDegree() != null) { + doctor.setEducationDegree(updateDto.educationDegree()); + } + if (updateDto.educationUniversity() != null) { + doctor.setEducationUniversity(updateDto.educationUniversity()); + } + if (updateDto.educationGraduationYear() != null) { + doctor.setEducationGraduationYear(updateDto.educationGraduationYear()); + } + if (updateDto.certifications() != null) { + doctor.setCertifications(updateDto.certifications()); + } + if (updateDto.languagesSpoken() != null) { + doctor.setLanguagesSpoken(updateDto.languagesSpoken()); + } + if (updateDto.hospitalAffiliations() != null) { + doctor.setHospitalAffiliations(updateDto.hospitalAffiliations()); + } + if (updateDto.insuranceAccepted() != null) { + doctor.setInsuranceAccepted(updateDto.insuranceAccepted()); + } + if (updateDto.professionalMemberships() != null) { + doctor.setProfessionalMemberships(updateDto.professionalMemberships()); + } + + Doctor saved = doctorRepository.save(doctor); + return userMapper.toDoctorResponse(saved); + } + + @Transactional + public PatientResponseDto updatePatientProfile(String email, PatientUpdateDto updateDto) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + Patient patient = patientRepository.findByUser(user) + .orElseThrow(() -> new RuntimeException("Patient profile not found")); + + if (updateDto.emergencyContactName() != null && !updateDto.emergencyContactName().isBlank()) { + patient.setEmergencyContactName(updateDto.emergencyContactName()); + } + if (updateDto.emergencyContactPhone() != null && !updateDto.emergencyContactPhone().isBlank()) { + patient.setEmergencyContactPhone(updateDto.emergencyContactPhone()); + } + if (updateDto.bloodType() != null && !updateDto.bloodType().isBlank()) { + patient.setBloodType(updateDto.bloodType()); + } + if (updateDto.allergies() != null) { + patient.setAllergies(updateDto.allergies()); + } + // Enterprise fields + if (updateDto.dateOfBirth() != null) { + patient.setDateOfBirth(updateDto.dateOfBirth()); + } + if (updateDto.gender() != null) { + patient.setGender(updateDto.gender()); + } + if (updateDto.streetAddress() != null) { + patient.setStreetAddress(updateDto.streetAddress()); + } + if (updateDto.city() != null) { + patient.setCity(updateDto.city()); + } + if (updateDto.state() != null) { + patient.setState(updateDto.state()); + } + if (updateDto.zipCode() != null) { + patient.setZipCode(updateDto.zipCode()); + } + if (updateDto.country() != null) { + patient.setCountry(updateDto.country()); + } + if (updateDto.insuranceProvider() != null) { + patient.setInsuranceProvider(updateDto.insuranceProvider()); + } + if (updateDto.insurancePolicyNumber() != null) { + patient.setInsurancePolicyNumber(updateDto.insurancePolicyNumber()); + } + if (updateDto.medicalHistorySummary() != null) { + patient.setMedicalHistorySummary(updateDto.medicalHistorySummary()); + } + if (updateDto.currentMedications() != null) { + patient.setCurrentMedications(updateDto.currentMedications()); + } + if (updateDto.primaryCarePhysicianName() != null) { + patient.setPrimaryCarePhysicianName(updateDto.primaryCarePhysicianName()); + } + if (updateDto.primaryCarePhysicianPhone() != null) { + patient.setPrimaryCarePhysicianPhone(updateDto.primaryCarePhysicianPhone()); + } + if (updateDto.preferredLanguage() != null) { + patient.setPreferredLanguage(updateDto.preferredLanguage()); + } + if (updateDto.occupation() != null) { + patient.setOccupation(updateDto.occupation()); + } + if (updateDto.maritalStatus() != null) { + patient.setMaritalStatus(updateDto.maritalStatus()); + } + + Patient saved = patientRepository.save(patient); + return userMapper.toPatientResponse(saved); + } + + @Transactional + public void changePassword(String email, String currentPassword, String newPassword, String confirmNewPassword) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Verify current password + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + log.warn("Password change failed - incorrect current password for user: {}", email); + throw new BadCredentialsException("Current password is incorrect"); + } + + // Check password match + if (!newPassword.equals(confirmNewPassword)) { + throw new PasswordMismatchException("New passwords do not match"); + } + + // Validate new password complexity + PasswordValidator.ValidationResult passwordValidation = passwordValidator.validate(newPassword); + if (!passwordValidation.isValid()) { + log.warn("Password change failed - validation failed for user {}: {}", email, passwordValidation.getMessage()); + throw new IllegalArgumentException("New password does not meet requirements: " + passwordValidation.getMessage()); + } + + // Check if new password is same as current password + if (passwordEncoder.matches(newPassword, user.getPassword())) { + throw new IllegalArgumentException("New password must be different from current password"); + } + + // Check password history (prevent reuse) + passwordPolicyService.validatePasswordPolicy(user, newPassword); + + // Update password with history tracking and expiration + passwordPolicyService.updatePassword(user, newPassword); + + log.info("Password changed successfully for user: {}", email); + } + + @Transactional(readOnly = true) + public DoctorResponseDto getCurrentDoctorProfile(String email) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + Doctor doctor = doctorRepository.findByUser(user) + .orElseThrow(() -> new RuntimeException("Doctor profile not found")); + + return userMapper.toDoctorResponse(doctor); + } + + @Transactional(readOnly = true) + public PatientResponseDto getCurrentPatientProfile(String email) { + UserModel user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + Patient patient = patientRepository.findByUser(user) + .orElseThrow(() -> new RuntimeException("Patient profile not found")); + + return userMapper.toPatientResponse(patient); + } + + @Transactional(readOnly = true) + public List getAllDoctors() { + return doctorRepository.findAllWithActiveUser().stream() + .map(userMapper::toDoctorResponse) + .toList(); + } + + @Transactional(readOnly = true) + public List getAllPatients() { + return patientRepository.findAllWithActiveUser().stream() + .map(userMapper::toPatientResponse) + .toList(); + } + + @Transactional(readOnly = true) + public DoctorResponseDto getDoctorProfileById(java.util.UUID doctorId) { + Doctor doctor = doctorRepository.findById(doctorId) + .orElseThrow(() -> new RuntimeException("Doctor not found")); + return userMapper.toDoctorResponse(doctor); + } + + @Transactional(readOnly = true) + public PatientResponseDto getPatientProfileById(java.util.UUID patientId) { + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new RuntimeException("Patient not found")); + return userMapper.toPatientResponse(patient); + } +} diff --git a/src/main/java/com/gnx/telemedicine/service/VitalSignsService.java b/src/main/java/com/gnx/telemedicine/service/VitalSignsService.java new file mode 100644 index 0000000..c65b9c7 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/service/VitalSignsService.java @@ -0,0 +1,144 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.medical.VitalSignsRequestDto; +import com.gnx.telemedicine.dto.medical.VitalSignsResponseDto; +import com.gnx.telemedicine.mappers.VitalSignsMapper; +import com.gnx.telemedicine.model.Appointment; +import com.gnx.telemedicine.model.VitalSigns; +import com.gnx.telemedicine.repository.AppointmentRepository; +import com.gnx.telemedicine.repository.PatientRepository; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.repository.VitalSignsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class VitalSignsService { + + private final VitalSignsRepository vitalSignsRepository; + private final VitalSignsMapper vitalSignsMapper; + private final PatientRepository patientRepository; + private final AppointmentRepository appointmentRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getVitalSignsByPatientId(UUID patientId) { + return vitalSignsRepository.findByPatientIdOrderByRecordedAtDesc(patientId) + .stream() + .map(vitalSignsMapper::toVitalSignsResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public VitalSignsResponseDto getLatestVitalSignsByPatientId(UUID patientId) { + VitalSigns vitalSigns = vitalSignsRepository.findLatestByPatientId(patientId); + if (vitalSigns == null) { + return null; // Return null instead of throwing exception - let controller handle 404 + } + return vitalSignsMapper.toVitalSignsResponseDto(vitalSigns); + } + + @Transactional(readOnly = true) + public List getVitalSignsByAppointmentId(UUID appointmentId) { + return vitalSignsRepository.findByAppointmentId(appointmentId) + .stream() + .map(vitalSignsMapper::toVitalSignsResponseDto) + .toList(); + } + + @Transactional + public VitalSignsResponseDto createVitalSigns(String userEmail, VitalSignsRequestDto requestDto) { + // Get current user (recorder) + var currentUser = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Validate and get patient + var patient = patientRepository.findById(requestDto.patientId()) + .orElseThrow(() -> new IllegalArgumentException("Patient not found")); + + // Validate appointment if provided + Appointment appointment = null; + if (requestDto.appointmentId() != null) { + appointment = appointmentRepository.findById(requestDto.appointmentId()) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found")); + } + + // Create vital signs + VitalSigns vitalSigns = vitalSignsMapper.toVitalSigns(requestDto); + // Don't set ID manually - let Hibernate generate it via @GeneratedValue + vitalSigns.setPatient(patient); + vitalSigns.setAppointment(appointment); + vitalSigns.setRecordedBy(currentUser); + vitalSigns.setRecordedAt(Instant.now()); + + // Calculate BMI if weight and height are provided + if (requestDto.weightKg() != null && requestDto.heightCm() != null) { + try { + BigDecimal heightM = requestDto.heightCm().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP); + if (heightM.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal bmi = requestDto.weightKg() + .divide(heightM.multiply(heightM), 1, RoundingMode.HALF_UP); + // Clamp BMI to column limits (precision 4, scale 1 => max 999.9) + if (bmi.compareTo(new BigDecimal("999.9")) > 0) { + bmi = new BigDecimal("999.9"); + } else if (bmi.compareTo(BigDecimal.ZERO) < 0) { + bmi = BigDecimal.ZERO; + } + vitalSigns.setBmi(bmi); + } + } catch (ArithmeticException ex) { + // Skip BMI if invalid inputs + vitalSigns.setBmi(null); + } + } + + // Normalize numeric fields to match DB precision/scale and reasonable ranges + if (vitalSigns.getTemperature() != null) { + BigDecimal t = vitalSigns.getTemperature().setScale(1, RoundingMode.HALF_UP); + // Acceptable human range roughly -50..60 C + if (t.compareTo(new BigDecimal("-50.0")) < 0) t = new BigDecimal("-50.0"); + if (t.compareTo(new BigDecimal("60.0")) > 0) t = new BigDecimal("60.0"); + vitalSigns.setTemperature(t); + } + if (vitalSigns.getOxygenSaturation() != null) { + BigDecimal o2 = vitalSigns.getOxygenSaturation().setScale(1, RoundingMode.HALF_UP); + if (o2.compareTo(BigDecimal.ZERO) < 0) o2 = BigDecimal.ZERO; + if (o2.compareTo(new BigDecimal("100.0")) > 0) o2 = new BigDecimal("100.0"); + vitalSigns.setOxygenSaturation(o2); + } + if (vitalSigns.getWeightKg() != null) { + BigDecimal w = vitalSigns.getWeightKg().setScale(2, RoundingMode.HALF_UP); + if (w.compareTo(BigDecimal.ZERO) < 0) w = BigDecimal.ZERO; + vitalSigns.setWeightKg(w); + } + if (vitalSigns.getHeightCm() != null) { + BigDecimal h = vitalSigns.getHeightCm().setScale(2, RoundingMode.HALF_UP); + if (h.compareTo(BigDecimal.ZERO) < 0) h = BigDecimal.ZERO; + vitalSigns.setHeightCm(h); + } + + VitalSigns saved = vitalSignsRepository.save(vitalSigns); + return vitalSignsMapper.toVitalSignsResponseDto(saved); + } + + @Transactional(readOnly = true) + public VitalSignsResponseDto getVitalSignsById(UUID id) { + VitalSigns vitalSigns = vitalSignsRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Vital signs not found")); + return vitalSignsMapper.toVitalSignsResponseDto(vitalSigns); + } + + @Transactional + public void deleteVitalSigns(UUID id) { + vitalSignsRepository.deleteById(id); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/util/CorrelationIdUtil.java b/src/main/java/com/gnx/telemedicine/util/CorrelationIdUtil.java new file mode 100644 index 0000000..45f32a5 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/util/CorrelationIdUtil.java @@ -0,0 +1,127 @@ +package com.gnx.telemedicine.util; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.util.UUID; + +/** + * Utility class for managing correlation IDs across the application. + * Uses MDC (Mapped Diagnostic Context) to store correlation IDs in the logging context. + */ +public class CorrelationIdUtil { + private static final String CORRELATION_ID_HEADER = "X-Correlation-ID"; + private static final String CORRELATION_ID_MDC_KEY = "correlationId"; + private static final String REQUEST_ID_MDC_KEY = "requestId"; + private static final String USER_ID_MDC_KEY = "userId"; + private static final String REQUEST_PATH_MDC_KEY = "requestPath"; + private static final String REQUEST_METHOD_MDC_KEY = "requestMethod"; + + /** + * Generate a new correlation ID. + */ + public static String generateCorrelationId() { + return UUID.randomUUID().toString(); + } + + /** + * Get correlation ID from request header or generate a new one. + */ + public static String getOrGenerateCorrelationId(HttpServletRequest request) { + String correlationId = request.getHeader(CORRELATION_ID_HEADER); + if (correlationId == null || correlationId.isEmpty()) { + correlationId = generateCorrelationId(); + } + return correlationId; + } + + /** + * Set correlation ID in MDC and response header. + */ + public static void setCorrelationId(String correlationId, HttpServletResponse response) { + MDC.put(CORRELATION_ID_MDC_KEY, correlationId); + response.setHeader(CORRELATION_ID_HEADER, correlationId); + } + + /** + * Set correlation ID in MDC. + */ + public static void setCorrelationId(String correlationId) { + MDC.put(CORRELATION_ID_MDC_KEY, correlationId); + } + + /** + * Get correlation ID from MDC. + */ + public static String getCorrelationId() { + return MDC.get(CORRELATION_ID_MDC_KEY); + } + + /** + * Set request ID in MDC. + */ + public static void setRequestId(String requestId) { + MDC.put(REQUEST_ID_MDC_KEY, requestId); + } + + /** + * Set user ID in MDC. + */ + public static void setUserId(String userId) { + if (userId != null && !userId.isEmpty()) { + MDC.put(USER_ID_MDC_KEY, userId); + } + } + + /** + * Set request path in MDC. + */ + public static void setRequestPath(String path) { + MDC.put(REQUEST_PATH_MDC_KEY, path); + } + + /** + * Set request method in MDC. + */ + public static void setRequestMethod(String method) { + MDC.put(REQUEST_METHOD_MDC_KEY, method); + } + + /** + * Set all request context in MDC. + */ + public static void setRequestContext(HttpServletRequest request, String userId) { + String correlationId = getOrGenerateCorrelationId(request); + setCorrelationId(correlationId); + setRequestPath(request.getRequestURI()); + setRequestMethod(request.getMethod()); + if (userId != null) { + setUserId(userId); + } + } + + /** + * Clear all MDC context. + */ + public static void clear() { + MDC.clear(); + } + + /** + * Remove correlation ID from MDC. + */ + public static void removeCorrelationId() { + MDC.remove(CORRELATION_ID_MDC_KEY); + } + + /** + * Remove user ID from MDC. + */ + public static void removeUserId() { + MDC.remove(USER_ID_MDC_KEY); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/util/InputSanitizer.java b/src/main/java/com/gnx/telemedicine/util/InputSanitizer.java new file mode 100644 index 0000000..f32fec6 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/util/InputSanitizer.java @@ -0,0 +1,287 @@ +package com.gnx.telemedicine.util; + +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; + +import java.util.regex.Pattern; + +/** + * Utility class for input sanitization to prevent XSS and injection attacks. + * Provides methods to clean and validate user input. + */ +public class InputSanitizer { + + // Pattern for SQL injection detection (basic patterns) + private static final Pattern SQL_INJECTION_PATTERN = Pattern.compile( + "(?i)(union|select|insert|update|delete|drop|create|alter|exec|execute|script|javascript|onload|onerror)" + ); + + // Pattern for script tags + private static final Pattern SCRIPT_TAG_PATTERN = Pattern.compile( + "(?i)]*>.*?", Pattern.DOTALL + ); + + // Pattern for event handlers + private static final Pattern EVENT_HANDLER_PATTERN = Pattern.compile( + "(?i)(on\\w+\\s*=\\s*[\"'][^\"']*[\"'])" + ); + + /** + * Sanitizes HTML content by removing dangerous tags and attributes. + * Uses Jsoup to clean HTML while preserving safe formatting. + * + * @param input The HTML content to sanitize + * @return Sanitized HTML content + */ + public static String sanitizeHtml(String input) { + if (input == null || input.isEmpty()) { + return input; + } + + // Use Jsoup to clean HTML - allows only safe tags + Safelist safelist = Safelist.relaxed() + .addTags("p", "br", "strong", "em", "u", "ol", "ul", "li") + .addAttributes("a", "href") + .addProtocols("a", "href", "http", "https"); + + return Jsoup.clean(input, safelist); + } + + /** + * Sanitizes plain text by removing all HTML tags and special characters. + * + * @param input The text to sanitize + * @return Plain text without HTML tags + */ + public static String sanitizeText(String input) { + if (input == null || input.isEmpty()) { + return input; + } + + // Remove all HTML tags + String cleaned = Jsoup.clean(input, Safelist.none()); + + // Remove leading/trailing whitespace + return cleaned.trim(); + } + + /** + * Sanitizes email addresses. + * + * @param email The email to sanitize + * @return Sanitized email or null if invalid + */ + public static String sanitizeEmail(String email) { + if (email == null || email.isEmpty()) { + return null; + } + + // Remove whitespace + email = email.trim().toLowerCase(); + + // Basic email validation pattern + Pattern emailPattern = Pattern.compile( + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ); + + if (!emailPattern.matcher(email).matches()) { + return null; + } + + // Sanitize to prevent injection + return sanitizeText(email); + } + + /** + * Sanitizes phone numbers. + * + * @param phone The phone number to sanitize + * @return Sanitized phone number + */ + public static String sanitizePhone(String phone) { + if (phone == null || phone.isEmpty()) { + return null; + } + + // Remove all non-digit characters except + and spaces + phone = phone.replaceAll("[^\\d+\\s()-]", ""); + + // Remove leading/trailing whitespace + return phone.trim(); + } + + /** + * Sanitizes UUID strings. + * + * @param uuid The UUID string to sanitize + * @return Sanitized UUID or null if invalid + */ + public static String sanitizeUuid(String uuid) { + if (uuid == null || uuid.isEmpty()) { + return null; + } + + // UUID pattern: 8-4-4-4-12 hexadecimal digits + Pattern uuidPattern = Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + ); + + uuid = uuid.trim(); + + if (!uuidPattern.matcher(uuid).matches()) { + return null; + } + + return uuid; + } + + /** + * Checks if input contains potentially dangerous SQL injection patterns. + * + * @param input The input to check + * @return true if potentially dangerous, false otherwise + */ + public static boolean containsSqlInjection(String input) { + if (input == null || input.isEmpty()) { + return false; + } + + return SQL_INJECTION_PATTERN.matcher(input).find(); + } + + /** + * Checks if input contains potentially dangerous XSS patterns. + * + * @param input The input to check + * @return true if potentially dangerous, false otherwise + */ + public static boolean containsXss(String input) { + if (input == null || input.isEmpty()) { + return false; + } + + return SCRIPT_TAG_PATTERN.matcher(input).find() || + EVENT_HANDLER_PATTERN.matcher(input).find(); + } + + /** + * Sanitizes input for use in SQL queries (parameterized queries are still recommended). + * Escapes special characters that could be used for SQL injection. + * + * @param input The input to sanitize + * @return Sanitized input + */ + public static String sanitizeForSql(String input) { + if (input == null) { + return null; + } + + // Remove SQL comment patterns + input = input.replace("--", ""); + input = input.replace("/*", ""); + input = input.replace("*/", ""); + + // Escape single quotes + input = input.replace("'", "''"); + + // Remove semicolons (statement terminators) + input = input.replace(";", ""); + + return input.trim(); + } + + /** + * Sanitizes input for use in file paths. + * Prevents directory traversal attacks. + * + * @param input The file path to sanitize + * @return Sanitized file path + */ + public static String sanitizeFilePath(String input) { + if (input == null || input.isEmpty()) { + return null; + } + + // Remove directory traversal patterns + input = input.replace("..", ""); + input = input.replace("//", "/"); + input = input.replace("\\\\", "\\"); + + // Remove leading slashes + input = input.replaceAll("^[/\\\\]+", ""); + + // Remove dangerous characters + input = input.replaceAll("[<>:\"|?*]", ""); + + return input.trim(); + } + + /** + * Sanitizes input for use in URLs. + * + * @param input The URL to sanitize + * @return Sanitized URL or null if invalid + */ + public static String sanitizeUrl(String input) { + if (input == null || input.isEmpty()) { + return null; + } + + input = input.trim(); + + // Only allow http, https protocols + if (!input.startsWith("http://") && !input.startsWith("https://")) { + return null; + } + + // Remove script tags and event handlers + input = sanitizeHtml(input); + + return input; + } + + /** + * Validates and sanitizes input length. + * + * @param input The input to validate + * @param maxLength Maximum allowed length + * @return Sanitized input or null if exceeds max length + */ + public static String sanitizeLength(String input, int maxLength) { + if (input == null) { + return null; + } + + input = input.trim(); + + if (input.length() > maxLength) { + return null; + } + + return input; + } + + /** + * Sanitizes numeric input. + * + * @param input The numeric string to sanitize + * @return Sanitized numeric string or null if invalid + */ + public static String sanitizeNumeric(String input) { + if (input == null || input.isEmpty()) { + return null; + } + + // Remove all non-numeric characters (except decimal point and minus sign) + String cleaned = input.replaceAll("[^0-9.-]", ""); + + // Validate it's a valid number + try { + Double.parseDouble(cleaned); + return cleaned; + } catch (NumberFormatException e) { + return null; + } + } +} + diff --git a/src/main/java/com/gnx/telemedicine/util/PaginationUtil.java b/src/main/java/com/gnx/telemedicine/util/PaginationUtil.java new file mode 100644 index 0000000..3cb98c4 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/util/PaginationUtil.java @@ -0,0 +1,123 @@ +package com.gnx.telemedicine.util; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * Utility class for pagination support. + * Provides methods to create Pageable objects from request parameters. + */ +public class PaginationUtil { + + // Default values + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 20; + private static final int MAX_SIZE = 100; + private static final int MIN_SIZE = 1; + + /** + * Creates a Pageable object from page and size parameters. + * + * @param page Page number (0-indexed) + * @param size Page size + * @return Pageable object + */ + public static Pageable createPageable(Integer page, Integer size) { + int pageNumber = page != null && page >= 0 ? page : DEFAULT_PAGE; + int pageSize = size != null ? Math.min(Math.max(size, MIN_SIZE), MAX_SIZE) : DEFAULT_SIZE; + + return PageRequest.of(pageNumber, pageSize); + } + + /** + * Creates a Pageable object with sorting. + * + * @param page Page number (0-indexed) + * @param size Page size + * @param sortBy Field to sort by + * @param direction Sort direction (ASC or DESC) + * @return Pageable object with sorting + */ + public static Pageable createPageable(Integer page, Integer size, String sortBy, String direction) { + int pageNumber = page != null && page >= 0 ? page : DEFAULT_PAGE; + int pageSize = size != null ? Math.min(Math.max(size, MIN_SIZE), MAX_SIZE) : DEFAULT_SIZE; + + Sort sort = createSort(sortBy, direction); + + return PageRequest.of(pageNumber, pageSize, sort); + } + + /** + * Creates a Sort object from sort field and direction. + * + * @param sortBy Field to sort by + * @param direction Sort direction (ASC or DESC) + * @return Sort object + */ + public static Sort createSort(String sortBy, String direction) { + if (sortBy == null || sortBy.isEmpty()) { + return Sort.unsorted(); + } + + Sort.Direction sortDirection = Sort.Direction.ASC; + if (direction != null && direction.equalsIgnoreCase("DESC")) { + sortDirection = Sort.Direction.DESC; + } + + // Sanitize sort field to prevent SQL injection + String sanitizedSortBy = sanitizeSortField(sortBy); + + return Sort.by(sortDirection, sanitizedSortBy); + } + + /** + * Sanitizes sort field name to prevent SQL injection. + * Only allows alphanumeric characters, underscores, and dots. + * + * @param sortBy Sort field name + * @return Sanitized sort field name + */ + private static String sanitizeSortField(String sortBy) { + if (sortBy == null || sortBy.isEmpty()) { + return "id"; // Default sort field + } + + // Remove any characters that are not alphanumeric, underscore, or dot + String sanitized = sortBy.replaceAll("[^a-zA-Z0-9_.]", ""); + + // If sanitization removed everything, use default + if (sanitized.isEmpty()) { + return "id"; + } + + return sanitized; + } + + /** + * Validates and sanitizes page number. + * + * @param page Page number + * @return Validated page number (0-indexed) + */ + public static int validatePage(Integer page) { + if (page == null || page < 0) { + return DEFAULT_PAGE; + } + return page; + } + + /** + * Validates and sanitizes page size. + * + * @param size Page size + * @return Validated page size (between MIN_SIZE and MAX_SIZE) + */ + public static int validateSize(Integer size) { + if (size == null) { + return DEFAULT_SIZE; + } + return Math.min(Math.max(size, MIN_SIZE), MAX_SIZE); + } +} + diff --git a/src/main/java/com/gnx/telemedicine/util/PasswordValidator.java b/src/main/java/com/gnx/telemedicine/util/PasswordValidator.java new file mode 100644 index 0000000..0185b23 --- /dev/null +++ b/src/main/java/com/gnx/telemedicine/util/PasswordValidator.java @@ -0,0 +1,255 @@ +package com.gnx.telemedicine.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.regex.Pattern; + +/** + * Utility class for password validation and complexity checking. + * Implements enterprise-grade password policies. + * Configurable via application properties. + */ +@Component +public class PasswordValidator { + + // Password policy constants - configurable via properties + @Value("${security.password.min-length:8}") + private int minLength; + + @Value("${security.password.max-length:128}") + private int maxLength; + + @Value("${security.password.require-uppercase:true}") + private boolean requireUppercase; + + @Value("${security.password.require-lowercase:true}") + private boolean requireLowercase; + + @Value("${security.password.require-digit:true}") + private boolean requireDigit; + + @Value("${security.password.require-special-char:true}") + private boolean requireSpecialChar; + + @Value("${security.password.max-repeated-chars:4}") + private int maxRepeatedChars; + + @Value("${security.password.max-sequential-chars:4}") + private int maxSequentialChars; + + @Value("${security.password.min-strength-score:50}") + private int minStrengthScore; + + @Value("${security.password.check-common-passwords:true}") + private boolean checkCommonPasswords; + + // Patterns for password validation + private static final Pattern UPPERCASE_PATTERN = Pattern.compile(".*[A-Z].*"); + private static final Pattern LOWERCASE_PATTERN = Pattern.compile(".*[a-z].*"); + private static final Pattern DIGIT_PATTERN = Pattern.compile(".*\\d.*"); + private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*"); + + // Common weak passwords (should be expanded) + private static final String[] COMMON_PASSWORDS = { + "password", "12345678", "password123", "admin123", "qwerty123", + "welcome123", "password1", "123456789", "letmein", "welcome" + }; + + /** + * Validates password against complexity requirements. + * + * @param password The password to validate + * @return ValidationResult with isValid flag and error message + */ + public ValidationResult validate(String password) { + if (password == null || password.isEmpty()) { + return new ValidationResult(false, "Password is required"); + } + + // Check length + if (password.length() < minLength) { + return new ValidationResult(false, + String.format("Password must be at least %d characters long", minLength)); + } + + if (password.length() > maxLength) { + return new ValidationResult(false, + String.format("Password must be no more than %d characters long", maxLength)); + } + + // Check for uppercase letter + if (requireUppercase && !UPPERCASE_PATTERN.matcher(password).matches()) { + return new ValidationResult(false, + "Password must contain at least one uppercase letter"); + } + + // Check for lowercase letter + if (requireLowercase && !LOWERCASE_PATTERN.matcher(password).matches()) { + return new ValidationResult(false, + "Password must contain at least one lowercase letter"); + } + + // Check for digit + if (requireDigit && !DIGIT_PATTERN.matcher(password).matches()) { + return new ValidationResult(false, + "Password must contain at least one digit"); + } + + // Check for special character + if (requireSpecialChar && !SPECIAL_CHAR_PATTERN.matcher(password).matches()) { + return new ValidationResult(false, + "Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)"); + } + + // Check for common weak passwords + if (checkCommonPasswords) { + String lowerPassword = password.toLowerCase(); + for (String common : COMMON_PASSWORDS) { + if (lowerPassword.contains(common.toLowerCase())) { + return new ValidationResult(false, + "Password is too common or weak. Please choose a stronger password"); + } + } + } + + // Check for repeated characters (e.g., "aaaaaa") + if (hasRepeatedCharacters(password, maxRepeatedChars)) { + return new ValidationResult(false, + String.format("Password contains too many repeated characters (maximum %d allowed)", maxRepeatedChars)); + } + + // Check for sequential characters (e.g., "1234", "abcd") + if (hasSequentialCharacters(password, maxSequentialChars)) { + return new ValidationResult(false, + String.format("Password contains sequential characters which are easy to guess (maximum %d allowed)", maxSequentialChars)); + } + + // Check minimum strength score + int strengthScore = calculateStrength(password); + if (strengthScore < minStrengthScore) { + return new ValidationResult(false, + String.format("Password is too weak. Minimum strength score required: %d (current: %d)", minStrengthScore, strengthScore)); + } + + return new ValidationResult(true, "Password is valid"); + } + + /** + * Static method for backward compatibility. + * Note: This uses default values and should be replaced with instance method. + * @deprecated Use instance method validate() instead + */ + @Deprecated + public static ValidationResult validateStatic(String password) { + // Create a default instance for static access + PasswordValidator validator = new PasswordValidator(); + validator.minLength = 8; + validator.maxLength = 128; + validator.requireUppercase = true; + validator.requireLowercase = true; + validator.requireDigit = true; + validator.requireSpecialChar = true; + validator.maxRepeatedChars = 4; + validator.maxSequentialChars = 4; + validator.minStrengthScore = 50; + validator.checkCommonPasswords = true; + return validator.validate(password); + } + + /** + * Checks if password contains repeated characters. + */ + private static boolean hasRepeatedCharacters(String password, int maxRepeats) { + char[] chars = password.toCharArray(); + int count = 1; + char prev = chars[0]; + + for (int i = 1; i < chars.length; i++) { + if (chars[i] == prev) { + count++; + if (count > maxRepeats) { + return true; + } + } else { + count = 1; + prev = chars[i]; + } + } + return false; + } + + /** + * Checks if password contains sequential characters. + */ + private static boolean hasSequentialCharacters(String password, int sequenceLength) { + String lowerPassword = password.toLowerCase(); + char[] chars = lowerPassword.toCharArray(); + + for (int i = 0; i <= chars.length - sequenceLength; i++) { + boolean isSequential = true; + for (int j = 1; j < sequenceLength; j++) { + if (chars[i + j] != chars[i + j - 1] + 1) { + isSequential = false; + break; + } + } + if (isSequential) { + return true; + } + } + return false; + } + + /** + * Calculates password strength score (0-100). + */ + public int calculateStrength(String password) { + if (password == null || password.isEmpty()) { + return 0; + } + + int score = 0; + + // Length score (max 30 points) + if (password.length() >= 12) score += 30; + else if (password.length() >= 10) score += 20; + else if (password.length() >= 8) score += 10; + + // Character variety score (max 40 points) + if (UPPERCASE_PATTERN.matcher(password).matches()) score += 10; + if (LOWERCASE_PATTERN.matcher(password).matches()) score += 10; + if (DIGIT_PATTERN.matcher(password).matches()) score += 10; + if (SPECIAL_CHAR_PATTERN.matcher(password).matches()) score += 10; + + // Complexity bonus (max 30 points) + int uniqueChars = (int) password.chars().distinct().count(); + if (uniqueChars >= password.length() * 0.8) score += 30; + else if (uniqueChars >= password.length() * 0.6) score += 20; + else if (uniqueChars >= password.length() * 0.4) score += 10; + + return Math.min(100, score); + } + + /** + * Validation result class. + */ + public static class ValidationResult { + private final boolean isValid; + private final String message; + + public ValidationResult(boolean isValid, String message) { + this.isValid = isValid; + this.message = message; + } + + public boolean isValid() { + return isValid; + } + + public String getMessage() { + return message; + } + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..29d8ac4 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,256 @@ +server: + port: 8080 + address: 127.0.0.1 # Bind only to localhost, not accessible from other addresses + +# API Versioning Configuration +api: + version: + current: ${API_VERSION_CURRENT:v3} # Current API version + minimum: ${API_VERSION_MINIMUM:v1} # Minimum supported API version + prefix: ${API_VERSION_PREFIX:/api} # API version prefix + versioning: + enabled: ${API_VERSIONING_ENABLED:true} # Enable API versioning + show-in-headers: ${API_VERSIONING_SHOW_IN_HEADERS:true} # Show version in response headers + show-deprecation-warnings: ${API_VERSIONING_SHOW_DEPRECATION:true} # Show deprecation warnings + +spring: + application: + name: telemedicine + cache: + type: ${CACHE_TYPE:simple} # Use 'simple' (in-memory) if Redis is not available, 'redis' if Redis is configured + redis: + time-to-live: ${CACHE_TTL:3600000} # 1 hour default (in milliseconds) + cache-null-values: false # Don't cache null values + use-key-prefix: true + key-prefix: ${CACHE_KEY_PREFIX:telemedicine:cache:} + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: ${REDIS_TIMEOUT:2000ms} + lettuce: + pool: + max-active: ${REDIS_POOL_MAX_ACTIVE:8} + max-idle: ${REDIS_POOL_MAX_IDLE:8} + min-idle: ${REDIS_POOL_MIN_IDLE:0} + max-wait: ${REDIS_POOL_MAX_WAIT:-1ms} + datasource: + url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/telemedicine} + username: ${DATABASE_USERNAME:postgres} + password: ${DATABASE_PASSWORD:password} + # Encryption at rest - PostgreSQL supports transparent data encryption + # Configure SSL/TLS for database connections + hikari: + connection-init-sql: "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED" + jpa: + show-sql: ${JPA_SHOW_SQL:true} + open-in-view: false # Disable Open Session in View pattern for better performance + properties: + hibernate: + # Enable encryption for sensitive fields + format_sql: true + use_sql_comments: true + # Connection pool settings for enterprise + connection: + provider_disables_autocommit: false + servlet: + multipart: + max-file-size: 5MB + max-request-size: 10MB + mail: + host: ${MAIL_HOST:mail.gnxsoft.com} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME:support@gnxsoft.com} + password: ${MAIL_PASSWORD} + protocol: smtp + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + connectiontimeout: 10000 + timeout: 10000 + writetimeout: 10000 + # Debug mode to see what's happening + debug: ${MAIL_DEBUG:false} + # Try different authentication methods + auth.mechanisms: PLAIN LOGIN + auth.plain.disable: false + auth.login.disable: false + jackson: + serialization: + write-dates-as-timestamps: false + deserialization: + fail-on-unknown-properties: false + date-format: yyyy-MM-dd + +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION:86400} # Access token expiration (24 hours) + refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800} # Refresh token expiration (7 days) + cookie: + enabled: ${JWT_COOKIE_ENABLED:true} + name: ${JWT_COOKIE_NAME:jwtToken} + max-age: ${JWT_COOKIE_MAX_AGE:86400} + refresh-token: + max-tokens-per-user: ${JWT_REFRESH_MAX_TOKENS:5} # Maximum number of active refresh tokens per user + +gemini: + api-key: ${GEMINI_API_KEY} + +# Rate Limiting Configuration +rate-limit: + requests-per-minute: 60 + requests-per-hour: 1000 + login-attempts-per-minute: 5 + +# Security Configuration +security: + login: + max-attempts: 5 + lockout-duration-minutes: 30 + session: + timeout-seconds: 1800 # 30 minutes + max-concurrent-sessions: 1 # Allow only 1 concurrent session per user + max-sessions-prevents-login: false # If true, prevents login when max sessions reached + password: + # Password complexity requirements + min-length: ${SECURITY_PASSWORD_MIN_LENGTH:8} + max-length: ${SECURITY_PASSWORD_MAX_LENGTH:128} + require-uppercase: ${SECURITY_PASSWORD_REQUIRE_UPPERCASE:true} + require-lowercase: ${SECURITY_PASSWORD_REQUIRE_LOWERCASE:true} + require-digit: ${SECURITY_PASSWORD_REQUIRE_DIGIT:true} + require-special-char: ${SECURITY_PASSWORD_REQUIRE_SPECIAL_CHAR:true} + max-repeated-chars: ${SECURITY_PASSWORD_MAX_REPEATED_CHARS:4} + max-sequential-chars: ${SECURITY_PASSWORD_MAX_SEQUENTIAL_CHARS:4} + min-strength-score: ${SECURITY_PASSWORD_MIN_STRENGTH_SCORE:50} # 0-100 + check-common-passwords: ${SECURITY_PASSWORD_CHECK_COMMON:true} + # Password history and expiration + history-count: ${SECURITY_PASSWORD_HISTORY_COUNT:5} # Number of previous passwords to prevent reuse + expiration-days: ${SECURITY_PASSWORD_EXPIRATION_DAYS:90} # Password expiration in days + enforce-history: ${SECURITY_PASSWORD_ENFORCE_HISTORY:true} # Prevent password reuse + enforce-expiration: ${SECURITY_PASSWORD_ENFORCE_EXPIRATION:true} # Enforce password expiration + headers: + # HSTS (HTTP Strict Transport Security) Configuration + hsts: + enabled: ${SECURITY_HSTS_ENABLED:${server.ssl.enabled:false}} # Enable only when SSL is enabled + max-age: ${SECURITY_HSTS_MAX_AGE:63072000} # 2 years in seconds + include-subdomains: ${SECURITY_HSTS_INCLUDE_SUBDOMAINS:true} + preload: ${SECURITY_HSTS_PRELOAD:false} # Only enable if you've submitted your domain to HSTS preload list + # Content Security Policy Configuration + csp: + enabled: ${SECURITY_CSP_ENABLED:true} + # X-Frame-Options: DENY, SAMEORIGIN, or ALLOW-FROM + frame-options: ${SECURITY_FRAME_OPTIONS:DENY} + # X-Content-Type-Options: nosniff + content-type-options: ${SECURITY_CONTENT_TYPE_OPTIONS:nosniff} + # X-XSS-Protection: 0, 1, or 1; mode=block (legacy header, CSP is preferred) + xss-protection: ${SECURITY_XSS_PROTECTION:1; mode=block} + # Referrer-Policy: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + referrer-policy: ${SECURITY_REFERRER_POLICY:strict-origin-when-cross-origin} + +# WebRTC/TURN Configuration +webrtc: + turn: + server: ${TURN_HOST:localhost} + port: 3478 + public-ip: ${TURN_PUBLIC_IP:193.194.155.249} # Public IP for external access + public-port: ${TURN_PUBLIC_PORT:3478} # Port for public TURN server (usually same as local) + username: ${TURN_USERNAME:telemedicine} + password: ${TURN_PASSWORD:changeme} + realm: localdomain + +# Actuator Configuration +management: + endpoints: + web: + exposure: + include: health,info,metrics + # Enable CORS for monitoring (restrict in production) + cors: + allowed-origins: "*" + allowed-methods: GET,POST + endpoint: + health: + show-details: ${HEALTH_SHOW_DETAILS:when-authorized} # always, when-authorized, never + probes: + enabled: ${HEALTH_PROBES_ENABLED:true} # Enable liveness and readiness probes + health: + # Configure health check groups + groups: + readiness: + include: databaseHealthIndicator,emailServiceHealthIndicator,geminiApiHealthIndicator + liveness: + include: ping + # Configure health indicators + db: + enabled: ${HEALTH_DB_ENABLED:true} + mail: + enabled: ${HEALTH_MAIL_ENABLED:true} + diskspace: + enabled: ${HEALTH_DISKSPACE_ENABLED:true} + threshold: ${HEALTH_DISKSPACE_THRESHOLD:10GB} + metrics: + export: + prometheus: + enabled: ${METRICS_PROMETHEUS_ENABLED:true} + tags: + application: ${spring.application.name:telemedicine} + environment: ${ENVIRONMENT:development} + distribution: + percentiles-histogram: + http.server.requests: true + percentiles: + http.server.requests: 0.5, 0.9, 0.95, 0.99 + slo: + http.server.requests: 10ms, 50ms, 100ms, 200ms, 500ms, 1s, 2s, 5s + +# GDPR/HIPAA Compliance Configuration +compliance: + gdpr: + # GDPR compliance settings + data-retention-days: 3650 # 10 years default + auto-delete-enabled: false # Manual review required + consent-required: true + privacy-policy-version: "1.0" + cookie-policy-version: "1.0" + hipaa: + # HIPAA compliance settings + audit-log-retention-days: 2555 # 7 years + phi-access-logging: true + minimum-necessary: true # Enforce minimum necessary standard + encryption-required: true + breach-notification-days: 60 # Must notify within 60 days + encryption: + # Encryption configuration + at-rest: + enabled: true # Enable encryption at rest (database level) + algorithm: "AES-256" + in-transit: + enabled: true # Enable TLS/SSL for all connections + tls-version: "1.2,1.3" + require-client-cert: false + +# Logging Configuration +logging: + pattern: + # Include correlation ID, user ID, request path, and method in log output + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{correlationId:-N/A}] [%X{userId:-anonymous}] %X{requestMethod:-N/A} %X{requestPath:-N/A} - %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{correlationId:-N/A}] [%X{userId:-anonymous}] %X{requestMethod:-N/A} %X{requestPath:-N/A} - %logger{36} - %msg%n" + level: + root: INFO + com.gnx.telemedicine: ${LOG_LEVEL:DEBUG} + org.springframework.security: ${SECURITY_LOG_LEVEL:DEBUG} + org.springframework.security.authentication: ${SECURITY_AUTH_LOG_LEVEL:TRACE} + org.springframework.security.authentication.dao: ${SECURITY_AUTH_DAO_LOG_LEVEL:TRACE} + org.springframework.web: ${WEB_LOG_LEVEL:INFO} + org.hibernate: ${HIBERNATE_LOG_LEVEL:WARN} + org.hibernate.SQL: ${SQL_LOG_LEVEL:DEBUG} + org.hibernate.type.descriptor.sql.BasicBinder: ${SQL_PARAM_LOG_LEVEL:TRACE} + file: + name: ${LOG_FILE:logs/telemedicine.log} + max-size: ${LOG_MAX_SIZE:10MB} + max-history: ${LOG_MAX_HISTORY:30} diff --git a/src/main/resources/db/migration/V10__add_prescriptions.sql b/src/main/resources/db/migration/V10__add_prescriptions.sql new file mode 100644 index 0000000..85cdbe8 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_prescriptions.sql @@ -0,0 +1,52 @@ +-- Create prescriptions table for managing digital prescriptions +CREATE TABLE prescriptions +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + doctor_id UUID NOT NULL REFERENCES doctors (id), + appointment_id UUID REFERENCES appointments (id), + medication_name VARCHAR(255) NOT NULL, + medication_code VARCHAR(50), -- NDC code + dosage VARCHAR(100) NOT NULL, + frequency VARCHAR(100) NOT NULL, + quantity INTEGER NOT NULL, + refills INTEGER DEFAULT 0, + instructions TEXT, + start_date DATE NOT NULL, + end_date DATE, + status VARCHAR(50) DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'COMPLETED', 'CANCELLED', 'DISCONTINUED')), + pharmacy_name VARCHAR(255), + pharmacy_address TEXT, + pharmacy_phone VARCHAR(20), + prescription_number VARCHAR(100) UNIQUE, + e_prescription_sent BOOLEAN DEFAULT FALSE, + e_prescription_sent_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users (id) +); + +-- Create medication intake logs for tracking medication adherence +CREATE TABLE medication_intake_logs +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + prescription_id UUID NOT NULL REFERENCES prescriptions (id) ON DELETE CASCADE, + scheduled_time TIMESTAMP NOT NULL, + taken_at TIMESTAMP, + taken BOOLEAN DEFAULT FALSE, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for better query performance +CREATE INDEX idx_prescriptions_patient_id ON prescriptions (patient_id); +CREATE INDEX idx_prescriptions_doctor_id ON prescriptions (doctor_id); +CREATE INDEX idx_prescriptions_appointment_id ON prescriptions (appointment_id); +CREATE INDEX idx_prescriptions_status ON prescriptions (status); +CREATE INDEX idx_prescriptions_start_date ON prescriptions (start_date); +CREATE INDEX idx_prescriptions_prescription_number ON prescriptions (prescription_number); +CREATE INDEX idx_prescriptions_created_at ON prescriptions (created_at DESC); + +CREATE INDEX idx_medication_intake_logs_prescription_id ON medication_intake_logs (prescription_id); +CREATE INDEX idx_medication_intake_logs_scheduled_time ON medication_intake_logs (scheduled_time); +CREATE INDEX idx_medication_intake_logs_taken ON medication_intake_logs (taken) WHERE taken = FALSE; + diff --git a/src/main/resources/db/migration/V11__add_hipaa_audit_logging.sql b/src/main/resources/db/migration/V11__add_hipaa_audit_logging.sql new file mode 100644 index 0000000..d5ad5a7 --- /dev/null +++ b/src/main/resources/db/migration/V11__add_hipaa_audit_logging.sql @@ -0,0 +1,62 @@ +-- Create HIPAA audit logs table for tracking all PHI access +CREATE TABLE hipaa_audit_logs +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users (id), + action_type VARCHAR(100) NOT NULL CHECK (action_type IN ('VIEW', 'CREATE', 'UPDATE', 'DELETE', 'EXPORT', 'PRINT', 'DOWNLOAD')), + resource_type VARCHAR(100) NOT NULL, -- MEDICAL_RECORD, PRESCRIPTION, APPOINTMENT, VITAL_SIGNS, LAB_RESULT, PATIENT + resource_id UUID NOT NULL, + patient_id UUID REFERENCES patients (id), + ip_address VARCHAR(45), + user_agent TEXT, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + details JSONB, -- Additional context information + success BOOLEAN DEFAULT TRUE, + error_message TEXT +); + +-- Create PHI access logs table for detailed tracking of which PHI fields were accessed +CREATE TABLE phi_access_logs +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users (id), + patient_id UUID NOT NULL REFERENCES patients (id), + access_type VARCHAR(50) NOT NULL, -- Treatment, Payment, Operations, Authorization + accessed_fields TEXT[], -- Which PHI fields were accessed + purpose VARCHAR(255), -- Reason for access + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(45), + user_agent TEXT +); + +-- Create breach notifications table +CREATE TABLE breach_notifications +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + incident_date DATE NOT NULL, + discovery_date DATE NOT NULL, + breach_type VARCHAR(100) NOT NULL, -- UNAUTHORIZED_ACCESS, DISCLOSURE, LOSS, THEFT + affected_patients_count INTEGER, + description TEXT NOT NULL, + mitigation_steps TEXT, + notified_at TIMESTAMP, + status VARCHAR(50) DEFAULT 'INVESTIGATING' CHECK (status IN ('INVESTIGATING', 'CONTAINED', 'RESOLVED', 'REPORTED')), + created_by UUID NOT NULL REFERENCES users (id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for better query performance +CREATE INDEX idx_hipaa_audit_logs_user_id ON hipaa_audit_logs (user_id); +CREATE INDEX idx_hipaa_audit_logs_patient_id ON hipaa_audit_logs (patient_id); +CREATE INDEX idx_hipaa_audit_logs_resource_type ON hipaa_audit_logs (resource_type); +CREATE INDEX idx_hipaa_audit_logs_timestamp ON hipaa_audit_logs (timestamp DESC); +CREATE INDEX idx_hipaa_audit_logs_action_type ON hipaa_audit_logs (action_type); + +CREATE INDEX idx_phi_access_logs_user_id ON phi_access_logs (user_id); +CREATE INDEX idx_phi_access_logs_patient_id ON phi_access_logs (patient_id); +CREATE INDEX idx_phi_access_logs_timestamp ON phi_access_logs (timestamp DESC); + +CREATE INDEX idx_breach_notifications_status ON breach_notifications (status); +CREATE INDEX idx_breach_notifications_incident_date ON breach_notifications (incident_date DESC); + diff --git a/src/main/resources/db/migration/V12__add_security_features.sql b/src/main/resources/db/migration/V12__add_security_features.sql new file mode 100644 index 0000000..aded762 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_security_features.sql @@ -0,0 +1,55 @@ +-- Create two-factor authentication table +CREATE TABLE two_factor_auth +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES users (id) ON DELETE CASCADE, + secret_key VARCHAR(255) NOT NULL, + enabled BOOLEAN DEFAULT FALSE, + backup_codes TEXT[], -- Array of backup codes + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create login attempts tracking table for account lockout +CREATE TABLE login_attempts +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(100) NOT NULL, + ip_address VARCHAR(45), + success BOOLEAN DEFAULT FALSE, + failure_reason VARCHAR(255), + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create trusted devices table +CREATE TABLE trusted_devices +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + device_name VARCHAR(255), + device_fingerprint VARCHAR(255) NOT NULL, + ip_address VARCHAR(45), + first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_trusted BOOLEAN DEFAULT FALSE, + UNIQUE(user_id, device_fingerprint) +); + +-- Add account lockout fields to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS account_locked_until TIMESTAMP; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_failed_login TIMESTAMP; + +-- Create indexes for better query performance +CREATE INDEX idx_two_factor_auth_user_id ON two_factor_auth (user_id); +CREATE INDEX idx_two_factor_auth_enabled ON two_factor_auth (enabled) WHERE enabled = TRUE; + +CREATE INDEX idx_login_attempts_email ON login_attempts (email); +CREATE INDEX idx_login_attempts_timestamp ON login_attempts (timestamp DESC); +CREATE INDEX idx_login_attempts_ip_address ON login_attempts (ip_address); +CREATE INDEX idx_login_attempts_email_timestamp ON login_attempts (email, timestamp DESC); + +CREATE INDEX idx_trusted_devices_user_id ON trusted_devices (user_id); +CREATE INDEX idx_trusted_devices_device_fingerprint ON trusted_devices (device_fingerprint); +CREATE INDEX idx_trusted_devices_is_trusted ON trusted_devices (is_trusted) WHERE is_trusted = TRUE; + diff --git a/src/main/resources/db/migration/V13__add_default_duration_to_doctors.sql b/src/main/resources/db/migration/V13__add_default_duration_to_doctors.sql new file mode 100644 index 0000000..1a316e3 --- /dev/null +++ b/src/main/resources/db/migration/V13__add_default_duration_to_doctors.sql @@ -0,0 +1,6 @@ +-- Add default_duration_minutes column to doctors table +ALTER TABLE doctors ADD COLUMN IF NOT EXISTS default_duration_minutes INTEGER DEFAULT 30 CHECK (default_duration_minutes > 0 AND default_duration_minutes <= 120); + +-- Update existing doctors to have default duration of 30 minutes +UPDATE doctors SET default_duration_minutes = 30 WHERE default_duration_minutes IS NULL; + diff --git a/src/main/resources/db/migration/V14__make_lab_result_medical_record_id_nullable.sql b/src/main/resources/db/migration/V14__make_lab_result_medical_record_id_nullable.sql new file mode 100644 index 0000000..a1bf33b --- /dev/null +++ b/src/main/resources/db/migration/V14__make_lab_result_medical_record_id_nullable.sql @@ -0,0 +1,5 @@ +-- Make medical_record_id optional in lab_results table +-- Lab results can exist independently of medical records +ALTER TABLE lab_results + ALTER COLUMN medical_record_id DROP NOT NULL; + diff --git a/src/main/resources/db/migration/V15__add_patient_safety_features.sql b/src/main/resources/db/migration/V15__add_patient_safety_features.sql new file mode 100644 index 0000000..253ba77 --- /dev/null +++ b/src/main/resources/db/migration/V15__add_patient_safety_features.sql @@ -0,0 +1,115 @@ +-- Patient Safety Features Migration +-- Includes Clinical Alerts, Duplicate Patient Detection, Critical Results, and Sentinel Events + +-- Clinical Alerts table for drug interactions and other clinical warnings +CREATE TABLE clinical_alerts +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + alert_type VARCHAR(50) NOT NULL, -- DRUG_INTERACTION, ALLERGY, CONTRAINDICATION, etc. + severity VARCHAR(20) NOT NULL CHECK (severity IN ('INFO', 'WARNING', 'CRITICAL')), + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + medication_name VARCHAR(255), + related_prescription_id UUID REFERENCES prescriptions (id) ON DELETE SET NULL, + acknowledged BOOLEAN DEFAULT FALSE, + acknowledged_at TIMESTAMP, + acknowledged_by UUID REFERENCES users (id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP, + resolved_by UUID REFERENCES users (id) +); + +-- Duplicate Patient Detection records +CREATE TABLE duplicate_patient_records +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + primary_patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + duplicate_patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + match_score DECIMAL(5,2) NOT NULL, -- 0-100 match confidence + status VARCHAR(20) DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'CONFIRMED', 'RESOLVED', 'REJECTED')), + reviewed_by UUID REFERENCES users (id), + reviewed_at TIMESTAMP, + review_notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(primary_patient_id, duplicate_patient_id) +); + +-- Match reasons for duplicate records +CREATE TABLE duplicate_match_reasons +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + duplicate_record_id UUID NOT NULL REFERENCES duplicate_patient_records (id) ON DELETE CASCADE, + reason TEXT NOT NULL +); + +-- Critical Results Management +CREATE TABLE critical_results +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + lab_result_id UUID NOT NULL REFERENCES lab_results (id) ON DELETE CASCADE, + patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + doctor_id UUID NOT NULL REFERENCES doctors (id), + criticality_level VARCHAR(20) NOT NULL CHECK (criticality_level IN ('URGENT', 'CRITICAL', 'CRITICAL_PANIC')), + test_name VARCHAR(255) NOT NULL, + result_value VARCHAR(255), + reference_range VARCHAR(100), + clinical_significance TEXT, + acknowledgment_required BOOLEAN DEFAULT TRUE, + acknowledged BOOLEAN DEFAULT FALSE, + acknowledged_at TIMESTAMP, + acknowledged_by UUID REFERENCES users (id), + acknowledgment_method VARCHAR(50), -- CALL, EMAIL, SMS, IN_PERSON + follow_up_required BOOLEAN DEFAULT TRUE, + follow_up_status VARCHAR(50), + notified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Sentinel Event Reporting +CREATE TABLE sentinel_events +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + event_type VARCHAR(100) NOT NULL, -- DEATH, SURGICAL_ERROR, MEDICATION_ERROR, etc. + severity VARCHAR(20) NOT NULL CHECK (severity IN ('SEVERE', 'MODERATE', 'MILD')), + patient_id UUID REFERENCES patients (id) ON DELETE SET NULL, + doctor_id UUID REFERENCES doctors (id), + appointment_id UUID REFERENCES appointments (id), + description TEXT NOT NULL, + location VARCHAR(255), -- Where event occurred + occurred_at TIMESTAMP NOT NULL, + reported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reported_by UUID NOT NULL REFERENCES users (id), + status VARCHAR(20) DEFAULT 'REPORTED' CHECK (status IN ('REPORTED', 'UNDER_INVESTIGATION', 'RESOLVED', 'CLOSED')), + investigation_notes TEXT, + root_cause_analysis TEXT, + corrective_action TEXT, + resolved_at TIMESTAMP, + resolved_by UUID REFERENCES users (id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX idx_clinical_alerts_patient_id ON clinical_alerts (patient_id); +CREATE INDEX idx_clinical_alerts_acknowledged ON clinical_alerts (acknowledged) WHERE acknowledged = FALSE; +CREATE INDEX idx_clinical_alerts_severity ON clinical_alerts (severity); +CREATE INDEX idx_clinical_alerts_created_at ON clinical_alerts (created_at DESC); + +CREATE INDEX idx_duplicate_patient_records_primary ON duplicate_patient_records (primary_patient_id); +CREATE INDEX idx_duplicate_patient_records_duplicate ON duplicate_patient_records (duplicate_patient_id); +CREATE INDEX idx_duplicate_patient_records_status ON duplicate_patient_records (status); +CREATE INDEX idx_duplicate_patient_records_created_at ON duplicate_patient_records (created_at DESC); + +CREATE INDEX idx_critical_results_patient_id ON critical_results (patient_id); +CREATE INDEX idx_critical_results_doctor_id ON critical_results (doctor_id); +CREATE INDEX idx_critical_results_acknowledged ON critical_results (acknowledged) WHERE acknowledged = FALSE; +CREATE INDEX idx_critical_results_criticality ON critical_results (criticality_level); +CREATE INDEX idx_critical_results_created_at ON critical_results (created_at DESC); + +CREATE INDEX idx_sentinel_events_patient_id ON sentinel_events (patient_id); +CREATE INDEX idx_sentinel_events_doctor_id ON sentinel_events (doctor_id); +CREATE INDEX idx_sentinel_events_status ON sentinel_events (status); +CREATE INDEX idx_sentinel_events_event_type ON sentinel_events (event_type); +CREATE INDEX idx_sentinel_events_occurred_at ON sentinel_events (occurred_at DESC); +CREATE INDEX idx_sentinel_events_reported_at ON sentinel_events (reported_at DESC); + diff --git a/src/main/resources/db/migration/V16__add_doctor_to_clinical_alerts.sql b/src/main/resources/db/migration/V16__add_doctor_to_clinical_alerts.sql new file mode 100644 index 0000000..5013669 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_doctor_to_clinical_alerts.sql @@ -0,0 +1,42 @@ +-- Add doctor_id to clinical_alerts table +-- This allows alerts to be assigned to the treating doctor +-- Doctors will only see alerts for their own patients + +ALTER TABLE clinical_alerts +ADD COLUMN doctor_id UUID REFERENCES doctors (id) ON DELETE SET NULL; + +-- Create index for better query performance when filtering by doctor +CREATE INDEX idx_clinical_alerts_doctor_id ON clinical_alerts (doctor_id); + +-- Update existing alerts to assign them to the patient's most recent treating doctor +-- Based on appointments or prescriptions +UPDATE clinical_alerts ca +SET doctor_id = ( + SELECT a.doctor_id + FROM appointments a + WHERE a.patient_id = ca.patient_id + AND a.status IN ('SCHEDULED', 'CONFIRMED', 'COMPLETED') + AND a.doctor_id IS NOT NULL + ORDER BY a.created_at DESC + LIMIT 1 +) +WHERE ca.doctor_id IS NULL + AND EXISTS ( + SELECT 1 FROM appointments a WHERE a.patient_id = ca.patient_id AND a.doctor_id IS NOT NULL + ); + +-- If no appointment exists, try to assign from prescriptions +UPDATE clinical_alerts ca +SET doctor_id = ( + SELECT p.doctor_id + FROM prescriptions p + WHERE p.patient_id = ca.patient_id + AND p.doctor_id IS NOT NULL + ORDER BY p.created_at DESC + LIMIT 1 +) +WHERE ca.doctor_id IS NULL + AND EXISTS ( + SELECT 1 FROM prescriptions p WHERE p.patient_id = ca.patient_id AND p.doctor_id IS NOT NULL + ); + diff --git a/src/main/resources/db/migration/V17__add_enterprise_profile_fields.sql b/src/main/resources/db/migration/V17__add_enterprise_profile_fields.sql new file mode 100644 index 0000000..d0845ca --- /dev/null +++ b/src/main/resources/db/migration/V17__add_enterprise_profile_fields.sql @@ -0,0 +1,43 @@ +-- Add enterprise fields to doctors table for comprehensive profile +ALTER TABLE doctors +ADD COLUMN IF NOT EXISTS street_address VARCHAR(255), +ADD COLUMN IF NOT EXISTS city VARCHAR(100), +ADD COLUMN IF NOT EXISTS state VARCHAR(100), +ADD COLUMN IF NOT EXISTS zip_code VARCHAR(20), +ADD COLUMN IF NOT EXISTS country VARCHAR(100), +ADD COLUMN IF NOT EXISTS education_degree VARCHAR(200), +ADD COLUMN IF NOT EXISTS education_university VARCHAR(200), +ADD COLUMN IF NOT EXISTS education_graduation_year INTEGER, +ADD COLUMN IF NOT EXISTS certifications TEXT[], -- Array of certification names +ADD COLUMN IF NOT EXISTS languages_spoken TEXT[], -- Array of languages +ADD COLUMN IF NOT EXISTS hospital_affiliations TEXT[], -- Array of hospital names +ADD COLUMN IF NOT EXISTS insurance_accepted TEXT[], -- Array of insurance provider names +ADD COLUMN IF NOT EXISTS professional_memberships TEXT[]; -- Array of membership organizations + +-- Add enterprise fields to patients table for comprehensive profile +ALTER TABLE patients +ADD COLUMN IF NOT EXISTS date_of_birth DATE, +ADD COLUMN IF NOT EXISTS gender VARCHAR(20), +ADD COLUMN IF NOT EXISTS street_address VARCHAR(255), +ADD COLUMN IF NOT EXISTS city VARCHAR(100), +ADD COLUMN IF NOT EXISTS state VARCHAR(100), +ADD COLUMN IF NOT EXISTS zip_code VARCHAR(20), +ADD COLUMN IF NOT EXISTS country VARCHAR(100), +ADD COLUMN IF NOT EXISTS insurance_provider VARCHAR(200), +ADD COLUMN IF NOT EXISTS insurance_policy_number VARCHAR(100), +ADD COLUMN IF NOT EXISTS medical_history_summary TEXT, +ADD COLUMN IF NOT EXISTS current_medications TEXT[], -- Array of medication names +ADD COLUMN IF NOT EXISTS primary_care_physician_name VARCHAR(200), +ADD COLUMN IF NOT EXISTS primary_care_physician_phone VARCHAR(20), +ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(50), +ADD COLUMN IF NOT EXISTS occupation VARCHAR(200), +ADD COLUMN IF NOT EXISTS marital_status VARCHAR(20); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_doctors_city ON doctors(city); +CREATE INDEX IF NOT EXISTS idx_doctors_state ON doctors(state); +CREATE INDEX IF NOT EXISTS idx_doctors_specialization ON doctors(specialization); +CREATE INDEX IF NOT EXISTS idx_patients_city ON patients(city); +CREATE INDEX IF NOT EXISTS idx_patients_state ON patients(state); +CREATE INDEX IF NOT EXISTS idx_patients_date_of_birth ON patients(date_of_birth); + diff --git a/src/main/resources/db/migration/V18__add_appointment_soft_delete.sql b/src/main/resources/db/migration/V18__add_appointment_soft_delete.sql new file mode 100644 index 0000000..f4391f9 --- /dev/null +++ b/src/main/resources/db/migration/V18__add_appointment_soft_delete.sql @@ -0,0 +1,10 @@ +-- Add soft delete fields for appointments +-- Allows patients and doctors to hide appointments from their view without deleting them for the other party +ALTER TABLE appointments +ADD COLUMN deleted_by_patient BOOLEAN DEFAULT FALSE NOT NULL, +ADD COLUMN deleted_by_doctor BOOLEAN DEFAULT FALSE NOT NULL; + +-- Create index for performance when filtering deleted appointments +CREATE INDEX idx_appointments_deleted_by_patient ON appointments(deleted_by_patient); +CREATE INDEX idx_appointments_deleted_by_doctor ON appointments(deleted_by_doctor); + diff --git a/src/main/resources/db/migration/V19__add_message_soft_delete.sql b/src/main/resources/db/migration/V19__add_message_soft_delete.sql new file mode 100644 index 0000000..c4baf7d --- /dev/null +++ b/src/main/resources/db/migration/V19__add_message_soft_delete.sql @@ -0,0 +1,9 @@ +-- Add soft delete columns to messages table +ALTER TABLE messages + ADD COLUMN deleted_by_sender BOOLEAN DEFAULT FALSE, + ADD COLUMN deleted_by_receiver BOOLEAN DEFAULT FALSE; + +-- Create indexes for better query performance +CREATE INDEX idx_messages_deleted_by_sender ON messages (deleted_by_sender); +CREATE INDEX idx_messages_deleted_by_receiver ON messages (deleted_by_receiver); + diff --git a/src/main/resources/db/migration/V1__initial_migration.sql b/src/main/resources/db/migration/V1__initial_migration.sql new file mode 100644 index 0000000..997a146 --- /dev/null +++ b/src/main/resources/db/migration/V1__initial_migration.sql @@ -0,0 +1,15 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE users +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + phone_number VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + created_at DATE NOT NULL DEFAULT CURRENT_DATE, + updated_at DATE NOT NULL DEFAULT CURRENT_DATE +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V20__add_user_blocks.sql b/src/main/resources/db/migration/V20__add_user_blocks.sql new file mode 100644 index 0000000..06bf469 --- /dev/null +++ b/src/main/resources/db/migration/V20__add_user_blocks.sql @@ -0,0 +1,19 @@ +-- Create user_blocks table to track blocking relationships +CREATE TABLE user_blocks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Ensure a user cannot block themselves + CHECK (blocker_id != blocked_id), + + -- Ensure unique blocking relationship (one user can only block another user once) + UNIQUE (blocker_id, blocked_id) +); + +-- Create indexes for better query performance +CREATE INDEX idx_user_blocks_blocker ON user_blocks (blocker_id); +CREATE INDEX idx_user_blocks_blocked ON user_blocks (blocked_id); +CREATE INDEX idx_user_blocks_relationship ON user_blocks (blocker_id, blocked_id); + diff --git a/src/main/resources/db/migration/V21__add_gdpr_compliance.sql b/src/main/resources/db/migration/V21__add_gdpr_compliance.sql new file mode 100644 index 0000000..dcbfb57 --- /dev/null +++ b/src/main/resources/db/migration/V21__add_gdpr_compliance.sql @@ -0,0 +1,169 @@ +-- GDPR Compliance Tables + +-- Data Subject Consent Management +CREATE TABLE data_subject_consents +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + consent_type VARCHAR(100) NOT NULL, -- PRIVACY_POLICY, COOKIES, MARKETING, DATA_PROCESSING, THIRD_PARTY_SHARING + consent_status VARCHAR(50) NOT NULL DEFAULT 'PENDING' CHECK (consent_status IN ('GRANTED', 'DENIED', 'WITHDRAWN', 'PENDING')), + consent_version VARCHAR(50), -- Version of privacy policy/terms when consent was given + consent_method VARCHAR(50), -- WEB_FORM, EMAIL, IN_PERSON, API + ip_address VARCHAR(45), + user_agent TEXT, + granted_at TIMESTAMP, + withdrawn_at TIMESTAMP, + expires_at TIMESTAMP, -- Optional expiration date + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Ensure one consent record per user per consent type + UNIQUE(user_id, consent_type) +); + +-- Data Subject Requests (GDPR Article 15-22) +CREATE TABLE data_subject_requests +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + request_type VARCHAR(100) NOT NULL CHECK (request_type IN ('ACCESS', 'RECTIFICATION', 'ERASURE', 'RESTRICTION', 'PORTABILITY', 'OBJECTION')), + request_status VARCHAR(50) NOT NULL DEFAULT 'PENDING' CHECK (request_status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'REJECTED', 'CANCELLED')), + description TEXT, -- User's description of the request + requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + rejected_at TIMESTAMP, + rejection_reason TEXT, -- Reason for rejection if applicable + response_data JSONB, -- Response data (for portability requests) + verification_token VARCHAR(255), -- Token for verifying identity + verified_at TIMESTAMP, + processed_by UUID REFERENCES users (id), -- Admin who processed the request + notes TEXT, -- Internal notes + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Data Processing Records (GDPR Article 30 - Record of Processing Activities) +CREATE TABLE data_processing_records +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + processing_purpose VARCHAR(255) NOT NULL, -- Purpose of processing + data_categories TEXT[], -- Categories of personal data processed + data_subjects TEXT[], -- Categories of data subjects (PATIENTS, DOCTORS, ADMINS) + recipients TEXT[], -- Categories of recipients + transfers_to_third_countries TEXT[], -- Third countries data is transferred to + retention_period VARCHAR(100), -- How long data is retained + security_measures TEXT[], -- Security measures in place + data_controller VARCHAR(255), -- Name of data controller + data_processor VARCHAR(255), -- Name of data processor + legal_basis VARCHAR(255), -- Legal basis for processing + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Data Retention Policies +CREATE TABLE data_retention_policies +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + data_type VARCHAR(100) NOT NULL, -- USER_DATA, MEDICAL_RECORD, APPOINTMENT, AUDIT_LOG, etc. + retention_period_days INTEGER NOT NULL, -- Number of days to retain data + auto_delete_enabled BOOLEAN DEFAULT FALSE, + legal_requirement TEXT, -- Legal basis for retention period + last_cleanup_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(data_type) +); + +-- HIPAA Accounting of Disclosures +CREATE TABLE accounting_of_disclosures +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users (id), -- Who accessed/disclosed + disclosure_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + disclosure_type VARCHAR(100) NOT NULL, -- ROUTINE, NON_ROUTINE, AUTHORIZED, REQUIRED_BY_LAW + purpose TEXT NOT NULL, -- Purpose of disclosure + recipient_name VARCHAR(255), -- Name of recipient + recipient_address TEXT, -- Address of recipient + information_disclosed TEXT, -- What information was disclosed + authorized_by UUID REFERENCES users (id), -- Who authorized the disclosure + authorization_date TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Data Minimization Tracking +CREATE TABLE data_minimization_logs +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users (id), + action_type VARCHAR(100) NOT NULL, -- COLLECTED, ACCESSED, MODIFIED, DELETED + data_category VARCHAR(100) NOT NULL, -- Category of data + purpose TEXT, -- Purpose for which data was collected/used + legal_basis VARCHAR(255), -- Legal basis for processing + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(45), + user_agent TEXT +); + +-- Create indexes for better query performance +CREATE INDEX idx_data_subject_consents_user_id ON data_subject_consents (user_id); +CREATE INDEX idx_data_subject_consents_consent_type ON data_subject_consents (consent_type); +CREATE INDEX idx_data_subject_consents_status ON data_subject_consents (consent_status); +CREATE INDEX idx_data_subject_consents_user_type ON data_subject_consents (user_id, consent_type); + +CREATE INDEX idx_data_subject_requests_user_id ON data_subject_requests (user_id); +CREATE INDEX idx_data_subject_requests_type ON data_subject_requests (request_type); +CREATE INDEX idx_data_subject_requests_status ON data_subject_requests (request_status); +CREATE INDEX idx_data_subject_requests_requested_at ON data_subject_requests (requested_at DESC); +CREATE INDEX idx_data_subject_requests_verification_token ON data_subject_requests (verification_token); + +CREATE INDEX idx_data_processing_records_purpose ON data_processing_records (processing_purpose); + +CREATE INDEX idx_data_retention_policies_data_type ON data_retention_policies (data_type); + +CREATE INDEX idx_accounting_of_disclosures_patient_id ON accounting_of_disclosures (patient_id); +CREATE INDEX idx_accounting_of_disclosures_user_id ON accounting_of_disclosures (user_id); +CREATE INDEX idx_accounting_of_disclosures_disclosure_date ON accounting_of_disclosures (disclosure_date DESC); +CREATE INDEX idx_accounting_of_disclosures_type ON accounting_of_disclosures (disclosure_type); + +CREATE INDEX idx_data_minimization_logs_user_id ON data_minimization_logs (user_id); +CREATE INDEX idx_data_minimization_logs_timestamp ON data_minimization_logs (timestamp DESC); +CREATE INDEX idx_data_minimization_logs_data_category ON data_minimization_logs (data_category); + +-- Insert default data retention policies +INSERT INTO data_retention_policies (data_type, retention_period_days, auto_delete_enabled, legal_requirement) +VALUES + ('USER_DATA', 3650, FALSE, 'HIPAA requires retention of patient records for 6 years from last service date'), + ('MEDICAL_RECORD', 2190, FALSE, 'HIPAA requires retention of medical records for 6 years'), + ('APPOINTMENT', 2555, FALSE, 'HIPAA requires retention of appointment records for 7 years'), + ('AUDIT_LOG', 2555, TRUE, 'HIPAA requires retention of audit logs for 7 years'), + ('PHI_ACCESS_LOG', 2555, TRUE, 'HIPAA requires retention of PHI access logs for 7 years'), + ('HIPAA_AUDIT_LOG', 2555, TRUE, 'HIPAA requires retention of HIPAA audit logs for 7 years'), + ('BREACH_NOTIFICATION', 3650, FALSE, 'HIPAA requires retention of breach notifications for 10 years'), + ('MESSAGE', 365, TRUE, 'Standard retention period for messages'), + ('PRESCRIPTION', 2190, FALSE, 'HIPAA requires retention of prescription records for 6 years') +ON CONFLICT (data_type) DO NOTHING; + +-- Insert default data processing records +INSERT INTO data_processing_records ( + processing_purpose, + data_categories, + data_subjects, + recipients, + security_measures, + data_controller, + legal_basis +) +VALUES + ( + 'Telemedicine Platform Services', + ARRAY['Personal Identifiers', 'Health Information', 'Contact Information', 'Payment Information'], + ARRAY['PATIENTS', 'DOCTORS', 'ADMINS'], + ARRAY['Healthcare Providers', 'System Administrators', 'Authorized Third-Party Services'], + ARRAY['Encryption at Rest', 'Encryption in Transit', 'Access Controls', 'Audit Logging', 'Two-Factor Authentication'], + 'GNX Soft LTD', + 'Consent, Contract, Legal Obligation, Vital Interests' + ) +ON CONFLICT DO NOTHING; + diff --git a/src/main/resources/db/migration/V22__add_password_reset_fields.sql b/src/main/resources/db/migration/V22__add_password_reset_fields.sql new file mode 100644 index 0000000..900abe1 --- /dev/null +++ b/src/main/resources/db/migration/V22__add_password_reset_fields.sql @@ -0,0 +1,8 @@ +-- Add password reset token fields to users table +ALTER TABLE users +ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255), +ADD COLUMN IF NOT EXISTS password_reset_token_expiry TIMESTAMP; + +-- Create index on password_reset_token for faster lookups +CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token); + diff --git a/src/main/resources/db/migration/V23__add_refresh_tokens.sql b/src/main/resources/db/migration/V23__add_refresh_tokens.sql new file mode 100644 index 0000000..7891f28 --- /dev/null +++ b/src/main/resources/db/migration/V23__add_refresh_tokens.sql @@ -0,0 +1,37 @@ +-- Migration: Add refresh_tokens table for refresh token mechanism +-- Version: V23 +-- Description: Creates refresh_tokens table to store refresh tokens for JWT authentication + +CREATE TABLE refresh_tokens +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + token VARCHAR(500) NOT NULL UNIQUE, + user_id UUID NOT NULL, + expiry_date TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP, + device_fingerprint VARCHAR(500), + ip_address VARCHAR(45), + user_agent VARCHAR(500), + revoked BOOLEAN NOT NULL DEFAULT FALSE, + revoked_at TIMESTAMP, + revoked_reason VARCHAR(500), + CONSTRAINT fk_refresh_token_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- Create indexes for better query performance +CREATE INDEX idx_refresh_token_token ON refresh_tokens (token); +CREATE INDEX idx_refresh_token_user_id ON refresh_tokens (user_id); +CREATE INDEX idx_refresh_token_expiry ON refresh_tokens (expiry_date); +CREATE INDEX idx_refresh_token_user_revoked ON refresh_tokens (user_id, revoked, expiry_date); + +-- Add comment to table +COMMENT ON TABLE refresh_tokens IS 'Stores refresh tokens for JWT authentication. Refresh tokens are long-lived tokens used to obtain new access tokens.'; +COMMENT ON COLUMN refresh_tokens.token IS 'The refresh token value (JWT)'; +COMMENT ON COLUMN refresh_tokens.user_id IS 'Reference to the user who owns this refresh token'; +COMMENT ON COLUMN refresh_tokens.expiry_date IS 'When this refresh token expires'; +COMMENT ON COLUMN refresh_tokens.revoked IS 'Whether this refresh token has been revoked'; +COMMENT ON COLUMN refresh_tokens.device_fingerprint IS 'Device fingerprint for tracking which device the token was issued to'; +COMMENT ON COLUMN refresh_tokens.ip_address IS 'IP address from which the token was created'; +COMMENT ON COLUMN refresh_tokens.user_agent IS 'User agent string from which the token was created'; + diff --git a/src/main/resources/db/migration/V24__add_password_history_and_expiration.sql b/src/main/resources/db/migration/V24__add_password_history_and_expiration.sql new file mode 100644 index 0000000..b0ee35f --- /dev/null +++ b/src/main/resources/db/migration/V24__add_password_history_and_expiration.sql @@ -0,0 +1,37 @@ +-- Migration: Add password history and expiration fields +-- Version: V24 +-- Description: Creates password_history table and adds password expiration fields to users table + +-- Create password_history table +CREATE TABLE password_history +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + user_id UUID NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_password_history_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- Create indexes for better query performance +CREATE INDEX idx_password_history_user_id ON password_history (user_id); +CREATE INDEX idx_password_history_created_at ON password_history (created_at); + +-- Add password expiration fields to users table +ALTER TABLE users +ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS password_expires_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS password_expiration_days INTEGER DEFAULT 90, +ADD COLUMN IF NOT EXISTS password_history_count INTEGER DEFAULT 5; + +-- Add comment to table +COMMENT ON TABLE password_history IS 'Stores password history to prevent password reuse. Tracks previous password hashes for each user.'; +COMMENT ON COLUMN password_history.password_hash IS 'Hashed password value (BCrypt hash)'; +COMMENT ON COLUMN password_history.user_id IS 'Reference to the user who owns this password history entry'; +COMMENT ON COLUMN password_history.created_at IS 'When this password was set'; + +-- Add comments to users table columns +COMMENT ON COLUMN users.password_changed_at IS 'When the password was last changed'; +COMMENT ON COLUMN users.password_expires_at IS 'When the password expires (calculated from password_changed_at + password_expiration_days)'; +COMMENT ON COLUMN users.password_expiration_days IS 'Number of days until password expires (default: 90 days)'; +COMMENT ON COLUMN users.password_history_count IS 'Number of previous passwords to keep in history (default: 5)'; + diff --git a/src/main/resources/db/migration/V2__add_patients_and_doctors.sql b/src/main/resources/db/migration/V2__add_patients_and_doctors.sql new file mode 100644 index 0000000..9f42d11 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_patients_and_doctors.sql @@ -0,0 +1,24 @@ + +CREATE TABLE doctors +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + user_id UUID NOT NULL UNIQUE REFERENCES users (id) ON DELETE CASCADE, + medical_license_number VARCHAR(100) NOT NULL UNIQUE, + specialization VARCHAR(100) NOT NULL, + years_of_experience INTEGER NOT NULL, + biography TEXT, + consultation_fee DECIMAL(10, 2) NOT NULL, + is_verified BOOLEAN DEFAULT TRUE, + created_at DATE NOT NULL DEFAULT CURRENT_DATE +); + +CREATE TABLE patients +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES users (id) ON DELETE CASCADE, + emergency_contact_name VARCHAR(200), + emergency_contact_phone VARCHAR(20), + blood_type VARCHAR(5) CHECK (blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')), + allergies TEXT[], + created_at DATE DEFAULT CURRENT_DATE +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__add_appointments.sql b/src/main/resources/db/migration/V3__add_appointments.sql new file mode 100644 index 0000000..0d2700e --- /dev/null +++ b/src/main/resources/db/migration/V3__add_appointments.sql @@ -0,0 +1,11 @@ +CREATE TABLE appointments +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + doctor_id UUID NOT NULL REFERENCES doctors (id) ON DELETE CASCADE, + scheduled_date DATE NOT NULL, + scheduled_time TIME NOT NULL, + duration_minutes INT NOT NULL DEFAULT 30, + status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED' CHECK (status IN ('SCHEDULED', 'CANCELLED', 'CONFIRMED', 'COMPLETED')), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__add_doctor_availability.sql b/src/main/resources/db/migration/V5__add_doctor_availability.sql new file mode 100644 index 0000000..b7837a7 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_doctor_availability.sql @@ -0,0 +1,17 @@ + +CREATE TABLE doctor_availability +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + doctor_id UUID NOT NULL REFERENCES doctors (id) ON DELETE CASCADE, + day_of_week VARCHAR(10) NOT NULL CHECK (day_of_week IN ('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY')), + start_time TIME NOT NULL, + end_time TIME NOT NULL, + is_available BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT valid_time_range CHECK (end_time > start_time), + CONSTRAINT no_overlap UNIQUE (doctor_id, day_of_week, start_time, end_time) +); + +CREATE INDEX idx_doctor_availability_doctor_id ON doctor_availability (doctor_id); +CREATE INDEX idx_doctor_availability_day ON doctor_availability (day_of_week); \ No newline at end of file diff --git a/src/main/resources/db/migration/V6__add_messages.sql b/src/main/resources/db/migration/V6__add_messages.sql new file mode 100644 index 0000000..f3fc404 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_messages.sql @@ -0,0 +1,26 @@ +-- Create messages table for chat functionality +CREATE TABLE messages +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + sender_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + receiver_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + content TEXT NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Ensure sender and receiver are different + CHECK (sender_id != receiver_id) +); + +-- Create indexes for better query performance +CREATE INDEX idx_messages_sender ON messages (sender_id); +CREATE INDEX idx_messages_receiver ON messages (receiver_id); +CREATE INDEX idx_messages_created_at ON messages (created_at DESC); +CREATE INDEX idx_messages_conversation ON messages (sender_id, receiver_id, created_at DESC); + +-- Add online status tracking to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_online BOOLEAN DEFAULT FALSE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +CREATE INDEX idx_users_online_status ON users (is_online) WHERE is_online = TRUE; + diff --git a/src/main/resources/db/migration/V7__add_avatar_to_users.sql b/src/main/resources/db/migration/V7__add_avatar_to_users.sql new file mode 100644 index 0000000..ea218d0 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_avatar_to_users.sql @@ -0,0 +1,6 @@ +-- Add avatar_url column to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500); + +-- Create index for avatar_url queries (optional but helpful for performance) +CREATE INDEX IF NOT EXISTS idx_users_avatar_url ON users (avatar_url) WHERE avatar_url IS NOT NULL; + diff --git a/src/main/resources/db/migration/V8__add_user_status.sql b/src/main/resources/db/migration/V8__add_user_status.sql new file mode 100644 index 0000000..087b9fe --- /dev/null +++ b/src/main/resources/db/migration/V8__add_user_status.sql @@ -0,0 +1,4 @@ +-- Add user_status column to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS user_status VARCHAR(20) DEFAULT 'OFFLINE'; +UPDATE users SET user_status = CASE WHEN is_online = true THEN 'ONLINE' ELSE 'OFFLINE' END WHERE user_status IS NULL; + diff --git a/src/main/resources/db/migration/V9__add_medical_records.sql b/src/main/resources/db/migration/V9__add_medical_records.sql new file mode 100644 index 0000000..5fa2c23 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_medical_records.sql @@ -0,0 +1,71 @@ +-- Create medical records table for patient medical history +CREATE TABLE medical_records +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + doctor_id UUID NOT NULL REFERENCES doctors (id), + appointment_id UUID REFERENCES appointments (id), + record_type VARCHAR(50) NOT NULL CHECK (record_type IN ('DIAGNOSIS', 'LAB_RESULT', 'IMAGING', 'VITAL_SIGNS', 'NOTE', 'PROCEDURE', 'TREATMENT_PLAN', 'OTHER')), + title VARCHAR(255) NOT NULL, + content TEXT, + diagnosis_code VARCHAR(50), -- ICD-10 codes + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users (id) +); + +-- Create vital signs table for tracking patient vitals +CREATE TABLE vital_signs +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + appointment_id UUID REFERENCES appointments (id), + recorded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + recorded_by UUID REFERENCES users (id), + temperature DECIMAL(4, 1), -- Celsius + blood_pressure_systolic INTEGER, + blood_pressure_diastolic INTEGER, + heart_rate INTEGER, -- BPM + respiratory_rate INTEGER, -- BPM + oxygen_saturation DECIMAL(4, 1), -- Percentage + weight_kg DECIMAL(5, 2), + height_cm DECIMAL(5, 2), + bmi DECIMAL(4, 1), + notes TEXT, + medical_record_id UUID REFERENCES medical_records (id) ON DELETE SET NULL +); + +-- Create lab results table +CREATE TABLE lab_results +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + medical_record_id UUID NOT NULL REFERENCES medical_records (id) ON DELETE CASCADE, + patient_id UUID NOT NULL REFERENCES patients (id) ON DELETE CASCADE, + test_name VARCHAR(255) NOT NULL, + test_code VARCHAR(50), + result_value VARCHAR(255), + unit VARCHAR(50), + reference_range VARCHAR(100), + status VARCHAR(50) DEFAULT 'NORMAL' CHECK (status IN ('NORMAL', 'ABNORMAL', 'CRITICAL', 'PENDING')), + performed_at TIMESTAMP, + result_file_url TEXT, -- For PDF/lab reports + ordered_by UUID REFERENCES users (id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for better query performance +CREATE INDEX idx_medical_records_patient_id ON medical_records (patient_id); +CREATE INDEX idx_medical_records_doctor_id ON medical_records (doctor_id); +CREATE INDEX idx_medical_records_appointment_id ON medical_records (appointment_id); +CREATE INDEX idx_medical_records_record_type ON medical_records (record_type); +CREATE INDEX idx_medical_records_created_at ON medical_records (created_at DESC); + +CREATE INDEX idx_vital_signs_patient_id ON vital_signs (patient_id); +CREATE INDEX idx_vital_signs_recorded_at ON vital_signs (recorded_at DESC); +CREATE INDEX idx_vital_signs_appointment_id ON vital_signs (appointment_id); + +CREATE INDEX idx_lab_results_patient_id ON lab_results (patient_id); +CREATE INDEX idx_lab_results_medical_record_id ON lab_results (medical_record_id); +CREATE INDEX idx_lab_results_status ON lab_results (status); +CREATE INDEX idx_lab_results_performed_at ON lab_results (performed_at DESC); + diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..45f86d3 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + ${LOG_FILE} + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz + + 100MB + + 30 + 3GB + + + + + + ${LOG_FILE}.error + + ERROR + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_FILE}.error.%d{yyyy-MM-dd}.%i.gz + + 50MB + + 90 + 1GB + + + + + + ${LOG_FILE}.audit + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] AUDIT - %msg%n + + + ${LOG_FILE}.audit.%d{yyyy-MM-dd}.%i.gz + + 100MB + + 365 + 10GB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/ssl/backend-keystore.p12 b/src/main/resources/ssl/backend-keystore.p12 new file mode 100644 index 0000000..c817a69 Binary files /dev/null and b/src/main/resources/ssl/backend-keystore.p12 differ diff --git a/src/test/java/com/gnx/telemedicine/service/AuthServiceTest.java b/src/test/java/com/gnx/telemedicine/service/AuthServiceTest.java new file mode 100644 index 0000000..4973416 --- /dev/null +++ b/src/test/java/com/gnx/telemedicine/service/AuthServiceTest.java @@ -0,0 +1,152 @@ +package com.gnx.telemedicine.service; + +import com.gnx.telemedicine.dto.auth.JwtResponseDto; +import com.gnx.telemedicine.dto.auth.UserLoginDto; +import com.gnx.telemedicine.exception.TwoFactorAuthenticationRequiredException; +import com.gnx.telemedicine.model.UserModel; +import com.gnx.telemedicine.model.enums.Role; +import com.gnx.telemedicine.repository.UserRepository; +import com.gnx.telemedicine.security.JwtUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for AuthService. + */ +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private JwtUtils jwtUtils; + + @Mock + private UserRepository userRepository; + + @Mock + private LoginAttemptService loginAttemptService; + + @Mock + private TwoFactorAuthService twoFactorAuthService; + + @Mock + private HttpServletRequest request; + + @InjectMocks + private AuthService authService; + + private UserModel testUser; + private UserLoginDto validLoginDto; + + @BeforeEach + void setUp() { + testUser = new UserModel(); + testUser.setId(java.util.UUID.randomUUID()); + testUser.setEmail("test@example.com"); + testUser.setPassword("encodedPassword"); + testUser.setRole(Role.PATIENT); + testUser.setIsActive(true); + + validLoginDto = new UserLoginDto("test@example.com", "password123", null, null); + } + + @Test + void testLoginSuccess() { + // Arrange + when(loginAttemptService.isAccountLocked(anyString())).thenReturn(false); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(null); + when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(testUser)); + when(twoFactorAuthService.is2FAEnabled(anyString())).thenReturn(false); + when(jwtUtils.generateToken(anyString())).thenReturn("test-token"); + + // Act + JwtResponseDto result = authService.login(validLoginDto, request); + + // Assert + assertNotNull(result); + assertEquals("test-token", result.token()); + verify(loginAttemptService).recordSuccessfulLogin(anyString(), any()); + verify(jwtUtils).generateToken(anyString()); + } + + @Test + void testLoginWith2FARequired() { + // Arrange + when(loginAttemptService.isAccountLocked(anyString())).thenReturn(false); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(null); + when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(testUser)); + when(twoFactorAuthService.is2FAEnabled(anyString())).thenReturn(true); + + // Act & Assert + assertThrows(TwoFactorAuthenticationRequiredException.class, () -> { + authService.login(validLoginDto, request); + }); + + verify(loginAttemptService, never()).recordFailedLogin(anyString(), anyString(), any()); + } + + @Test + void testLoginAccountLocked() { + // Arrange + when(loginAttemptService.isAccountLocked(anyString())).thenReturn(true); + + // Act & Assert + assertThrows(org.springframework.security.authentication.DisabledException.class, () -> { + authService.login(validLoginDto, request); + }); + + verify(loginAttemptService).recordFailedLogin(anyString(), anyString(), any()); + verify(authenticationManager, never()).authenticate(any()); + } + + @Test + void testLoginAccountDeactivated() { + // Arrange + testUser.setIsActive(false); + when(loginAttemptService.isAccountLocked(anyString())).thenReturn(false); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(null); + when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(testUser)); + + // Act & Assert + assertThrows(org.springframework.security.authentication.DisabledException.class, () -> { + authService.login(validLoginDto, request); + }); + + verify(loginAttemptService).recordFailedLogin(anyString(), anyString(), any()); + } + + @Test + void testLoginInvalidCredentials() { + // Arrange + when(loginAttemptService.isAccountLocked(anyString())).thenReturn(false); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new BadCredentialsException("Invalid credentials")); + + // Act & Assert + assertThrows(BadCredentialsException.class, () -> { + authService.login(validLoginDto, request); + }); + + verify(loginAttemptService).recordFailedLogin(anyString(), anyString(), any()); + } +} + diff --git a/src/test/java/com/gnx/telemedicine/util/InputSanitizerTest.java b/src/test/java/com/gnx/telemedicine/util/InputSanitizerTest.java new file mode 100644 index 0000000..5dbf597 --- /dev/null +++ b/src/test/java/com/gnx/telemedicine/util/InputSanitizerTest.java @@ -0,0 +1,104 @@ +package com.gnx.telemedicine.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for InputSanitizer utility class. + */ +class InputSanitizerTest { + + @Test + void testSanitizeHtml() { + String input = "

Safe content

"; + String result = InputSanitizer.sanitizeHtml(input); + assertFalse(result.contains(""), "Should detect XSS"); + assertTrue(InputSanitizer.containsXss("onclick='malicious()'"), "Should detect event handlers"); + assertFalse(InputSanitizer.containsXss("normal text"), "Should not flag normal text"); + } + + @Test + void testSanitizeFilePath() { + String path = "../../../etc/passwd"; + String result = InputSanitizer.sanitizeFilePath(path); + assertFalse(result.contains(".."), "Should remove directory traversal"); + + String safePath = "uploads/file.jpg"; + String result2 = InputSanitizer.sanitizeFilePath(safePath); + assertTrue(result2.contains("file.jpg"), "Should preserve safe path"); + } + + @Test + void testSanitizeLength() { + String input = "test"; + String result = InputSanitizer.sanitizeLength(input, 10); + assertEquals(input, result, "Should accept valid length"); + + String longInput = "a".repeat(101); + String result2 = InputSanitizer.sanitizeLength(longInput, 100); + assertNull(result2, "Should reject input exceeding max length"); + } + + @Test + void testSanitizeNumeric() { + String validNumber = "123.45"; + String result = InputSanitizer.sanitizeNumeric(validNumber); + assertEquals(validNumber, result, "Should accept valid number"); + + String invalidNumber = "abc123"; + String result2 = InputSanitizer.sanitizeNumeric(invalidNumber); + assertNull(result2, "Should reject invalid number"); + } +} + diff --git a/src/test/java/com/gnx/telemedicine/util/PaginationUtilTest.java b/src/test/java/com/gnx/telemedicine/util/PaginationUtilTest.java new file mode 100644 index 0000000..0f059ae --- /dev/null +++ b/src/test/java/com/gnx/telemedicine/util/PaginationUtilTest.java @@ -0,0 +1,83 @@ +package com.gnx.telemedicine.util; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for PaginationUtil utility class. + */ +class PaginationUtilTest { + + @Test + void testCreatePageable() { + Pageable pageable = PaginationUtil.createPageable(0, 20); + assertNotNull(pageable, "Should create Pageable"); + assertEquals(0, pageable.getPageNumber(), "Should have correct page number"); + assertEquals(20, pageable.getPageSize(), "Should have correct page size"); + } + + @Test + void testCreatePageableWithSorting() { + Pageable pageable = PaginationUtil.createPageable(0, 20, "email", "ASC"); + assertNotNull(pageable, "Should create Pageable with sorting"); + assertTrue(pageable.getSort().isSorted(), "Should have sorting"); + assertEquals(Sort.Direction.ASC, pageable.getSort().getOrderFor("email").getDirection()); + } + + @Test + void testCreatePageableWithDefaultValues() { + Pageable pageable = PaginationUtil.createPageable(null, null); + assertEquals(0, pageable.getPageNumber(), "Should default to page 0"); + assertEquals(20, pageable.getPageSize(), "Should default to size 20"); + } + + @Test + void testCreatePageableWithMaxSize() { + Pageable pageable = PaginationUtil.createPageable(0, 200); + assertEquals(100, pageable.getPageSize(), "Should cap at max size 100"); + } + + @Test + void testCreatePageableWithMinSize() { + Pageable pageable = PaginationUtil.createPageable(0, 0); + assertEquals(1, pageable.getPageSize(), "Should enforce min size 1"); + } + + @Test + void testValidatePage() { + assertEquals(0, PaginationUtil.validatePage(0), "Should accept valid page"); + assertEquals(0, PaginationUtil.validatePage(null), "Should default to 0"); + assertEquals(0, PaginationUtil.validatePage(-1), "Should default negative to 0"); + } + + @Test + void testValidateSize() { + assertEquals(20, PaginationUtil.validateSize(20), "Should accept valid size"); + assertEquals(20, PaginationUtil.validateSize(null), "Should default to 20"); + assertEquals(1, PaginationUtil.validateSize(0), "Should enforce min size 1"); + assertEquals(100, PaginationUtil.validateSize(200), "Should cap at max size 100"); + } + + @Test + void testCreateSort() { + Sort sort = PaginationUtil.createSort("email", "ASC"); + assertNotNull(sort, "Should create Sort"); + assertEquals(Sort.Direction.ASC, sort.getOrderFor("email").getDirection()); + } + + @Test + void testCreateSortDescending() { + Sort sort = PaginationUtil.createSort("email", "DESC"); + assertEquals(Sort.Direction.DESC, sort.getOrderFor("email").getDirection()); + } + + @Test + void testCreateSortWithNullField() { + Sort sort = PaginationUtil.createSort(null, "ASC"); + assertFalse(sort.isSorted(), "Should return unsorted for null field"); + } +} + diff --git a/src/test/java/com/gnx/telemedicine/util/PasswordValidatorTest.java b/src/test/java/com/gnx/telemedicine/util/PasswordValidatorTest.java new file mode 100644 index 0000000..9341067 --- /dev/null +++ b/src/test/java/com/gnx/telemedicine/util/PasswordValidatorTest.java @@ -0,0 +1,77 @@ +package com.gnx.telemedicine.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for PasswordValidator utility class. + */ +class PasswordValidatorTest { + + @Test + void testValidPassword() { + PasswordValidator.ValidationResult result = PasswordValidator.validateStatic("ValidPass123!"); + assertTrue(result.isValid(), "Password should be valid"); + } + + @Test + void testPasswordTooShort() { + PasswordValidator.ValidationResult result = PasswordValidator.validateStatic("Short1!"); + assertFalse(result.isValid(), "Password should be invalid"); + assertTrue(result.getMessage().contains("at least"), "Error message should mention minimum length"); + } + + @Test + void testPasswordMissingUppercase() { + PasswordValidator.ValidationResult result = PasswordValidator.validateStatic("lowercase123!"); + assertFalse(result.isValid(), "Password should be invalid"); + assertTrue(result.getMessage().contains("uppercase"), "Error message should mention uppercase"); + } + + @Test + void testPasswordMissingLowercase() { + PasswordValidator.ValidationResult result = PasswordValidator.validateStatic("UPPERCASE123!"); + assertFalse(result.isValid(), "Password should be invalid"); + assertTrue(result.getMessage().contains("lowercase"), "Error message should mention lowercase"); + } + + @Test + void testPasswordMissingDigit() { + PasswordValidator.ValidationResult result = PasswordValidator.validateStatic("NoDigitHere!"); + assertFalse(result.isValid(), "Password should be invalid"); + assertTrue(result.getMessage().contains("digit"), "Error message should mention digit"); + } + + @Test + void testPasswordMissingSpecialChar() { + PasswordValidator.ValidationResult result = PasswordValidator.validateStatic("NoSpecialChar123"); + assertFalse(result.isValid(), "Password should be invalid"); + assertTrue(result.getMessage().contains("special"), "Error message should mention special character"); + } + + @Test + void testPasswordNull() { + PasswordValidator.ValidationResult result = PasswordValidator.validateStatic(null); + assertFalse(result.isValid(), "Null password should be invalid"); + assertTrue(result.getMessage().contains("required"), "Error message should mention required"); + } + + @Test + void testPasswordEmpty() { + PasswordValidator.ValidationResult result = PasswordValidator.validateStatic(""); + assertFalse(result.isValid(), "Empty password should be invalid"); + } + + @Test + void testPasswordStrengthCalculation() { + PasswordValidator validator = new PasswordValidator(); + int weakScore = validator.calculateStrength("weak"); + int mediumScore = validator.calculateStrength("MediumPass123!"); + int strongScore = validator.calculateStrength("VeryStrongPassword123!@#"); + + assertTrue(weakScore < mediumScore, "Weak password should have lower score"); + assertTrue(mediumScore < strongScore, "Strong password should have higher score"); + assertTrue(strongScore > 50, "Strong password should have score > 50"); + } +} + diff --git a/uploads/avatars/358c33c5-662e-48dd-92c4-0ea2366579c8.png b/uploads/avatars/358c33c5-662e-48dd-92c4-0ea2366579c8.png new file mode 100644 index 0000000..2884634 Binary files /dev/null and b/uploads/avatars/358c33c5-662e-48dd-92c4-0ea2366579c8.png differ diff --git a/uploads/avatars/365da6a2-b67f-49fc-b2a2-034be0164408.jpg b/uploads/avatars/365da6a2-b67f-49fc-b2a2-034be0164408.jpg new file mode 100644 index 0000000..2af914c Binary files /dev/null and b/uploads/avatars/365da6a2-b67f-49fc-b2a2-034be0164408.jpg differ diff --git a/uploads/avatars/40141676-c193-4095-844a-76e50ee6c9f7.jpg b/uploads/avatars/40141676-c193-4095-844a-76e50ee6c9f7.jpg new file mode 100644 index 0000000..2af914c Binary files /dev/null and b/uploads/avatars/40141676-c193-4095-844a-76e50ee6c9f7.jpg differ diff --git a/uploads/avatars/41d9e370-194f-4285-8372-75e7804613c4.png b/uploads/avatars/41d9e370-194f-4285-8372-75e7804613c4.png new file mode 100644 index 0000000..9451c39 Binary files /dev/null and b/uploads/avatars/41d9e370-194f-4285-8372-75e7804613c4.png differ diff --git a/uploads/avatars/67e6a212-a7e0-456e-b6fc-e64f5fb1b038.png b/uploads/avatars/67e6a212-a7e0-456e-b6fc-e64f5fb1b038.png new file mode 100644 index 0000000..9451c39 Binary files /dev/null and b/uploads/avatars/67e6a212-a7e0-456e-b6fc-e64f5fb1b038.png differ diff --git a/uploads/avatars/a922fd99-fba3-4025-9955-8293bc5f7d59.png b/uploads/avatars/a922fd99-fba3-4025-9955-8293bc5f7d59.png new file mode 100644 index 0000000..9451c39 Binary files /dev/null and b/uploads/avatars/a922fd99-fba3-4025-9955-8293bc5f7d59.png differ