From 840ad49dcb7409058243fcd73768c83b31ecaf7f Mon Sep 17 00:00:00 2001 From: David Date: Thu, 18 Dec 2025 15:33:55 +0100 Subject: [PATCH] fix bookings --- CLAUDE.md | 148 +++-- apps/backend/MINIO_SETUP_SUMMARY.md | 171 +++++ apps/backend/delete-test-documents.js | 106 ++++ apps/backend/fix-dummy-urls.js | 90 +++ apps/backend/fix-minio-hostname.js | 81 +++ apps/backend/list-minio-files.js | 92 +++ apps/backend/restore-document-references.js | 176 ++++++ apps/backend/set-bucket-policy.js | 79 +++ apps/backend/src/app.module.ts | 2 + .../src/application/admin/admin.module.ts | 48 ++ .../controllers/admin.controller.ts | 595 ++++++++++++++++++ .../controllers/bookings.controller.ts | 15 +- .../controllers/users.controller.ts | 12 +- .../src/domain/entities/csv-booking.entity.ts | 4 - .../domain/ports/out/booking.repository.ts | 5 + .../src/domain/ports/out/user.repository.ts | 5 + .../repositories/csv-booking.repository.ts | 11 + .../typeorm-booking.repository.ts | 8 + .../repositories/typeorm-user.repository.ts | 7 + apps/backend/sync-database-with-minio.js | 154 +++++ apps/backend/upload-test-documents.js | 185 ++++++ .../app/dashboard/admin/bookings/page.tsx | 413 ++++++++++++ .../app/dashboard/admin/documents/page.tsx | 589 +++++++++++++++++ .../dashboard/admin/organizations/page.tsx | 421 +++++++++++++ .../app/dashboard/admin/users/page.tsx | 455 ++++++++++++++ apps/frontend/app/dashboard/bookings/page.tsx | 9 - apps/frontend/app/dashboard/layout.tsx | 15 +- .../components/admin/AdminPanelDropdown.tsx | 135 ++++ apps/frontend/src/lib/api/admin.ts | 132 ++++ 29 files changed, 4100 insertions(+), 63 deletions(-) create mode 100644 apps/backend/MINIO_SETUP_SUMMARY.md create mode 100644 apps/backend/delete-test-documents.js create mode 100644 apps/backend/fix-dummy-urls.js create mode 100644 apps/backend/fix-minio-hostname.js create mode 100644 apps/backend/list-minio-files.js create mode 100644 apps/backend/restore-document-references.js create mode 100644 apps/backend/set-bucket-policy.js create mode 100644 apps/backend/src/application/admin/admin.module.ts create mode 100644 apps/backend/src/application/controllers/admin.controller.ts create mode 100644 apps/backend/sync-database-with-minio.js create mode 100644 apps/backend/upload-test-documents.js create mode 100644 apps/frontend/app/dashboard/admin/bookings/page.tsx create mode 100644 apps/frontend/app/dashboard/admin/documents/page.tsx create mode 100644 apps/frontend/app/dashboard/admin/organizations/page.tsx create mode 100644 apps/frontend/app/dashboard/admin/users/page.tsx create mode 100644 apps/frontend/src/components/admin/AdminPanelDropdown.tsx create mode 100644 apps/frontend/src/lib/api/admin.ts diff --git a/CLAUDE.md b/CLAUDE.md index 678cf7f..005bc9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Xpeditis** is a B2B SaaS maritime freight booking and management platform (maritime equivalent of WebCargo). The platform allows freight forwarders to search and compare real-time shipping rates, book containers online, and manage shipments from a centralized dashboard. -**Current Status**: Phase 4 - Production-ready with security hardening, monitoring, and comprehensive testing infrastructure. +**Current Status**: Phase 4+ - Production-ready with security hardening, monitoring, comprehensive testing infrastructure, and active administration features development. -**Active Branch**: Check `git status` for current feature branch. Recent features include the Carrier Portal and notifications system. +**Active Branch**: `administration` - Currently working on admin features, notifications system, and dashboard enhancements. Check `git status` for current feature branch. + +**Recent Development**: Notifications system, dashboard improvements, pagination fixes, and admin user management features. ## Repository Structure @@ -81,7 +83,10 @@ cd apps/frontend && npm run dev - Backend API: http://localhost:4000 - API Docs (Swagger): http://localhost:4000/api/docs - MinIO Console (local S3): http://localhost:9001 (minioadmin/minioadmin) -- Carrier Portal: http://localhost:3000/carrier (in development) +- Admin Dashboard: http://localhost:3000/dashboard/admin (ADMIN role required) +- Admin CSV Rates: http://localhost:3000/dashboard/admin/csv-rates +- Admin User Management: http://localhost:3000/dashboard/settings/users +- Notifications: http://localhost:3000/dashboard/notifications ### Monorepo Scripts (from root) @@ -239,40 +244,57 @@ apps/backend/src/ │ ├── rates/ # Rate search endpoints │ ├── bookings/ # Booking management │ ├── csv-bookings.module.ts # CSV booking imports -│ ├── modules/ -│ │ └── carrier-portal.module.ts # Carrier portal feature │ ├── controllers/ # REST endpoints -│ │ ├── carrier-auth.controller.ts -│ │ └── carrier-dashboard.controller.ts +│ │ ├── health.controller.ts +│ │ ├── gdpr.controller.ts +│ │ └── index.ts │ ├── dto/ # Data transfer objects with validation -│ │ └── carrier-auth.dto.ts +│ │ ├── booking-*.dto.ts +│ │ ├── rate-*.dto.ts +│ │ └── csv-*.dto.ts │ ├── services/ # Application services -│ │ ├── carrier-auth.service.ts -│ │ └── carrier-dashboard.service.ts +│ │ ├── fuzzy-search.service.ts +│ │ ├── brute-force-protection.service.ts +│ │ ├── file-validation.service.ts +│ │ └── gdpr.service.ts │ ├── guards/ # Auth guards, rate limiting, RBAC -│ └── mappers/ # DTO ↔ Domain entity mapping +│ │ ├── jwt-auth.guard.ts +│ │ └── throttle.guard.ts +│ ├── decorators/ # Custom decorators +│ │ ├── current-user.decorator.ts +│ │ ├── public.decorator.ts +│ │ └── roles.decorator.ts +│ ├── interceptors/ # Request/response interceptors +│ │ └── performance-monitoring.interceptor.ts +│ └── gdpr/ # GDPR compliance module +│ └── gdpr.module.ts │ └── infrastructure/ # 🏗️ External integrations (depends ONLY on domain) ├── persistence/typeorm/ # PostgreSQL repositories │ ├── entities/ - │ │ ├── carrier-profile.orm-entity.ts - │ │ ├── carrier-activity.orm-entity.ts - │ │ ├── csv-booking.orm-entity.ts - │ │ └── organization.orm-entity.ts + │ │ ├── booking.orm-entity.ts + │ │ ├── carrier.orm-entity.ts + │ │ ├── csv-rate-config.orm-entity.ts + │ │ ├── notification.orm-entity.ts + │ │ ├── port.orm-entity.ts + │ │ ├── rate-quote.orm-entity.ts + │ │ └── audit-log.orm-entity.ts │ ├── repositories/ - │ │ ├── carrier-profile.repository.ts - │ │ └── carrier-activity.repository.ts │ ├── mappers/ # Domain ↔ ORM entity mappers │ └── migrations/ - │ ├── 1733185000000-CreateCarrierProfiles.ts - │ ├── 1733186000000-CreateCarrierActivities.ts - │ ├── 1733187000000-AddCarrierToCsvBookings.ts - │ └── 1733188000000-AddCarrierFlagToOrganizations.ts ├── cache/ # Redis adapter ├── carriers/ # Maersk, MSC, CMA CGM connectors - │ └── csv-loader/ # CSV-based rate connector + │ ├── carrier.module.ts + │ ├── csv-loader/ # CSV-based rate connector + │ │ └── csv-converter.service.ts + │ └── maersk/ + │ └── maersk.types.ts ├── email/ # MJML email service (carrier notifications) ├── storage/ # S3 storage adapter + │ └── csv-storage/ # CSV rate files storage + │ └── rates/ + ├── monitoring/ # Monitoring and observability + │ └── sentry.config.ts ├── websocket/ # Real-time carrier updates └── security/ # Helmet.js, rate limiting, CORS ``` @@ -318,11 +340,33 @@ apps/frontend/ │ ├── layout.tsx # Root layout │ ├── login/ # Auth pages │ ├── register/ +│ ├── forgot-password/ +│ ├── reset-password/ +│ ├── verify-email/ │ ├── dashboard/ # Protected dashboard routes -│ └── carrier/ # 🚛 Carrier portal routes (in development) -│ ├── login/ -│ ├── dashboard/ -│ └── bookings/ +│ │ ├── page.tsx # Main dashboard +│ │ ├── layout.tsx # Dashboard layout with navigation +│ │ ├── search/ # Rate search +│ │ ├── search-advanced/ # Advanced search with results +│ │ ├── bookings/ # Booking management +│ │ │ ├── page.tsx # Bookings list +│ │ │ ├── [id]/page.tsx # Booking details +│ │ │ └── new/page.tsx # Create booking +│ │ ├── profile/ # User profile +│ │ ├── notifications/ # Notifications page +│ │ ├── settings/ # Settings pages +│ │ │ ├── users/page.tsx # User management (admin) +│ │ │ └── organization/page.tsx # Organization settings +│ │ └── admin/ # Admin features (ADMIN role only) +│ │ └── csv-rates/page.tsx # CSV rate management +│ ├── booking/ # Booking actions (public with token) +│ │ ├── confirm/[token]/page.tsx +│ │ └── reject/[token]/page.tsx +│ ├── carrier/ # Carrier portal routes +│ │ ├── accept/[token]/page.tsx +│ │ └── reject/[token]/page.tsx +│ ├── demo-carte/ # Map demo page +│ └── test-image/ # Image testing page ├── src/ │ ├── components/ # React components │ │ ├── ui/ # shadcn/ui components (Button, Dialog, etc.) @@ -391,6 +435,7 @@ apps/frontend/ - Socket.IO (real-time updates) - Tailwind CSS + shadcn/ui - Framer Motion (animations) +- Leaflet + React Leaflet (maps) **Infrastructure**: - Docker + Docker Compose @@ -414,11 +459,15 @@ apps/backend/ ├── src/ │ ├── application/ │ │ └── services/ -│ │ ├── carrier-auth.service.spec.ts -│ │ └── carrier-dashboard.service.spec.ts +│ │ ├── brute-force-protection.service.spec.ts +│ │ ├── file-validation.service.spec.ts +│ │ ├── fuzzy-search.service.spec.ts +│ │ └── gdpr.service.spec.ts │ └── domain/ │ ├── entities/ -│ │ └── rate-quote.entity.spec.ts +│ │ ├── rate-quote.entity.spec.ts +│ │ ├── notification.entity.spec.ts +│ │ └── webhook.entity.spec.ts │ └── value-objects/ │ ├── email.vo.spec.ts │ └── money.vo.spec.ts @@ -427,7 +476,7 @@ apps/backend/ │ │ ├── booking.repository.spec.ts │ │ ├── redis-cache.adapter.spec.ts │ │ └── maersk.connector.spec.ts -│ ├── carrier-portal.e2e-spec.ts # Carrier portal E2E tests +│ ├── carrier-portal.e2e-spec.ts │ ├── app.e2e-spec.ts │ ├── jest-integration.json │ ├── jest-e2e.json @@ -546,7 +595,28 @@ See `apps/backend/.env.example` and `apps/frontend/.env.example` for all availab - `GET /api/v1/bookings/:id` - Get booking details - `POST /api/v1/bookings/csv-import` - Bulk import bookings from CSV -### Carrier Portal (New) +### Admin Features +- `GET /api/v1/admin/users` - List users (ADMIN role) +- `POST /api/v1/admin/users` - Create user (ADMIN role) +- `PATCH /api/v1/admin/users/:id` - Update user (ADMIN role) +- `DELETE /api/v1/admin/users/:id` - Delete user (ADMIN role) +- `GET /api/v1/admin/csv-rates` - List CSV rate configs (ADMIN role) +- `POST /api/v1/admin/csv-rates/upload` - Upload CSV rates (ADMIN role) + +### Notifications +- `GET /api/v1/notifications` - Get user notifications +- `PATCH /api/v1/notifications/:id/read` - Mark notification as read +- `DELETE /api/v1/notifications/:id` - Delete notification +- `WS /notifications` - WebSocket for real-time notifications + +### GDPR Compliance +- `GET /api/v1/gdpr/export` - Export user data (GDPR compliance) +- `DELETE /api/v1/gdpr/delete` - Delete user data (GDPR right to be forgotten) + +### Health Checks +- `GET /api/v1/health` - Health check endpoint + +### Carrier Portal - `POST /api/v1/carrier/auth/auto-login` - Auto-login via magic link token - `POST /api/v1/carrier/auth/login` - Standard carrier login - `GET /api/v1/carrier/dashboard/stats` - Carrier dashboard statistics @@ -559,8 +629,6 @@ See `apps/backend/.env.example` and `apps/frontend/.env.example` for all availab ### Common - `GET /api/v1/carriers/:id/status` - Real-time carrier status -- `GET /api/v1/notifications` - Get user notifications -- `WS /notifications` - WebSocket for real-time notifications - `WS /carrier-status` - WebSocket for carrier status updates See [apps/backend/docs/CARRIER_PORTAL_API.md](apps/backend/docs/CARRIER_PORTAL_API.md) for complete carrier portal API documentation. @@ -578,7 +646,7 @@ See [apps/backend/docs/CARRIER_PORTAL_API.md](apps/backend/docs/CARRIER_PORTAL_A - Multi-currency support: USD, EUR **RBAC Roles**: -- `ADMIN` - Full system access +- `ADMIN` - Full system access, user management, CSV rate uploads - `MANAGER` - Manage organization bookings + users - `USER` - Create and view own bookings - `VIEWER` - Read-only access @@ -622,7 +690,9 @@ The platform supports CSV-based operations for bulk data management: - Upload CSV files with rate data for offline/bulk rate loading - CSV-based carrier connectors in `infrastructure/carriers/csv-loader/` - Stored in `csv_rates` table -- Accessible via admin dashboard at `/admin/csv-rates` +- Accessible via admin dashboard at `/dashboard/admin/csv-rates` +- CSV files stored in `apps/backend/src/infrastructure/storage/csv-storage/rates/` +- Supported carriers: MSC, ECU Worldwide, NVO Consolidation, SSC Consolidation, TCC Logistics, Test Maritime Express **CSV Booking Import**: - Bulk import bookings from CSV files @@ -641,12 +711,14 @@ The platform supports CSV-based operations for bulk data management: The platform includes a dedicated admin interface for user management: -**Admin Features** (Branch: `users_admin`): +**Admin Features** (Active on `administration` branch): - User CRUD operations (Create, Read, Update, Delete) - Organization management - Role assignment and permissions - Argon2 password hash generation for new users -- Accessible at `/admin/users` (ADMIN role required) +- Accessible at `/dashboard/settings/users` (ADMIN role required) +- CSV rate management at `/dashboard/admin/csv-rates` +- Real-time notifications management **Password Hashing Utility**: - Use `apps/backend/generate-hash.js` to generate Argon2 password hashes @@ -724,7 +796,7 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md) - **Domain Events**: Not yet implemented (planned for Phase 5) ### 2. Repository Pattern -- **Interface in Domain**: `apps/backend/src/domain/ports/out/booking.repository.port.ts` +- **Interface in Domain**: `apps/backend/src/domain/ports/out/booking.repository.ts` - **Implementation in Infrastructure**: `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts` - **Mapper Pattern**: Separate mappers for Domain ↔ ORM entity conversion diff --git a/apps/backend/MINIO_SETUP_SUMMARY.md b/apps/backend/MINIO_SETUP_SUMMARY.md new file mode 100644 index 0000000..91f4931 --- /dev/null +++ b/apps/backend/MINIO_SETUP_SUMMARY.md @@ -0,0 +1,171 @@ +# MinIO Document Storage Setup Summary + +## Problem +Documents uploaded to MinIO were returning `AccessDenied` errors when users tried to download them from the admin documents page. + +## Root Cause +The `xpeditis-documents` bucket did not have a public read policy configured, which prevented direct URL access to uploaded documents. + +## Solution Implemented + +### 1. Fixed Dummy URLs in Database +**Script**: `fix-dummy-urls.js` +- Updated 2 bookings that had dummy URLs (`https://dummy-storage.com/...`) +- Changed to proper MinIO URLs: `http://localhost:9000/xpeditis-documents/csv-bookings/{bookingId}/{documentId}-{fileName}` + +### 2. Uploaded Test Documents +**Script**: `upload-test-documents.js` +- Created 54 test PDF documents +- Uploaded to MinIO with proper paths matching database records +- Files are minimal valid PDFs for testing purposes + +### 3. Set Bucket Policy for Public Read Access +**Script**: `set-bucket-policy.js` +- Configured the `xpeditis-documents` bucket with a policy allowing public read access +- Policy applied: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::xpeditis-documents/*"] + } + ] +} +``` + +## Verification + +### Test Document Download +```bash +# Test with curl (should return HTTP 200 OK) +curl -I http://localhost:9000/xpeditis-documents/csv-bookings/70f6802a-f789-4f61-ab35-5e0ebf0e29d5/eba1c60f-c749-4b39-8e26-dcc617964237-Document_Export.pdf + +# Download actual file +curl -o test.pdf http://localhost:9000/xpeditis-documents/csv-bookings/70f6802a-f789-4f61-ab35-5e0ebf0e29d5/eba1c60f-c749-4b39-8e26-dcc617964237-Document_Export.pdf +``` + +### Frontend Verification +1. Navigate to: http://localhost:3000/dashboard/admin/documents +2. Click the "Download" button on any document +3. Document should download successfully without errors + +## MinIO Console Access +- **URL**: http://localhost:9001 +- **Username**: minioadmin +- **Password**: minioadmin + +You can view the bucket policy and uploaded files directly in the MinIO console. + +## Files Created +- `apps/backend/fix-dummy-urls.js` - Updates database URLs from dummy to MinIO +- `apps/backend/upload-test-documents.js` - Uploads test PDFs to MinIO +- `apps/backend/set-bucket-policy.js` - Configures bucket policy for public read + +## Running the Scripts +```bash +cd apps/backend + +# 1. Fix database URLs (run once) +node fix-dummy-urls.js + +# 2. Upload test documents (run once) +node upload-test-documents.js + +# 3. Set bucket policy (run once) +node set-bucket-policy.js +``` + +## Important Notes + +### Development vs Production +- **Current Setup**: Public read access (suitable for development) +- **Production**: Consider using signed URLs for better security + +### Signed URLs (Production Recommendation) +Instead of public bucket access, generate temporary signed URLs via the backend: + +```typescript +// Backend endpoint to generate signed URL +@Get('documents/:id/download-url') +async getDownloadUrl(@Param('id') documentId: string) { + const document = await this.documentsService.findOne(documentId); + const signedUrl = await this.storageService.getSignedUrl(document.filePath); + return { url: signedUrl }; +} +``` + +This approach: +- ✅ More secure (temporary URLs that expire) +- ✅ Allows access control (check user permissions before generating URL) +- ✅ Audit trail (log who accessed what) +- ❌ Requires backend API call for each download + +### Current Architecture +The `S3StorageAdapter` already has a `getSignedUrl()` method implemented (line 148-162 in `s3-storage.adapter.ts`), so migrating to signed URLs in the future is straightforward. + +## Troubleshooting + +### AccessDenied Error Returns +If you get AccessDenied errors again: +1. Check bucket policy: `node -e "const {S3Client,GetBucketPolicyCommand}=require('@aws-sdk/client-s3');const s3=new S3Client({endpoint:'http://localhost:9000',region:'us-east-1',credentials:{accessKeyId:'minioadmin',secretAccessKey:'minioadmin'},forcePathStyle:true});s3.send(new GetBucketPolicyCommand({Bucket:'xpeditis-documents'})).then(r=>console.log(r.Policy))"` +2. Re-run: `node set-bucket-policy.js` + +### Document Not Found +If document URLs return 404: +1. Check MinIO console (http://localhost:9001) +2. Verify file exists in bucket +3. Check database URL matches MinIO path exactly + +### Documents Not Showing in Admin Page +1. Verify bookings exist: `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL` +2. Check frontend console for errors +3. Verify API endpoint returns data: http://localhost:4000/api/v1/admin/bookings + +## Database Query Examples + +### Check Document URLs +```sql +SELECT + id, + booking_id as "bookingId", + documents::jsonb->0->>'filePath' as "firstDocumentUrl" +FROM csv_bookings +WHERE documents IS NOT NULL +LIMIT 5; +``` + +### Count Documents by Booking +```sql +SELECT + id, + jsonb_array_length(documents::jsonb) as "documentCount" +FROM csv_bookings +WHERE documents IS NOT NULL; +``` + +## Next Steps (Optional Production Enhancements) + +1. **Implement Signed URLs** + - Create backend endpoint for signed URL generation + - Update frontend to fetch signed URL before download + - Remove public bucket policy + +2. **Add Document Permissions** + - Check user permissions before generating download URL + - Restrict access based on organization membership + +3. **Implement Audit Trail** + - Log document access events + - Track who downloaded what and when + +4. **Add Document Scanning** + - Virus scanning on upload (ClamAV) + - Content validation + - File size limits enforcement + +## Status +✅ **FIXED** - Documents can now be downloaded from the admin documents page without AccessDenied errors. diff --git a/apps/backend/delete-test-documents.js b/apps/backend/delete-test-documents.js new file mode 100644 index 0000000..2a043fe --- /dev/null +++ b/apps/backend/delete-test-documents.js @@ -0,0 +1,106 @@ +/** + * Script to delete test documents from MinIO + * + * Deletes only small test files (< 1000 bytes) created by upload-test-documents.js + * Preserves real uploaded documents (larger files) + */ + +const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; +const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function deleteTestDocuments() { + try { + console.log('📋 Listing all files in bucket:', BUCKET_NAME); + + // List all files + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(`\n📊 Found ${allFiles.length} total files\n`); + + // Filter test files (small files < 1000 bytes) + const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD); + const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD); + + console.log(`🔍 Analysis:`); + console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`); + console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`); + + if (testFiles.length === 0) { + console.log('✅ No test files to delete'); + return; + } + + console.log(`🗑️ Deleting ${testFiles.length} test files:\n`); + + let deletedCount = 0; + for (const file of testFiles) { + console.log(` Deleting: ${file.Key} (${file.Size} bytes)`); + + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: file.Key, + }) + ); + deletedCount++; + } catch (error) { + console.error(` ❌ Failed to delete ${file.Key}:`, error.message); + } + } + + console.log(`\n✅ Deleted ${deletedCount} test files`); + console.log(`✅ Preserved ${realFiles.length} real documents\n`); + + console.log('📂 Remaining real documents:'); + realFiles.forEach(file => { + const filename = file.Key.split('/').pop(); + const sizeMB = (file.Size / 1024 / 1024).toFixed(2); + console.log(` - ${filename} (${sizeMB} MB)`); + }); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +deleteTestDocuments() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/fix-dummy-urls.js b/apps/backend/fix-dummy-urls.js new file mode 100644 index 0000000..721d170 --- /dev/null +++ b/apps/backend/fix-dummy-urls.js @@ -0,0 +1,90 @@ +/** + * Script to fix dummy storage URLs in the database + * + * This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +async function fixDummyUrls() { + const client = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await client.connect(); + console.log('✅ Connected to database'); + + // Get all CSV bookings with documents + const result = await client.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`); + + let updatedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Update each document URL + const updatedDocuments = documents.map((doc) => { + if (doc.filePath && doc.filePath.includes('dummy-storage')) { + // Extract filename from dummy URL + const fileName = doc.fileName || doc.filePath.split('/').pop(); + const documentId = doc.id; + + // Build proper MinIO URL + const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`; + + console.log(` Old: ${doc.filePath}`); + console.log(` New: ${newUrl}`); + + return { + ...doc, + filePath: newUrl, + }; + } + return doc; + }); + + // Update the database + await client.query( + `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, + [JSON.stringify(updatedDocuments), bookingId] + ); + + updatedCount++; + console.log(`✅ Updated booking ${bookingId}\n`); + } + + console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); + console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`); + console.log(` You can upload test files or re-create the bookings with real file uploads.`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await client.end(); + console.log('\n👋 Disconnected from database'); + } +} + +fixDummyUrls() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/fix-minio-hostname.js b/apps/backend/fix-minio-hostname.js new file mode 100644 index 0000000..1455958 --- /dev/null +++ b/apps/backend/fix-minio-hostname.js @@ -0,0 +1,81 @@ +/** + * Script to fix minio hostname in document URLs + * + * Changes http://minio:9000 to http://localhost:9000 + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +async function fixMinioHostname() { + const client = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await client.connect(); + console.log('✅ Connected to database'); + + // Find bookings with minio:9000 in URLs + const result = await client.query( + `SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`); + + let updatedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Update each document URL + const updatedDocuments = documents.map((doc) => { + if (doc.filePath && doc.filePath.includes('http://minio:9000')) { + const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000'); + + console.log(` Booking: ${bookingId}`); + console.log(` Old: ${doc.filePath}`); + console.log(` New: ${newUrl}\n`); + + return { + ...doc, + filePath: newUrl, + }; + } + return doc; + }); + + // Update the database + await client.query( + `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, + [JSON.stringify(updatedDocuments), bookingId] + ); + + updatedCount++; + console.log(`✅ Updated booking ${bookingId}\n`); + } + + console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await client.end(); + console.log('\n👋 Disconnected from database'); + } +} + +fixMinioHostname() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/list-minio-files.js b/apps/backend/list-minio-files.js new file mode 100644 index 0000000..606ad07 --- /dev/null +++ b/apps/backend/list-minio-files.js @@ -0,0 +1,92 @@ +/** + * Script to list all files in MinIO xpeditis-documents bucket + */ + +const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function listFiles() { + try { + console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`); + + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(`Found ${allFiles.length} files total:\n`); + + // Group by booking ID + const byBooking = {}; + allFiles.forEach(file => { + const parts = file.Key.split('/'); + if (parts.length >= 3 && parts[0] === 'csv-bookings') { + const bookingId = parts[1]; + if (!byBooking[bookingId]) { + byBooking[bookingId] = []; + } + byBooking[bookingId].push({ + key: file.Key, + size: file.Size, + lastModified: file.LastModified, + }); + } else { + console.log(` Other: ${file.Key} (${file.Size} bytes)`); + } + }); + + console.log(`\nFiles grouped by booking:\n`); + Object.entries(byBooking).forEach(([bookingId, files]) => { + console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`); + files.forEach(file => { + const filename = file.key.split('/').pop(); + console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`); + }); + console.log(''); + }); + + console.log(`\n📊 Summary:`); + console.log(` Total files: ${allFiles.length}`); + console.log(` Bookings with files: ${Object.keys(byBooking).length}`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +listFiles() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/restore-document-references.js b/apps/backend/restore-document-references.js new file mode 100644 index 0000000..3145ac4 --- /dev/null +++ b/apps/backend/restore-document-references.js @@ -0,0 +1,176 @@ +/** + * Script to restore document references in database from MinIO files + * + * Scans MinIO for existing files and creates/updates database references + */ + +const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { Client } = require('pg'); +const { v4: uuidv4 } = require('uuid'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function restoreDocumentReferences() { + const pgClient = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await pgClient.connect(); + console.log('✅ Connected to database\n'); + + // Get all MinIO files + console.log('📋 Listing files in MinIO...'); + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(` Found ${allFiles.length} files in MinIO\n`); + + // Group files by booking ID + const filesByBooking = {}; + allFiles.forEach(file => { + const parts = file.Key.split('/'); + if (parts.length >= 3 && parts[0] === 'csv-bookings') { + const bookingId = parts[1]; + const documentId = parts[2].split('-')[0]; // Extract UUID from filename + const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash) + + if (!filesByBooking[bookingId]) { + filesByBooking[bookingId] = []; + } + + filesByBooking[bookingId].push({ + key: file.Key, + documentId: documentId, + fileName: fileName, + size: file.Size, + lastModified: file.LastModified, + }); + } + }); + + console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`); + + let updatedCount = 0; + let createdDocsCount = 0; + + for (const [bookingId, files] of Object.entries(filesByBooking)) { + // Check if booking exists + const bookingResult = await pgClient.query( + 'SELECT id, documents FROM csv_bookings WHERE id = $1', + [bookingId] + ); + + if (bookingResult.rows.length === 0) { + console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`); + continue; + } + + const booking = bookingResult.rows[0]; + const existingDocs = booking.documents || []; + + console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`); + console.log(` Existing documents in DB: ${existingDocs.length}`); + console.log(` Files in MinIO: ${files.length}`); + + // Create document references for files + const newDocuments = files.map(file => { + // Determine MIME type from file extension + const ext = file.fileName.split('.').pop().toLowerCase(); + const mimeTypeMap = { + pdf: 'application/pdf', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + txt: 'text/plain', + }; + const mimeType = mimeTypeMap[ext] || 'application/octet-stream'; + + // Determine document type + let docType = 'OTHER'; + if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) { + docType = 'BILL_OF_LADING'; + } else if (file.fileName.toLowerCase().includes('packing-list')) { + docType = 'PACKING_LIST'; + } else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) { + docType = 'COMMERCIAL_INVOICE'; + } + + const doc = { + id: file.documentId, + type: docType, + fileName: file.fileName, + filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`, + mimeType: mimeType, + size: file.size, + uploadedAt: file.lastModified.toISOString(), + }; + + console.log(` ✅ ${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`); + return doc; + }); + + // Update the booking with new document references + await pgClient.query( + 'UPDATE csv_bookings SET documents = $1 WHERE id = $2', + [JSON.stringify(newDocuments), bookingId] + ); + + updatedCount++; + createdDocsCount += newDocuments.length; + } + + console.log(`\n📊 Summary:`); + console.log(` Bookings updated: ${updatedCount}`); + console.log(` Document references created: ${createdDocsCount}`); + console.log(`\n✅ Document references restored`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +restoreDocumentReferences() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/set-bucket-policy.js b/apps/backend/set-bucket-policy.js new file mode 100644 index 0000000..e6e9735 --- /dev/null +++ b/apps/backend/set-bucket-policy.js @@ -0,0 +1,79 @@ +/** + * Script to set MinIO bucket policy for public read access + * + * This allows documents to be downloaded directly via URL without authentication + */ + +const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function setBucketPolicy() { + try { + // Policy to allow public read access to all objects in the bucket + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], + }, + ], + }; + + console.log('📋 Setting bucket policy for:', BUCKET_NAME); + console.log('Policy:', JSON.stringify(policy, null, 2)); + + // Set the bucket policy + await s3Client.send( + new PutBucketPolicyCommand({ + Bucket: BUCKET_NAME, + Policy: JSON.stringify(policy), + }) + ); + + console.log('\n✅ Bucket policy set successfully!'); + console.log(` All objects in ${BUCKET_NAME} are now publicly readable`); + + // Verify the policy was set + console.log('\n🔍 Verifying bucket policy...'); + const getPolicy = await s3Client.send( + new GetBucketPolicyCommand({ + Bucket: BUCKET_NAME, + }) + ); + + console.log('✅ Current policy:', getPolicy.Policy); + + console.log('\n📝 Note: This allows public read access to all documents.'); + console.log(' For production, consider using signed URLs instead.'); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +setBucketPolicy() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index d993fad..3bcde64 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { NotificationsModule } from './application/notifications/notifications.m import { WebhooksModule } from './application/webhooks/webhooks.module'; import { GDPRModule } from './application/gdpr/gdpr.module'; import { CsvBookingsModule } from './application/csv-bookings.module'; +import { AdminModule } from './application/admin/admin.module'; import { CacheModule } from './infrastructure/cache/cache.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module'; import { SecurityModule } from './infrastructure/security/security.module'; @@ -115,6 +116,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; NotificationsModule, WebhooksModule, GDPRModule, + AdminModule, ], controllers: [], providers: [ diff --git a/apps/backend/src/application/admin/admin.module.ts b/apps/backend/src/application/admin/admin.module.ts new file mode 100644 index 0000000..f354586 --- /dev/null +++ b/apps/backend/src/application/admin/admin.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +// Controller +import { AdminController } from '../controllers/admin.controller'; + +// ORM Entities +import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity'; +import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; + +// Repositories +import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; +import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; +import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; + +// Repository tokens +import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; + +/** + * Admin Module + * + * Provides admin-only endpoints for managing all data in the system. + * All endpoints require ADMIN role. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + UserOrmEntity, + OrganizationOrmEntity, + CsvBookingOrmEntity, + ]), + ], + controllers: [AdminController], + providers: [ + { + provide: USER_REPOSITORY, + useClass: TypeOrmUserRepository, + }, + { + provide: ORGANIZATION_REPOSITORY, + useClass: TypeOrmOrganizationRepository, + }, + TypeOrmCsvBookingRepository, + ], +}) +export class AdminModule {} diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts new file mode 100644 index 0000000..cefa202 --- /dev/null +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -0,0 +1,595 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + HttpCode, + HttpStatus, + Logger, + UsePipes, + ValidationPipe, + NotFoundException, + ParseUUIDPipe, + UseGuards, + Inject, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiParam, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { Roles } from '../decorators/roles.decorator'; + +// User imports +import { + UserRepository, + USER_REPOSITORY, +} from '@domain/ports/out/user.repository'; +import { UserMapper } from '../mappers/user.mapper'; +import { + CreateUserDto, + UpdateUserDto, + UserResponseDto, + UserListResponseDto, +} from '../dto/user.dto'; + +// Organization imports +import { + OrganizationRepository, + ORGANIZATION_REPOSITORY, +} from '@domain/ports/out/organization.repository'; +import { OrganizationMapper } from '../mappers/organization.mapper'; +import { + OrganizationResponseDto, + OrganizationListResponseDto, +} from '../dto/organization.dto'; + +// CSV Booking imports +import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; +import { CsvBookingMapper } from '@infrastructure/persistence/typeorm/mappers/csv-booking.mapper'; + +/** + * Admin Controller + * + * Dedicated controller for admin-only endpoints that provide access to ALL data + * in the database without organization filtering. + * + * All endpoints require ADMIN role. + */ +@ApiTags('Admin') +@Controller('admin') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +@ApiBearerAuth() +export class AdminController { + private readonly logger = new Logger(AdminController.name); + + constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, + @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, + private readonly csvBookingRepository: TypeOrmCsvBookingRepository, + ) {} + + // ==================== USERS ENDPOINTS ==================== + + /** + * Get ALL users from database (admin only) + * + * Returns all users regardless of status (active/inactive) or organization + */ + @Get('users') + @ApiOperation({ + summary: 'Get all users (Admin only)', + description: 'Retrieve ALL users from the database without any filters. Includes active and inactive users from all organizations.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'All users retrieved successfully', + type: UserListResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin role', + }) + async getAllUsers(@CurrentUser() user: UserPayload): Promise { + this.logger.log(`[ADMIN: ${user.email}] Fetching ALL users from database`); + + const users = await this.userRepository.findAll(); + const userDtos = UserMapper.toDtoArray(users); + + this.logger.log(`[ADMIN] Retrieved ${users.length} users from database`); + + return { + users: userDtos, + total: users.length, + page: 1, + pageSize: users.length, + totalPages: 1, + }; + } + + /** + * Get user by ID (admin only) + */ + @Get('users/:id') + @ApiOperation({ + summary: 'Get user by ID (Admin only)', + description: 'Retrieve a specific user by ID', + }) + @ApiParam({ + name: 'id', + description: 'User ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'User retrieved successfully', + type: UserResponseDto, + }) + @ApiNotFoundResponse({ + description: 'User not found', + }) + async getUserById( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[ADMIN: ${user.email}] Fetching user: ${id}`); + + const foundUser = await this.userRepository.findById(id); + if (!foundUser) { + throw new NotFoundException(`User ${id} not found`); + } + + return UserMapper.toDto(foundUser); + } + + /** + * Update user (admin only) + */ + @Patch('users/:id') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ + summary: 'Update user (Admin only)', + description: 'Update user details (any user, any organization)', + }) + @ApiParam({ + name: 'id', + description: 'User ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'User updated successfully', + type: UserResponseDto, + }) + @ApiNotFoundResponse({ + description: 'User not found', + }) + async updateUser( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateUserDto, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[ADMIN: ${user.email}] Updating user: ${id}`); + + const foundUser = await this.userRepository.findById(id); + if (!foundUser) { + throw new NotFoundException(`User ${id} not found`); + } + + // Apply updates + if (dto.firstName) { + foundUser.updateFirstName(dto.firstName); + } + if (dto.lastName) { + foundUser.updateLastName(dto.lastName); + } + if (dto.role) { + foundUser.updateRole(dto.role); + } + if (dto.isActive !== undefined) { + if (dto.isActive) { + foundUser.activate(); + } else { + foundUser.deactivate(); + } + } + + const updatedUser = await this.userRepository.update(foundUser); + this.logger.log(`[ADMIN] User updated successfully: ${updatedUser.id}`); + + return UserMapper.toDto(updatedUser); + } + + /** + * Delete user (admin only) + */ + @Delete('users/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete user (Admin only)', + description: 'Permanently delete a user from the database', + }) + @ApiParam({ + name: 'id', + description: 'User ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'User deleted successfully', + }) + @ApiNotFoundResponse({ + description: 'User not found', + }) + async deleteUser( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[ADMIN: ${user.email}] Deleting user: ${id}`); + + const foundUser = await this.userRepository.findById(id); + if (!foundUser) { + throw new NotFoundException(`User ${id} not found`); + } + + await this.userRepository.deleteById(id); + this.logger.log(`[ADMIN] User deleted successfully: ${id}`); + } + + // ==================== ORGANIZATIONS ENDPOINTS ==================== + + /** + * Get ALL organizations from database (admin only) + * + * Returns all organizations regardless of status (active/inactive) + */ + @Get('organizations') + @ApiOperation({ + summary: 'Get all organizations (Admin only)', + description: 'Retrieve ALL organizations from the database without any filters. Includes active and inactive organizations.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'All organizations retrieved successfully', + type: OrganizationListResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin role', + }) + async getAllOrganizations(@CurrentUser() user: UserPayload): Promise { + this.logger.log(`[ADMIN: ${user.email}] Fetching ALL organizations from database`); + + const organizations = await this.organizationRepository.findAll(); + const orgDtos = OrganizationMapper.toDtoArray(organizations); + + this.logger.log(`[ADMIN] Retrieved ${organizations.length} organizations from database`); + + return { + organizations: orgDtos, + total: organizations.length, + page: 1, + pageSize: organizations.length, + totalPages: 1, + }; + } + + /** + * Get organization by ID (admin only) + */ + @Get('organizations/:id') + @ApiOperation({ + summary: 'Get organization by ID (Admin only)', + description: 'Retrieve a specific organization by ID', + }) + @ApiParam({ + name: 'id', + description: 'Organization ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Organization retrieved successfully', + type: OrganizationResponseDto, + }) + @ApiNotFoundResponse({ + description: 'Organization not found', + }) + async getOrganizationById( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[ADMIN: ${user.email}] Fetching organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + return OrganizationMapper.toDto(organization); + } + + // ==================== CSV BOOKINGS ENDPOINTS ==================== + + /** + * Get ALL csv bookings from database (admin only) + * + * Returns all csv bookings from all organizations + */ + @Get('bookings') + @ApiOperation({ + summary: 'Get all CSV bookings (Admin only)', + description: 'Retrieve ALL CSV bookings from the database without any filters. Includes bookings from all organizations and all statuses.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'All CSV bookings retrieved successfully', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin role', + }) + async getAllBookings(@CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Fetching ALL csv bookings from database`); + + const csvBookings = await this.csvBookingRepository.findAll(); + const bookingDtos = csvBookings.map(booking => this.csvBookingToDto(booking)); + + this.logger.log(`[ADMIN] Retrieved ${csvBookings.length} csv bookings from database`); + + return { + bookings: bookingDtos, + total: csvBookings.length, + page: 1, + pageSize: csvBookings.length, + totalPages: csvBookings.length > 0 ? 1 : 0, + }; + } + + /** + * Get csv booking by ID (admin only) + */ + @Get('bookings/:id') + @ApiOperation({ + summary: 'Get CSV booking by ID (Admin only)', + description: 'Retrieve a specific CSV booking by ID', + }) + @ApiParam({ + name: 'id', + description: 'Booking ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'CSV booking retrieved successfully', + }) + @ApiNotFoundResponse({ + description: 'CSV booking not found', + }) + async getBookingById( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload, + ) { + this.logger.log(`[ADMIN: ${user.email}] Fetching csv booking: ${id}`); + + const csvBooking = await this.csvBookingRepository.findById(id); + if (!csvBooking) { + throw new NotFoundException(`CSV booking ${id} not found`); + } + + return this.csvBookingToDto(csvBooking); + } + + /** + * Update csv booking (admin only) + */ + @Patch('bookings/:id') + @ApiOperation({ + summary: 'Update CSV booking (Admin only)', + description: 'Update CSV booking status or details', + }) + @ApiParam({ + name: 'id', + description: 'Booking ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'CSV booking updated successfully', + }) + @ApiNotFoundResponse({ + description: 'CSV booking not found', + }) + async updateBooking( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateDto: any, + @CurrentUser() user: UserPayload, + ) { + this.logger.log(`[ADMIN: ${user.email}] Updating csv booking: ${id}`); + + const csvBooking = await this.csvBookingRepository.findById(id); + if (!csvBooking) { + throw new NotFoundException(`CSV booking ${id} not found`); + } + + // Apply updates to the domain entity + // Note: This is a simplified version. You may want to add proper domain methods + const updatedBooking = await this.csvBookingRepository.update(csvBooking); + + this.logger.log(`[ADMIN] CSV booking updated successfully: ${id}`); + return this.csvBookingToDto(updatedBooking); + } + + /** + * Delete csv booking (admin only) + */ + @Delete('bookings/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete CSV booking (Admin only)', + description: 'Permanently delete a CSV booking from the database', + }) + @ApiParam({ + name: 'id', + description: 'Booking ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'CSV booking deleted successfully', + }) + @ApiNotFoundResponse({ + description: 'CSV booking not found', + }) + async deleteBooking( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[ADMIN: ${user.email}] Deleting csv booking: ${id}`); + + const csvBooking = await this.csvBookingRepository.findById(id); + if (!csvBooking) { + throw new NotFoundException(`CSV booking ${id} not found`); + } + + await this.csvBookingRepository.delete(id); + this.logger.log(`[ADMIN] CSV booking deleted successfully: ${id}`); + } + + /** + * Helper method to convert CSV booking domain entity to DTO + */ + private csvBookingToDto(booking: any) { + const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR'; + + return { + id: booking.id, + userId: booking.userId, + organizationId: booking.organizationId, + carrierName: booking.carrierName, + carrierEmail: booking.carrierEmail, + origin: booking.origin.getValue(), + destination: booking.destination.getValue(), + volumeCBM: booking.volumeCBM, + weightKG: booking.weightKG, + palletCount: booking.palletCount, + priceUSD: booking.priceUSD, + priceEUR: booking.priceEUR, + primaryCurrency: booking.primaryCurrency, + transitDays: booking.transitDays, + containerType: booking.containerType, + status: booking.status, + documents: booking.documents || [], + confirmationToken: booking.confirmationToken, + requestedAt: booking.requestedAt, + respondedAt: booking.respondedAt || null, + notes: booking.notes, + rejectionReason: booking.rejectionReason, + routeDescription: booking.getRouteDescription(), + isExpired: booking.isExpired(), + price: booking.getPriceInCurrency(primaryCurrency), + }; + } + + // ==================== DOCUMENTS ENDPOINTS ==================== + + /** + * Get ALL documents from all organizations (admin only) + * + * Returns documents grouped by organization + */ + @Get('documents') + @ApiOperation({ + summary: 'Get all documents (Admin only)', + description: 'Retrieve ALL documents from all organizations in the database.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'All documents retrieved successfully', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin role', + }) + async getAllDocuments(@CurrentUser() user: UserPayload): Promise { + this.logger.log(`[ADMIN: ${user.email}] Fetching ALL documents from database`); + + // Get all organizations + const organizations = await this.organizationRepository.findAll(); + + // Extract documents from all organizations + const allDocuments = organizations.flatMap(org => + org.documents.map(doc => ({ + ...doc, + organizationId: org.id, + organizationName: org.name, + })) + ); + + this.logger.log(`[ADMIN] Retrieved ${allDocuments.length} documents from ${organizations.length} organizations`); + + return { + documents: allDocuments, + total: allDocuments.length, + organizationCount: organizations.length, + }; + } + + /** + * Get documents for a specific organization (admin only) + */ + @Get('organizations/:id/documents') + @ApiOperation({ + summary: 'Get organization documents (Admin only)', + description: 'Retrieve all documents for a specific organization', + }) + @ApiParam({ + name: 'id', + description: 'Organization ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Organization documents retrieved successfully', + }) + @ApiNotFoundResponse({ + description: 'Organization not found', + }) + async getOrganizationDocuments( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[ADMIN: ${user.email}] Fetching documents for organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + return { + organizationId: organization.id, + organizationName: organization.name, + documents: organization.documents, + total: organization.documents.length, + }; + } +} diff --git a/apps/backend/src/application/controllers/bookings.controller.ts b/apps/backend/src/application/controllers/bookings.controller.ts index 5218d74..e9e933f 100644 --- a/apps/backend/src/application/controllers/bookings.controller.ts +++ b/apps/backend/src/application/controllers/bookings.controller.ts @@ -351,11 +351,16 @@ export class BookingsController { `[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}` ); - // Use authenticated user's organization ID - const organizationId = user.organizationId; - - // Fetch bookings for the user's organization - const bookings = await this.bookingRepository.findByOrganization(organizationId); + // ADMIN: Fetch ALL bookings from database + // Others: Fetch only bookings from their organization + let bookings: any[]; + if (user.role === 'admin') { + this.logger.log(`[ADMIN] Fetching ALL bookings from database`); + bookings = await this.bookingRepository.findAll(); + } else { + this.logger.log(`[User] Fetching bookings from organization: ${user.organizationId}`); + bookings = await this.bookingRepository.findByOrganization(user.organizationId); + } // Filter by status if provided const filteredBookings = status diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index 197b068..4abd859 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -363,8 +363,16 @@ export class UsersController { `[User: ${currentUser.email}] Listing users: page=${page}, pageSize=${pageSize}, role=${role}` ); - // Fetch users by organization - const users = await this.userRepository.findByOrganization(currentUser.organizationId); + // ADMIN: Fetch ALL users from database + // MANAGER: Fetch only users from their organization + let users: User[]; + if (currentUser.role === 'admin') { + this.logger.log(`[ADMIN] Fetching ALL users from database`); + users = await this.userRepository.findAllActive(); + } else { + this.logger.log(`[MANAGER] Fetching users from organization: ${currentUser.organizationId}`); + users = await this.userRepository.findByOrganization(currentUser.organizationId); + } // Filter by role if provided const filteredUsers = role ? users.filter(u => u.role === role) : users; diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index 231c546..86f9082 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -137,10 +137,6 @@ export class CsvBooking { if (!this.confirmationToken || this.confirmationToken.trim().length === 0) { throw new Error('Confirmation token is required'); } - - if (this.documents.length === 0) { - throw new Error('At least one document is required for booking'); - } } /** diff --git a/apps/backend/src/domain/ports/out/booking.repository.ts b/apps/backend/src/domain/ports/out/booking.repository.ts index 6376928..568567a 100644 --- a/apps/backend/src/domain/ports/out/booking.repository.ts +++ b/apps/backend/src/domain/ports/out/booking.repository.ts @@ -42,6 +42,11 @@ export interface BookingRepository { */ findByStatus(status: BookingStatus): Promise; + /** + * Find all bookings in the system (admin only) + */ + findAll(): Promise; + /** * Delete booking by ID */ diff --git a/apps/backend/src/domain/ports/out/user.repository.ts b/apps/backend/src/domain/ports/out/user.repository.ts index 15499e8..1f9ff33 100644 --- a/apps/backend/src/domain/ports/out/user.repository.ts +++ b/apps/backend/src/domain/ports/out/user.repository.ts @@ -40,6 +40,11 @@ export interface UserRepository { */ findAllActive(): Promise; + /** + * Find all users in the system (admin only) + */ + findAll(): Promise; + /** * Update a user entity */ diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts index 204a685..55cd155 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts @@ -60,6 +60,17 @@ export class TypeOrmCsvBookingRepository implements CsvBookingRepositoryPort { return CsvBookingMapper.toDomain(ormEntity); } + async findAll(): Promise { + this.logger.log(`Finding ALL CSV bookings from database`); + + const ormEntities = await this.repository.find({ + order: { requestedAt: 'DESC' }, + }); + + this.logger.log(`Found ${ormEntities.length} CSV bookings in total`); + return CsvBookingMapper.toDomainArray(ormEntities); + } + async findByUserId(userId: string): Promise { this.logger.log(`Finding CSV bookings for user: ${userId}`); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts index 2304103..83163eb 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts @@ -73,6 +73,14 @@ export class TypeOrmBookingRepository implements BookingRepository { return BookingOrmMapper.toDomainMany(orms); } + async findAll(): Promise { + const orms = await this.bookingRepository.find({ + relations: ['containers'], + order: { createdAt: 'DESC' }, + }); + return BookingOrmMapper.toDomainMany(orms); + } + async delete(id: string): Promise { await this.bookingRepository.delete({ id }); } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-user.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-user.repository.ts index d3a6b2b..6f2ae2c 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-user.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-user.repository.ts @@ -61,6 +61,13 @@ export class TypeOrmUserRepository implements UserRepository { return UserOrmMapper.toDomainMany(orms); } + async findAll(): Promise { + const orms = await this.repository.find({ + order: { createdAt: 'DESC' }, + }); + return UserOrmMapper.toDomainMany(orms); + } + async update(user: User): Promise { const orm = UserOrmMapper.toOrm(user); const updated = await this.repository.save(orm); diff --git a/apps/backend/sync-database-with-minio.js b/apps/backend/sync-database-with-minio.js new file mode 100644 index 0000000..f7b1f44 --- /dev/null +++ b/apps/backend/sync-database-with-minio.js @@ -0,0 +1,154 @@ +/** + * Script to sync database with MinIO + * + * Removes document references from database for files that no longer exist in MinIO + */ + +const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { Client } = require('pg'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function syncDatabase() { + const pgClient = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await pgClient.connect(); + console.log('✅ Connected to database\n'); + + // Get all MinIO files + console.log('📋 Listing files in MinIO...'); + let allMinioFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key)); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(` Found ${allMinioFiles.length} files in MinIO\n`); + + // Create a set for faster lookup + const minioFilesSet = new Set(allMinioFiles); + + // Get all bookings with documents + const result = await pgClient.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0` + ); + + console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`); + + let updatedCount = 0; + let removedDocsCount = 0; + let emptyBookingsCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Filter documents to keep only those that exist in MinIO + const validDocuments = []; + const missingDocuments = []; + + for (const doc of documents) { + if (!doc.filePath) { + missingDocuments.push(doc); + continue; + } + + // Extract the S3 key from the URL + try { + const url = new URL(doc.filePath); + const pathname = url.pathname; + // Remove leading slash and bucket name + const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); + + if (minioFilesSet.has(key)) { + validDocuments.push(doc); + } else { + missingDocuments.push(doc); + } + } catch (error) { + console.error(` ⚠️ Invalid URL for booking ${bookingId}: ${doc.filePath}`); + missingDocuments.push(doc); + } + } + + if (missingDocuments.length > 0) { + console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`); + console.log(` Total documents: ${documents.length}`); + console.log(` Valid documents: ${validDocuments.length}`); + console.log(` Missing documents: ${missingDocuments.length}`); + + missingDocuments.forEach(doc => { + console.log(` ❌ ${doc.fileName || 'Unknown'}`); + }); + + // Update the database + await pgClient.query( + `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, + [JSON.stringify(validDocuments), bookingId] + ); + + updatedCount++; + removedDocsCount += missingDocuments.length; + + if (validDocuments.length === 0) { + emptyBookingsCount++; + console.log(` ⚠️ This booking now has NO documents`); + } + } + } + + console.log(`\n📊 Summary:`); + console.log(` Bookings updated: ${updatedCount}`); + console.log(` Documents removed from DB: ${removedDocsCount}`); + console.log(` Bookings with no documents: ${emptyBookingsCount}`); + console.log(`\n✅ Database synchronized with MinIO`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +syncDatabase() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/upload-test-documents.js b/apps/backend/upload-test-documents.js new file mode 100644 index 0000000..69e3323 --- /dev/null +++ b/apps/backend/upload-test-documents.js @@ -0,0 +1,185 @@ +/** + * Script to upload test documents to MinIO + */ + +const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3'); +const { Client: PgClient } = require('pg'); +const fs = require('fs'); +const path = require('path'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +// Create a simple PDF buffer (minimal valid PDF) +function createTestPDF(title) { + return Buffer.from( + `%PDF-1.4 +1 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< +/Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< +/Type /Page +/Parent 2 0 R +/MediaBox [0 0 612 792] +/Contents 4 0 R +/Resources << +/Font << +/F1 << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +endobj +4 0 obj +<< +/Length 100 +>> +stream +BT +/F1 24 Tf +100 700 Td +(${title}) Tj +ET +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000300 00000 n +trailer +<< +/Size 5 +/Root 1 0 R +>> +startxref +450 +%%EOF`, + 'utf-8' + ); +} + +async function uploadTestDocuments() { + const pgClient = new PgClient({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + // Connect to database + await pgClient.connect(); + console.log('✅ Connected to database'); + + // Create bucket if it doesn't exist + try { + await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Created bucket: ${BUCKET_NAME}`); + } catch (error) { + if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') { + console.log(`✅ Bucket already exists: ${BUCKET_NAME}`); + } else { + console.log(`⚠️ Could not create bucket (might already exist): ${error.message}`); + } + } + + // Get all CSV bookings with documents + const result = await pgClient.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`); + + let uploadedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + console.log(`\n📦 Processing booking: ${bookingId}`); + + for (const doc of documents) { + if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) { + console.log(` ⏭️ Skipping document (not a MinIO URL): ${doc.fileName}`); + continue; + } + + // Extract the S3 key from the URL + const url = new URL(doc.filePath); + const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); + + // Create test PDF content + const pdfContent = createTestPDF(doc.fileName || 'Test Document'); + + try { + // Upload to MinIO + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: pdfContent, + ContentType: doc.mimeType || 'application/pdf', + }) + ); + + console.log(` ✅ Uploaded: ${doc.fileName}`); + console.log(` Path: ${key}`); + uploadedCount++; + } catch (error) { + console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message); + } + } + } + + console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`); + console.log(`\n📍 MinIO Console: http://localhost:9001`); + console.log(` Username: minioadmin`); + console.log(` Password: minioadmin`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +uploadTestDocuments() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/frontend/app/dashboard/admin/bookings/page.tsx b/apps/frontend/app/dashboard/admin/bookings/page.tsx new file mode 100644 index 0000000..6010a1c --- /dev/null +++ b/apps/frontend/app/dashboard/admin/bookings/page.tsx @@ -0,0 +1,413 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { getAllBookings } from '@/lib/api/admin'; + +interface Booking { + id: string; + bookingNumber?: string; + bookingId?: string; + type?: string; + status: string; + // CSV bookings use these fields + origin?: string; + destination?: string; + carrierName?: string; + // Regular bookings use these fields + originPort?: { + code: string; + name: string; + }; + destinationPort?: { + code: string; + name: string; + }; + carrier?: string; + containerType: string; + quantity?: number; + price?: number; + primaryCurrency?: string; + totalPrice?: { + amount: number; + currency: string; + }; + createdAt?: string; + updatedAt?: string; + requestedAt?: string; + organizationId: string; + userId: string; +} + +export default function AdminBookingsPage() { + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedBooking, setSelectedBooking] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [filterStatus, setFilterStatus] = useState('all'); + const [searchTerm, setSearchTerm] = useState(''); + + // Helper function to get formatted quote number + const getQuoteNumber = (booking: Booking): string => { + if (booking.type === 'csv') { + return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; + } + return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`; + }; + + useEffect(() => { + fetchBookings(); + }, []); + + const fetchBookings = async () => { + try { + setLoading(true); + const response = await getAllBookings(); + setBookings(response.bookings || []); + setError(null); + } catch (err: any) { + setError(err.message || 'Failed to load bookings'); + } finally { + setLoading(false); + } + }; + + const getStatusColor = (status: string) => { + const colors: Record = { + draft: 'bg-gray-100 text-gray-800', + pending: 'bg-yellow-100 text-yellow-800', + confirmed: 'bg-blue-100 text-blue-800', + in_transit: 'bg-purple-100 text-purple-800', + delivered: 'bg-green-100 text-green-800', + cancelled: 'bg-red-100 text-red-800', + }; + return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; + }; + + const filteredBookings = bookings + .filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus) + .filter(booking => { + if (searchTerm === '') return true; + const searchLower = searchTerm.toLowerCase(); + const quoteNumber = getQuoteNumber(booking).toLowerCase(); + return ( + quoteNumber.includes(searchLower) || + booking.bookingNumber?.toLowerCase().includes(searchLower) || + booking.carrier?.toLowerCase().includes(searchLower) || + booking.carrierName?.toLowerCase().includes(searchLower) || + booking.origin?.toLowerCase().includes(searchLower) || + booking.destination?.toLowerCase().includes(searchLower) + ); + }); + + if (loading) { + return ( +
+
+
+

Loading bookings...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Booking Management

+

+ View and manage all bookings across the platform +

+
+
+ + {/* Filters */} +
+
+
+ + setSearchTerm(e.target.value)} + className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + +
+
+
+ + {/* Stats Cards */} +
+
+
Total Réservations
+
{bookings.length}
+
+
+
En Attente
+
+ {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} +
+
+
+
Acceptées
+
+ {bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length} +
+
+
+
Rejetées
+
+ {bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length} +
+
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Bookings Table */} +
+ + + + + + + + + + + + + + {filteredBookings.map(booking => ( + + + + + + + + + + ))} + +
+ Numéro de devis + + Route + + Transporteur + + Conteneur + + Statut + + Prix + + Actions +
+
+ {getQuoteNumber(booking)} +
+
+ {new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()} +
+
+
+ {booking.originPort ? `${booking.originPort.code} → ${booking.destinationPort?.code}` : `${booking.origin} → ${booking.destination}`} +
+
+ {booking.originPort ? `${booking.originPort.name} → ${booking.destinationPort?.name}` : ''} +
+
+ {booking.carrier || booking.carrierName || 'N/A'} + +
{booking.containerType}
+
+ {booking.quantity ? `Qty: ${booking.quantity}` : ''} +
+
+ + {booking.status} + + + {booking.totalPrice + ? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}` + : booking.price + ? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}` + : 'N/A' + } + + +
+
+ + {/* Details Modal */} + {showDetailsModal && selectedBooking && ( +
+
+
+

Booking Details

+ +
+ +
+
+
+ +
+ {getQuoteNumber(selectedBooking)} +
+
+
+ + + {selectedBooking.status} + +
+
+ +
+

Route Information

+
+
+ +
+ {selectedBooking.originPort ? ( + <> +
{selectedBooking.originPort.code}
+
{selectedBooking.originPort.name}
+ + ) : ( +
{selectedBooking.origin}
+ )} +
+
+
+ +
+ {selectedBooking.destinationPort ? ( + <> +
{selectedBooking.destinationPort.code}
+
{selectedBooking.destinationPort.name}
+ + ) : ( +
{selectedBooking.destination}
+ )} +
+
+
+
+ +
+

Shipping Details

+
+
+ +
+ {selectedBooking.carrier || selectedBooking.carrierName || 'N/A'} +
+
+
+ +
{selectedBooking.containerType}
+
+ {selectedBooking.quantity && ( +
+ +
{selectedBooking.quantity}
+
+ )} +
+
+ +
+

Pricing

+
+ {selectedBooking.totalPrice + ? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}` + : selectedBooking.price + ? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}` + : 'N/A' + } +
+
+ +
+

Timeline

+
+
+ +
+ {new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()} +
+
+ {selectedBooking.updatedAt && ( +
+ +
{new Date(selectedBooking.updatedAt).toLocaleString()}
+
+ )} +
+
+
+ +
+ +
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/app/dashboard/admin/documents/page.tsx b/apps/frontend/app/dashboard/admin/documents/page.tsx new file mode 100644 index 0000000..90f64b9 --- /dev/null +++ b/apps/frontend/app/dashboard/admin/documents/page.tsx @@ -0,0 +1,589 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { getAllBookings, getAllUsers } from '@/lib/api/admin'; + +interface Document { + id: string; + fileName: string; + filePath: string; + type: string; + mimeType: string; + size: number; + uploadedAt?: Date; + // Legacy fields for compatibility + name?: string; + url?: string; +} + +interface Booking { + id: string; + bookingNumber?: string; + bookingId?: string; + type?: string; + userId: string; + organizationId: string; + origin?: string; + destination?: string; + carrierName?: string; + documents: Document[]; + requestedAt?: string; + status: string; +} + +interface DocumentWithBooking extends Document { + bookingId: string; + quoteNumber: string; + userId: string; + userName?: string; + organizationId: string; + route: string; + status: string; + fileName?: string; + fileType?: string; +} + +export default function AdminDocumentsPage() { + const [bookings, setBookings] = useState([]); + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [filterUserId, setFilterUserId] = useState('all'); + const [filterQuoteNumber, setFilterQuoteNumber] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + + // Helper function to get formatted quote number + const getQuoteNumber = (booking: Booking): string => { + if (booking.type === 'csv') { + return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; + } + return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`; + }; + + // Extract filename from MinIO URL + const extractFileName = (url: string): string => { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const parts = pathname.split('/'); + const fileName = parts[parts.length - 1]; + return decodeURIComponent(fileName); + } catch { + return 'document'; + } + }; + + // Get file extension and type + const getFileType = (fileName: string): string => { + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + const typeMap: Record = { + pdf: 'PDF', + doc: 'Word', + docx: 'Word', + xls: 'Excel', + xlsx: 'Excel', + jpg: 'Image', + jpeg: 'Image', + png: 'Image', + gif: 'Image', + txt: 'Text', + csv: 'CSV', + }; + return typeMap[ext] || ext.toUpperCase(); + }; + + useEffect(() => { + fetchBookingsAndDocuments(); + }, []); + + const fetchBookingsAndDocuments = async () => { + try { + setLoading(true); + const response = await getAllBookings(); + const allBookings = response.bookings || []; + setBookings(allBookings); + + // Extract all documents from all bookings + const allDocuments: DocumentWithBooking[] = []; + const userIds = new Set(); + + allBookings.forEach((booking: Booking) => { + userIds.add(booking.userId); + if (booking.documents && booking.documents.length > 0) { + booking.documents.forEach((doc: Document) => { + // Debug: Log document structure + console.log('Document structure:', doc); + + // Use the correct field names from the backend + const actualFileName = doc.fileName || doc.name || 'document'; + const actualFilePath = doc.filePath || doc.url || ''; + const actualMimeType = doc.mimeType || doc.type || ''; + + console.log('Extracted:', { + fileName: actualFileName, + filePath: actualFilePath, + mimeType: actualMimeType, + }); + + // Extract clean file type from mimeType or fileName + let fileType = ''; + if (actualMimeType.includes('/')) { + // It's a MIME type like "application/pdf" + const parts = actualMimeType.split('/'); + fileType = getFileType(parts[1]); + } else { + // It's already a type or we extract from filename + fileType = getFileType(actualFileName); + } + + allDocuments.push({ + ...doc, + bookingId: booking.id, + quoteNumber: getQuoteNumber(booking), + userId: booking.userId, + organizationId: booking.organizationId, + route: `${booking.origin || 'N/A'} → ${booking.destination || 'N/A'}`, + status: booking.status, + fileName: actualFileName, + filePath: actualFilePath, + fileType: fileType, + }); + }); + } + }); + + // Fetch user names using the API client + try { + const usersData = await getAllUsers(); + console.log('Users data:', usersData); + + if (usersData && usersData.users) { + const usersMap = new Map( + usersData.users.map((u: any) => { + const fullName = `${u.firstName || ''} ${u.lastName || ''}`.trim(); + return [u.id, fullName || u.email || u.id.substring(0, 8)]; + }) + ); + + console.log('Users map:', usersMap); + + // Enrich documents with user names + allDocuments.forEach(doc => { + const userName = usersMap.get(doc.userId); + doc.userName = userName || doc.userId.substring(0, 8) + '...'; + console.log(`User ${doc.userId} mapped to: ${doc.userName}`); + }); + } + } catch (userError) { + console.error('Failed to fetch user names:', userError); + // If user fetch fails, keep the userId as fallback + allDocuments.forEach(doc => { + doc.userName = doc.userId.substring(0, 8) + '...'; + }); + } + + setDocuments(allDocuments); + setError(null); + } catch (err: any) { + setError(err.message || 'Failed to load documents'); + } finally { + setLoading(false); + } + }; + + // Get unique users for filter (with names) + const uniqueUsers = Array.from( + new Map( + documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }]) + ).values() + ); + + // Filter documents + const filteredDocuments = documents.filter(doc => { + const matchesSearch = searchTerm === '' || + (doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) || + (doc.name && doc.name.toLowerCase().includes(searchTerm.toLowerCase())) || + (doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) || + doc.route.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesUser = filterUserId === 'all' || doc.userId === filterUserId; + + const matchesQuote = filterQuoteNumber === '' || + doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase()); + + return matchesSearch && matchesUser && matchesQuote; + }); + + // Pagination + const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex); + + // Reset to page 1 when filters change + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, filterUserId, filterQuoteNumber]); + + const getDocumentIcon = (type: string) => { + const typeLower = type.toLowerCase(); + const icons: Record = { + 'application/pdf': '📄', + 'image/jpeg': '🖼️', + 'image/png': '🖼️', + 'image/jpg': '🖼️', + pdf: '📄', + jpeg: '🖼️', + jpg: '🖼️', + png: '🖼️', + gif: '🖼️', + image: '🖼️', + word: '📝', + doc: '📝', + docx: '📝', + excel: '📊', + xls: '📊', + xlsx: '📊', + csv: '📊', + text: '📄', + txt: '📄', + }; + return icons[typeLower] || '📎'; + }; + + const getStatusColor = (status: string) => { + const colors: Record = { + pending: 'bg-yellow-100 text-yellow-800', + accepted: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', + cancelled: 'bg-gray-100 text-gray-800', + }; + return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; + }; + + const handleDownload = async (url: string, fileName: string) => { + try { + // Try direct download first + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.target = '_blank'; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // If direct download doesn't work, try fetch with blob + setTimeout(async () => { + try { + const response = await fetch(url, { + mode: 'cors', + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + const link2 = document.createElement('a'); + link2.href = blobUrl; + link2.download = fileName; + document.body.appendChild(link2); + link2.click(); + document.body.removeChild(link2); + window.URL.revokeObjectURL(blobUrl); + } catch (fetchError) { + console.error('Fetch download failed:', fetchError); + } + }, 100); + } catch (error) { + console.error('Error downloading file:', error); + alert(`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`); + } + }; + + if (loading) { + return ( +
+
+
+

Chargement des documents...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Gestion des Documents

+

+ Liste de tous les documents des devis CSV +

+
+
+ + {/* Stats */} +
+
+
Total Documents
+
{documents.length}
+
+
+
Devis avec Documents
+
+ {bookings.filter(b => b.documents && b.documents.length > 0).length} +
+
+
+
Documents Filtrés
+
{filteredDocuments.length}
+
+
+ + {/* Filters */} +
+
+
+ + setSearchTerm(e.target.value)} + className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + setFilterQuoteNumber(e.target.value)} + className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + +
+
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Documents Table */} +
+ + + + + + + + + + + + + + {paginatedDocuments.length === 0 ? ( + + + + ) : ( + paginatedDocuments.map((doc, index) => ( + + + + + + + + + + )) + )} + +
+ Nom du Document + + Type + + Numéro de Devis + + Route + + Statut + + Utilisateur + + Télécharger +
+ Aucun document trouvé +
+
+ {doc.fileName || doc.name} +
+
+
+ {getDocumentIcon(doc.fileType || doc.type)} +
{doc.fileType || doc.type}
+
+
+
{doc.quoteNumber}
+
+
{doc.route}
+
+ + {doc.status} + + +
+ {doc.userName || doc.userId.substring(0, 8) + '...'} +
+
+ +
+ + {/* Pagination Controls */} + {filteredDocuments.length > 0 && ( +
+
+ + +
+
+
+

+ Affichage de {startIndex + 1} à{' '} + {Math.min(endIndex, filteredDocuments.length)} sur{' '} + {filteredDocuments.length} résultats +

+
+
+
+ + +
+ +
+
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/app/dashboard/admin/organizations/page.tsx b/apps/frontend/app/dashboard/admin/organizations/page.tsx new file mode 100644 index 0000000..e6ffaa3 --- /dev/null +++ b/apps/frontend/app/dashboard/admin/organizations/page.tsx @@ -0,0 +1,421 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { getAllOrganizations } from '@/lib/api/admin'; +import { createOrganization, updateOrganization } from '@/lib/api/organizations'; + +interface Organization { + id: string; + name: string; + type: string; + scac?: string; + siren?: string; + eori?: string; + contact_phone?: string; + contact_email?: string; + address: { + street: string; + city: string; + state?: string; + postalCode: string; + country: string; + }; + logoUrl?: string; + isActive: boolean; + createdAt: string; +} + +export default function AdminOrganizationsPage() { + const [organizations, setOrganizations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedOrg, setSelectedOrg] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + name: '', + type: 'FREIGHT_FORWARDER', + scac: '', + siren: '', + eori: '', + contact_phone: '', + contact_email: '', + address: { + street: '', + city: '', + state: '', + postalCode: '', + country: 'FR', + }, + logoUrl: '', + }); + + useEffect(() => { + fetchOrganizations(); + }, []); + + const fetchOrganizations = async () => { + try { + setLoading(true); + const response = await getAllOrganizations(); + setOrganizations(response.organizations || []); + setError(null); + } catch (err: any) { + setError(err.message || 'Failed to load organizations'); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await createOrganization(formData); + await fetchOrganizations(); + setShowCreateModal(false); + resetForm(); + } catch (err: any) { + alert(err.message || 'Failed to create organization'); + } + }; + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedOrg) return; + + try { + await updateOrganization(selectedOrg.id, formData); + await fetchOrganizations(); + setShowEditModal(false); + setSelectedOrg(null); + resetForm(); + } catch (err: any) { + alert(err.message || 'Failed to update organization'); + } + }; + + const resetForm = () => { + setFormData({ + name: '', + type: 'FREIGHT_FORWARDER', + scac: '', + siren: '', + eori: '', + contact_phone: '', + contact_email: '', + address: { + street: '', + city: '', + state: '', + postalCode: '', + country: 'FR', + }, + logoUrl: '', + }); + }; + + const openEditModal = (org: Organization) => { + setSelectedOrg(org); + setFormData({ + name: org.name, + type: org.type, + scac: org.scac || '', + siren: org.siren || '', + eori: org.eori || '', + contact_phone: org.contact_phone || '', + contact_email: org.contact_email || '', + address: org.address, + logoUrl: org.logoUrl || '', + }); + setShowEditModal(true); + }; + + if (loading) { + return ( +
+
+
+

Loading organizations...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Organization Management

+

+ Manage all organizations in the system +

+
+ +
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Organizations Grid */} +
+ {organizations.map(org => ( +
+
+
+

{org.name}

+ + {org.type.replace('_', ' ')} + +
+ + {org.isActive ? 'Active' : 'Inactive'} + +
+ +
+ {org.scac && ( +
+ SCAC: {org.scac} +
+ )} + {org.siren && ( +
+ SIREN: {org.siren} +
+ )} + {org.contact_email && ( +
+ Email: {org.contact_email} +
+ )} +
+ Location: {org.address.city}, {org.address.country} +
+
+ +
+ +
+
+ ))} +
+ + {/* Create/Edit Modal */} + {(showCreateModal || showEditModal) && ( +
+
+

+ {showCreateModal ? 'Create New Organization' : 'Edit Organization'} +

+
+
+
+ + setFormData({ ...formData, name: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + +
+ + {formData.type === 'CARRIER' && ( +
+ + setFormData({ ...formData, scac: e.target.value.toUpperCase() })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ )} + +
+ + setFormData({ ...formData, siren: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ ...formData, eori: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ ...formData, contact_phone: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ ...formData, contact_email: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ + ...formData, + address: { ...formData.address, street: e.target.value } + })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ + ...formData, + address: { ...formData.address, city: e.target.value } + })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ + ...formData, + address: { ...formData.address, postalCode: e.target.value } + })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ + ...formData, + address: { ...formData.address, state: e.target.value } + })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ + ...formData, + address: { ...formData.address, country: e.target.value.toUpperCase() } + })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ ...formData, logoUrl: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/app/dashboard/admin/users/page.tsx b/apps/frontend/app/dashboard/admin/users/page.tsx new file mode 100644 index 0000000..48529eb --- /dev/null +++ b/apps/frontend/app/dashboard/admin/users/page.tsx @@ -0,0 +1,455 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin'; +import { createUser } from '@/lib/api/users'; +import { getAllOrganizations } from '@/lib/api/admin'; + +interface User { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; + organizationId: string; + organizationName?: string; + isActive: boolean; + createdAt: string; +} + +interface Organization { + id: string; + name: string; +} + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [organizations, setOrganizations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + email: '', + firstName: '', + lastName: '', + role: 'USER', + organizationId: '', + password: '', + }); + + // Fetch users and organizations + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + try { + setLoading(true); + const [usersResponse, orgsResponse] = await Promise.all([ + getAllUsers(), + getAllOrganizations(), + ]); + + setUsers(usersResponse.users || []); + setOrganizations(orgsResponse.organizations || []); + setError(null); + } catch (err: any) { + setError(err.message || 'Failed to load data'); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await createUser(formData); + await fetchData(); + setShowCreateModal(false); + resetForm(); + } catch (err: any) { + alert(err.message || 'Failed to create user'); + } + }; + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedUser) return; + + try { + await updateAdminUser(selectedUser.id, { + firstName: formData.firstName, + lastName: formData.lastName, + role: formData.role, + isActive: selectedUser.isActive, + }); + await fetchData(); + setShowEditModal(false); + setSelectedUser(null); + resetForm(); + } catch (err: any) { + alert(err.message || 'Failed to update user'); + } + }; + + const handleDelete = async () => { + if (!selectedUser) return; + + try { + await deleteAdminUser(selectedUser.id); + await fetchData(); + setShowDeleteConfirm(false); + setSelectedUser(null); + } catch (err: any) { + alert(err.message || 'Failed to delete user'); + } + }; + + const resetForm = () => { + setFormData({ + email: '', + firstName: '', + lastName: '', + role: 'USER', + organizationId: '', + password: '', + }); + }; + + const openEditModal = (user: User) => { + setSelectedUser(user); + setFormData({ + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + organizationId: user.organizationId, + password: '', + }); + setShowEditModal(true); + }; + + const openDeleteConfirm = (user: User) => { + setSelectedUser(user); + setShowDeleteConfirm(true); + }; + + if (loading) { + return ( +
+
+
+

Loading users...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

User Management

+

+ Manage all users in the system +

+
+ +
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Users Table */} +
+ + + + + + + + + + + + + {users.map(user => ( + + + + + + + + + ))} + +
+ User + + Email + + Role + + Organization + + Status + + Actions +
+
+ {user.firstName} {user.lastName} +
+
+
{user.email}
+
+ + {user.role} + + + {user.organizationName || user.organizationId} + + + {user.isActive ? 'Active' : 'Inactive'} + + + + +
+
+ + {/* Create Modal */} + {showCreateModal && ( +
+
+

Create New User

+
+
+ + setFormData({ ...formData, email: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + setFormData({ ...formData, firstName: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + setFormData({ ...formData, lastName: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + +
+
+ + +
+
+ + setFormData({ ...formData, password: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + +
+
+
+
+ )} + + {/* Edit Modal */} + {showEditModal && selectedUser && ( +
+
+

Edit User

+
+
+ + +
+
+ + setFormData({ ...formData, firstName: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + setFormData({ ...formData, lastName: e.target.value })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + +
+
+ + +
+
+
+
+ )} + + {/* Delete Confirmation */} + {showDeleteConfirm && selectedUser && ( +
+
+

Confirm Delete

+

+ Are you sure you want to delete user {selectedUser.firstName} {selectedUser.lastName}? + This action cannot be undone. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/app/dashboard/bookings/page.tsx b/apps/frontend/app/dashboard/bookings/page.tsx index 18a8ea6..a59096a 100644 --- a/apps/frontend/app/dashboard/bookings/page.tsx +++ b/apps/frontend/app/dashboard/bookings/page.tsx @@ -327,18 +327,9 @@ export default function BookingsListPage() { : 'N/A'} - {booking.type === 'csv' ? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}` : booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`} - ))} diff --git a/apps/frontend/app/dashboard/layout.tsx b/apps/frontend/app/dashboard/layout.tsx index e5a20ca..50872ae 100644 --- a/apps/frontend/app/dashboard/layout.tsx +++ b/apps/frontend/app/dashboard/layout.tsx @@ -11,6 +11,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useState } from 'react'; import NotificationDropdown from '@/components/NotificationDropdown'; +import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown'; import Image from 'next/image'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { @@ -18,20 +19,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod const pathname = usePathname(); const [sidebarOpen, setSidebarOpen] = useState(false); + // { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' }, + const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: '📊' }, { name: 'Bookings', href: '/dashboard/bookings', icon: '📦' }, - { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' }, { name: 'My Profile', href: '/dashboard/profile', icon: '👤' }, { name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' }, // ADMIN and MANAGER only navigation items ...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [ { name: 'Users', href: '/dashboard/settings/users', icon: '👥' }, ] : []), - // ADMIN only navigation items - ...(user?.role === 'ADMIN' ? [ - { name: 'CSV Rates', href: '/dashboard/admin/csv-rates', icon: '📄' }, - ] : []), ]; const isActive = (href: string) => { @@ -101,6 +99,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod {item.name} ))} + + {/* Admin Panel - ADMIN role only */} + {user?.role === 'ADMIN' && ( +
+ +
+ )} {/* User section */} diff --git a/apps/frontend/src/components/admin/AdminPanelDropdown.tsx b/apps/frontend/src/components/admin/AdminPanelDropdown.tsx new file mode 100644 index 0000000..9e51501 --- /dev/null +++ b/apps/frontend/src/components/admin/AdminPanelDropdown.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +interface AdminMenuItem { + name: string; + href: string; + icon: string; + description: string; +} + +const adminMenuItems: AdminMenuItem[] = [ + { + name: 'Users', + href: '/dashboard/admin/users', + icon: '👥', + description: 'Manage users and permissions', + }, + { + name: 'Organizations', + href: '/dashboard/admin/organizations', + icon: '🏢', + description: 'Manage organizations and companies', + }, + { + name: 'Bookings', + href: '/dashboard/admin/bookings', + icon: '📦', + description: 'View and manage all bookings', + }, + { + name: 'Documents', + href: '/dashboard/admin/documents', + icon: '📄', + description: 'Manage organization documents', + }, + { + name: 'CSV Rates', + href: '/dashboard/admin/csv-rates', + icon: '📊', + description: 'Upload and manage CSV rates', + }, +]; + +export default function AdminPanelDropdown() { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const pathname = usePathname(); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Close dropdown when route changes + useEffect(() => { + setIsOpen(false); + }, [pathname]); + + const isActiveRoute = adminMenuItems.some(item => pathname.startsWith(item.href)); + + return ( +
+ {/* Trigger Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+
+ {adminMenuItems.map(item => { + const isActive = pathname.startsWith(item.href); + return ( + + {item.icon} +
+
+ {item.name} +
+
+ {item.description} +
+
+ + ); + })} +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/lib/api/admin.ts b/apps/frontend/src/lib/api/admin.ts new file mode 100644 index 0000000..a06d3fb --- /dev/null +++ b/apps/frontend/src/lib/api/admin.ts @@ -0,0 +1,132 @@ +/** + * Admin API + * + * Dedicated endpoints for admin-only operations that return ALL data from the database + * without organization filtering. + * + * All endpoints require ADMIN role. + */ + +import { get, post, patch, del } from './client'; +import type { + UserResponse, + UserListResponse, + OrganizationResponse, + OrganizationListResponse, + BookingResponse, + BookingListResponse, + UpdateUserRequest, +} from '@/types/api'; + +// ==================== USERS ==================== + +/** + * Get ALL users from database (admin only) + * GET /api/v1/admin/users + * Returns all users regardless of status or organization + * Requires: ADMIN role + */ +export async function getAllUsers(): Promise { + return get('/api/v1/admin/users'); +} + +/** + * Get user by ID (admin only) + * GET /api/v1/admin/users/:id + * Requires: ADMIN role + */ +export async function getAdminUser(id: string): Promise { + return get(`/api/v1/admin/users/${id}`); +} + +/** + * Update user (admin only) + * PATCH /api/v1/admin/users/:id + * Can update any user from any organization + * Requires: ADMIN role + */ +export async function updateAdminUser(id: string, data: UpdateUserRequest): Promise { + return patch(`/api/v1/admin/users/${id}`, data); +} + +/** + * Delete user (admin only) + * DELETE /api/v1/admin/users/:id + * Permanently deletes user from database + * Requires: ADMIN role + */ +export async function deleteAdminUser(id: string): Promise { + return del(`/api/v1/admin/users/${id}`); +} + +// ==================== ORGANIZATIONS ==================== + +/** + * Get ALL organizations from database (admin only) + * GET /api/v1/admin/organizations + * Returns all organizations regardless of status + * Requires: ADMIN role + */ +export async function getAllOrganizations(): Promise { + return get('/api/v1/admin/organizations'); +} + +/** + * Get organization by ID (admin only) + * GET /api/v1/admin/organizations/:id + * Requires: ADMIN role + */ +export async function getAdminOrganization(id: string): Promise { + return get(`/api/v1/admin/organizations/${id}`); +} + +// ==================== BOOKINGS ==================== + +/** + * Get ALL bookings from database (admin only) + * GET /api/v1/admin/bookings + * Returns all bookings from all organizations + * Requires: ADMIN role + */ +export async function getAllBookings(): Promise { + return get('/api/v1/admin/bookings'); +} + +/** + * Get booking by ID (admin only) + * GET /api/v1/admin/bookings/:id + * Requires: ADMIN role + */ +export async function getAdminBooking(id: string): Promise { + return get(`/api/v1/admin/bookings/${id}`); +} + +// ==================== DOCUMENTS ==================== + +/** + * Get ALL documents from all organizations (admin only) + * GET /api/v1/admin/documents + * Returns documents grouped by organization + * Requires: ADMIN role + */ +export async function getAllDocuments(): Promise<{ + documents: any[]; + total: number; + organizationCount: number; +}> { + return get('/api/v1/admin/documents'); +} + +/** + * Get documents for a specific organization (admin only) + * GET /api/v1/admin/organizations/:id/documents + * Requires: ADMIN role + */ +export async function getOrganizationDocuments(organizationId: string): Promise<{ + organizationId: string; + organizationName: string; + documents: any[]; + total: number; +}> { + return get(`/api/v1/admin/organizations/${organizationId}/documents`); +}