Commit 049b121e by Augusto

UnikeInspections

parent 32c2842e
# Inspection Photos CRUD
This document describes the new Inspection Photos CRUD functionality that has been added to the Cellnex API.
## Overview
The Inspection Photos module provides full CRUD (Create, Read, Update, Delete) operations for photos associated with inspections. Each inspection can have multiple photos, and each photo can have an optional description.
## Database Schema
The `InspectionPhoto` model has been updated to include a `description` field:
```prisma
model InspectionPhoto {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
description String? // New field
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inspectionId Int
inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
@@index([inspectionId])
}
```
## API Endpoints
### 1. Upload Inspection Photo
- **POST** `/inspection-photos`
- **Description**: Upload a new photo for an inspection
- **Authentication**: Required (JWT)
- **Roles**: ADMIN, MANAGER, OPERATOR, PARTNER, SUPERADMIN
- **Content-Type**: `multipart/form-data`
- **Body**:
- `inspectionId` (number, required): ID of the inspection
- `description` (string, optional): Description for the photo
- `photo` (file, required): Photo file (max 5MB, images only)
### 2. Get All Inspection Photos
- **GET** `/inspection-photos`
- **Description**: Retrieve all inspection photos with optional filtering
- **Authentication**: Required (JWT)
- **Query Parameters**:
- `inspectionId` (optional): Filter by inspection ID
### 3. Get Photos by Inspection ID
- **GET** `/inspection-photos/inspection/:inspectionId`
- **Description**: Get all photos for a specific inspection
- **Authentication**: Required (JWT)
- **Path Parameters**:
- `inspectionId` (required): ID of the inspection
### 4. Get Inspection Photo by ID
- **GET** `/inspection-photos/:id`
- **Description**: Get a specific inspection photo by its ID
- **Authentication**: Required (JWT)
- **Path Parameters**:
- `id` (required): ID of the inspection photo
### 5. Update Inspection Photo
- **PUT** `/inspection-photos/:id`
- **Description**: Update the description of an inspection photo
- **Authentication**: Required (JWT)
- **Roles**: ADMIN, MANAGER, OPERATOR, PARTNER, SUPERADMIN
- **Path Parameters**:
- `id` (required): ID of the inspection photo
- **Body**:
- `description` (string, optional): New description for the photo
### 6. Delete Inspection Photo
- **DELETE** `/inspection-photos/:id`
- **Description**: Delete an inspection photo and remove the file from storage
- **Authentication**: Required (JWT)
- **Roles**: ADMIN, MANAGER, OPERATOR, PARTNER, SUPERADMIN
- **Path Parameters**:
- `id` (required): ID of the inspection photo
## File Storage
Photos are stored in the file system under the following structure:
- Development: `uploads/inspection/{inspectionId}/`
- Production: `/home/api-cellnex/public_html/uploads/inspection/{inspectionId}/`
## File Validation
- **File Types**: Only image files (jpg, jpeg, png, gif) are allowed
- **File Size**: Maximum 5MB per file
- **File Naming**: Original filename is preserved
## Integration with Existing Inspection Module
The existing inspection creation process still supports uploading multiple photos during inspection creation. The new CRUD module provides additional functionality for:
1. Adding photos to existing inspections
2. Updating photo descriptions
3. Deleting individual photos
4. Managing photos independently of the inspection creation process
## Response Format
All endpoints return data in the following format:
```typescript
{
id: number;
url: string;
filename: string;
mimeType: string;
size: number;
description?: string;
inspectionId: number;
createdAt: Date;
updatedAt: Date;
}
```
## Error Handling
The module includes comprehensive error handling for:
- Invalid file types
- File size limits
- Non-existent inspections
- Non-existent photos
- Permission errors
- File system errors
## Usage Examples
### Upload a Photo
```bash
curl -X POST http://localhost:3000/inspection-photos \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "inspectionId=1" \
-F "description=Front view of the site" \
-F "photo=@/path/to/photo.jpg"
```
### Get Photos for an Inspection
```bash
curl -X GET http://localhost:3000/inspection-photos/inspection/1 \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### Update Photo Description
```bash
curl -X PUT http://localhost:3000/inspection-photos/1 \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"description": "Updated description"}'
```
### Delete a Photo
```bash
curl -X DELETE http://localhost:3000/inspection-photos/1 \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
# Automatic Inspection Scheduling
This feature automatically creates new inspections for sites that need them, based on the annual inspection cycle.
## How it works
1. **Annual Cycle**: Each site should be inspected annually
2. **3-Month Advance Notice**: New inspections are automatically created 3 months before the 1-year anniversary of the last inspection
3. **Automatic Creation**: The system creates inspections with default "NA" (Not Applicable) responses that need to be completed by inspectors
## Features
### Manual Endpoints
#### Check Sites Needing Inspections
```
GET /api/inspection/scheduling/check
```
Returns a list of sites that need new inspections within the next 3 months.
**Response:**
```json
[
{
"siteId": 1,
"siteCode": "SITE001",
"siteName": "Site Name",
"lastInspectionDate": "2024-05-21T13:00:00.000Z",
"nextInspectionDate": "2025-05-21T13:00:00.000Z"
}
]
```
#### Create Automatic Inspections
```
POST /api/inspection/scheduling/create
```
Automatically creates new inspections for all sites that need them.
**Response:**
```json
{
"created": 5,
"sites": [
{
"siteId": 1,
"siteCode": "SITE001",
"siteName": "Site Name",
"inspectionId": 123
}
]
}
```
### Scheduled Tasks
#### Daily Check (6:00 AM)
- Automatically checks for sites needing inspections
- Creates new inspections if needed
- Logs the results
#### Weekly Check (Monday 8:00 AM)
- Backup check to ensure no sites are missed
- Logs sites that still need inspections
- Does not create inspections automatically
## Logic
### When a site needs an inspection:
1. **Never inspected**: Site has no previous inspections
2. **Annual anniversary**: 1 year has passed since the last inspection
3. **3-month advance**: The 1-year anniversary is within the next 3 months
### Example timeline:
- **May 2024**: Site is inspected
- **February 2025**: System detects that the 1-year anniversary (May 2025) is within 3 months
- **February 2025**: Automatic inspection is created with "NA" responses
- **May 2025**: Inspector should complete the inspection
## Permissions
- **Check endpoint**: ADMIN, MANAGER, OPERATOR
- **Create endpoint**: ADMIN, MANAGER, OPERATOR
- **Scheduled tasks**: Run automatically without authentication
## Configuration
The scheduling is configured in `src/modules/inspection/inspection-scheduler.service.ts`:
- Daily check: `CronExpression.EVERY_DAY_AT_6AM`
- Weekly check: `0 8 * * 1` (Monday 8:00 AM)
## Monitoring
Check the application logs for:
- Daily inspection scheduling results
- Weekly inspection check results
- Any errors during the process
## Manual Override
If you need to manually trigger the process:
1. Use the check endpoint to see what sites need inspections
2. Use the create endpoint to manually create inspections
3. The scheduled tasks will continue to run automatically
## Notes
- Inspections are created with the current date and status "PENDING"
- All questions are set to "NA" (Not Applicable) by default
- Comments indicate the inspection was automatically generated
- Inspectors need to complete the responses and add photos
- The system logs all activities for monitoring
## Inspection Status
Inspections now have a status field to track their lifecycle:
- **PENDING**: Inspection is created but not yet started (default for automatic inspections)
- **IN_PROGRESS**: Inspection is currently being performed
- **COMPLETED**: Inspection has been finished with all responses
- **CANCELLED**: Inspection was cancelled and won't be completed
### Status Management
- **Update Status**: Use `PATCH /api/inspection/{id}/status` to change the status
- **Filter by Status**: Use the `status` query parameter in `GET /api/inspection` to filter inspections
- **Automatic Status**: New inspections created automatically start with "PENDING" status
# Site Maintenance Feature
This feature allows for tracking and managing site maintenance records, including form responses and photos.
## Setup Instructions for New Client
1. Initialize the database with the maintenance models:
```bash
# Run the clean setup script
./scripts/clean-setup-for-new-client.sh
```
This script will:
- Back up the current schema
- Remove existing migration history
- Generate a fresh Prisma client
- Create a new initial migration with all models
- Seed the database with maintenance questions
2. If you encounter type errors after changing the schema, run:
```bash
# Update the Prisma client
./scripts/fix-prisma-client.sh
```
## Maintenance Model
Each maintenance record includes:
- Date of maintenance
- Site association
- Optional general comment
- Set of responses to predefined questions
- Optional photos documenting the site condition
## Response Options
The maintenance form uses three standard response options:
- YES - The item is in good condition/working properly
- NO - The item needs attention/repair
- NA - Not applicable for this site
## API Endpoints
- `POST /maintenance` - Create a new maintenance record
- `GET /maintenance` - List all maintenance records with filtering options
- `GET /maintenance/:id` - Get details of a specific maintenance record
- `GET /maintenance/questions` - Get the list of maintenance questions
## Roles and Permissions
The following roles can create maintenance records:
- ADMIN
- MANAGER
- OPERATOR
- PARTNER
All authenticated users can view maintenance records.
## Photos
Maintenance photos are stored in the `uploads/maintenance/{maintenanceId}` directory.
Each photo is associated with a specific maintenance record.
\ No newline at end of file
# New Client Setup Guide
This guide provides step-by-step instructions for setting up the Cellnex API for a new client with the Site Maintenance feature.
## Initial Setup
1. Create a new branch for the client:
```bash
git checkout -b client-name
```
2. Set up the database and environment variables:
```bash
# Run the setup script with your database details
./scripts/setup-new-client-db.sh --name client_db --user client_user --password your_password
```
This script will:
- Create a .env file with database configuration
- Create the database and user (if PostgreSQL is available locally)
- Enable the PostGIS extension
- Run initial migrations
- Seed maintenance questions
## Manual Database Setup (if automatic setup doesn't work)
If you need to set up the database manually:
1. Create a .env file with your database connection string:
```
DATABASE_URL=postgresql://username:password@localhost:5432/database_name?schema=public
```
2. Create the database and enable the PostGIS extension:
```sql
CREATE DATABASE database_name;
CREATE USER username WITH ENCRYPTED PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE database_name TO username;
\c database_name
CREATE EXTENSION IF NOT EXISTS postgis;
```
3. Run the migration and seed scripts:
```bash
# Generate Prisma client
npx prisma generate
# Run migrations
npx prisma migrate dev --name init_with_maintenance_models
# Seed maintenance questions
npx ts-node prisma/seed-maintenance.ts
```
## File Structure
The maintenance feature consists of the following components:
- **Models**: Defined in `prisma/schema.prisma`
- `Maintenance`: Main maintenance record
- `MaintenanceResponse`: Responses to maintenance questions
- `MaintenanceQuestion`: Predefined maintenance questions
- `MaintenancePhoto`: Photos attached to maintenance records
- **Module**: Located in `src/modules/maintenance/`
- Controllers
- Services
- DTOs
- Utilities for file handling
- **Uploads**: Photos are stored in `uploads/maintenance/{maintenanceId}/`
## API Endpoints
The maintenance feature provides the following endpoints:
- `POST /api/maintenance` - Create a new maintenance record
- `GET /api/maintenance` - List all maintenance records with filtering options
- `GET /api/maintenance/:id` - Get details of a specific maintenance record
- `GET /api/maintenance/questions` - Get the list of maintenance questions
## Development
To run the application in development mode:
```bash
# Install dependencies
npm install
# Start the development server
npm run start:dev
```
The API will be available at http://localhost:3001/api/
Swagger documentation is available at http://localhost:3001/docs
## Troubleshooting
### Prisma Client Type Errors
If you encounter type errors with the Prisma client:
```bash
# Update the Prisma client
./scripts/fix-prisma-client.sh
```
### File Upload Issues
If you encounter issues with file uploads:
1. Ensure the uploads directory exists and has proper permissions:
```bash
mkdir -p uploads/maintenance
chmod 755 uploads uploads/maintenance
```
2. Check if the environment variable `NODE_ENV` is set correctly (development/production).
### Database Connection Issues
If you can't connect to the database:
1. Verify the DATABASE_URL in your .env file
2. Ensure the database server is running
3. Check that the user has proper permissions
## Deployment
For production deployment:
1. Set the NODE_ENV environment variable to "production"
2. Update the DATABASE_URL in .env
3. Build the application:
```bash
npm run build
```
4. Start the production server:
```bash
npm run start:prod
```
5. Ensure the uploads directory in production has proper permissions.
\ No newline at end of file
# Inspection Questions Module
This module provides a complete CRUD (Create, Read, Update, Delete) interface for managing inspection questions in the Cellnex API.
## Overview
The Questions module allows administrators to manage the questions that are used in site inspections. Each question has an order index that determines the sequence in which questions appear during inspections.
## Features
- **Create Questions**: Add new inspection questions with validation
- **List Questions**: Get all questions with pagination and search functionality
- **Get Question by ID**: Retrieve a specific question
- **Update Questions**: Modify existing questions
- **Delete Questions**: Remove questions (only if they have no associated responses)
- **Reorder Questions**: Change the order of questions using their IDs
## API Endpoints
### Authentication
All endpoints require JWT authentication. Admin and SuperAdmin roles are required for create, update, and delete operations.
### Base URL
```
/api/questions
```
### Endpoints
#### POST `/questions`
Create a new inspection question.
**Required Role**: ADMIN, SUPERADMIN
**Request Body**:
```json
{
"question": "Is the site properly secured?",
"orderIndex": 1
}
```
**Response**:
```json
{
"id": 1,
"question": "Is the site properly secured?",
"orderIndex": 1,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
```
#### GET `/questions`
Get all questions with pagination and search.
**Query Parameters**:
- `search` (optional): Search questions by text
- `page` (optional): Page number (default: 1)
- `limit` (optional): Items per page (default: 10)
**Response**:
```json
{
"questions": [
{
"id": 1,
"question": "Is the site properly secured?",
"orderIndex": 1,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
],
"total": 1,
"page": 1,
"limit": 10,
"totalPages": 1
}
```
#### GET `/questions/:id`
Get a specific question by ID.
**Response**:
```json
{
"id": 1,
"question": "Is the site properly secured?",
"orderIndex": 1,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
```
#### PUT `/questions/:id`
Update an existing question.
**Required Role**: ADMIN, SUPERADMIN
**Request Body**:
```json
{
"question": "Updated question text?",
"orderIndex": 2
}
```
#### DELETE `/questions/:id`
Delete a question.
**Required Role**: ADMIN, SUPERADMIN
**Note**: Questions can only be deleted if they have no associated responses.
#### POST `/questions/reorder`
Reorder questions by providing an array of question IDs in the desired order.
**Required Role**: ADMIN, SUPERADMIN
**Request Body**:
```json
[3, 1, 2, 4]
```
**Response**:
```json
[
{
"id": 3,
"question": "Question 3",
"orderIndex": 1,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
},
{
"id": 1,
"question": "Question 1",
"orderIndex": 2,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
]
```
## Validation Rules
- **question**: Required string, maximum 500 characters
- **orderIndex**: Required integer, minimum value 1
- **orderIndex uniqueness**: Each order index must be unique across all questions
## Error Handling
The module provides comprehensive error handling:
- **400 Bad Request**: Validation errors
- **401 Unauthorized**: Missing or invalid JWT token
- **403 Forbidden**: Insufficient permissions
- **404 Not Found**: Question not found
- **409 Conflict**: Order index already exists or question has associated responses
## Integration with Inspection Module
The Questions module is integrated with the Inspection module:
- When creating automatic inspections, the system fetches all questions using the questions service
- Questions are ordered by their `orderIndex` field
- The inspection service uses the questions service instead of directly accessing the database
## Database Schema
The module works with the `InspectionQuestion` model:
```prisma
model InspectionQuestion {
id Int @id @default(autoincrement())
question String
orderIndex Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
responses InspectionResponse[]
}
```
## Testing
The module includes unit tests for the service layer. Run tests with:
```bash
npm run test questions.service.spec.ts
```
## Usage Examples
### Creating a new question
```bash
curl -X POST http://localhost:3000/api/questions \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"question": "Is the site properly secured?",
"orderIndex": 1
}'
```
### Getting all questions
```bash
curl -X GET "http://localhost:3000/api/questions?page=1&limit=10" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### Reordering questions
```bash
curl -X POST http://localhost:3000/api/questions/reorder \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '[3, 1, 2, 4]'
```
# Telecommunication Station Identification Module
This module provides a complete CRUD (Create, Read, Update, Delete) interface for managing telecommunication station identification records in the Cellnex API.
## Overview
The Telecommunication Station Identification module allows administrators to manage identification records for telecommunications stations and their anti-fall systems. Each record is associated with a specific site and contains information about the station identifier, serial numbers, certification status, and model references.
## Features
- **Create Identification**: Add new telecommunication station identification records with validation
- **List Identifications**: Get all identification records with pagination and search functionality
- **Get Identification by ID**: Retrieve a specific identification record
- **Get Identification by Site ID**: Retrieve identification record for a specific site
- **Update Identifications**: Modify existing identification records
- **Delete Identifications**: Remove identification records
## Database Schema
The module works with the `TelecommunicationStationIdentification` model:
```prisma
model TelecommunicationStationIdentification {
id Int @id @default(autoincrement())
siteId Int @unique
stationIdentifier String @unique
serialNumber String
isFirstCertification Boolean
modelReference String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@index([siteId])
@@index([stationIdentifier])
}
```
## API Endpoints
### Authentication
All endpoints require JWT authentication. Admin and SuperAdmin roles are required for create, update, and delete operations.
### Base URL
```
/api/telecommunication-station-identification
```
### Endpoints
#### POST `/telecommunication-station-identification`
Create a new telecommunication station identification record.
**Required Role**: ADMIN, SUPERADMIN
**Request Body**:
```json
{
"siteId": 1,
"stationIdentifier": "19857_BARCA DE ALVA TX",
"serialNumber": "1234567890",
"isFirstCertification": true,
"modelReference": "1234567890"
}
```
**Response**:
```json
{
"id": 1,
"siteId": 1,
"stationIdentifier": "19857_BARCA DE ALVA TX",
"serialNumber": "1234567890",
"isFirstCertification": true,
"modelReference": "1234567890",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
```
#### GET `/telecommunication-station-identification`
Get all identification records with pagination and search.
**Query Parameters**:
- `stationIdentifier` (optional): Search by station identifier (partial match)
- `serialNumber` (optional): Search by serial number (partial match)
- `modelReference` (optional): Search by model reference (partial match)
- `isFirstCertification` (optional): Filter by first certification status
- `siteId` (optional): Filter by site ID
- `page` (optional): Page number (default: 1)
- `limit` (optional): Items per page (default: 10)
**Response**:
```json
{
"identifications": [
{
"id": 1,
"siteId": 1,
"stationIdentifier": "19857_BARCA DE ALVA TX",
"serialNumber": "1234567890",
"isFirstCertification": true,
"modelReference": "1234567890",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
],
"total": 1,
"page": 1,
"limit": 10,
"totalPages": 1
}
```
#### GET `/telecommunication-station-identification/:id`
Get a specific identification record by ID.
**Response**:
```json
{
"id": 1,
"siteId": 1,
"stationIdentifier": "19857_BARCA DE ALVA TX",
"serialNumber": "1234567890",
"isFirstCertification": true,
"modelReference": "1234567890",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
```
#### GET `/telecommunication-station-identification/site/:siteId`
Get identification record for a specific site.
**Response**:
```json
{
"id": 1,
"siteId": 1,
"stationIdentifier": "19857_BARCA DE ALVA TX",
"serialNumber": "1234567890",
"isFirstCertification": true,
"modelReference": "1234567890",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
```
#### PUT `/telecommunication-station-identification/:id`
Update an existing identification record.
**Required Role**: ADMIN, SUPERADMIN
**Request Body**:
```json
{
"stationIdentifier": "19857_BARCA DE ALVA TX_UPDATED",
"serialNumber": "1234567890_UPDATED",
"isFirstCertification": false,
"modelReference": "1234567890_UPDATED"
}
```
#### DELETE `/telecommunication-station-identification/:id`
Delete an identification record.
**Required Role**: ADMIN, SUPERADMIN
## Validation Rules
- **siteId**: Required integer, must reference an existing site
- **stationIdentifier**: Required string, maximum 255 characters, must be unique
- **serialNumber**: Required string, maximum 255 characters
- **isFirstCertification**: Required boolean
- **modelReference**: Required string, maximum 255 characters
## Business Rules
- Each site can have only one telecommunication station identification record
- Station identifiers must be unique across all records
- When updating a record, the new site ID must not already have an identification record
- When updating a record, the new station identifier must not already exist
## Error Handling
The module provides comprehensive error handling:
- **400 Bad Request**: Validation errors
- **401 Unauthorized**: Missing or invalid JWT token
- **403 Forbidden**: Insufficient permissions
- **404 Not Found**: Record not found
- **409 Conflict**: Site already has identification or station identifier already exists
## Integration with Site Module
The Telecommunication Station Identification module is integrated with the Site module:
- Each identification record is linked to a specific site via a one-to-one relationship
- The site model includes a reference to its telecommunication station identification
- Cascade deletion ensures that when a site is deleted, its identification record is also deleted
## Testing
The module includes unit tests for the service layer. Run tests with:
```bash
npm run test telecommunication-station-identification.service.spec.ts
```
## Usage Examples
### Creating a new identification record
```bash
curl -X POST http://localhost:3000/api/telecommunication-station-identification \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"siteId": 1,
"stationIdentifier": "19857_BARCA DE ALVA TX",
"serialNumber": "1234567890",
"isFirstCertification": true,
"modelReference": "1234567890"
}'
```
### Getting identification by site ID
```bash
curl -X GET "http://localhost:3000/api/telecommunication-station-identification/site/1" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### Searching identifications
```bash
curl -X GET "http://localhost:3000/api/telecommunication-station-identification?stationIdentifier=BARCA&page=1&limit=10" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
## Database Migration
After adding the new model to the Prisma schema, you'll need to create and run a migration:
```bash
npx prisma migrate dev --name add_telecommunication_station_identification
```
This will create the necessary database tables and update the Prisma client.
# Real-time Inspection Collaboration with WebSockets
This feature enables multiple inspectors to work on the same inspection simultaneously in real-time, seeing each other's updates instantly.
## Features
### Real-time Collaboration
- **Live Updates**: When one inspector updates a response, all other inspectors see it immediately
- **Status Synchronization**: Inspection status changes are broadcast to all connected users
- **User Presence**: See who else is currently working on the inspection
- **Typing Indicators**: Know when someone is typing a response
- **Room Management**: Automatic cleanup of empty inspection rooms
### WebSocket Events
#### Client to Server Events
| Event | Description | Payload |
| ----------------- | ------------------------ | --------------------------------------------------------------------------------- |
| `joinInspection` | Join an inspection room | `{ inspectionId: number }` |
| `leaveInspection` | Leave an inspection room | `{ inspectionId: number }` |
| `updateResponse` | Update a response | `{ inspectionId: number, response: CreateInspectionResponseDto, userId: number }` |
| `updateStatus` | Update inspection status | `{ inspectionId: number, status: string, userId: number }` |
| `typing` | Send typing indicator | `{ inspectionId: number, questionId: number, isTyping: boolean }` |
| `getActiveUsers` | Get list of active users | `{ inspectionId: number }` |
#### Server to Client Events
| Event | Description | Payload |
| ----------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| `inspectionData` | Current inspection data | `InspectionDto` |
| `responseUpdated` | Response was updated | `{ inspectionId: number, response: InspectionResponseDto, updatedBy: string, timestamp: Date }` |
| `statusUpdated` | Status was updated | `{ inspectionId: number, status: string, updatedBy: string, timestamp: Date }` |
| `userJoined` | User joined the room | `{ userId: string, inspectionId: number, timestamp: Date }` |
| `userLeft` | User left the room | `{ userId: string, inspectionId: number, timestamp: Date }` |
| `userTyping` | User is typing | `{ userId: string, inspectionId: number, questionId: number, isTyping: boolean, timestamp: Date }` |
| `activeUsers` | List of active users | `{ inspectionId: number, users: string[], count: number, timestamp: Date }` |
| `error` | Error occurred | `{ message: string, details?: string }` |
## Connection
### WebSocket URL
```
ws://localhost:3000/inspection
```
### Authentication
Connect with JWT token in the auth object:
```javascript
const socket = io('http://localhost:3000/inspection', {
auth: {
token: 'your-jwt-token-here',
},
});
```
## Usage Examples
### JavaScript/TypeScript Client
```javascript
import { io } from 'socket.io-client';
// Connect to WebSocket
const socket = io('http://localhost:3000/inspection', {
auth: {
token: 'your-jwt-token',
},
});
// Join an inspection
socket.emit('joinInspection', { inspectionId: 123 });
// Listen for updates
socket.on('responseUpdated', (data) => {
console.log('Response updated:', data.response);
// Update your UI here
});
socket.on('statusUpdated', (data) => {
console.log('Status updated:', data.status);
// Update status in UI
});
socket.on('userJoined', (data) => {
console.log('User joined:', data.userId);
// Show user joined notification
});
// Update a response
socket.emit('updateResponse', {
inspectionId: 123,
response: {
questionId: 1,
response: 'YES',
comment: 'Equipment is working properly',
},
userId: 1,
});
// Update status
socket.emit('updateStatus', {
inspectionId: 123,
status: 'IN_PROGRESS',
userId: 1,
});
// Send typing indicator
socket.emit('typing', {
inspectionId: 123,
questionId: 1,
isTyping: true,
});
// Get active users
socket.emit('getActiveUsers', { inspectionId: 123 });
socket.on('activeUsers', (data) => {
console.log('Active users:', data.users);
});
```
### React Hook Example
```javascript
import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';
export const useInspectionWebSocket = (inspectionId, token) => {
const [socket, setSocket] = useState(null);
const [responses, setResponses] = useState([]);
const [status, setStatus] = useState('PENDING');
const [activeUsers, setActiveUsers] = useState([]);
useEffect(() => {
if (!inspectionId || !token) return;
const newSocket = io('http://localhost:3000/inspection', {
auth: { token },
});
newSocket.emit('joinInspection', { inspectionId });
newSocket.on('inspectionData', (data) => {
setResponses(data.responses);
setStatus(data.status);
});
newSocket.on('responseUpdated', (data) => {
setResponses((prev) =>
prev.map((r) => (r.id === data.response.id ? data.response : r)),
);
});
newSocket.on('statusUpdated', (data) => {
setStatus(data.status);
});
newSocket.on('activeUsers', (data) => {
setActiveUsers(data.users);
});
setSocket(newSocket);
return () => {
newSocket.emit('leaveInspection', { inspectionId });
newSocket.disconnect();
};
}, [inspectionId, token]);
const updateResponse = (response) => {
if (socket) {
socket.emit('updateResponse', {
inspectionId,
response,
userId: 1, // Get from auth context
});
}
};
const updateStatus = (newStatus) => {
if (socket) {
socket.emit('updateStatus', {
inspectionId,
status: newStatus,
userId: 1, // Get from auth context
});
}
};
return {
responses,
status,
activeUsers,
updateResponse,
updateStatus,
};
};
```
## Security
- **JWT Authentication**: All WebSocket connections require valid JWT tokens
- **Room Isolation**: Users can only access inspection rooms they have permission for
- **Input Validation**: All incoming data is validated before processing
- **Error Handling**: Comprehensive error handling and logging
## Performance
- **Room Cleanup**: Empty rooms are automatically cleaned up after 5 minutes
- **Efficient Broadcasting**: Updates are only sent to users in the relevant inspection room
- **Connection Management**: Proper handling of connections and disconnections
## Testing
Use the provided `websocket-client-example.html` file to test the WebSocket functionality:
1. Start your NestJS application
2. Open the HTML file in a browser
3. Enter your JWT token
4. Join an inspection room
5. Test real-time updates with multiple browser tabs
## Integration with Frontend
The WebSocket gateway integrates seamlessly with your existing REST API:
- **REST API**: For initial data loading and non-real-time operations
- **WebSocket**: For real-time collaboration and live updates
- **Hybrid Approach**: Use REST for CRUD operations, WebSocket for live updates
## Troubleshooting
### Common Issues
1. **Connection Failed**: Check if the server is running and the token is valid
2. **Updates Not Received**: Ensure you're in the correct inspection room
3. **Authentication Errors**: Verify your JWT token is valid and not expired
### Debug Mode
Enable debug logging by setting the log level in your NestJS application:
```typescript
// In main.ts
const app = await NestFactory.create(AppModule, {
logger: ['debug', 'error', 'warn', 'log'],
});
```
## Room Management
- **Automatic Join**: Users automatically join inspection-specific rooms
- **Presence Tracking**: System tracks who is currently in each inspection
- **Cleanup**: Empty rooms are automatically cleaned up to prevent memory leaks
- **Activity Logging**: All room activities are logged for debugging
-- Add description field to InspectionPhoto table
-- This migration adds an optional description field to store photo descriptions/captions
ALTER TABLE "InspectionPhoto"
ADD COLUMN "description" TEXT;
-- Add comment to document the new field
COMMENT ON COLUMN "InspectionPhoto"."description" IS 'Optional description or caption for the inspection photo';
-- Verify the change
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'InspectionPhoto'
AND column_name = 'description';
-- Add finalComment field to Inspection table
-- This field will store the comprehensive description and review at the end of an inspection
-- Add the finalComment column to the Inspection table
ALTER TABLE "Inspection" ADD COLUMN "finalComment" TEXT;
-- Add a comment to document the purpose of this field
COMMENT ON COLUMN "Inspection"."finalComment" IS 'Comprehensive description and review written at the end of the inspection process. Can be a long text with detailed observations, conclusions, and recommendations.';
-- Verify the column was added
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'Inspection'
AND column_name IN ('comment', 'finalComment')
ORDER BY column_name;
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function addNewQuestionsToInspections() {
try {
console.log(
'🔍 Starting script to add new questions to existing inspections...',
);
// Get ALL questions from the database
const allQuestions = await prisma.inspectionQuestion.findMany({
orderBy: { id: 'asc' },
});
console.log(`📝 Found ${allQuestions.length} total questions`);
// Get all inspections
const inspections = await prisma.inspection.findMany({
select: { id: true },
});
console.log(`🔍 Found ${inspections.length} inspections`);
// Find which questions are missing from inspections
console.log('\n🔍 Analyzing which questions need to be added...');
let questionsToAdd = [];
// Check each question to see how many inspections have responses for it
for (const question of allQuestions) {
const responseCount = await prisma.inspectionResponse.count({
where: { questionId: question.id },
});
if (responseCount < inspections.length) {
questionsToAdd.push({
...question,
missingCount: inspections.length - responseCount,
});
console.log(
`📝 Question ${question.id}: ${responseCount}/${inspections.length} responses (missing ${inspections.length - responseCount})`,
);
} else {
console.log(
`✅ Question ${question.id}: ${responseCount}/${inspections.length} responses (complete)`,
);
}
}
if (questionsToAdd.length === 0) {
console.log(
'\n✅ All questions already have responses in all inspections!',
);
return;
}
console.log(
`\n➕ Need to add responses for ${questionsToAdd.length} questions to some inspections`,
);
let totalAdded = 0;
const batchSize = 100;
for (let i = 0; i < inspections.length; i += batchSize) {
const batch = inspections.slice(i, i + batchSize);
console.log(
`📦 Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(inspections.length / batchSize)} (inspections ${i + 1}-${i + batch.length})`,
);
for (const inspection of batch) {
// Get existing responses for this inspection
const existingResponses = await prisma.inspectionResponse.findMany({
where: { inspectionId: inspection.id },
select: { questionId: true },
});
const existingQuestionIds = new Set(
existingResponses.map((r) => r.questionId),
);
// Find questions that need to be added to this inspection
const missingQuestions = questionsToAdd.filter(
(q) => !existingQuestionIds.has(q.id),
);
if (missingQuestions.length > 0) {
// Create responses for missing questions
const responsesToCreate = missingQuestions.map((question) => ({
inspectionId: inspection.id,
questionId: question.id,
response: 'NA', // Default response for new questions
comment: 'Automatically added for new question',
}));
await prisma.inspectionResponse.createMany({
data: responsesToCreate,
skipDuplicates: true,
});
totalAdded += responsesToCreate.length;
if (missingQuestions.length > 0) {
console.log(
` ✅ Added ${responsesToCreate.length} responses to inspection ${inspection.id}`,
);
}
}
}
}
console.log('\n🎉 Script completed successfully!');
console.log(`📊 Summary:`);
console.log(` - Processed inspections: ${inspections.length}`);
console.log(` - Total responses added: ${totalAdded}`);
console.log(
` - Questions with missing responses: ${questionsToAdd.length}`,
);
// Final verification
console.log('\n🔍 Final verification:');
const finalResponseCount = await prisma.inspectionResponse.count();
const expectedResponseCount = inspections.length * allQuestions.length;
console.log(` - Total responses in database: ${finalResponseCount}`);
console.log(
` - Expected responses: ${expectedResponseCount} (${inspections.length} inspections × ${allQuestions.length} questions)`,
);
console.log(
` - Match: ${finalResponseCount === expectedResponseCount ? '✅ Yes' : '❌ No'}`,
);
// Show final count per question
console.log('\n📋 Final response count per question:');
for (const question of allQuestions) {
const count = await prisma.inspectionResponse.count({
where: { questionId: question.id },
});
const status = count === inspections.length ? '✅' : '❌';
console.log(
` ${status} Question ${question.id}: ${count}/${inspections.length} responses`,
);
}
} catch (error) {
console.error('❌ Error:', error);
} finally {
await prisma.$disconnect();
}
}
// Run the script
addNewQuestionsToInspections();
-- Diagnostic script to check permissions and identify the correct database user
-- Run this to understand the current permission state
-- 1. Check what user your application is connecting as
SELECT current_user, session_user;
-- 2. Check all users in the database
SELECT usename as username, usesuper as is_superuser, usecreatedb as can_create_db
FROM pg_user
ORDER BY usename;
-- 3. Check current permissions on the TelecommunicationStationIdentification table
SELECT
schemaname,
tablename,
tableowner,
hasinsert,
hasselect,
hasupdate,
hasdelete
FROM pg_tables
WHERE tablename = 'TelecommunicationStationIdentification';
-- 4. Check detailed table permissions for all users
SELECT
grantee,
privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification';
-- 5. Check sequence permissions
SELECT
grantee,
privilege_type
FROM information_schema.role_usage_grants
WHERE object_name = 'TelecommunicationStationIdentification_id_seq';
-- 6. Check if the table exists and its owner
SELECT
t.table_name,
t.table_schema,
t.table_type,
c.column_name,
c.data_type
FROM information_schema.tables t
LEFT JOIN information_schema.columns c ON t.table_name = c.table_name
WHERE t.table_name = 'TelecommunicationStationIdentification'
ORDER BY c.ordinal_position;
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/database/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
-- Comprehensive permissions fix script
-- This script covers multiple scenarios and user possibilities
-- First, let's check what user we're connected as
\echo 'Current user:'
SELECT current_user;
-- Method 1: Grant to the most common database users
-- Try each of these - one should work based on your setup
-- For user 'cellnex'
GRANT ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" TO cellnex;
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO cellnex;
-- For user 'api_cellnex' (common naming pattern)
GRANT ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" TO api_cellnex;
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO api_cellnex;
-- For user 'postgres' (if your app connects as postgres)
GRANT ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" TO postgres;
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO postgres;
-- Method 2: Grant to all users in public role (broad approach)
GRANT ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" TO PUBLIC;
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO PUBLIC;
-- Method 3: Change table owner to match other working tables
-- First, check who owns the Site table (which works)
SELECT tableowner FROM pg_tables WHERE tablename = 'Site';
-- Then change ownership of TelecommunicationStationIdentification to match
-- Replace 'cellnex' with the owner of the Site table if different
ALTER TABLE "TelecommunicationStationIdentification" OWNER TO cellnex;
ALTER SEQUENCE "TelecommunicationStationIdentification_id_seq" OWNER TO cellnex;
-- Method 4: Ensure schema permissions
GRANT USAGE ON SCHEMA public TO cellnex;
GRANT USAGE ON SCHEMA public TO api_cellnex;
GRANT USAGE ON SCHEMA public TO postgres;
-- Method 5: Grant all privileges on all tables (nuclear option)
-- Uncomment these if the above don't work:
-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO cellnex;
-- GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO cellnex;
-- Grant permissions to TelecommunicationStationIdentification table
-- Replace 'cellnex' with your actual database user if different
-- Grant all permissions on the table to the database user
GRANT ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" TO cellnex;
-- Grant usage on the sequence for the primary key
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO cellnex;
-- Grant permissions on the public schema (if needed)
GRANT USAGE ON SCHEMA public TO cellnex;
-- Alternative approach: Grant all privileges on all tables in public schema to the user
-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO cellnex;
-- GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO cellnex;
-- Grant administrative permissions (DELETE, TRUNCATE, DROP) to cellnex user only
-- While keeping PUBLIC with safe permissions (SELECT, INSERT, UPDATE)
-- First, ensure PUBLIC has only safe permissions
REVOKE ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE ON TABLE "TelecommunicationStationIdentification" TO PUBLIC;
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO PUBLIC;
-- Now grant FULL administrative permissions to cellnex user
GRANT ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" TO cellnex;
GRANT ALL PRIVILEGES ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO cellnex;
-- Make cellnex the owner of the table (gives all permissions including DROP)
ALTER TABLE "TelecommunicationStationIdentification" OWNER TO cellnex;
ALTER SEQUENCE "TelecommunicationStationIdentification_id_seq" OWNER TO cellnex;
-- Verify the permissions
-- PUBLIC permissions (should be SELECT, INSERT, UPDATE only):
SELECT
'PUBLIC permissions:' as info,
grantee,
privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC'
ORDER BY privilege_type;
-- CELLNEX permissions (should have all privileges):
SELECT
'CELLNEX permissions:' as info,
grantee,
privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'cellnex'
ORDER BY privilege_type;
-- Table ownership:
SELECT
'Table ownership:' as info,
tablename,
tableowner
FROM pg_tables
WHERE tablename = 'TelecommunicationStationIdentification';
-- Make TelecommunicationStationIdentification table accessible to everyone
-- but WITHOUT delete/truncate permissions for security
-- Grant SELECT (read), INSERT (create), and UPDATE (modify) privileges to PUBLIC
-- but NOT DELETE or TRUNCATE
GRANT SELECT, INSERT, UPDATE ON TABLE "TelecommunicationStationIdentification" TO PUBLIC;
-- Grant usage and select on the sequence to PUBLIC for auto-increment IDs
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO PUBLIC;
-- Ensure the schema is accessible to PUBLIC (usually already is)
GRANT USAGE ON SCHEMA public TO PUBLIC;
-- Verify the permissions were granted (should show SELECT, INSERT, UPDATE but not DELETE)
SELECT
grantee,
privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC'
ORDER BY privilege_type;
-- Make TelecommunicationStationIdentification table accessible to everyone
-- This grants public access to the table and its sequence
-- Grant all privileges on the table to PUBLIC (everyone)
GRANT ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" TO PUBLIC;
-- Grant usage and select on the sequence to PUBLIC
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO PUBLIC;
-- Ensure the schema is accessible to PUBLIC (usually already is)
GRANT USAGE ON SCHEMA public TO PUBLIC;
-- Verify the permissions were granted
SELECT
grantee,
privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC';
...@@ -18,7 +18,10 @@ ...@@ -18,7 +18,10 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:seed": "ts-node prisma/seed.ts" "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:push": "drizzle-kit push"
}, },
"dependencies": { "dependencies": {
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
...@@ -34,6 +37,7 @@ ...@@ -34,6 +37,7 @@
"@nestjs/websockets": "^11.1.3", "@nestjs/websockets": "^11.1.3",
"@prisma/client": "^6.9.0", "@prisma/client": "^6.9.0",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/pg": "^8.15.5",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
...@@ -41,12 +45,15 @@ ...@@ -41,12 +45,15 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"fast-xml-parser": "^5.2.5", "fast-xml-parser": "^5.2.5",
"jimp": "^0.22.12", "jimp": "^0.22.12",
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.1", "sharp": "^0.34.1",
...@@ -102,8 +109,5 @@ ...@@ -102,8 +109,5 @@
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
} }
} }
# Site Data Import Instructions
This guide explains how to import site data from an Excel file into the database.
## Prerequisites
- Node.js installed
- Access to the project and database
- An Excel file (.xlsx) with site data
## Excel File Format
Your Excel file should have the following columns:
- `Site Code` - Unique identifier for the site (required)
- `Site Name` - Name of the site (required)
- `Lat` - Latitude coordinate (numeric)
- `Long` - Longitude coordinate (numeric)
- `Type` - Site type (optional)
## Running the Import
1. Place your Excel file in an accessible location
2. Run the import script using the following command:
```
npx ts-node prisma/import-sites.ts /path/to/your/file.xlsx
```
Replace `/path/to/your/file.xlsx` with the actual path to your Excel file.
3. The script will process each row in the file:
- If a site with the same `Site Code` already exists, it will be updated
- If the site doesn't exist, a new record will be created
- Validation errors will be logged but won't stop the import process
4. After completion, a summary will be displayed showing:
- Total records processed
- Number of sites created
- Number of sites updated
- Number of errors encountered
## Error Handling
Common errors to watch for:
- Missing required fields (Site Code, Site Name)
- Invalid coordinates (non-numeric values)
- Duplicate Site Codes in the Excel file
The script logs detailed error information to help diagnose issues.
## Tips for Large Files
Since your file contains approximately 7,000 records:
- The import may take several minutes to complete
- Progress updates are displayed every 100 records
- Consider running the import during off-peak hours
- Keep the terminal open until the process completes
## After Import
After importing, you may want to:
1. Verify the data in the database
2. Check the error logs for any records that failed to import
3. Run any necessary data validation or cleanup procedures
\ No newline at end of file
-- Add InspectionStatus enum
CREATE TYPE "InspectionStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED');
-- Add status column to Inspection table with default value
ALTER TABLE "Inspection" ADD COLUMN "status" "InspectionStatus" NOT NULL DEFAULT 'PENDING';
-- Add index on status column
CREATE INDEX "Inspection_status_idx" ON "Inspection"("status");
\ No newline at end of file
-- Add new inspection statuses
ALTER TYPE "InspectionStatus" ADD VALUE 'APPROVING';
ALTER TYPE "InspectionStatus" ADD VALUE 'REJECTED';
ALTER TYPE "InspectionStatus" ADD VALUE 'APPROVED';
\ No newline at end of file
-- CreateTable
CREATE TABLE "TelecommunicationStationIdentification" (
"id" SERIAL NOT NULL,
"siteId" INTEGER NOT NULL,
"stationIdentifier" TEXT NOT NULL,
"serialNumber" TEXT NOT NULL,
"isFirstCertification" BOOLEAN NOT NULL,
"modelReference" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TelecommunicationStationIdentification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TelecommunicationStationIdentification_siteId_key" ON "TelecommunicationStationIdentification"("siteId");
-- CreateIndex
CREATE UNIQUE INDEX "TelecommunicationStationIdentification_stationIdentifier_key" ON "TelecommunicationStationIdentification"("stationIdentifier");
-- CreateIndex
CREATE INDEX "TelecommunicationStationIdentification_siteId_idx" ON "TelecommunicationStationIdentification"("siteId");
-- CreateIndex
CREATE INDEX "TelecommunicationStationIdentification_stationIdentifier_idx" ON "TelecommunicationStationIdentification"("stationIdentifier");
-- AddForeignKey
ALTER TABLE "TelecommunicationStationIdentification" ADD CONSTRAINT "TelecommunicationStationIdentification_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Add PARTNER to the Role enum
ALTER TYPE "Role" ADD VALUE IF NOT EXISTS 'PARTNER';
-- Create UserSite table
CREATE TABLE IF NOT EXISTS "UserSite" (
"id" SERIAL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"siteId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserSite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserSite_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Create indexes
CREATE UNIQUE INDEX IF NOT EXISTS "UserSite_userId_siteId_key" ON "UserSite"("userId", "siteId");
CREATE INDEX IF NOT EXISTS "UserSite_userId_idx" ON "UserSite"("userId");
CREATE INDEX IF NOT EXISTS "UserSite_siteId_idx" ON "UserSite"("siteId");
\ No newline at end of file
CREATE EXTENSION IF NOT EXISTS postgis;
\ No newline at end of file
import fs from 'fs';
import * as AdmZip from 'adm-zip';
import { XMLParser } from 'fast-xml-parser';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function importKmzToSites(kmzPath: string) {
// 1. Extract KML from KMZ
const zip = new AdmZip(kmzPath);
const kmlEntry = zip.getEntries().find((e) => e.entryName.endsWith('.kml'));
if (!kmlEntry) throw new Error('No KML file found in KMZ');
const kmlString = kmlEntry.getData().toString('utf8');
// 2. Parse KML
const parser = new XMLParser({ ignoreAttributes: false });
const kml = parser.parse(kmlString);
// 3. Extract Placemarks (handle both array and single object)
const placemarks = (() => {
const doc = kml.kml.Document;
// Check if placemarks are directly in Document
if (Array.isArray(doc.Placemark)) return doc.Placemark;
if (doc.Placemark) return [doc.Placemark];
// Check if placemarks are in a Folder
if (doc.Folder && doc.Folder.Placemark) {
if (Array.isArray(doc.Folder.Placemark)) return doc.Folder.Placemark;
return [doc.Folder.Placemark];
}
return [];
})();
console.log(`Found ${placemarks.length} placemarks`);
for (const placemark of placemarks) {
const name = placemark.name || '';
if (!placemark.Point || !placemark.Point.coordinates) {
console.log('Skipping placemark without Point/coordinates:', name);
continue;
}
const [longitude, latitude] = placemark.Point.coordinates
.split(',')
.map(Number);
// You can customize how you extract siteCode/siteName here
const siteCode = name;
const siteName = name;
// 4. Insert into Site model (skip if missing lat/lon)
if (latitude && longitude && siteCode) {
try {
await prisma.site.create({
data: {
siteCode,
siteName,
latitude,
longitude,
},
});
console.log(`Imported site: ${siteCode} (${latitude}, ${longitude})`);
} catch (err) {
console.error(`Failed to import site ${siteCode}:`, err.message);
}
} else {
console.log(`Skipping invalid site: ${siteCode} (missing data)`);
}
}
console.log('Import complete!');
}
// Usage: npx ts-node prisma/import-sites-from-kmz.ts path/to/your/file.kmz
const kmzPath = process.argv[2];
if (!kmzPath) {
console.error(
'Usage: npx ts-node prisma/import-sites-from-kmz.ts <file.kmz>',
);
process.exit(1);
}
importKmzToSites(kmzPath)
.catch(console.error)
.finally(() => prisma.$disconnect());
import { PrismaClient } from '@prisma/client';
import * as xlsx from 'xlsx';
import * as path from 'path';
import * as fs from 'fs';
const prisma = new PrismaClient();
// Define interface for Excel row data
interface SiteRow {
'Site Code': string;
'Site Name': string;
'Lat': string | number;
'Long': string | number;
'Type': string;
[key: string]: any; // For any additional columns
}
async function importSitesFromExcel(filePath: string) {
try {
console.log(`Importing sites from ${filePath}...`);
// Check if file exists
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
// Read the Excel file
const workbook = xlsx.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const data = xlsx.utils.sheet_to_json<SiteRow>(worksheet);
console.log(`Found ${data.length} records to import`);
// Track results
let created = 0;
let updated = 0;
let errors = 0;
// Process each row
for (const row of data) {
try {
// Map Excel columns to database fields
const siteCode = row['Site Code'];
const siteName = row['Site Name'];
const latitude = parseFloat(row['Lat'].toString());
const longitude = parseFloat(row['Long'].toString());
const type = row['Type'];
// Validate required fields
if (!siteCode || !siteName) {
console.error(`Missing required fields in row:`, row);
errors++;
continue;
}
// Validate numeric coordinates
if (isNaN(latitude) || isNaN(longitude)) {
console.error(`Invalid coordinates in row:`, row);
errors++;
continue;
}
// Check if site already exists
const existingSite = await prisma.site.findUnique({
where: { siteCode },
});
if (existingSite) {
// Update existing site
await prisma.site.update({
where: { id: existingSite.id },
data: {
siteName,
latitude,
longitude,
type,
updatedAt: new Date(),
},
});
updated++;
} else {
// Create new site
await prisma.site.create({
data: {
siteCode,
siteName,
latitude,
longitude,
type,
},
});
created++;
}
} catch (err) {
console.error(`Error processing row:`, row, err);
errors++;
}
// Log progress periodically
if ((created + updated + errors) % 100 === 0) {
console.log(`Progress: ${created + updated + errors}/${data.length} records processed`);
}
}
console.log(`
Import completed:
- Total records: ${data.length}
- Created: ${created}
- Updated: ${updated}
- Errors: ${errors}
`);
} catch (error) {
console.error('Import failed:', error);
} finally {
await prisma.$disconnect();
}
}
// Get file path from command line argument
const filePath = process.argv[2];
if (!filePath) {
console.error('Please provide the path to the Excel file as an argument');
console.error('Example: npx ts-node prisma/import-sites.ts /path/to/sites.xlsx');
process.exit(1);
}
importSitesFromExcel(filePath);
\ No newline at end of file
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String
role Role @default(VIEWER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resetToken String?
resetTokenExpiry DateTime?
isActive Boolean @default(false)
partnerId Int?
candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater")
Comment Comment[]
inspectionsCreated Inspection[] @relation("InspectionCreator")
inspectionsUpdated Inspection[] @relation("InspectionUpdater")
refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
partner Partner? @relation(fields: [partnerId], references: [id])
associatedSites UserSite[]
@@index([email])
@@index([role])
@@index([partnerId])
}
model RefreshToken {
id Int @id @default(autoincrement())
token String @unique
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
}
model Site {
id Int @id @default(autoincrement())
siteCode String @unique
siteName String
latitude Float
longitude Float
type String?
isDigi Boolean @default(false)
isReported Boolean @default(false)
companies CompanyName[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
candidates CandidateSite[]
inspections Inspection[]
telecommunicationStationIdentification TelecommunicationStationIdentification?
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[]
@@index([siteCode])
}
model Candidate {
id Int @id @default(autoincrement())
candidateCode String
latitude Float
longitude Float
type String
address String
currentStatus String
onGoing Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
partnerId Int?
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
partner Partner? @relation(fields: [partnerId], references: [id])
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
sites CandidateSite[]
comments Comment[]
photos Photo[]
@@index([candidateCode])
@@index([currentStatus])
@@index([onGoing])
@@index([partnerId])
}
model CandidateSite {
id Int @id @default(autoincrement())
candidateId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([candidateId, siteId])
@@index([candidateId])
@@index([siteId])
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
createdById Int?
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id])
@@index([candidateId])
@@index([createdById])
}
model Photo {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
@@index([candidateId])
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model spatial_ref_sys {
srid Int @id
auth_name String? @db.VarChar(256)
auth_srid Int?
srtext String? @db.VarChar(2048)
proj4text String? @db.VarChar(2048)
}
model UserSite {
id Int @id @default(autoincrement())
userId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, siteId])
@@index([userId])
@@index([siteId])
}
model Partner {
id Int @id @default(autoincrement())
name String @unique
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidates Candidate[]
users User[]
@@index([name])
}
model InspectionQuestion {
id Int @id @default(autoincrement())
question String
orderIndex Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
responses InspectionResponse[]
}
model InspectionResponse {
id Int @id @default(autoincrement())
response InspectionResponseOption
comment String?
questionId Int
inspectionId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
question InspectionQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
@@index([questionId])
@@index([inspectionId])
}
model Inspection {
id Int @id @default(autoincrement())
date DateTime
comment String?
finalComment String?
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
status InspectionStatus @default(PENDING)
createdBy User? @relation("InspectionCreator", fields: [createdById], references: [id])
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
updatedBy User? @relation("InspectionUpdater", fields: [updatedById], references: [id])
photos InspectionPhoto[]
responses InspectionResponse[]
@@index([siteId])
@@index([createdById])
@@index([updatedById])
@@index([status])
}
model InspectionPhoto {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inspectionId Int
inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
@@index([inspectionId])
}
model TelecommunicationStationIdentification {
id Int @id @default(autoincrement())
siteId Int @unique
stationIdentifier String @unique
serialNumber String
isFirstCertification Boolean
modelReference String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@index([siteId])
@@index([stationIdentifier])
}
enum CompanyName {
VODAFONE
MEO
NOS
DIGI
}
enum Role {
ADMIN
MANAGER
OPERATOR
VIEWER
SUPERADMIN
PARTNER
}
enum InspectionResponseOption {
YES
NO
NA
}
enum InspectionStatus {
PENDING
IN_PROGRESS
COMPLETED
CANCELLED
APPROVING
REJECTED
APPROVED
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String
role Role @default(VIEWER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resetToken String?
resetTokenExpiry DateTime?
isActive Boolean @default(false)
candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater")
Comment Comment[]
refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
maintenancesCreated Maintenance[] @relation("MaintenanceCreator")
maintenancesUpdated Maintenance[] @relation("MaintenanceUpdater")
associatedSites UserSite[] // New relation for PARTNER role
partner Partner? @relation(fields: [partnerId], references: [id])
partnerId Int?
@@index([email])
@@index([role])
@@index([partnerId])
}
model RefreshToken {
id Int @id @default(autoincrement())
token String @unique
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
}
model Site {
id Int @id @default(autoincrement())
siteCode String @unique
siteName String
latitude Float
longitude Float
type String?
isDigi Boolean @default(false)
isReported Boolean @default(false)
companies CompanyName[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
candidates CandidateSite[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[] // New relation for PARTNER role
maintenances Maintenance[]
@@index([siteCode])
}
model Candidate {
id Int @id @default(autoincrement())
candidateCode String
latitude Float
longitude Float
type String
address String
currentStatus String
onGoing Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
sites CandidateSite[]
comments Comment[]
photos Photo[]
partnerId Int? // To track which partner created the candidate
partner Partner? @relation(fields: [partnerId], references: [id])
@@index([candidateCode])
@@index([currentStatus])
@@index([onGoing])
@@index([partnerId])
}
model CandidateSite {
id Int @id @default(autoincrement())
candidateId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([candidateId, siteId])
@@index([candidateId])
@@index([siteId])
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
createdById Int?
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id])
@@index([candidateId])
@@index([createdById])
}
model Photo {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
@@index([candidateId])
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model spatial_ref_sys {
srid Int @id
auth_name String? @db.VarChar(256)
auth_srid Int?
srtext String? @db.VarChar(2048)
proj4text String? @db.VarChar(2048)
}
model UserSite {
id Int @id @default(autoincrement())
userId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([userId, siteId])
@@index([userId])
@@index([siteId])
}
enum CompanyName {
VODAFONE
MEO
NOS
DIGI
}
enum Role {
ADMIN
MANAGER
OPERATOR
VIEWER
SUPERADMIN
PARTNER
}
model Partner {
id Int @id @default(autoincrement())
name String @unique
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
candidates Candidate[]
@@index([name])
}
enum MaintenanceResponseOption {
YES
NO
NA
}
model MaintenanceQuestion {
id Int @id @default(autoincrement())
question String
orderIndex Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
responses MaintenanceResponse[]
}
model MaintenanceResponse {
id Int @id @default(autoincrement())
response MaintenanceResponseOption
comment String?
questionId Int
maintenanceId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
question MaintenanceQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
maintenance Maintenance @relation(fields: [maintenanceId], references: [id], onDelete: Cascade)
@@index([questionId])
@@index([maintenanceId])
}
model Maintenance {
id Int @id @default(autoincrement())
date DateTime
comment String?
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
createdBy User? @relation("MaintenanceCreator", fields: [createdById], references: [id])
updatedBy User? @relation("MaintenanceUpdater", fields: [updatedById], references: [id])
responses MaintenanceResponse[]
photos MaintenancePhoto[]
@@index([siteId])
@@index([createdById])
@@index([updatedById])
}
model MaintenancePhoto {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
maintenanceId Int
maintenance Maintenance @relation(fields: [maintenanceId], references: [id], onDelete: Cascade)
@@index([maintenanceId])
}
import { PrismaClient, Role } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const hashedPassword = await bcrypt.hash('brandit123465', 10);
const superadmin = await prisma.user.upsert({
where: { email: 'augusto.fonte@brandit.pt' },
update: {
isActive: true,
role: Role.SUPERADMIN,
password: hashedPassword,
},
create: {
email: 'augusto.fonte@brandit.pt',
name: 'Augusto Fonte',
password: hashedPassword,
role: Role.SUPERADMIN,
isActive: true,
},
});
console.log('Created/Updated superadmin user:', superadmin);
// Inspection questions to seed
const inspectionQuestions = [
{
question:
'Cada componente de carril-guia foi ficado pelo meus uma vez, conforme dispositivo na secção B',
},
{
question:
'As distâncias entre fixações são no máx 1,68cm (2,24m escadas gémeas ZAL, Conforme dispositivo na secção B',
},
{
question:
'Nas uniões, as juntas são todas inferiores a 5mm, conforme dispositivo na secção B',
},
{
question:
'As uniões roscadas entre edificação e os elementos de fixação correspondem ai dispositivo da secção B',
},
{
question:
'Os elementos de fixação estão corretamente montados e todas as uniões roscadas (incluindo as de fábrica) estão firmemente apertadas. (binários de aperto conforme secção B)',
},
{
question:
'Todas as uniões roscadas estão protegidas contra o desaperto, em conformidade com o dispositivo da secção E',
},
{
question:
"No início do percurrso de subida encontra-se montado um 'batente de bloqueio inferior' em conformidade com o dispositivo em B",
},
{
question:
"No fim do percurso de subida encontra-se montado um 'batente de bloqueio superior' ou um 'batente de bloqueio terminal', em conforme indicado na secção B",
},
{
question:
"No início do percurso de subida, não montado diretamente ai nível do solo, estão montados dois (2) batentes de bloqueio 'inferior', conforme indicado na secção B",
},
{
question:
'o carril-guia passa pelo menos 1 metro acima da aresta superior do patamar, conforme o dispositivo na secção B',
},
{
question:
'Em conformidade com o dispositivo na secção B, não existem extensões acima da escada com mais de 38cm sem reforço de lonfarina (52,5xm em escadas YAL e ZAL)',
},
{
question:
'O reforço da longarina está corretamente montada conforme o dispositivo na secção B',
},
{
question:
'O ângulo de flexão máximo nas peças flexiveis foi observado (ver secção C)',
},
{
question:
'Todos os troços estão montados corretamente, em conformidade com o dispositivo da secção B',
},
{
question:
'As uniões dos trilhos-guia estão corretamente instaladas, conforme com o dispositivo da secção B',
},
{ question: 'A passagem entre trilhos-guia está alinhada' },
{ question: 'O carril-guia está livre de sujidades' },
{
question:
'Só foram utilizados elementos de fixação e uniões roscadas protegidos contra a corrosão (inspeções: os elementos de fixação e uniões não apresentam corrosão)',
},
{
question:
'O aparelho anti queda Soll só se deixa montar no sentido correto do seu duncionamento no percurso de subida e descida',
},
{ question: 'Existe a placa de identificação do fabricante' },
{
question:
'Foi realizado um percurso de ensaio e não forma detetadas quaisquer falhas',
},
{ question: 'Só foram instalados componentes do fabricante' },
{
question:
'A Escada e/ou elementos de suporte não apresentam danos visiveis, indício de deficiente fixação ou falhas de componentes que ponham em causa a sua utilização',
},
];
// Seed InspectionQuestions
await prisma.inspectionQuestion.deleteMany();
for (let i = 0; i < inspectionQuestions.length; i++) {
const q = inspectionQuestions[i];
await prisma.inspectionQuestion.create({
data: {
question: q.question,
orderIndex: i + 1,
},
});
}
console.log('Seeded InspectionQuestions:', inspectionQuestions.length);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Get the superadmin user
const superadmin = await prisma.user.findUnique({
where: { email: 'augusto.fonte@brandit.pt' },
});
if (!superadmin) {
console.error('Superadmin user not found. Please run the main seed.ts first.');
return;
}
// Sites data
const sitesData = [
{
siteCode: '04LO019',
siteName: 'CARRASCAL ALVIDE',
latitude: 38.7257,
longitude: -9.4229,
},
{
siteCode: '99LS120',
siteName: 'COSTA CAPARICA NORTE',
latitude: 38.6445,
longitude: -9.24,
},
{
siteCode: '06LO026',
siteName: 'MONTE ESTORIL OESTE',
latitude: 38.7042,
longitude: -9.4094,
},
{
siteCode: '047S2',
siteName: 'CASCAIS ESTORIL',
latitude: 38.7041,
longitude: -9.4078,
},
{
siteCode: '98LC129',
siteName: 'RATO',
latitude: 38.721,
longitude: -9.1531,
},
{
siteCode: '02AG024',
siteName: 'VILAMOURA FALESIA',
latitude: 37.0746,
longitude: -8.1234,
},
{
siteCode: '16RB005',
siteName: 'RENOVA2-ZIBREIRA',
latitude: 39.491,
longitude: -8.6121,
},
];
// Create sites
for (const siteData of sitesData) {
const site = await prisma.site.upsert({
where: { siteCode: siteData.siteCode },
update: {
siteName: siteData.siteName,
latitude: siteData.latitude,
longitude: siteData.longitude,
updatedById: superadmin.id,
},
create: {
siteCode: siteData.siteCode,
siteName: siteData.siteName,
latitude: siteData.latitude,
longitude: siteData.longitude,
createdById: superadmin.id,
updatedById: superadmin.id,
},
});
console.log(`Created/Updated site: ${site.siteCode} - ${site.siteName}`);
}
console.log('Sites seeding completed successfully!');
}
// Run the seeding function directly
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
\ No newline at end of file
-- Truncate all tables in the correct order due to foreign key constraints
TRUNCATE TABLE "Comment" CASCADE;
TRUNCATE TABLE "Candidate" CASCADE;
TRUNCATE TABLE "Site" CASCADE;
TRUNCATE TABLE "RefreshToken" CASCADE;
TRUNCATE TABLE "User" CASCADE;
-- Reset the sequences
ALTER SEQUENCE "User_id_seq" RESTART WITH 1;
ALTER SEQUENCE "RefreshToken_id_seq" RESTART WITH 1;
ALTER SEQUENCE "Site_id_seq" RESTART WITH 1;
ALTER SEQUENCE "Candidate_id_seq" RESTART WITH 1;
ALTER SEQUENCE "Comment_id_seq" RESTART WITH 1;
\ No newline at end of file
-- Alternative solution: Recreate the table with proper permissions
-- This is a more reliable approach if permission grants aren't working
-- Step 1: Backup any existing data (if you have any)
-- Uncomment if you have data to preserve:
-- CREATE TABLE "TelecommunicationStationIdentification_backup" AS
-- SELECT * FROM "TelecommunicationStationIdentification";
-- Step 2: Drop the existing table and sequence
DROP TABLE IF EXISTS "TelecommunicationStationIdentification" CASCADE;
DROP SEQUENCE IF EXISTS "TelecommunicationStationIdentification_id_seq" CASCADE;
-- Step 3: Get the owner of the Site table to match permissions
-- This query will show you who owns the Site table:
SELECT tableowner FROM pg_tables WHERE tablename = 'Site';
-- Step 4: Recreate the table with the same owner as Site table
-- Replace 'cellnex' with the actual owner from Step 3 if different
-- Set the session authorization to the correct owner
-- Uncomment and modify this line if needed:
-- SET SESSION AUTHORIZATION cellnex;
-- CreateTable with explicit owner
CREATE TABLE "TelecommunicationStationIdentification" (
"id" SERIAL NOT NULL,
"siteId" INTEGER NOT NULL,
"stationIdentifier" TEXT NOT NULL,
"serialNumber" TEXT NOT NULL,
"isFirstCertification" BOOLEAN NOT NULL,
"modelReference" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TelecommunicationStationIdentification_pkey" PRIMARY KEY ("id")
);
-- Step 5: Set the table owner explicitly
ALTER TABLE "TelecommunicationStationIdentification" OWNER TO cellnex;
ALTER SEQUENCE "TelecommunicationStationIdentification_id_seq" OWNER TO cellnex;
-- Step 6: Create indexes
CREATE UNIQUE INDEX "TelecommunicationStationIdentification_siteId_key" ON "TelecommunicationStationIdentification"("siteId");
CREATE UNIQUE INDEX "TelecommunicationStationIdentification_stationIdentifier_key" ON "TelecommunicationStationIdentification"("stationIdentifier");
CREATE INDEX "TelecommunicationStationIdentification_siteId_idx" ON "TelecommunicationStationIdentification"("siteId");
CREATE INDEX "TelecommunicationStationIdentification_stationIdentifier_idx" ON "TelecommunicationStationIdentification"("stationIdentifier");
-- Step 7: Add foreign key constraint
ALTER TABLE "TelecommunicationStationIdentification" ADD CONSTRAINT "TelecommunicationStationIdentification_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Step 8: Grant explicit permissions (belt and suspenders approach)
GRANT ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" TO cellnex;
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO cellnex;
-- Step 9: Restore data if you had any
-- Uncomment if you created a backup:
-- INSERT INTO "TelecommunicationStationIdentification"
-- SELECT * FROM "TelecommunicationStationIdentification_backup";
-- DROP TABLE "TelecommunicationStationIdentification_backup";
-- Remove DELETE and TRUNCATE permissions from PUBLIC on TelecommunicationStationIdentification
-- This fixes the overly permissive ALL PRIVILEGES that was granted earlier
-- Revoke the dangerous permissions from PUBLIC
REVOKE DELETE ON TABLE "TelecommunicationStationIdentification" FROM PUBLIC;
REVOKE TRUNCATE ON TABLE "TelecommunicationStationIdentification" FROM PUBLIC;
-- For extra security, also revoke DROP permission (in case it was granted)
-- Note: DROP permission is usually not part of ALL PRIVILEGES on tables, but just to be safe
REVOKE ALL PRIVILEGES ON TABLE "TelecommunicationStationIdentification" FROM PUBLIC;
-- Now grant back only the safe permissions
GRANT SELECT, INSERT, UPDATE ON TABLE "TelecommunicationStationIdentification" TO PUBLIC;
-- Keep the sequence permissions (these are safe and necessary)
GRANT USAGE, SELECT ON SEQUENCE "TelecommunicationStationIdentification_id_seq" TO PUBLIC;
-- Verify what permissions PUBLIC now has (should only show SELECT, INSERT, UPDATE)
SELECT
grantee,
privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC'
ORDER BY privilege_type;
...@@ -4,17 +4,14 @@ import { APP_GUARD } from '@nestjs/core'; ...@@ -4,17 +4,14 @@ import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { PrismaModule } from './common/prisma/prisma.module'; import { DatabaseModule } from './database/database.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { SitesModule } from './modules/sites/sites.module'; import { SitesModule } from './modules/sites/sites.module';
import { CandidatesModule } from './modules/candidates/candidates.module';
import { CommentsModule } from './modules/comments/comments.module';
import { MailerModule } from '@nestjs-modules/mailer'; import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { join } from 'path'; import { join } from 'path';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { PartnersModule } from './modules/partners/partners.module'; import { PartnersModule } from './modules/partners/partners.module';
import { InspectionModule } from './modules/inspection/inspection.module'; import { InspectionModule } from './modules/inspection/inspection.module';
import { QuestionsModule } from './modules/questions/questions.module'; import { QuestionsModule } from './modules/questions/questions.module';
...@@ -52,13 +49,10 @@ import { InspectionPhotosModule } from './modules/inspection-photos/inspection-p ...@@ -52,13 +49,10 @@ import { InspectionPhotosModule } from './modules/inspection-photos/inspection-p
}), }),
inject: [ConfigService], inject: [ConfigService],
}), }),
PrismaModule, DatabaseModule,
UsersModule, UsersModule,
AuthModule, AuthModule,
SitesModule, SitesModule,
CandidatesModule,
CommentsModule,
DashboardModule,
PartnersModule, PartnersModule,
InspectionModule, InspectionModule,
QuestionsModule, QuestionsModule,
......
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseService } from './database.service';
@Global()
@Module({
imports: [ConfigModule],
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private pool: Pool;
public db: ReturnType<typeof drizzle<typeof schema>>;
constructor(private configService: ConfigService) {}
async onModuleInit() {
const databaseUrl = this.configService.get<string>('DATABASE_URL');
this.pool = new Pool({
connectionString: databaseUrl,
});
this.db = drizzle(this.pool, { schema });
}
async onModuleDestroy() {
await this.pool.end();
}
}
...@@ -64,7 +64,7 @@ async function bootstrap() { ...@@ -64,7 +64,7 @@ async function bootstrap() {
SwaggerModule.setup('docs', app, document, swaggerOptions); SwaggerModule.setup('docs', app, document, swaggerOptions);
const port = process.env.PORT ?? 3001; const port = process.env.PORT ?? 3002;
await app.listen(port); await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`); console.log(`Application is running on: http://localhost:${port}`);
console.log( console.log(
......
...@@ -9,8 +9,9 @@ import { LoginDto } from './dto/login.dto'; ...@@ -9,8 +9,9 @@ import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RequestPasswordResetDto } from './dto/request-password-reset.dto'; import { RequestPasswordResetDto } from './dto/request-password-reset.dto';
import { ResetPasswordDto } from './dto/reset-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto';
import { PrismaService } from '../../common/prisma/prisma.service'; import { DatabaseService } from '../../database/database.service';
import { Role } from '@prisma/client'; import { users, refreshTokens } from '../../database/schema';
import { eq, and, gt } from 'drizzle-orm';
import { EmailService } from '../../common/email/email.service'; import { EmailService } from '../../common/email/email.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
...@@ -24,11 +25,11 @@ export class AuthService { ...@@ -24,11 +25,11 @@ export class AuthService {
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
private jwtService: JwtService, private jwtService: JwtService,
private prisma: PrismaService, private databaseService: DatabaseService,
private emailService: EmailService, private emailService: EmailService,
private configService: ConfigService, private configService: ConfigService,
private mailerService: MailerService, private mailerService: MailerService,
) { } ) {}
async validateUser(email: string, password: string): Promise<any> { async validateUser(email: string, password: string): Promise<any> {
const user = await this.usersService.findByEmail(email); const user = await this.usersService.findByEmail(email);
...@@ -51,28 +52,30 @@ export class AuthService { ...@@ -51,28 +52,30 @@ export class AuthService {
} }
// Get detailed user information including partnerId // Get detailed user information including partnerId
const userDetails = await this.prisma.user.findUnique({ const userDetails = await this.databaseService.db
where: { id: user.id }, .select({
select: { id: users.id,
id: true, email: users.email,
email: true, name: users.name,
name: true, role: users.role,
role: true, partnerId: users.partnerId,
partnerId: true, })
}, .from(users)
}); .where(eq(users.id, user.id))
.limit(1);
if (!userDetails) { if (!userDetails || userDetails.length === 0) {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException('User not found');
} }
const userDetail = userDetails[0];
const payload = { const payload = {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId // Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === Role.PARTNER && ...(user.role === 'PARTNER' &&
userDetails.partnerId && { partnerId: userDetails.partnerId }), userDetail.partnerId && { partnerId: userDetail.partnerId }),
}; };
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([
...@@ -84,12 +87,10 @@ export class AuthService { ...@@ -84,12 +87,10 @@ export class AuthService {
}), }),
]); ]);
await this.prisma.refreshToken.create({ await this.databaseService.db.insert(refreshTokens).values({
data: { token: refreshToken,
token: refreshToken, userId: user.id,
userId: user.id, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
}); });
return { return {
...@@ -100,9 +101,9 @@ export class AuthService { ...@@ -100,9 +101,9 @@ export class AuthService {
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role, role: user.role,
partnerId: userDetails.partnerId, partnerId: userDetail.partnerId,
}, },
client: 'verticalflow' client: 'verticalflow',
}; };
} }
...@@ -117,48 +118,52 @@ export class AuthService { ...@@ -117,48 +118,52 @@ export class AuthService {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException('User not found');
} }
const storedToken = await this.prisma.refreshToken.findFirst({ const storedToken = await this.databaseService.db
where: { .select()
token: refreshTokenDto.refreshToken, .from(refreshTokens)
userId: user.id, .where(
expiresAt: { and(
gt: new Date(), eq(refreshTokens.token, refreshTokenDto.refreshToken),
}, eq(refreshTokens.userId, user.id),
}, gt(refreshTokens.expiresAt, new Date()),
}); ),
)
.limit(1);
if (!storedToken) { if (!storedToken || storedToken.length === 0) {
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedException('Invalid refresh token');
} }
// Delete the used refresh token // Delete the used refresh token
await this.prisma.refreshToken.delete({ await this.databaseService.db
where: { id: storedToken.id }, .delete(refreshTokens)
}); .where(eq(refreshTokens.id, storedToken[0].id));
// Get detailed user information including partnerId // Get detailed user information including partnerId
const userDetails = await this.prisma.user.findUnique({ const userDetails = await this.databaseService.db
where: { id: user.id }, .select({
select: { id: users.id,
id: true, email: users.email,
email: true, role: users.role,
role: true, partnerId: users.partnerId,
partnerId: true, })
}, .from(users)
}); .where(eq(users.id, user.id))
.limit(1);
if (!userDetails) { if (!userDetails || userDetails.length === 0) {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException('User not found');
} }
const userDetail = userDetails[0];
// Generate new tokens // Generate new tokens
const newPayload = { const newPayload = {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId // Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === Role.PARTNER && ...(user.role === 'PARTNER' &&
userDetails.partnerId && { partnerId: userDetails.partnerId }), userDetail.partnerId && { partnerId: userDetail.partnerId }),
}; };
const [newAccessToken, newRefreshToken] = await Promise.all([ const [newAccessToken, newRefreshToken] = await Promise.all([
...@@ -171,18 +176,16 @@ export class AuthService { ...@@ -171,18 +176,16 @@ export class AuthService {
]); ]);
// Store new refresh token // Store new refresh token
await this.prisma.refreshToken.create({ await this.databaseService.db.insert(refreshTokens).values({
data: { token: newRefreshToken,
token: newRefreshToken, userId: user.id,
userId: user.id, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
}); });
return { return {
access_token: newAccessToken, access_token: newAccessToken,
refresh_token: newRefreshToken, refresh_token: newRefreshToken,
client: 'verticalflow' client: 'verticalflow',
}; };
} catch (error) { } catch (error) {
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedException('Invalid refresh token');
...@@ -254,7 +257,7 @@ export class AuthService { ...@@ -254,7 +257,7 @@ export class AuthService {
email: payload.email, email: payload.email,
role: payload.role, role: payload.role,
partnerId: payload.partnerId || null, partnerId: payload.partnerId || null,
client: 'verticalflow' client: 'verticalflow',
}; };
} catch (error) { } catch (error) {
throw new UnauthorizedException('Invalid token'); throw new UnauthorizedException('Invalid token');
......
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
Query,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { CandidatesService } from './candidates.service';
import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
import { QueryCandidateDto } from './dto/query-candidate.dto';
import { CandidateResponseDto } from './dto/candidate-response.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator';
import { Partner } from '../auth/decorators/partner.decorator';
import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { UploadPhotoDto } from './dto/upload-photo.dto';
import { multerConfig } from '../../common/multer/multer.config';
@ApiTags('candidates')
@Controller('candidates')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class CandidatesController {
constructor(private readonly candidatesService: CandidatesService) {}
@Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@ApiOperation({
summary: 'Create a new candidate',
description:
'Creates a new candidate with optional initial comment. If candidateCode is not provided, it will be automatically generated as an alphabetical code (A, B, C, ..., AA, AB, etc.) specific to the first site in the siteIds array.',
})
@ApiResponse({
status: 201,
description: 'The candidate has been successfully created.',
type: CandidateResponseDto,
})
create(
@Body() createCandidateDto: CreateCandidateDto,
@User('id') userId: number,
) {
return this.candidatesService.create(createCandidateDto, userId);
}
@Get()
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get all candidates' })
@ApiResponse({
status: 200,
description: 'Return all candidates.',
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/CandidateResponseDto' },
},
meta: {
type: 'object',
properties: {
total: { type: 'number' },
page: { type: 'number' },
limit: { type: 'number' },
totalPages: { type: 'number' },
},
},
},
},
})
findAll(@Query() query: QueryCandidateDto, @User('id') userId: number) {
return this.candidatesService.findAll(query, userId);
}
@Get('site/:siteId')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get candidates by site id' })
@ApiResponse({
status: 200,
description: 'Return the candidates for the site.',
type: [CandidateResponseDto],
})
findBySiteId(
@Param('siteId', ParseIntPipe) siteId: number,
@User('id') userId: number,
) {
return this.candidatesService.findBySiteId(siteId, userId);
}
@Get(':id')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get a candidate by id' })
@ApiResponse({
status: 200,
description: 'Return the candidate.',
type: CandidateResponseDto,
})
@ApiResponse({ status: 404, description: 'Candidate not found.' })
findOne(
@Param('id', ParseIntPipe) id: number,
@User('id') userId: number,
@Partner() partnerId: number | null,
@User('role') role: Role,
) {
// For PARTNER role, we restrict access based on the partnerId
if (role === Role.PARTNER) {
return this.candidatesService.findOneWithPartnerCheck(id, partnerId);
}
return this.candidatesService.findOne(id);
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Update a candidate' })
@ApiResponse({
status: 200,
description: 'The candidate has been successfully updated.',
type: CandidateResponseDto,
})
@ApiResponse({ status: 404, description: 'Candidate not found.' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateCandidateDto: UpdateCandidateDto,
@User('id') userId: number,
@Partner() partnerId: number | null,
@User('role') role: Role,
) {
// For PARTNER role, we restrict updates to candidates associated with their partner
if (role === Role.PARTNER) {
return this.candidatesService.updateWithPartnerCheck(
id,
updateCandidateDto,
userId,
partnerId,
);
}
return this.candidatesService.update(id, updateCandidateDto);
}
@Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.PARTNER, Role.MANAGER)
@ApiOperation({ summary: 'Delete a candidate' })
@ApiResponse({
status: 200,
description: 'The candidate has been successfully deleted.',
type: CandidateResponseDto,
})
@ApiResponse({ status: 404, description: 'Candidate not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.candidatesService.remove(id);
}
@Post(':id/sites')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Add multiple sites to a candidate' })
@ApiResponse({
status: 200,
description: 'The sites have been successfully added to the candidate.',
type: CandidateResponseDto,
})
@ApiResponse({ status: 404, description: 'Candidate not found.' })
addSitesToCandidate(
@Param('id', ParseIntPipe) id: number,
@Body() addSitesDto: AddSitesToCandidateDto,
@Partner() partnerId: number | null,
@User('role') role: Role,
) {
// For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) {
return this.candidatesService.addSitesToCandidateWithPartnerCheck(
id,
addSitesDto,
partnerId,
);
}
return this.candidatesService.addSitesToCandidate(id, addSitesDto);
}
@Post(':id/photos')
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: 'The image file to upload',
},
},
required: ['file'],
},
})
@UseInterceptors(FileInterceptor('file', multerConfig))
async uploadPhoto(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
@Partner() partnerId: number | null,
@User('role') role: Role,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
// For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) {
await this.candidatesService.checkCandidatePartner(id, partnerId);
}
return this.candidatesService.uploadPhoto(id, file, {
filename: file.originalname,
mimeType: file.mimetype,
size: file.size,
});
}
@Get(':id/photos')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
async getCandidatePhotos(
@Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null,
@User('role') role: Role,
) {
// For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) {
await this.candidatesService.checkCandidatePartner(id, partnerId);
}
return this.candidatesService.getCandidatePhotos(id);
}
@Delete('photos/:photoId')
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({ summary: 'Delete a photo by its ID' })
@ApiResponse({ status: 200, description: 'Photo deleted successfully' })
@ApiResponse({ status: 404, description: 'Photo not found' })
async deletePhoto(
@Param('photoId', ParseIntPipe) photoId: number,
@Partner() partnerId: number | null,
@User('role') role: Role,
) {
// For PARTNER role, check if the photo belongs to a candidate that belongs to their partner
if (role === Role.PARTNER) {
await this.candidatesService.checkPhotoPartner(photoId, partnerId);
}
return this.candidatesService.deletePhoto(photoId);
}
}
import { Module } from '@nestjs/common';
import { CandidatesService } from './candidates.service';
import { CandidatesController } from './candidates.controller';
import { PrismaModule } from '../../common/prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [CandidatesController],
providers: [CandidatesService],
exports: [CandidatesService],
})
export class CandidatesModule {}
import { IsArray, IsNumber } from 'class-validator';
export class AddSitesToCandidateDto {
@IsArray()
@IsNumber({}, { each: true })
siteIds: number[];
}
import { ApiProperty } from '@nestjs/swagger';
import { CandidateType, CandidateStatus } from './create-candidate.dto';
import { CommentResponseDto } from '../../comments/dto/comment-response.dto';
import { SiteResponseDto } from '../../sites/dto/site-response.dto';
export class CandidateSiteDto {
@ApiProperty({ description: 'CandidateSite ID' })
id: number;
@ApiProperty({ description: 'Site associated with this candidate' })
site: SiteResponseDto;
@ApiProperty({ description: 'Creation timestamp' })
createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date;
}
export class CandidateResponseDto {
@ApiProperty({ description: 'Candidate ID' })
id: number;
@ApiProperty({ description: 'Candidate code' })
candidateCode: string;
@ApiProperty({ description: 'Latitude coordinate' })
latitude: number;
@ApiProperty({ description: 'Longitude coordinate' })
longitude: number;
@ApiProperty({ enum: CandidateType, description: 'Type of candidate' })
type: CandidateType;
@ApiProperty({ description: 'Address of the candidate' })
address: string;
@ApiProperty({
enum: CandidateStatus,
description: 'Current status of the candidate',
})
currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' })
onGoing: boolean;
@ApiProperty({
description: 'Sites associated with this candidate',
type: [CandidateSiteDto],
})
sites: CandidateSiteDto[];
@ApiProperty({ description: 'Creation timestamp' })
createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date;
@ApiProperty({
description: 'Comments associated with this candidate',
type: [CommentResponseDto],
})
comments: CommentResponseDto[];
}
import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CandidateType {
Greenfield = 'Greenfield',
Indoor = 'Indoor',
Micro = 'Micro',
Rooftop = 'Rooftop',
Tunel = 'Tunel',
}
export enum CandidateStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
NEGOTIATION_ONGOING = 'NEGOTIATION_ONGOING',
MNO_VALIDATION = 'MNO_VALIDATION',
CLOSING = 'CLOSING',
SEARCH_AREA = 'SEARCH_AREA',
PAM = 'PAM',
}
export class CreateCandidateDto {
@ApiProperty({ description: 'Candidate code' })
@IsString()
@IsOptional()
candidateCode?: string;
@ApiProperty({ description: 'Latitude coordinate' })
@IsNumber()
latitude: number;
@ApiProperty({ description: 'Longitude coordinate' })
@IsNumber()
longitude: number;
@ApiProperty({ enum: CandidateType, description: 'Type of candidate' })
@IsEnum(CandidateType)
type: CandidateType;
@ApiProperty({ description: 'Address of the candidate' })
@IsString()
address: string;
@ApiProperty({ description: 'Current status of the candidate' })
@IsEnum(CandidateStatus)
currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' })
@IsBoolean()
onGoing: boolean;
@ApiProperty({
description: 'IDs of the sites this candidate belongs to',
type: [Number],
})
@IsNumber({}, { each: true })
siteIds: number[];
@ApiPropertyOptional({ description: 'Initial comment for the candidate' })
@IsString()
@IsOptional()
comment?: string;
}
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { CandidateType, CandidateStatus } from './create-candidate.dto';
export class QueryCandidateDto {
@ApiProperty({ description: 'Filter by candidate code', required: false })
@IsOptional()
@IsString()
candidateCode?: string;
@ApiProperty({
description: 'Filter by type',
required: false,
enum: CandidateType,
})
@IsOptional()
@IsEnum(CandidateType)
type?: CandidateType;
@ApiProperty({
description: 'Filter by current status',
required: false,
enum: CandidateStatus,
})
@IsOptional()
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
@ApiProperty({ description: 'Filter by ongoing status', required: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true')
onGoing?: boolean;
@ApiProperty({ description: 'Filter by site ID', required: false })
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value))
siteId?: number;
@ApiProperty({
description: 'Page number for pagination',
required: false,
default: 1,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
page?: number = 1;
@ApiProperty({
description: 'Number of items per page',
required: false,
default: 10,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
limit?: number = 10;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { CandidateType, CandidateStatus } from './create-candidate.dto';
export class UpdateCandidateDto {
@ApiPropertyOptional({ description: 'Candidate code' })
@IsOptional()
@IsString()
candidateCode?: string;
@ApiPropertyOptional({ description: 'Latitude coordinate' })
@IsOptional()
@IsNumber()
latitude?: number;
@ApiPropertyOptional({ description: 'Longitude coordinate' })
@IsOptional()
@IsNumber()
longitude?: number;
@ApiPropertyOptional({
enum: CandidateType,
description: 'Type of candidate',
})
@IsOptional()
@IsEnum(CandidateType)
type?: CandidateType;
@ApiPropertyOptional({ description: 'Address of the candidate' })
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional({
enum: CandidateStatus,
description: 'Current status of the candidate',
})
@IsOptional()
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
@ApiPropertyOptional({ description: 'Whether the candidate is ongoing' })
@IsOptional()
@IsBoolean()
onGoing?: boolean;
@ApiPropertyOptional({
description: 'IDs of the sites this candidate belongs to',
type: [Number],
})
@IsOptional()
@IsNumber({}, { each: true })
siteIds?: number[];
}
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional } from 'class-validator';
export class UploadPhotoDto {
@ApiProperty({
required: false,
description: 'Optional: The filename to use',
})
@IsOptional()
@IsString()
filename?: string;
@ApiProperty({
required: false,
description: 'Optional: The MIME type of the file',
})
@IsOptional()
@IsString()
mimeType?: string;
@ApiProperty({
required: false,
description: 'Optional: The size of the file in bytes',
})
@IsOptional()
@IsNumber()
size?: number;
}
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
UseGuards,
ParseIntPipe,
Req,
Put,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiBody,
} from '@nestjs/swagger';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentResponseDto } from './dto/comment-response.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { Request } from 'express';
@ApiTags('comments')
@Controller('comments')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Post()
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Create a new comment' })
@ApiBody({ type: CreateCommentDto })
@ApiResponse({
status: 201,
description: 'The comment has been successfully created.',
type: CommentResponseDto,
})
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createCommentDto: CreateCommentDto, @Req() req: Request) {
const user = req.user as any;
createCommentDto.createdById = user.id;
return this.commentsService.create(createCommentDto);
}
@Get('candidate/:candidateId')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get all comments for a candidate' })
@ApiResponse({
status: 200,
description: 'Return all comments for the candidate.',
type: [CommentResponseDto],
})
findAll(@Param('candidateId', ParseIntPipe) candidateId: number) {
return this.commentsService.findAll(candidateId);
}
@Get(':id')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get a comment by id' })
@ApiResponse({
status: 200,
description: 'Return the comment.',
type: CommentResponseDto,
})
@ApiResponse({ status: 404, description: 'Comment not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.commentsService.findOne(id);
}
@Delete(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Delete a comment' })
@ApiResponse({
status: 200,
description: 'The comment has been successfully deleted.',
type: CommentResponseDto,
})
@ApiResponse({ status: 404, description: 'Comment not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.commentsService.remove(id);
}
@Put(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Update a comment' })
@ApiBody({ type: UpdateCommentDto })
@ApiResponse({
status: 200,
description: 'The comment has been successfully updated.',
type: CommentResponseDto,
})
@ApiResponse({ status: 404, description: 'Comment not found.' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateCommentDto: UpdateCommentDto,
) {
return this.commentsService.update(id, updateCommentDto);
}
}
import { Module } from '@nestjs/common';
import { CommentsService } from './comments.service';
import { CommentsController } from './comments.controller';
import { PrismaService } from '../../common/prisma/prisma.service';
@Module({
controllers: [CommentsController],
providers: [CommentsService, PrismaService],
exports: [CommentsService],
})
export class CommentsModule {}
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
@Injectable()
export class CommentsService {
constructor(private prisma: PrismaService) {}
async create(createCommentDto: CreateCommentDto) {
return this.prisma.comment.create({
data: {
content: createCommentDto.content,
candidateId: createCommentDto.candidateId,
createdById: createCommentDto.createdById,
},
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
async findAll(candidateId: number) {
return this.prisma.comment.findMany({
where: {
candidateId,
},
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
}
async findOne(id: number) {
return this.prisma.comment.findUnique({
where: { id },
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
async remove(id: number) {
return this.prisma.comment.delete({
where: { id },
});
}
async update(id: number, updateCommentDto: UpdateCommentDto) {
try {
return await this.prisma.comment.update({
where: { id },
data: {
content: updateCommentDto.content,
updatedAt: new Date(),
},
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
} catch (error) {
throw new NotFoundException(`Comment with ID ${id} not found`);
}
}
}
import { ApiProperty } from '@nestjs/swagger';
class UserResponseDto {
@ApiProperty({ description: 'User ID' })
id: number;
@ApiProperty({ description: 'User name' })
name: string;
@ApiProperty({ description: 'User email' })
email: string;
}
export class CommentResponseDto {
@ApiProperty({ description: 'Comment ID' })
id: number;
@ApiProperty({ description: 'Comment content' })
content: string;
@ApiProperty({ description: 'Creation timestamp' })
createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date;
@ApiProperty({ description: 'ID of the candidate this comment belongs to' })
candidateId: number;
@ApiProperty({
description: 'User who created the comment',
type: UserResponseDto,
})
createdBy: UserResponseDto;
}
import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class CreateCommentDto {
@ApiProperty({
description: 'The content of the comment',
example: 'This is a comment about the candidate',
})
@IsString()
@IsNotEmpty()
content: string;
@ApiProperty({
description: 'The ID of the candidate this comment is for',
example: 64,
})
@Type(() => Number)
@IsInt()
@IsNotEmpty()
candidateId: number;
@ApiProperty({
description:
'The ID of the user creating the comment (optional, will be set automatically)',
example: 1,
required: false,
})
@Type(() => Number)
@IsInt()
@IsOptional()
createdById?: number;
}
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateCommentDto {
@ApiProperty({
description: 'The updated content of the comment',
example: 'This is an updated comment about the candidate',
})
@IsString()
@IsNotEmpty()
content: string;
}
import { Controller, Get, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { DashboardStatsDto } from './dto/dashboard.dto';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
@ApiTags('dashboard')
@Controller('dashboard')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Get dashboard statistics and data' })
@ApiResponse({
status: 200,
description: 'Return dashboard statistics and data',
type: DashboardStatsDto,
})
getDashboard() {
return this.dashboardService.getDashboardStats();
}
}
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { DashboardStatsDto } from './dto/dashboard.dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) {}
async getDashboardStats(): Promise<DashboardStatsDto> {
// Get total counts
const [totalSites, totalCandidates, totalUsers] = await Promise.all([
this.prisma.site.count(),
this.prisma.candidate.count(),
this.prisma.user.count(),
]);
// Get ongoing candidates count
const ongoingCandidates = await this.prisma.candidate.count({
where: { onGoing: true },
});
// Get candidates by status
const candidatesByStatus = await this.prisma.candidate.groupBy({
by: ['currentStatus'],
_count: true,
});
// Get candidates per site with BigInt count conversion to Number
const candidatesPerSite = await this.prisma.$queryRaw`
SELECT
"Site"."id" as "siteId",
"Site"."siteCode",
"Site"."siteName",
CAST(COUNT("CandidateSite"."candidateId") AS INTEGER) as count
FROM "Site"
LEFT JOIN "CandidateSite" ON "CandidateSite"."siteId" = "Site"."id"
GROUP BY "Site"."id", "Site"."siteCode", "Site"."siteName"
ORDER BY count DESC
LIMIT 10
`;
// Get recent activity
const recentActivity = await this.prisma.$queryRaw`
SELECT
'site' as type,
"Site"."id" as id,
'created' as action,
"Site"."createdAt" as timestamp,
"Site"."createdById" as "userId",
u.name as "userName"
FROM "Site"
JOIN "User" u ON u.id = "Site"."createdById"
UNION ALL
SELECT
'candidate' as type,
"Candidate"."id" as id,
'created' as action,
"Candidate"."createdAt" as timestamp,
"Candidate"."createdById" as "userId",
u.name as "userName"
FROM "Candidate"
JOIN "User" u ON u.id = "Candidate"."createdById"
ORDER BY timestamp DESC
LIMIT 10
`;
// Get users by role
const usersByRole = await this.prisma.user.groupBy({
by: ['role'],
_count: true,
});
// Helper function to convert BigInt values to numbers
const convertBigIntToNumber = (obj: any): any => {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj === 'bigint') {
return Number(obj);
}
if (typeof obj === 'object') {
if (Array.isArray(obj)) {
return obj.map(convertBigIntToNumber);
}
const result = {};
for (const key in obj) {
result[key] = convertBigIntToNumber(obj[key]);
}
return result;
}
return obj;
};
return {
totalSites,
totalCandidates,
ongoingCandidates,
candidatesByStatus: candidatesByStatus.reduce((acc, curr) => {
acc[curr.currentStatus] = curr._count;
return acc;
}, {}),
candidatesPerSite: convertBigIntToNumber(candidatesPerSite),
recentActivity: convertBigIntToNumber(recentActivity),
userStats: {
totalUsers,
usersByRole: usersByRole.reduce((acc, curr) => {
acc[curr.role] = curr._count;
return acc;
}, {}),
},
};
}
}
import { ApiProperty } from '@nestjs/swagger';
export class DashboardStatsDto {
@ApiProperty({ description: 'Total number of sites' })
totalSites: number;
@ApiProperty({ description: 'Total number of candidates' })
totalCandidates: number;
@ApiProperty({ description: 'Number of ongoing candidates' })
ongoingCandidates: number;
@ApiProperty({ description: 'Number of candidates by status' })
candidatesByStatus: {
[key: string]: number;
};
@ApiProperty({ description: 'Number of candidates per site' })
candidatesPerSite: {
siteId: number;
siteCode: string;
siteName: string;
count: number;
}[];
@ApiProperty({ description: 'Recent activity' })
recentActivity: {
id: number;
type: 'site' | 'candidate';
action: 'created' | 'updated';
timestamp: Date;
userId: number;
userName: string;
}[];
@ApiProperty({ description: 'User statistics' })
userStats: {
totalUsers: number;
usersByRole: {
[key: string]: number;
};
};
}
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { InspectionPhotosController } from './inspection-photos.controller'; import { InspectionPhotosController } from './inspection-photos.controller';
import { InspectionPhotosService } from './inspection-photos.service'; import { InspectionPhotosService } from './inspection-photos.service';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({ @Module({
imports: [PrismaModule],
controllers: [InspectionPhotosController], controllers: [InspectionPhotosController],
providers: [InspectionPhotosService], providers: [InspectionPhotosService],
exports: [InspectionPhotosService], exports: [InspectionPhotosService],
......
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service'; import { DatabaseService } from '../../database/database.service';
import { inspections, inspectionPhotos } from '../../database/schema';
import { eq, and, count, asc, desc, SQL } from 'drizzle-orm';
import { import {
CreateInspectionPhotoDto, CreateInspectionPhotoDto,
UpdateInspectionPhotoDto, UpdateInspectionPhotoDto,
...@@ -12,18 +14,20 @@ import * as path from 'path'; ...@@ -12,18 +14,20 @@ import * as path from 'path';
@Injectable() @Injectable()
export class InspectionPhotosService { export class InspectionPhotosService {
constructor(private prisma: PrismaService) {} constructor(private databaseService: DatabaseService) {}
async createInspectionPhoto( async createInspectionPhoto(
dto: CreateInspectionPhotoDto, dto: CreateInspectionPhotoDto,
file: Express.Multer.File, file: Express.Multer.File,
): Promise<InspectionPhotoResponseDto> { ): Promise<InspectionPhotoResponseDto> {
// Check if inspection exists // Check if inspection exists
const inspection = await this.prisma.inspection.findUnique({ const inspectionList = await this.databaseService.db
where: { id: dto.inspectionId }, .select()
}); .from(inspections)
.where(eq(inspections.id, dto.inspectionId))
.limit(1);
if (!inspection) { if (inspectionList.length === 0) {
throw new NotFoundException( throw new NotFoundException(
`Inspection with ID ${dto.inspectionId} not found`, `Inspection with ID ${dto.inspectionId} not found`,
); );
...@@ -33,16 +37,17 @@ export class InspectionPhotosService { ...@@ -33,16 +37,17 @@ export class InspectionPhotosService {
const filePaths = await saveInspectionPhotos([file], dto.inspectionId); const filePaths = await saveInspectionPhotos([file], dto.inspectionId);
// Create photo record in database // Create photo record in database
const photo = await this.prisma.inspectionPhoto.create({ const [photo] = await this.databaseService.db
data: { .insert(inspectionPhotos)
.values({
filename: file.originalname, filename: file.originalname,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
url: filePaths[0], url: filePaths[0],
description: dto.description, description: dto.description,
inspection: { connect: { id: dto.inspectionId } }, inspectionId: dto.inspectionId,
}, })
}); .returning();
return this.mapToDto(photo); return this.mapToDto(photo);
} }
...@@ -50,18 +55,21 @@ export class InspectionPhotosService { ...@@ -50,18 +55,21 @@ export class InspectionPhotosService {
async findAllInspectionPhotos( async findAllInspectionPhotos(
dto: FindInspectionPhotosDto, dto: FindInspectionPhotosDto,
): Promise<InspectionPhotoResponseDto[]> { ): Promise<InspectionPhotoResponseDto[]> {
const filter: any = {}; // Build where conditions
const conditions: SQL[] = [];
if (dto.inspectionId) { if (dto.inspectionId) {
filter.inspectionId = dto.inspectionId; conditions.push(eq(inspectionPhotos.inspectionId, dto.inspectionId));
} }
const photos = await this.prisma.inspectionPhoto.findMany({ const whereCondition =
where: filter, conditions.length > 0 ? and(...conditions) : undefined;
orderBy: {
createdAt: 'desc', const photos = await this.databaseService.db
}, .select()
}); .from(inspectionPhotos)
.where(whereCondition)
.orderBy(desc(inspectionPhotos.createdAt));
return photos.map(this.mapToDto); return photos.map(this.mapToDto);
} }
...@@ -69,96 +77,86 @@ export class InspectionPhotosService { ...@@ -69,96 +77,86 @@ export class InspectionPhotosService {
async findInspectionPhotoById( async findInspectionPhotoById(
id: number, id: number,
): Promise<InspectionPhotoResponseDto> { ): Promise<InspectionPhotoResponseDto> {
const photo = await this.prisma.inspectionPhoto.findUnique({ const photoList = await this.databaseService.db
where: { id }, .select()
}); .from(inspectionPhotos)
.where(eq(inspectionPhotos.id, id))
.limit(1);
if (!photo) { if (photoList.length === 0) {
throw new NotFoundException(`Inspection photo with ID ${id} not found`); throw new NotFoundException(`Inspection photo with ID ${id} not found`);
} }
return this.mapToDto(photo); return this.mapToDto(photoList[0]);
} }
async updateInspectionPhoto( async updateInspectionPhoto(
id: number, id: number,
dto: UpdateInspectionPhotoDto, dto: UpdateInspectionPhotoDto,
): Promise<InspectionPhotoResponseDto> { ): Promise<InspectionPhotoResponseDto> {
const photo = await this.prisma.inspectionPhoto.findUnique({ const [updatedPhoto] = await this.databaseService.db
where: { id }, .update(inspectionPhotos)
}); .set(dto)
.where(eq(inspectionPhotos.id, id))
.returning();
if (!photo) { if (!updatedPhoto) {
throw new NotFoundException(`Inspection photo with ID ${id} not found`); throw new NotFoundException(`Inspection photo with ID ${id} not found`);
} }
const updatedPhoto = await this.prisma.inspectionPhoto.update({
where: { id },
data: {
description: dto.description,
},
});
return this.mapToDto(updatedPhoto); return this.mapToDto(updatedPhoto);
} }
async deleteInspectionPhoto(id: number): Promise<void> { async getPhotosByInspectionId(
const photo = await this.prisma.inspectionPhoto.findUnique({ inspectionId: number,
where: { id }, ): Promise<InspectionPhotoResponseDto[]> {
}); const photos = await this.databaseService.db
.select()
.from(inspectionPhotos)
.where(eq(inspectionPhotos.inspectionId, inspectionId))
.orderBy(desc(inspectionPhotos.createdAt));
return photos.map(this.mapToDto);
}
if (!photo) { async deleteInspectionPhoto(id: number): Promise<void> {
// Get photo info first to delete file from disk
const photoList = await this.databaseService.db
.select()
.from(inspectionPhotos)
.where(eq(inspectionPhotos.id, id))
.limit(1);
if (photoList.length === 0) {
throw new NotFoundException(`Inspection photo with ID ${id} not found`); throw new NotFoundException(`Inspection photo with ID ${id} not found`);
} }
// Delete file from disk const photo = photoList[0];
// Delete file from disk if it exists
try { try {
const filePath = path.join(process.cwd(), photo.url); const filePath = path.join(process.cwd(), photo.url);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} }
} catch (error) { } catch (error) {
console.error(`Error deleting file ${photo.url}:`, error); console.error('Error deleting photo file:', error);
// Continue with database deletion even if file deletion fails // Continue with database deletion even if file deletion fails
} }
// Delete from database // Delete from database
await this.prisma.inspectionPhoto.delete({ await this.databaseService.db
where: { id }, .delete(inspectionPhotos)
}); .where(eq(inspectionPhotos.id, id));
}
async getPhotosByInspectionId(
inspectionId: number,
): Promise<InspectionPhotoResponseDto[]> {
// Check if inspection exists
const inspection = await this.prisma.inspection.findUnique({
where: { id: inspectionId },
});
if (!inspection) {
throw new NotFoundException(
`Inspection with ID ${inspectionId} not found`,
);
}
const photos = await this.prisma.inspectionPhoto.findMany({
where: { inspectionId },
orderBy: {
createdAt: 'desc',
},
});
return photos.map(this.mapToDto);
} }
private mapToDto(photo: any): InspectionPhotoResponseDto { private mapToDto(photo: any): InspectionPhotoResponseDto {
return { return {
id: photo.id, id: photo.id,
url: photo.url,
filename: photo.filename, filename: photo.filename,
mimeType: photo.mimeType, mimeType: photo.mimeType,
size: photo.size, size: photo.size,
url: photo.url,
description: photo.description, description: photo.description,
inspectionId: photo.inspectionId, inspectionId: photo.inspectionId,
createdAt: photo.createdAt, createdAt: photo.createdAt,
......
...@@ -16,7 +16,15 @@ import { FilesInterceptor } from '@nestjs/platform-express'; ...@@ -16,7 +16,15 @@ import { FilesInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client'; // Role enum values as constants
const Role = {
ADMIN: 'ADMIN',
MANAGER: 'MANAGER',
OPERATOR: 'OPERATOR',
VIEWER: 'VIEWER',
SUPERADMIN: 'SUPERADMIN',
PARTNER: 'PARTNER',
} as const;
import { InspectionService } from './inspection.service'; import { InspectionService } from './inspection.service';
import { import {
CreateInspectionDto, CreateInspectionDto,
......
...@@ -11,7 +11,7 @@ import { Server, Socket } from 'socket.io'; ...@@ -11,7 +11,7 @@ import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common'; import { Logger, UseGuards } from '@nestjs/common';
import { WsJwtAuthGuard } from '../auth/guards/ws-jwt-auth.guard'; import { WsJwtAuthGuard } from '../auth/guards/ws-jwt-auth.guard';
import { InspectionService } from './inspection.service'; import { InspectionService } from './inspection.service';
import { PrismaService } from '../../common/prisma/prisma.service'; import { DatabaseService } from '../../database/database.service';
import { CreateInspectionResponseDto } from './dto/create-inspection.dto'; import { CreateInspectionResponseDto } from './dto/create-inspection.dto';
import { UpdateInspectionStatusDto } from './dto/update-inspection-status.dto'; import { UpdateInspectionStatusDto } from './dto/update-inspection-status.dto';
...@@ -39,7 +39,7 @@ export class InspectionGateway ...@@ -39,7 +39,7 @@ export class InspectionGateway
constructor( constructor(
private readonly inspectionService: InspectionService, private readonly inspectionService: InspectionService,
private readonly prisma: PrismaService, private readonly database: DatabaseService,
) {} ) {}
async handleConnection(client: Socket) { async handleConnection(client: Socket) {
...@@ -286,8 +286,8 @@ export class InspectionGateway ...@@ -286,8 +286,8 @@ export class InspectionGateway
try { try {
// Check if inspection exists and is PENDING // Check if inspection exists and is PENDING
const inspection = await this.prisma.inspection.findUnique({ const inspection = await this.database.db.query.inspections.findFirst({
where: { id: inspectionId }, where: (inspections, { eq }) => eq(inspections.id, inspectionId),
}); });
if (!inspection) { if (!inspection) {
......
...@@ -3,13 +3,13 @@ import { InspectionController } from './inspection.controller'; ...@@ -3,13 +3,13 @@ import { InspectionController } from './inspection.controller';
import { InspectionService } from './inspection.service'; import { InspectionService } from './inspection.service';
import { InspectionSchedulerService } from './inspection-scheduler.service'; import { InspectionSchedulerService } from './inspection-scheduler.service';
import { InspectionGateway } from './inspection.gateway'; import { InspectionGateway } from './inspection.gateway';
import { PrismaModule } from '../../common/prisma/prisma.module';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { QuestionsModule } from '../questions/questions.module'; import { QuestionsModule } from '../questions/questions.module';
import { DatabaseModule } from '../../database/database.module';
@Module({ @Module({
imports: [ imports: [
PrismaModule, DatabaseModule,
QuestionsModule, QuestionsModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET, secret: process.env.JWT_SECRET,
......
...@@ -24,7 +24,15 @@ import { PartnerAuthGuard } from '../auth/guards/partner-auth.guard'; ...@@ -24,7 +24,15 @@ import { PartnerAuthGuard } from '../auth/guards/partner-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { User } from '../auth/decorators/user.decorator'; import { User } from '../auth/decorators/user.decorator';
import { Partner } from '../auth/decorators/partner.decorator'; import { Partner } from '../auth/decorators/partner.decorator';
import { Role } from '@prisma/client'; // Role enum values as constants
const Role = {
ADMIN: 'ADMIN',
MANAGER: 'MANAGER',
OPERATOR: 'OPERATOR',
VIEWER: 'VIEWER',
SUPERADMIN: 'SUPERADMIN',
PARTNER: 'PARTNER',
} as const;
@ApiTags('partners') @ApiTags('partners')
@Controller('partners') @Controller('partners')
...@@ -53,7 +61,7 @@ export class PartnersController { ...@@ -53,7 +61,7 @@ export class PartnersController {
description: 'Return all partners.', description: 'Return all partners.',
type: [PartnerResponseDto], type: [PartnerResponseDto],
}) })
findAll(@Partner() partnerId: number | null, @User('role') role: Role) { findAll(@Partner() partnerId: number | null, @User('role') role: string) {
// For PARTNER users, we'll only return their own partner // For PARTNER users, we'll only return their own partner
if (role === Role.PARTNER && partnerId) { if (role === Role.PARTNER && partnerId) {
return this.partnersService.findOne(partnerId); return this.partnersService.findOne(partnerId);
......
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PartnersController } from './partners.controller'; import { PartnersController } from './partners.controller';
import { PartnersService } from './partners.service'; import { PartnersService } from './partners.service';
import { PrismaService } from '../../common/prisma/prisma.service';
@Module({ @Module({
controllers: [PartnersController], controllers: [PartnersController],
providers: [PartnersService, PrismaService], providers: [PartnersService],
exports: [PartnersService], exports: [PartnersService],
}) })
export class PartnersModule {} export class PartnersModule {}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment