Commit 32c2842e by Augusto

telecommunication-station

parent e9c035ff
# 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"
```
# 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.
-- 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;
-- 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';
-- 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;
......@@ -65,6 +65,7 @@ model Site {
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[]
......@@ -202,20 +203,21 @@ model InspectionResponse {
}
model Inspection {
id Int @id @default(autoincrement())
date DateTime
comment 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[]
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])
......@@ -229,6 +231,7 @@ model InspectionPhoto {
filename String
mimeType String
size Int
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inspectionId Int
......@@ -237,6 +240,22 @@ model InspectionPhoto {
@@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
......
-- 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;
......@@ -17,6 +17,9 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { PartnersModule } from './modules/partners/partners.module';
import { InspectionModule } from './modules/inspection/inspection.module';
import { QuestionsModule } from './modules/questions/questions.module';
import { TelecommunicationStationIdentificationModule } from './modules/telecommunication-station-identification/telecommunication-station-identification.module';
import { InspectionPhotosModule } from './modules/inspection-photos/inspection-photos.module';
@Module({
imports: [
......@@ -58,6 +61,9 @@ import { InspectionModule } from './modules/inspection/inspection.module';
DashboardModule,
PartnersModule,
InspectionModule,
QuestionsModule,
TelecommunicationStationIdentificationModule,
InspectionPhotosModule,
],
controllers: [AppController],
providers: [
......
......@@ -64,7 +64,7 @@ async function bootstrap() {
SwaggerModule.setup('docs', app, document, swaggerOptions);
const port = process.env.PORT ?? 3002;
const port = process.env.PORT ?? 3001;
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(
......
......@@ -65,6 +65,14 @@ export class CandidatesController {
}
@Get()
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get all candidates' })
@ApiResponse({
status: 200,
......@@ -92,6 +100,14 @@ export class CandidatesController {
}
@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,
......@@ -106,6 +122,14 @@ export class CandidatesController {
}
@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,
......@@ -194,6 +218,7 @@ export class CandidatesController {
}
@Post(':id/photos')
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
......@@ -232,6 +257,14 @@ export class CandidatesController {
}
@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,
......@@ -245,6 +278,7 @@ export class CandidatesController {
}
@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' })
......
......@@ -51,6 +51,14 @@ export class CommentsController {
}
@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,
......@@ -62,6 +70,14 @@ export class CommentsController {
}
@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,
......
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateInspectionPhotoDto {
@ApiProperty({
description: 'ID of the inspection this photo belongs to',
example: 1,
type: Number,
})
@IsNumber()
inspectionId: number;
@ApiPropertyOptional({
description: 'Optional description or caption for the photo',
example: 'Front view of the site entrance',
type: String,
})
@IsOptional()
@IsString()
description?: string;
}
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsNumber } from 'class-validator';
import { Transform } from 'class-transformer';
export class FindInspectionPhotosDto {
@ApiPropertyOptional({
description: 'Filter by inspection ID',
example: 1,
type: Number,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
@IsNumber()
inspectionId?: number;
}
export * from './create-inspection-photo.dto';
export * from './update-inspection-photo.dto';
export * from './inspection-photo-response.dto';
export * from './find-inspection-photos.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class InspectionPhotoResponseDto {
@ApiProperty({
description: 'Unique identifier of the inspection photo',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'URL to access the photo',
example: '/uploads/inspection/1/photo1.jpg',
type: String,
})
url: string;
@ApiProperty({
description: 'Original filename of the photo',
example: 'photo1.jpg',
type: String,
})
filename: string;
@ApiProperty({
description: 'MIME type of the photo',
example: 'image/jpeg',
type: String,
})
mimeType: string;
@ApiProperty({
description: 'Size of the photo in bytes',
example: 1024000,
type: Number,
})
size: number;
@ApiPropertyOptional({
description: 'Optional description or caption for the photo',
example: 'Front view of the site entrance',
type: String,
})
description?: string;
@ApiProperty({
description: 'ID of the inspection this photo belongs to',
example: 1,
type: Number,
})
inspectionId: number;
@ApiProperty({
description: 'Date and time when the photo was uploaded',
example: '2025-05-21T13:15:30.000Z',
type: Date,
})
createdAt: Date;
@ApiProperty({
description: 'Date and time when the photo was last updated',
example: '2025-05-21T13:15:30.000Z',
type: Date,
})
updatedAt: Date;
}
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class UpdateInspectionPhotoDto {
@ApiPropertyOptional({
description: 'Optional description or caption for the photo',
example: 'Updated description for the photo',
type: String,
})
@IsOptional()
@IsString()
description?: string;
}
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
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 { InspectionPhotosService } from './inspection-photos.service';
import {
CreateInspectionPhotoDto,
UpdateInspectionPhotoDto,
InspectionPhotoResponseDto,
FindInspectionPhotosDto,
} from './dto';
import { multerConfig } from '../../common/multer/multer.config';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
@ApiTags('inspection-photos')
@Controller('inspection-photos')
@ApiBearerAuth('access-token')
export class InspectionPhotosController {
constructor(
private readonly inspectionPhotosService: InspectionPhotosService,
) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@UseInterceptors(FileInterceptor('photo', multerConfig))
@ApiOperation({
summary: 'Upload a new inspection photo',
description:
'Uploads a new photo for an inspection. Only users with ADMIN, MANAGER, OPERATOR, PARTNER, or SUPERADMIN roles can upload photos.',
})
@ApiResponse({
status: 201,
description: 'The photo has been successfully uploaded.',
type: InspectionPhotoResponseDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Inspection not found.' })
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Photo upload data',
schema: {
type: 'object',
required: ['inspectionId', 'photo'],
properties: {
inspectionId: {
type: 'number',
example: 1,
description: 'ID of the inspection this photo belongs to',
},
description: {
type: 'string',
example: 'Front view of the site entrance',
description: 'Optional description for the photo',
},
photo: {
type: 'string',
format: 'binary',
description: 'Photo file (max 5MB, images only)',
},
},
},
})
async uploadInspectionPhoto(
@Body() createInspectionPhotoDto: CreateInspectionPhotoDto,
@UploadedFile() file: Express.Multer.File,
) {
return this.inspectionPhotosService.createInspectionPhoto(
createInspectionPhotoDto,
file,
);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Find all inspection photos with optional filters',
description:
'Retrieves a list of inspection photos. Can be filtered by inspection ID.',
})
@ApiResponse({
status: 200,
description: 'List of inspection photos.',
type: [InspectionPhotoResponseDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiQuery({
name: 'inspectionId',
required: false,
type: Number,
description: 'Filter by inspection ID',
example: 1,
})
async findAllInspectionPhotos(
@Query() findInspectionPhotosDto: FindInspectionPhotosDto,
) {
return this.inspectionPhotosService.findAllInspectionPhotos(
findInspectionPhotosDto,
);
}
@Get('inspection/:inspectionId')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Get all photos for a specific inspection',
description: 'Retrieves all photos associated with a specific inspection.',
})
@ApiResponse({
status: 200,
description: 'List of photos for the inspection.',
type: [InspectionPhotoResponseDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 404, description: 'Inspection not found.' })
@ApiParam({
name: 'inspectionId',
type: Number,
description: 'ID of the inspection',
example: 1,
})
async getPhotosByInspectionId(
@Param('inspectionId', ParseIntPipe) inspectionId: number,
) {
return this.inspectionPhotosService.getPhotosByInspectionId(inspectionId);
}
@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Find inspection photo by ID',
description: 'Retrieves a specific inspection photo by its ID.',
})
@ApiResponse({
status: 200,
description: 'The inspection photo.',
type: InspectionPhotoResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 404, description: 'Inspection photo not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the inspection photo to retrieve',
example: 1,
})
async findInspectionPhotoById(@Param('id', ParseIntPipe) id: number) {
return this.inspectionPhotosService.findInspectionPhotoById(id);
}
@Put(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Update an inspection photo',
description:
'Updates the description of an inspection photo. Only users with ADMIN, MANAGER, OPERATOR, PARTNER, or SUPERADMIN roles can update photos.',
})
@ApiResponse({
status: 200,
description: 'The inspection photo has been successfully updated.',
type: InspectionPhotoResponseDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Inspection photo not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the inspection photo to update',
example: 1,
})
@ApiBody({
description: 'Update data for the inspection photo',
type: UpdateInspectionPhotoDto,
})
async updateInspectionPhoto(
@Param('id', ParseIntPipe) id: number,
@Body() updateInspectionPhotoDto: UpdateInspectionPhotoDto,
) {
return this.inspectionPhotosService.updateInspectionPhoto(
id,
updateInspectionPhotoDto,
);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Delete an inspection photo',
description:
'Deletes an inspection photo and removes the file from storage. Only users with ADMIN, MANAGER, OPERATOR, PARTNER, or SUPERADMIN roles can delete photos.',
})
@ApiResponse({
status: 200,
description: 'The inspection photo has been successfully deleted.',
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Inspection photo not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the inspection photo to delete',
example: 1,
})
async deleteInspectionPhoto(@Param('id', ParseIntPipe) id: number) {
await this.inspectionPhotosService.deleteInspectionPhoto(id);
return { message: 'Inspection photo deleted successfully' };
}
}
import { Module } from '@nestjs/common';
import { InspectionPhotosController } from './inspection-photos.controller';
import { InspectionPhotosService } from './inspection-photos.service';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [InspectionPhotosController],
providers: [InspectionPhotosService],
exports: [InspectionPhotosService],
})
export class InspectionPhotosModule {}
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import {
CreateInspectionPhotoDto,
UpdateInspectionPhotoDto,
InspectionPhotoResponseDto,
FindInspectionPhotosDto,
} from './dto';
import { saveInspectionPhotos } from '../inspection/inspection.utils';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class InspectionPhotosService {
constructor(private prisma: PrismaService) {}
async createInspectionPhoto(
dto: CreateInspectionPhotoDto,
file: Express.Multer.File,
): Promise<InspectionPhotoResponseDto> {
// Check if inspection exists
const inspection = await this.prisma.inspection.findUnique({
where: { id: dto.inspectionId },
});
if (!inspection) {
throw new NotFoundException(
`Inspection with ID ${dto.inspectionId} not found`,
);
}
// Save file to disk
const filePaths = await saveInspectionPhotos([file], dto.inspectionId);
// Create photo record in database
const photo = await this.prisma.inspectionPhoto.create({
data: {
filename: file.originalname,
mimeType: file.mimetype,
size: file.size,
url: filePaths[0],
description: dto.description,
inspection: { connect: { id: dto.inspectionId } },
},
});
return this.mapToDto(photo);
}
async findAllInspectionPhotos(
dto: FindInspectionPhotosDto,
): Promise<InspectionPhotoResponseDto[]> {
const filter: any = {};
if (dto.inspectionId) {
filter.inspectionId = dto.inspectionId;
}
const photos = await this.prisma.inspectionPhoto.findMany({
where: filter,
orderBy: {
createdAt: 'desc',
},
});
return photos.map(this.mapToDto);
}
async findInspectionPhotoById(
id: number,
): Promise<InspectionPhotoResponseDto> {
const photo = await this.prisma.inspectionPhoto.findUnique({
where: { id },
});
if (!photo) {
throw new NotFoundException(`Inspection photo with ID ${id} not found`);
}
return this.mapToDto(photo);
}
async updateInspectionPhoto(
id: number,
dto: UpdateInspectionPhotoDto,
): Promise<InspectionPhotoResponseDto> {
const photo = await this.prisma.inspectionPhoto.findUnique({
where: { id },
});
if (!photo) {
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);
}
async deleteInspectionPhoto(id: number): Promise<void> {
const photo = await this.prisma.inspectionPhoto.findUnique({
where: { id },
});
if (!photo) {
throw new NotFoundException(`Inspection photo with ID ${id} not found`);
}
// Delete file from disk
try {
const filePath = path.join(process.cwd(), photo.url);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (error) {
console.error(`Error deleting file ${photo.url}:`, error);
// Continue with database deletion even if file deletion fails
}
// Delete from database
await this.prisma.inspectionPhoto.delete({
where: { 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 {
return {
id: photo.id,
url: photo.url,
filename: photo.filename,
mimeType: photo.mimeType,
size: photo.size,
description: photo.description,
inspectionId: photo.inspectionId,
createdAt: photo.createdAt,
updatedAt: photo.updatedAt,
};
}
}
......@@ -66,6 +66,17 @@ export class CreateInspectionDto {
@IsOptional()
comment?: string;
@ApiPropertyOptional({
description:
'Comprehensive final comment with detailed inspection review and conclusions',
example:
'Final inspection review: The site is in excellent overall condition. All safety protocols are being followed. Minor maintenance recommendations: 1) Replace weathered cable ties on antenna supports, 2) Clean and lubricate door hinges, 3) Schedule next inspection for Q2 2026. No critical issues identified.',
type: String,
})
@IsString()
@IsOptional()
finalComment?: string;
@ApiProperty({
description: 'Responses to inspection questions',
type: [CreateInspectionResponseDto],
......
......@@ -2,3 +2,6 @@ export * from './create-inspection.dto';
export * from './find-inspection.dto';
export * from './inspection-response.dto';
export * from './inspection-response-option.enum';
export * from './start-inspection.dto';
export * from './update-final-comment.dto';
export * from './update-inspection-status.dto';
......@@ -78,6 +78,13 @@ export class InspectionPhotoDto {
type: String,
})
filename: string;
@ApiPropertyOptional({
description: 'Optional description or caption for the photo',
example: 'Front view of the site entrance',
type: String,
})
description?: string;
}
export class InspectionDto {
......@@ -102,6 +109,15 @@ export class InspectionDto {
})
comment?: string;
@ApiPropertyOptional({
description:
'Comprehensive final comment with detailed inspection review and conclusions',
example:
'Final inspection review: The site is in excellent overall condition. All safety protocols are being followed. Minor maintenance recommendations: 1) Replace weathered cable ties on antenna supports, 2) Clean and lubricate door hinges, 3) Schedule next inspection for Q2 2026. No critical issues identified.',
type: String,
})
finalComment?: string;
@ApiProperty({
description: 'ID of the site where inspection was performed',
example: 1,
......
import { IsString, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateFinalCommentDto {
@ApiPropertyOptional({
description:
'Comprehensive final comment with detailed inspection review and conclusions',
example:
'Final inspection review: The site is in excellent overall condition. All safety protocols are being followed. Minor maintenance recommendations: 1) Replace weathered cable ties on antenna supports, 2) Clean and lubricate door hinges, 3) Schedule next inspection for Q2 2026. No critical issues identified.',
type: String,
})
@IsString()
@IsOptional()
finalComment?: string;
}
......@@ -25,6 +25,7 @@ import {
import { FindInspectionDto } from './dto/find-inspection.dto';
import { UpdateInspectionStatusDto } from './dto/update-inspection-status.dto';
import { StartInspectionDto } from './dto/start-inspection.dto';
import { UpdateFinalCommentDto } from './dto/update-final-comment.dto';
import { multerConfig } from '../../common/multer/multer.config';
import {
ApiTags,
......@@ -532,4 +533,46 @@ export class InspectionController {
startInspectionDto.comment,
);
}
@Patch(':id/final-comment')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Update the final comment of an inspection',
description:
'Updates the final comment of an inspection with comprehensive review and conclusions. This is typically done at the end of the inspection process.',
})
@ApiResponse({
status: 200,
description: 'The inspection final comment has been successfully updated.',
type: InspectionDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Inspection not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the inspection to update',
example: 1,
})
@ApiBody({
description: 'Final comment data',
type: UpdateFinalCommentDto,
})
async updateFinalComment(
@Param('id', ParseIntPipe) id: number,
@Body() updateFinalCommentDto: UpdateFinalCommentDto,
@Req() req,
) {
return this.inspectionService.updateFinalComment(
id,
updateFinalCommentDto.finalComment,
req.user.id,
);
}
}
......@@ -5,10 +5,12 @@ import { InspectionSchedulerService } from './inspection-scheduler.service';
import { InspectionGateway } from './inspection.gateway';
import { PrismaModule } from '../../common/prisma/prisma.module';
import { JwtModule } from '@nestjs/jwt';
import { QuestionsModule } from '../questions/questions.module';
@Module({
imports: [
PrismaModule,
QuestionsModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '1d' },
......
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { QuestionsService } from '../questions/questions.service';
import {
CreateInspectionDto,
CreateInspectionResponseDto,
......@@ -15,7 +16,10 @@ import { saveInspectionPhotos } from './inspection.utils';
@Injectable()
export class InspectionService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private questionsService: QuestionsService,
) {}
async createInspection(
dto: CreateInspectionDto,
......@@ -36,6 +40,7 @@ export class InspectionService {
data: {
date: new Date(dto.date),
comment: dto.comment,
finalComment: dto.finalComment,
site: { connect: { id: dto.siteId } },
createdBy: userId ? { connect: { id: userId } } : undefined,
responses: {
......@@ -70,6 +75,7 @@ export class InspectionService {
mimeType: file.mimetype,
size: file.size,
url: filePaths[index],
description: null, // No description for photos uploaded during inspection creation
inspection: { connect: { id: inspection.id } },
},
});
......@@ -162,25 +168,15 @@ export class InspectionService {
}
async getInspectionQuestions() {
return this.prisma.inspectionQuestion.findMany({
orderBy: {
orderIndex: 'asc',
},
const result = await this.questionsService.findAllQuestions({
page: 1,
limit: 1000, // Get all questions
});
return result.questions;
}
async getInspectionQuestionById(id: number) {
const question = await this.prisma.inspectionQuestion.findUnique({
where: { id },
});
if (!question) {
throw new NotFoundException(
`Inspection question with ID ${id} not found`,
);
}
return question;
return this.questionsService.findQuestionById(id);
}
async getResponsesByInspectionId(
......@@ -408,17 +404,17 @@ export class InspectionService {
for (const site of sitesNeedingInspections) {
try {
// Get all inspection questions
const questions = await this.prisma.inspectionQuestion.findMany({
orderBy: {
orderIndex: 'asc',
},
const questionsResult = await this.questionsService.findAllQuestions({
page: 1,
limit: 1000, // Get all questions
});
const questions = questionsResult.questions;
// Create empty responses for all questions
const responses = questions.map((question) => ({
questionId: question.id,
response: 'NA' as any, // Default to NA (Not Applicable)
comment: 'Automatically generated inspection - pending completion',
comment: null,
}));
// Create the inspection
......@@ -487,9 +483,7 @@ export class InspectionService {
where: { id: inspectionId },
data: {
status: 'IN_PROGRESS',
comment: comment
? `${inspection.comment || ''}\n\nStarted by user ${userId}: ${comment}`
: inspection.comment,
comment: comment || inspection.comment,
updatedBy: { connect: { id: userId } },
},
include: {
......@@ -578,17 +572,17 @@ export class InspectionService {
}
// Get all inspection questions
const questions = await this.prisma.inspectionQuestion.findMany({
orderBy: {
orderIndex: 'asc',
},
const questionsResult = await this.questionsService.findAllQuestions({
page: 1,
limit: 1000, // Get all questions
});
const questions = questionsResult.questions;
// Create empty responses for all questions
const responses = questions.map((question) => ({
questionId: question.id,
response: 'NA' as any, // Default to NA (Not Applicable)
comment: 'Redo inspection - pending completion',
comment: null,
}));
// Create the new inspection
......@@ -622,11 +616,49 @@ export class InspectionService {
return this.mapToDto(newInspection);
}
/**
* Update the final comment of an inspection
*/
async updateFinalComment(
inspectionId: number,
finalComment: string | undefined,
userId: number,
): Promise<InspectionDto> {
const inspection = await this.prisma.inspection.findUnique({
where: { id: inspectionId },
});
if (!inspection) {
throw new NotFoundException(
`Inspection with ID ${inspectionId} not found`,
);
}
const updatedInspection = await this.prisma.inspection.update({
where: { id: inspectionId },
data: {
finalComment,
updatedBy: { connect: { id: userId } },
},
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
return this.mapToDto(updatedInspection);
}
private mapToDto(inspection: any): InspectionDto {
return {
id: inspection.id,
date: inspection.date,
comment: inspection.comment,
finalComment: inspection.finalComment,
siteId: inspection.siteId,
status: inspection.status,
createdAt: inspection.createdAt,
......@@ -645,6 +677,7 @@ export class InspectionService {
id: photo.id,
url: photo.url,
filename: photo.filename,
description: photo.description,
})),
};
}
......
import { IsString, IsInt, Min, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateQuestionDto {
@ApiProperty({
description: 'The question text',
example: 'Is the site properly secured?',
maxLength: 500,
})
@IsString()
@MaxLength(500)
question: string;
@ApiProperty({
description: 'The order index for displaying questions',
example: 1,
minimum: 1,
})
@IsInt()
@Min(1)
orderIndex: number;
}
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export class FindQuestionsDto {
@ApiProperty({
description: 'Search questions by text (partial match)',
example: 'security',
required: false,
})
@IsOptional()
@IsString()
search?: string;
@ApiProperty({
description: 'Page number for pagination',
example: 1,
minimum: 1,
required: false,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
@IsInt()
@Min(1)
page?: number = 1;
@ApiProperty({
description: 'Number of items per page',
example: 10,
minimum: 1,
required: false,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
@IsInt()
@Min(1)
limit?: number = 10;
}
export * from './create-question.dto';
export * from './update-question.dto';
export * from './question-response.dto';
export * from './find-questions.dto';
import { ApiProperty } from '@nestjs/swagger';
export class QuestionResponseDto {
@ApiProperty({
description: 'The unique identifier of the question',
example: 1,
})
id: number;
@ApiProperty({
description: 'The question text',
example: 'Is the site properly secured?',
})
question: string;
@ApiProperty({
description: 'The order index for displaying questions',
example: 1,
})
orderIndex: number;
@ApiProperty({
description: 'When the question was created',
example: '2024-01-15T10:30:00.000Z',
})
createdAt: Date;
@ApiProperty({
description: 'When the question was last updated',
example: '2024-01-15T10:30:00.000Z',
})
updatedAt: Date;
}
import { PartialType } from '@nestjs/swagger';
import { CreateQuestionDto } from './create-question.dto';
export class UpdateQuestionDto extends PartialType(CreateQuestionDto) {}
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
ApiBearerAuth,
} from '@nestjs/swagger';
import { QuestionsService } from './questions.service';
import {
CreateQuestionDto,
UpdateQuestionDto,
QuestionResponseDto,
FindQuestionsDto,
} from './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';
@ApiTags('Inspection Questions')
@ApiBearerAuth('access-token')
@Controller('questions')
export class QuestionsController {
constructor(private readonly questionsService: QuestionsService) {}
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Create a new inspection question' })
@ApiResponse({
status: 201,
description: 'Question created successfully',
type: QuestionResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - validation error',
})
@ApiResponse({
status: 409,
description: 'Conflict - order index already exists',
})
async createQuestion(
@Body() dto: CreateQuestionDto,
): Promise<QuestionResponseDto> {
return this.questionsService.createQuestion(dto);
}
@Get()
@UseGuards(RolesGuard)
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get all inspection questions with pagination' })
@ApiQuery({
name: 'search',
required: false,
description: 'Search questions by text',
})
@ApiQuery({
name: 'page',
required: false,
description: 'Page number',
type: Number,
})
@ApiQuery({
name: 'limit',
required: false,
description: 'Items per page',
type: Number,
})
@ApiResponse({
status: 200,
description: 'Questions retrieved successfully',
schema: {
type: 'object',
properties: {
questions: {
type: 'array',
items: { $ref: '#/components/schemas/QuestionResponseDto' },
},
total: { type: 'number' },
page: { type: 'number' },
limit: { type: 'number' },
totalPages: { type: 'number' },
},
},
})
async findAllQuestions(@Query() dto: FindQuestionsDto) {
return this.questionsService.findAllQuestions(dto);
}
@Get(':id')
@UseGuards(RolesGuard)
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get a specific inspection question by ID' })
@ApiParam({
name: 'id',
description: 'Question ID',
type: Number,
})
@ApiResponse({
status: 200,
description: 'Question retrieved successfully',
type: QuestionResponseDto,
})
@ApiResponse({
status: 404,
description: 'Question not found',
})
async findQuestionById(
@Param('id', ParseIntPipe) id: number,
): Promise<QuestionResponseDto> {
return this.questionsService.findQuestionById(id);
}
@Put(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Update an inspection question' })
@ApiParam({
name: 'id',
description: 'Question ID',
type: Number,
})
@ApiResponse({
status: 200,
description: 'Question updated successfully',
type: QuestionResponseDto,
})
@ApiResponse({
status: 404,
description: 'Question not found',
})
@ApiResponse({
status: 409,
description: 'Conflict - order index already exists',
})
async updateQuestion(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateQuestionDto,
): Promise<QuestionResponseDto> {
return this.questionsService.updateQuestion(id, dto);
}
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Delete an inspection question' })
@ApiParam({
name: 'id',
description: 'Question ID',
type: Number,
})
@ApiResponse({
status: 200,
description: 'Question deleted successfully',
})
@ApiResponse({
status: 404,
description: 'Question not found',
})
@ApiResponse({
status: 409,
description: 'Conflict - question has associated responses',
})
async deleteQuestion(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.questionsService.deleteQuestion(id);
}
@Post('reorder')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Reorder inspection questions' })
@ApiResponse({
status: 200,
description: 'Questions reordered successfully',
type: [QuestionResponseDto],
})
@ApiResponse({
status: 404,
description: 'One or more question IDs not found',
})
async reorderQuestions(
@Body() questionIds: number[],
): Promise<QuestionResponseDto[]> {
return this.questionsService.reorderQuestions(questionIds);
}
}
import { Module } from '@nestjs/common';
import { QuestionsController } from './questions.controller';
import { QuestionsService } from './questions.service';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [QuestionsController],
providers: [QuestionsService],
exports: [QuestionsService],
})
export class QuestionsModule {}
import { Test, TestingModule } from '@nestjs/testing';
import { QuestionsService } from './questions.service';
import { PrismaService } from '../../common/prisma/prisma.service';
describe('QuestionsService', () => {
let service: QuestionsService;
let prismaService: PrismaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
QuestionsService,
{
provide: PrismaService,
useValue: {
inspectionQuestion: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
},
},
],
}).compile();
service = module.get<QuestionsService>(QuestionsService);
prismaService = module.get<PrismaService>(PrismaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('createQuestion', () => {
it('should create a new question', async () => {
const createQuestionDto = {
question: 'Test question?',
orderIndex: 1,
};
const expectedQuestion = {
id: 1,
question: 'Test question?',
orderIndex: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
jest
.spyOn(prismaService.inspectionQuestion, 'findFirst')
.mockResolvedValue(null);
jest
.spyOn(prismaService.inspectionQuestion, 'create')
.mockResolvedValue(expectedQuestion);
const result = await service.createQuestion(createQuestionDto);
expect(result).toEqual(expectedQuestion);
expect(prismaService.inspectionQuestion.create).toHaveBeenCalledWith({
data: createQuestionDto,
});
});
it('should throw ConflictException if orderIndex already exists', async () => {
const createQuestionDto = {
question: 'Test question?',
orderIndex: 1,
};
const existingQuestion = {
id: 2,
question: 'Existing question?',
orderIndex: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
jest
.spyOn(prismaService.inspectionQuestion, 'findFirst')
.mockResolvedValue(existingQuestion);
await expect(service.createQuestion(createQuestionDto)).rejects.toThrow(
'Question with order index 1 already exists',
);
});
});
});
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import {
CreateQuestionDto,
UpdateQuestionDto,
QuestionResponseDto,
FindQuestionsDto,
} from './dto';
@Injectable()
export class QuestionsService {
constructor(private prisma: PrismaService) {}
async createQuestion(dto: CreateQuestionDto): Promise<QuestionResponseDto> {
// Check if orderIndex already exists
const existingQuestion = await this.prisma.inspectionQuestion.findFirst({
where: { orderIndex: dto.orderIndex },
});
if (existingQuestion) {
throw new ConflictException(
`Question with order index ${dto.orderIndex} already exists`,
);
}
const question = await this.prisma.inspectionQuestion.create({
data: {
question: dto.question,
orderIndex: dto.orderIndex,
},
});
return this.mapToDto(question);
}
async findAllQuestions(dto: FindQuestionsDto): Promise<{
questions: QuestionResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
const { search, page = 1, limit = 10 } = dto;
const skip = (page - 1) * limit;
const where: any = {};
if (search) {
where.question = {
contains: search,
mode: 'insensitive',
};
}
const [questions, total] = await Promise.all([
this.prisma.inspectionQuestion.findMany({
where,
orderBy: {
orderIndex: 'asc',
},
skip,
take: limit,
}),
this.prisma.inspectionQuestion.count({ where }),
]);
return {
questions: questions.map(this.mapToDto),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findQuestionById(id: number): Promise<QuestionResponseDto> {
const question = await this.prisma.inspectionQuestion.findUnique({
where: { id },
});
if (!question) {
throw new NotFoundException(`Question with ID ${id} not found`);
}
return this.mapToDto(question);
}
async updateQuestion(
id: number,
dto: UpdateQuestionDto,
): Promise<QuestionResponseDto> {
// Check if question exists
const existingQuestion = await this.prisma.inspectionQuestion.findUnique({
where: { id },
});
if (!existingQuestion) {
throw new NotFoundException(`Question with ID ${id} not found`);
}
// If orderIndex is being updated, check for conflicts
if (dto.orderIndex !== undefined && dto.orderIndex !== existingQuestion.orderIndex) {
const conflictingQuestion = await this.prisma.inspectionQuestion.findFirst({
where: {
orderIndex: dto.orderIndex,
id: { not: id },
},
});
if (conflictingQuestion) {
throw new ConflictException(
`Question with order index ${dto.orderIndex} already exists`,
);
}
}
const updatedQuestion = await this.prisma.inspectionQuestion.update({
where: { id },
data: dto,
});
return this.mapToDto(updatedQuestion);
}
async deleteQuestion(id: number): Promise<void> {
// Check if question exists
const question = await this.prisma.inspectionQuestion.findUnique({
where: { id },
include: {
responses: {
take: 1,
},
},
});
if (!question) {
throw new NotFoundException(`Question with ID ${id} not found`);
}
// Check if question has responses
if (question.responses.length > 0) {
throw new ConflictException(
`Cannot delete question with ID ${id} because it has associated responses`,
);
}
await this.prisma.inspectionQuestion.delete({
where: { id },
});
}
async reorderQuestions(questionIds: number[]): Promise<QuestionResponseDto[]> {
// Validate that all question IDs exist
const questions = await this.prisma.inspectionQuestion.findMany({
where: {
id: { in: questionIds },
},
});
if (questions.length !== questionIds.length) {
throw new NotFoundException('One or more question IDs not found');
}
// Update order indices
const updatePromises = questionIds.map((id, index) =>
this.prisma.inspectionQuestion.update({
where: { id },
data: { orderIndex: index + 1 },
}),
);
const updatedQuestions = await Promise.all(updatePromises);
return updatedQuestions.map(this.mapToDto);
}
private mapToDto(question: any): QuestionResponseDto {
return {
id: question.id,
question: question.question,
orderIndex: question.orderIndex,
createdAt: question.createdAt,
updatedAt: question.updatedAt,
};
}
}
......@@ -53,6 +53,14 @@ export class SitesController {
}
@Get('map')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({
summary: 'Get all sites for map view (without pagination)',
description:
......@@ -106,6 +114,14 @@ export class SitesController {
}
@Get('code/:siteCode')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get a site by code' })
@ApiResponse({
status: 200,
......@@ -118,6 +134,14 @@ export class SitesController {
}
@Get(':id')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get a site by id' })
@ApiResponse({
status: 200,
......@@ -138,6 +162,14 @@ export class SitesController {
}
@Get(':id/with-candidates')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get a site with its candidates' })
@ApiQuery({
name: 'partnerId',
......
import { Test, TestingModule } from '@nestjs/testing';
import { SitesService } from './sites.service';
import { PrismaService } from '../../common/prisma/prisma.service';
describe('SitesService', () => {
let service: SitesService;
let prismaService: PrismaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SitesService,
{
provide: PrismaService,
useValue: {
site: {
findUnique: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
},
},
],
}).compile();
service = module.get<SitesService>(SitesService);
prismaService = module.get<PrismaService>(PrismaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('nextInspectionDueDate calculation', () => {
it('should calculate next inspection due date correctly', () => {
// Test with a site that had its last inspection on January 15, 2023
const lastInspectionDate = new Date('2023-01-15T10:00:00.000Z');
const currentDate = new Date('2024-01-10T10:00:00.000Z'); // January 10, 2024
// Mock Date.now to return our test date
const originalDateNow = Date.now;
Date.now = jest.fn(() => currentDate.getTime());
const result =
service['calculateNextInspectionDueDate'](lastInspectionDate);
// Should return January 14, 2024 (one day before the anniversary)
expect(result).toEqual(new Date('2024-01-14T10:00:00.000Z'));
// Restore original Date.now
Date.now = originalDateNow;
});
it('should return null when no last inspection date is provided', () => {
const result = service['calculateNextInspectionDueDate'](null);
expect(result).toBeNull();
});
it('should handle future inspection dates correctly', () => {
// Test with a site that had its last inspection on December 15, 2023
const lastInspectionDate = new Date('2023-12-15T10:00:00.000Z');
const currentDate = new Date('2024-01-10T10:00:00.000Z'); // January 10, 2024
// Mock Date.now to return our test date
const originalDateNow = Date.now;
Date.now = jest.fn(() => currentDate.getTime());
const result =
service['calculateNextInspectionDueDate'](lastInspectionDate);
// Should return December 14, 2024 (one day before the anniversary)
expect(result).toEqual(new Date('2024-12-14T10:00:00.000Z'));
// Restore original Date.now
Date.now = originalDateNow;
});
});
});
......@@ -480,6 +480,16 @@ export class SitesService {
},
},
},
inspections: {
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
status: true,
createdAt: true,
},
},
_count: {
select: {
candidates: true,
......@@ -492,18 +502,29 @@ export class SitesService {
throw new NotFoundException(`Site with ID ${id} not found`);
}
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
};
}
const highestCandidateStatus =
site.candidates && site.candidates.length > 0
? this.getHighestPriorityStatus(site.candidates)
: null;
// Get the latest inspection status
const latestInspectionStatus =
site.inspections && site.inspections.length > 0
? site.inspections[0].status
: null;
const lastInspectionDate =
site.inspections && site.inspections.length > 0
? site.inspections[0].createdAt
: null;
const nextInspectionDueDate =
this.calculateNextInspectionDueDate(lastInspectionDate);
return site;
return {
...site,
highestCandidateStatus,
latestInspectionStatus,
nextInspectionDueDate,
};
}
async findOneWithCandidates(id: number, partnerId?: number | null) {
......@@ -560,6 +581,16 @@ export class SitesService {
},
},
},
inspections: {
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
status: true,
createdAt: true,
},
},
},
});
......@@ -567,18 +598,29 @@ export class SitesService {
throw new NotFoundException(`Site with ID ${id} not found`);
}
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
};
}
const highestCandidateStatus =
site.candidates && site.candidates.length > 0
? this.getHighestPriorityStatus(site.candidates)
: null;
// Get the latest inspection status
const latestInspectionStatus =
site.inspections && site.inspections.length > 0
? site.inspections[0].status
: null;
const lastInspectionDate =
site.inspections && site.inspections.length > 0
? site.inspections[0].createdAt
: null;
const nextInspectionDueDate =
this.calculateNextInspectionDueDate(lastInspectionDate);
return site;
return {
...site,
highestCandidateStatus,
latestInspectionStatus,
nextInspectionDueDate,
};
}
async findOneWithCandidatesFilteredByPartner(
......@@ -632,6 +674,16 @@ export class SitesService {
},
},
},
inspections: {
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
status: true,
createdAt: true,
},
},
},
});
......@@ -647,6 +699,18 @@ export class SitesService {
),
};
// Get the latest inspection status
const latestInspectionStatus =
site.inspections && site.inspections.length > 0
? site.inspections[0].status
: null;
const lastInspectionDate =
site.inspections && site.inspections.length > 0
? site.inspections[0].createdAt
: null;
const nextInspectionDueDate =
this.calculateNextInspectionDueDate(lastInspectionDate);
// Add highest priority status if the site has filtered candidates
if (filteredSite.candidates && filteredSite.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(
......@@ -655,10 +719,16 @@ export class SitesService {
return {
...filteredSite,
highestCandidateStatus,
latestInspectionStatus,
nextInspectionDueDate,
};
}
return filteredSite;
return {
...filteredSite,
latestInspectionStatus,
nextInspectionDueDate,
};
}
async update(id: number, updateSiteDto: UpdateSiteDto, userId: number) {
......@@ -763,6 +833,16 @@ export class SitesService {
},
},
},
inspections: {
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
status: true,
createdAt: true,
},
},
_count: {
select: {
candidates: true,
......@@ -775,18 +855,29 @@ export class SitesService {
throw new NotFoundException(`Site with code ${siteCode} not found`);
}
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
};
}
const highestCandidateStatus =
site.candidates && site.candidates.length > 0
? this.getHighestPriorityStatus(site.candidates)
: null;
return site;
// Get the latest inspection status
const latestInspectionStatus =
site.inspections && site.inspections.length > 0
? site.inspections[0].status
: null;
const lastInspectionDate =
site.inspections && site.inspections.length > 0
? site.inspections[0].createdAt
: null;
const nextInspectionDueDate =
this.calculateNextInspectionDueDate(lastInspectionDate);
return {
...site,
highestCandidateStatus,
latestInspectionStatus,
nextInspectionDueDate,
};
}
async findAllForMap(findSitesDto: FindSitesDto) {
......
import { IsString, IsBoolean, IsNotEmpty, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTelecommunicationStationIdentificationDto {
@ApiProperty({
description: 'The site ID this identification belongs to',
example: 1,
})
@IsNotEmpty()
siteId: number;
@ApiProperty({
description: 'Unique identifier for the telecommunications station',
example: '19857_BARCA DE ALVA TX',
maxLength: 255,
})
@IsString()
@IsNotEmpty()
@MaxLength(255)
stationIdentifier: string;
@ApiProperty({
description: 'Serial or manufacturing number of the equipment',
example: '1234567890',
maxLength: 255,
})
@IsString()
@IsNotEmpty()
@MaxLength(255)
serialNumber: string;
@ApiProperty({
description: 'Indicates whether this is the first certification',
example: true,
})
@IsBoolean()
isFirstCertification: boolean;
@ApiProperty({
description: 'Model reference number of the equipment',
example: '1234567890',
maxLength: 255,
})
@IsString()
@IsNotEmpty()
@MaxLength(255)
modelReference: string;
}
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export class FindTelecommunicationStationIdentificationsDto {
@ApiProperty({
description: 'Search by station identifier (partial match)',
example: 'BARCA DE ALVA',
required: false,
})
@IsOptional()
@IsString()
stationIdentifier?: string;
@ApiProperty({
description: 'Search by serial number (partial match)',
example: '123456',
required: false,
})
@IsOptional()
@IsString()
serialNumber?: string;
@ApiProperty({
description: 'Search by model reference (partial match)',
example: '123456',
required: false,
})
@IsOptional()
@IsString()
modelReference?: string;
@ApiProperty({
description: 'Filter by first certification status',
example: true,
required: false,
})
@IsOptional()
isFirstCertification?: boolean;
@ApiProperty({
description: 'Filter by site ID',
example: 1,
required: false,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
@IsInt()
siteId?: number;
@ApiProperty({
description: 'Page number for pagination',
example: 1,
minimum: 1,
required: false,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
@IsInt()
@Min(1)
page?: number = 1;
@ApiProperty({
description: 'Number of items per page',
example: 10,
minimum: 1,
required: false,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
@IsInt()
@Min(1)
limit?: number = 10;
}
export * from './create-telecommunication-station-identification.dto';
export * from './update-telecommunication-station-identification.dto';
export * from './telecommunication-station-identification-response.dto';
export * from './find-telecommunication-station-identifications.dto';
import { ApiProperty } from '@nestjs/swagger';
export class TelecommunicationStationIdentificationResponseDto {
@ApiProperty({
description: 'The unique identifier of the telecommunication station identification',
example: 1,
})
id: number;
@ApiProperty({
description: 'The site ID this identification belongs to',
example: 1,
})
siteId: number;
@ApiProperty({
description: 'Unique identifier for the telecommunications station',
example: '19857_BARCA DE ALVA TX',
})
stationIdentifier: string;
@ApiProperty({
description: 'Serial or manufacturing number of the equipment',
example: '1234567890',
})
serialNumber: string;
@ApiProperty({
description: 'Indicates whether this is the first certification',
example: true,
})
isFirstCertification: boolean;
@ApiProperty({
description: 'Model reference number of the equipment',
example: '1234567890',
})
modelReference: string;
@ApiProperty({
description: 'When the identification was created',
example: '2024-01-15T10:30:00.000Z',
})
createdAt: Date;
@ApiProperty({
description: 'When the identification was last updated',
example: '2024-01-15T10:30:00.000Z',
})
updatedAt: Date;
}
import { PartialType } from '@nestjs/swagger';
import { CreateTelecommunicationStationIdentificationDto } from './create-telecommunication-station-identification.dto';
export class UpdateTelecommunicationStationIdentificationDto extends PartialType(
CreateTelecommunicationStationIdentificationDto,
) {}
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
ApiBearerAuth,
} from '@nestjs/swagger';
import { TelecommunicationStationIdentificationService } from './telecommunication-station-identification.service';
import {
CreateTelecommunicationStationIdentificationDto,
UpdateTelecommunicationStationIdentificationDto,
TelecommunicationStationIdentificationResponseDto,
FindTelecommunicationStationIdentificationsDto,
} from './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';
@ApiTags('Telecommunication Station Identification')
@ApiBearerAuth('access-token')
@Controller('telecommunication-station-identification')
export class TelecommunicationStationIdentificationController {
constructor(
private readonly telecommunicationStationIdentificationService: TelecommunicationStationIdentificationService,
) {}
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({
summary: 'Create a new telecommunication station identification',
})
@ApiResponse({
status: 201,
description:
'Telecommunication station identification created successfully',
type: TelecommunicationStationIdentificationResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - validation error',
})
@ApiResponse({
status: 404,
description: 'Site not found',
})
@ApiResponse({
status: 409,
description:
'Conflict - site already has identification or station identifier already exists',
})
async createTelecommunicationStationIdentification(
@Body() dto: CreateTelecommunicationStationIdentificationDto,
): Promise<TelecommunicationStationIdentificationResponseDto> {
return this.telecommunicationStationIdentificationService.createTelecommunicationStationIdentification(
dto,
);
}
@Get()
@UseGuards(RolesGuard)
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({
summary:
'Get all telecommunication station identifications with pagination',
})
@ApiQuery({
name: 'stationIdentifier',
required: false,
description: 'Search by station identifier',
})
@ApiQuery({
name: 'serialNumber',
required: false,
description: 'Search by serial number',
})
@ApiQuery({
name: 'modelReference',
required: false,
description: 'Search by model reference',
})
@ApiQuery({
name: 'isFirstCertification',
required: false,
description: 'Filter by first certification status',
type: Boolean,
})
@ApiQuery({
name: 'siteId',
required: false,
description: 'Filter by site ID',
type: Number,
})
@ApiQuery({
name: 'page',
required: false,
description: 'Page number',
type: Number,
})
@ApiQuery({
name: 'limit',
required: false,
description: 'Items per page',
type: Number,
})
@ApiResponse({
status: 200,
description:
'Telecommunication station identifications retrieved successfully',
schema: {
type: 'object',
properties: {
identifications: {
type: 'array',
items: {
$ref: '#/components/schemas/TelecommunicationStationIdentificationResponseDto',
},
},
total: { type: 'number' },
page: { type: 'number' },
limit: { type: 'number' },
totalPages: { type: 'number' },
},
},
})
async findAllTelecommunicationStationIdentifications(
@Query() dto: FindTelecommunicationStationIdentificationsDto,
) {
return this.telecommunicationStationIdentificationService.findAllTelecommunicationStationIdentifications(
dto,
);
}
@Get(':id')
@UseGuards(RolesGuard)
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({
summary: 'Get a specific telecommunication station identification by ID',
})
@ApiParam({
name: 'id',
description: 'Telecommunication station identification ID',
type: Number,
})
@ApiResponse({
status: 200,
description:
'Telecommunication station identification retrieved successfully',
type: TelecommunicationStationIdentificationResponseDto,
})
@ApiResponse({
status: 404,
description: 'Telecommunication station identification not found',
})
async findTelecommunicationStationIdentificationById(
@Param('id', ParseIntPipe) id: number,
): Promise<TelecommunicationStationIdentificationResponseDto> {
return this.telecommunicationStationIdentificationService.findTelecommunicationStationIdentificationById(
id,
);
}
@Get('site/:siteId')
@UseGuards(RolesGuard)
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({
summary: 'Get telecommunication station identification by site ID',
})
@ApiParam({
name: 'siteId',
description: 'Site ID',
type: Number,
})
@ApiResponse({
status: 200,
description:
'Telecommunication station identification retrieved successfully',
type: TelecommunicationStationIdentificationResponseDto,
})
@ApiResponse({
status: 404,
description: 'Telecommunication station identification not found',
})
async findTelecommunicationStationIdentificationBySiteId(
@Param('siteId', ParseIntPipe) siteId: number,
): Promise<TelecommunicationStationIdentificationResponseDto> {
return this.telecommunicationStationIdentificationService.findTelecommunicationStationIdentificationBySiteId(
siteId,
);
}
@Put(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({
summary: 'Update a telecommunication station identification',
})
@ApiParam({
name: 'id',
description: 'Telecommunication station identification ID',
type: Number,
})
@ApiResponse({
status: 200,
description:
'Telecommunication station identification updated successfully',
type: TelecommunicationStationIdentificationResponseDto,
})
@ApiResponse({
status: 404,
description: 'Telecommunication station identification not found',
})
@ApiResponse({
status: 409,
description:
'Conflict - site already has identification or station identifier already exists',
})
async updateTelecommunicationStationIdentification(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateTelecommunicationStationIdentificationDto,
): Promise<TelecommunicationStationIdentificationResponseDto> {
return this.telecommunicationStationIdentificationService.updateTelecommunicationStationIdentification(
id,
dto,
);
}
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({
summary: 'Delete a telecommunication station identification',
})
@ApiParam({
name: 'id',
description: 'Telecommunication station identification ID',
type: Number,
})
@ApiResponse({
status: 200,
description:
'Telecommunication station identification deleted successfully',
})
@ApiResponse({
status: 404,
description: 'Telecommunication station identification not found',
})
async deleteTelecommunicationStationIdentification(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
return this.telecommunicationStationIdentificationService.deleteTelecommunicationStationIdentification(
id,
);
}
}
import { Module } from '@nestjs/common';
import { TelecommunicationStationIdentificationController } from './telecommunication-station-identification.controller';
import { TelecommunicationStationIdentificationService } from './telecommunication-station-identification.service';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TelecommunicationStationIdentificationController],
providers: [TelecommunicationStationIdentificationService],
exports: [TelecommunicationStationIdentificationService],
})
export class TelecommunicationStationIdentificationModule {}
import { Test, TestingModule } from '@nestjs/testing';
import { TelecommunicationStationIdentificationService } from './telecommunication-station-identification.service';
import { PrismaService } from '../../common/prisma/prisma.service';
describe('TelecommunicationStationIdentificationService', () => {
let service: TelecommunicationStationIdentificationService;
let prismaService: PrismaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TelecommunicationStationIdentificationService,
{
provide: PrismaService,
useValue: {
site: {
findUnique: jest.fn(),
},
telecommunicationStationIdentification: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
},
},
],
}).compile();
service = module.get<TelecommunicationStationIdentificationService>(TelecommunicationStationIdentificationService);
prismaService = module.get<PrismaService>(PrismaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('createTelecommunicationStationIdentification', () => {
it('should create a new telecommunication station identification', async () => {
const createDto = {
siteId: 1,
stationIdentifier: '19857_BARCA DE ALVA TX',
serialNumber: '1234567890',
isFirstCertification: true,
modelReference: '1234567890',
};
const expectedIdentification = {
id: 1,
siteId: 1,
stationIdentifier: '19857_BARCA DE ALVA TX',
serialNumber: '1234567890',
isFirstCertification: true,
modelReference: '1234567890',
createdAt: new Date(),
updatedAt: new Date(),
};
const mockSite = {
id: 1,
siteCode: 'SITE001',
siteName: 'Test Site',
};
jest.spyOn(prismaService.site, 'findUnique').mockResolvedValue(mockSite);
jest.spyOn(prismaService.telecommunicationStationIdentification, 'findUnique')
.mockResolvedValue(null)
.mockResolvedValueOnce(null) // First call for siteId check
.mockResolvedValueOnce(null); // Second call for stationIdentifier check
jest.spyOn(prismaService.telecommunicationStationIdentification, 'create').mockResolvedValue(expectedIdentification);
const result = await service.createTelecommunicationStationIdentification(createDto);
expect(result).toEqual(expectedIdentification);
expect(prismaService.telecommunicationStationIdentification.create).toHaveBeenCalledWith({
data: createDto,
});
});
it('should throw NotFoundException if site does not exist', async () => {
const createDto = {
siteId: 999,
stationIdentifier: '19857_BARCA DE ALVA TX',
serialNumber: '1234567890',
isFirstCertification: true,
modelReference: '1234567890',
};
jest.spyOn(prismaService.site, 'findUnique').mockResolvedValue(null);
await expect(service.createTelecommunicationStationIdentification(createDto)).rejects.toThrow(
'Site with ID 999 not found',
);
});
it('should throw ConflictException if site already has identification', async () => {
const createDto = {
siteId: 1,
stationIdentifier: '19857_BARCA DE ALVA TX',
serialNumber: '1234567890',
isFirstCertification: true,
modelReference: '1234567890',
};
const mockSite = {
id: 1,
siteCode: 'SITE001',
siteName: 'Test Site',
};
const existingIdentification = {
id: 1,
siteId: 1,
stationIdentifier: 'EXISTING_ID',
serialNumber: '1234567890',
isFirstCertification: true,
modelReference: '1234567890',
createdAt: new Date(),
updatedAt: new Date(),
};
jest.spyOn(prismaService.site, 'findUnique').mockResolvedValue(mockSite);
jest.spyOn(prismaService.telecommunicationStationIdentification, 'findUnique')
.mockResolvedValue(existingIdentification);
await expect(service.createTelecommunicationStationIdentification(createDto)).rejects.toThrow(
'Site with ID 1 already has a telecommunication station identification',
);
});
});
});
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import {
CreateTelecommunicationStationIdentificationDto,
UpdateTelecommunicationStationIdentificationDto,
TelecommunicationStationIdentificationResponseDto,
FindTelecommunicationStationIdentificationsDto,
} from './dto';
@Injectable()
export class TelecommunicationStationIdentificationService {
constructor(private prisma: PrismaService) {}
async createTelecommunicationStationIdentification(
dto: CreateTelecommunicationStationIdentificationDto,
): Promise<TelecommunicationStationIdentificationResponseDto> {
// Check if site exists
const site = await this.prisma.site.findUnique({
where: { id: dto.siteId },
});
if (!site) {
throw new NotFoundException(`Site with ID ${dto.siteId} not found`);
}
// Check if site already has a telecommunication station identification
const existingIdentification = await this.prisma.telecommunicationStationIdentification.findUnique({
where: { siteId: dto.siteId },
});
if (existingIdentification) {
throw new ConflictException(
`Site with ID ${dto.siteId} already has a telecommunication station identification`,
);
}
// Check if station identifier already exists
const existingStationIdentifier = await this.prisma.telecommunicationStationIdentification.findUnique({
where: { stationIdentifier: dto.stationIdentifier },
});
if (existingStationIdentifier) {
throw new ConflictException(
`Station identifier ${dto.stationIdentifier} already exists`,
);
}
const identification = await this.prisma.telecommunicationStationIdentification.create({
data: {
siteId: dto.siteId,
stationIdentifier: dto.stationIdentifier,
serialNumber: dto.serialNumber,
isFirstCertification: dto.isFirstCertification,
modelReference: dto.modelReference,
},
});
return this.mapToDto(identification);
}
async findAllTelecommunicationStationIdentifications(
dto: FindTelecommunicationStationIdentificationsDto,
): Promise<{
identifications: TelecommunicationStationIdentificationResponseDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
const {
stationIdentifier,
serialNumber,
modelReference,
isFirstCertification,
siteId,
page = 1,
limit = 10,
} = dto;
const skip = (page - 1) * limit;
const where: any = {};
if (stationIdentifier) {
where.stationIdentifier = {
contains: stationIdentifier,
mode: 'insensitive',
};
}
if (serialNumber) {
where.serialNumber = {
contains: serialNumber,
mode: 'insensitive',
};
}
if (modelReference) {
where.modelReference = {
contains: modelReference,
mode: 'insensitive',
};
}
if (isFirstCertification !== undefined) {
where.isFirstCertification = isFirstCertification;
}
if (siteId) {
where.siteId = siteId;
}
const [identifications, total] = await Promise.all([
this.prisma.telecommunicationStationIdentification.findMany({
where,
orderBy: {
createdAt: 'desc',
},
skip,
take: limit,
include: {
site: {
select: {
id: true,
siteCode: true,
siteName: true,
},
},
},
}),
this.prisma.telecommunicationStationIdentification.count({ where }),
]);
return {
identifications: identifications.map(this.mapToDto),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findTelecommunicationStationIdentificationById(
id: number,
): Promise<TelecommunicationStationIdentificationResponseDto> {
const identification = await this.prisma.telecommunicationStationIdentification.findUnique({
where: { id },
include: {
site: {
select: {
id: true,
siteCode: true,
siteName: true,
},
},
},
});
if (!identification) {
throw new NotFoundException(
`Telecommunication station identification with ID ${id} not found`,
);
}
return this.mapToDto(identification);
}
async findTelecommunicationStationIdentificationBySiteId(
siteId: number,
): Promise<TelecommunicationStationIdentificationResponseDto> {
const identification = await this.prisma.telecommunicationStationIdentification.findUnique({
where: { siteId },
include: {
site: {
select: {
id: true,
siteCode: true,
siteName: true,
},
},
},
});
if (!identification) {
throw new NotFoundException(
`Telecommunication station identification for site ID ${siteId} not found`,
);
}
return this.mapToDto(identification);
}
async updateTelecommunicationStationIdentification(
id: number,
dto: UpdateTelecommunicationStationIdentificationDto,
): Promise<TelecommunicationStationIdentificationResponseDto> {
// Check if identification exists
const existingIdentification = await this.prisma.telecommunicationStationIdentification.findUnique({
where: { id },
});
if (!existingIdentification) {
throw new NotFoundException(
`Telecommunication station identification with ID ${id} not found`,
);
}
// If siteId is being updated, check if the new site exists and doesn't already have an identification
if (dto.siteId !== undefined && dto.siteId !== existingIdentification.siteId) {
const site = await this.prisma.site.findUnique({
where: { id: dto.siteId },
});
if (!site) {
throw new NotFoundException(`Site with ID ${dto.siteId} not found`);
}
const existingSiteIdentification = await this.prisma.telecommunicationStationIdentification.findUnique({
where: { siteId: dto.siteId },
});
if (existingSiteIdentification) {
throw new ConflictException(
`Site with ID ${dto.siteId} already has a telecommunication station identification`,
);
}
}
// If station identifier is being updated, check for conflicts
if (dto.stationIdentifier !== undefined && dto.stationIdentifier !== existingIdentification.stationIdentifier) {
const existingStationIdentifier = await this.prisma.telecommunicationStationIdentification.findUnique({
where: { stationIdentifier: dto.stationIdentifier },
});
if (existingStationIdentifier) {
throw new ConflictException(
`Station identifier ${dto.stationIdentifier} already exists`,
);
}
}
const updatedIdentification = await this.prisma.telecommunicationStationIdentification.update({
where: { id },
data: dto,
include: {
site: {
select: {
id: true,
siteCode: true,
siteName: true,
},
},
},
});
return this.mapToDto(updatedIdentification);
}
async deleteTelecommunicationStationIdentification(id: number): Promise<void> {
// Check if identification exists
const identification = await this.prisma.telecommunicationStationIdentification.findUnique({
where: { id },
});
if (!identification) {
throw new NotFoundException(
`Telecommunication station identification with ID ${id} not found`,
);
}
await this.prisma.telecommunicationStationIdentification.delete({
where: { id },
});
}
private mapToDto(identification: any): TelecommunicationStationIdentificationResponseDto {
return {
id: identification.id,
siteId: identification.siteId,
stationIdentifier: identification.stationIdentifier,
serialNumber: identification.serialNumber,
isFirstCertification: identification.isFirstCertification,
modelReference: identification.modelReference,
createdAt: identification.createdAt,
updatedAt: identification.updatedAt,
};
}
}
......@@ -47,6 +47,14 @@ export class UsersController {
}
@Get()
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get all users' })
@ApiResponse({
status: 200,
......@@ -105,6 +113,14 @@ export class UsersController {
}
@Get(':id')
@Roles(
Role.ADMIN,
Role.MANAGER,
Role.OPERATOR,
Role.VIEWER,
Role.PARTNER,
Role.SUPERADMIN,
)
@ApiOperation({ summary: 'Get a user by id' })
@ApiResponse({
status: 200,
......
-- Comprehensive verification script to check TelecommunicationStationIdentification permissions
-- Run this after applying permission changes to verify everything is correct
\echo '=== PERMISSION VERIFICATION REPORT ==='
\echo ''
-- 1. Check what permissions PUBLIC has on the table
\echo '1. PUBLIC permissions on TelecommunicationStationIdentification table:'
SELECT
grantee,
privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC'
ORDER BY privilege_type;
\echo ''
-- 2. Check sequence permissions for PUBLIC
\echo '2. PUBLIC permissions on TelecommunicationStationIdentification_id_seq sequence:'
SELECT
grantee,
privilege_type
FROM information_schema.role_usage_grants
WHERE object_name = 'TelecommunicationStationIdentification_id_seq'
AND grantee = 'PUBLIC';
\echo ''
-- 3. Check all permissions on the table (all users/roles)
\echo '3. All permissions on TelecommunicationStationIdentification table:'
SELECT
grantee,
privilege_type,
is_grantable
FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
ORDER BY grantee, privilege_type;
\echo ''
-- 4. Check table ownership
\echo '4. Table ownership information:'
SELECT
schemaname,
tablename,
tableowner
FROM pg_tables
WHERE tablename = 'TelecommunicationStationIdentification';
\echo ''
-- 5. Compare with Site table permissions (reference for what should work)
\echo '5. Site table permissions for comparison:'
SELECT
grantee,
privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'Site'
AND grantee = 'PUBLIC'
ORDER BY privilege_type;
\echo ''
-- 6. Check what user the application would connect as
\echo '6. Current database user:'
SELECT current_user, session_user;
\echo ''
-- 7. Expected vs Actual permissions summary
\echo '7. EXPECTED PERMISSIONS FOR PUBLIC:'
\echo ' ✓ SELECT (read data)'
\echo ' ✓ INSERT (create records)'
\echo ' ✓ UPDATE (modify records)'
\echo ' ✓ USAGE (on sequence)'
\echo ' ✗ DELETE (should NOT be present)'
\echo ' ✗ TRUNCATE (should NOT be present)'
\echo ''
-- 8. Quick permission test queries (these should work)
\echo '8. Testing basic operations:'
\echo 'The following operations should be allowed:'
-- Test SELECT (should work)
SELECT 'SELECT test: ' || CASE
WHEN EXISTS (
SELECT 1 FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC'
AND privilege_type = 'SELECT'
) THEN '✓ ALLOWED'
ELSE '✗ DENIED'
END as select_permission;
-- Test INSERT (should work)
SELECT 'INSERT test: ' || CASE
WHEN EXISTS (
SELECT 1 FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC'
AND privilege_type = 'INSERT'
) THEN '✓ ALLOWED'
ELSE '✗ DENIED'
END as insert_permission;
-- Test UPDATE (should work)
SELECT 'UPDATE test: ' || CASE
WHEN EXISTS (
SELECT 1 FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC'
AND privilege_type = 'UPDATE'
) THEN '✓ ALLOWED'
ELSE '✗ DENIED'
END as update_permission;
-- Test DELETE (should NOT exist)
SELECT 'DELETE test: ' || CASE
WHEN EXISTS (
SELECT 1 FROM information_schema.role_table_grants
WHERE table_name = 'TelecommunicationStationIdentification'
AND grantee = 'PUBLIC'
AND privilege_type = 'DELETE'
) THEN '✗ GRANTED (SHOULD REMOVE THIS!)'
ELSE '✓ PROPERLY DENIED'
END as delete_permission;
\echo ''
\echo '=== END VERIFICATION REPORT ==='
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