Commit c8c5d592 by Augusto

Settling

parent 5b4fb32a
# Site Maintenance Feature
This feature allows for tracking and managing site maintenance records, including form responses and photos.
## Setup Instructions for New Client
1. Initialize the database with the maintenance models:
```bash
# Run the clean setup script
./scripts/clean-setup-for-new-client.sh
```
This script will:
- Back up the current schema
- Remove existing migration history
- Generate a fresh Prisma client
- Create a new initial migration with all models
- Seed the database with maintenance questions
2. If you encounter type errors after changing the schema, run:
```bash
# Update the Prisma client
./scripts/fix-prisma-client.sh
```
## Maintenance Model
Each maintenance record includes:
- Date of maintenance
- Site association
- Optional general comment
- Set of responses to predefined questions
- Optional photos documenting the site condition
## Response Options
The maintenance form uses three standard response options:
- YES - The item is in good condition/working properly
- NO - The item needs attention/repair
- NA - Not applicable for this site
## API Endpoints
- `POST /maintenance` - Create a new maintenance record
- `GET /maintenance` - List all maintenance records with filtering options
- `GET /maintenance/:id` - Get details of a specific maintenance record
- `GET /maintenance/questions` - Get the list of maintenance questions
## Roles and Permissions
The following roles can create maintenance records:
- ADMIN
- MANAGER
- OPERATOR
- PARTNER
All authenticated users can view maintenance records.
## Photos
Maintenance photos are stored in the `uploads/maintenance/{maintenanceId}` directory.
Each photo is associated with a specific maintenance record.
\ No newline at end of file
# New Client Setup Guide
This guide provides step-by-step instructions for setting up the Cellnex API for a new client with the Site Maintenance feature.
## Initial Setup
1. Create a new branch for the client:
```bash
git checkout -b client-name
```
2. Set up the database and environment variables:
```bash
# Run the setup script with your database details
./scripts/setup-new-client-db.sh --name client_db --user client_user --password your_password
```
This script will:
- Create a .env file with database configuration
- Create the database and user (if PostgreSQL is available locally)
- Enable the PostGIS extension
- Run initial migrations
- Seed maintenance questions
## Manual Database Setup (if automatic setup doesn't work)
If you need to set up the database manually:
1. Create a .env file with your database connection string:
```
DATABASE_URL=postgresql://username:password@localhost:5432/database_name?schema=public
```
2. Create the database and enable the PostGIS extension:
```sql
CREATE DATABASE database_name;
CREATE USER username WITH ENCRYPTED PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE database_name TO username;
\c database_name
CREATE EXTENSION IF NOT EXISTS postgis;
```
3. Run the migration and seed scripts:
```bash
# Generate Prisma client
npx prisma generate
# Run migrations
npx prisma migrate dev --name init_with_maintenance_models
# Seed maintenance questions
npx ts-node prisma/seed-maintenance.ts
```
## File Structure
The maintenance feature consists of the following components:
- **Models**: Defined in `prisma/schema.prisma`
- `Maintenance`: Main maintenance record
- `MaintenanceResponse`: Responses to maintenance questions
- `MaintenanceQuestion`: Predefined maintenance questions
- `MaintenancePhoto`: Photos attached to maintenance records
- **Module**: Located in `src/modules/maintenance/`
- Controllers
- Services
- DTOs
- Utilities for file handling
- **Uploads**: Photos are stored in `uploads/maintenance/{maintenanceId}/`
## API Endpoints
The maintenance feature provides the following endpoints:
- `POST /api/maintenance` - Create a new maintenance record
- `GET /api/maintenance` - List all maintenance records with filtering options
- `GET /api/maintenance/:id` - Get details of a specific maintenance record
- `GET /api/maintenance/questions` - Get the list of maintenance questions
## Development
To run the application in development mode:
```bash
# Install dependencies
npm install
# Start the development server
npm run start:dev
```
The API will be available at http://localhost:3001/api/
Swagger documentation is available at http://localhost:3001/docs
## Troubleshooting
### Prisma Client Type Errors
If you encounter type errors with the Prisma client:
```bash
# Update the Prisma client
./scripts/fix-prisma-client.sh
```
### File Upload Issues
If you encounter issues with file uploads:
1. Ensure the uploads directory exists and has proper permissions:
```bash
mkdir -p uploads/maintenance
chmod 755 uploads uploads/maintenance
```
2. Check if the environment variable `NODE_ENV` is set correctly (development/production).
### Database Connection Issues
If you can't connect to the database:
1. Verify the DATABASE_URL in your .env file
2. Ensure the database server is running
3. Check that the user has proper permissions
## Deployment
For production deployment:
1. Set the NODE_ENV environment variable to "production"
2. Update the DATABASE_URL in .env
3. Build the application:
```bash
npm run build
```
4. Start the production server:
```bash
npm run start:prod
```
5. Ensure the uploads directory in production has proper permissions.
\ No newline at end of file
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'MANAGER', 'OPERATOR', 'VIEWER');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'VIEWER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Site" (
"id" SERIAL NOT NULL,
"siteCode" TEXT NOT NULL,
"siteName" TEXT NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" INTEGER,
"updatedById" INTEGER,
CONSTRAINT "Site_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Candidate" (
"id" SERIAL NOT NULL,
"candidateCode" TEXT NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"type" TEXT NOT NULL,
"address" TEXT NOT NULL,
"comments" TEXT,
"currentStatus" TEXT NOT NULL,
"onGoing" BOOLEAN NOT NULL DEFAULT false,
"siteId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" INTEGER,
"updatedById" INTEGER,
CONSTRAINT "Candidate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE UNIQUE INDEX "Site_siteCode_key" ON "Site"("siteCode");
-- CreateIndex
CREATE INDEX "Site_siteCode_idx" ON "Site"("siteCode");
-- CreateIndex
CREATE INDEX "Candidate_candidateCode_idx" ON "Candidate"("candidateCode");
-- CreateIndex
CREATE INDEX "Candidate_currentStatus_idx" ON "Candidate"("currentStatus");
-- CreateIndex
CREATE INDEX "Candidate_siteId_idx" ON "Candidate"("siteId");
-- CreateIndex
CREATE INDEX "Candidate_onGoing_idx" ON "Candidate"("onGoing");
-- CreateIndex
CREATE UNIQUE INDEX "Candidate_siteId_candidateCode_key" ON "Candidate"("siteId", "candidateCode");
-- AddForeignKey
ALTER TABLE "Site" ADD CONSTRAINT "Site_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Site" ADD CONSTRAINT "Site_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterEnum
ALTER TYPE "Role" ADD VALUE 'SUPERADMIN';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "resetToken" TEXT,
ADD COLUMN "resetTokenExpiry" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
-- CreateIndex
CREATE INDEX "RefreshToken_token_idx" ON "RefreshToken"("token");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT false;
/*
Warnings:
- You are about to drop the column `comments` on the `Candidate` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Candidate" DROP COLUMN "comments";
-- CreateTable
CREATE TABLE "Comment" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"candidateId" INTEGER NOT NULL,
"createdById" INTEGER,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Comment_candidateId_idx" ON "Comment"("candidateId");
-- CreateIndex
CREATE INDEX "Comment_createdById_idx" ON "Comment"("createdById");
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
/*
Warnings:
- You are about to drop the column `siteId` on the `Candidate` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "Candidate" DROP CONSTRAINT "Candidate_siteId_fkey";
-- DropIndex
DROP INDEX "Candidate_siteId_candidateCode_key";
-- DropIndex
DROP INDEX "Candidate_siteId_idx";
-- AlterTable
ALTER TABLE "Candidate" DROP COLUMN "siteId";
-- CreateTable
CREATE TABLE "CandidateSite" (
"id" SERIAL NOT NULL,
"candidateId" INTEGER NOT NULL,
"siteId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CandidateSite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CandidateSite_candidateId_siteId_key" ON "CandidateSite"("candidateId", "siteId");
-- CreateIndex
CREATE INDEX "CandidateSite_candidateId_idx" ON "CandidateSite"("candidateId");
-- CreateIndex
CREATE INDEX "CandidateSite_siteId_idx" ON "CandidateSite"("siteId");
-- Migrate existing data
INSERT INTO "CandidateSite" ("candidateId", "siteId", "createdAt", "updatedAt")
SELECT id, "siteId", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Candidate"
WHERE "siteId" IS NOT NULL;
-- AddForeignKey
ALTER TABLE "CandidateSite" ADD CONSTRAINT "CandidateSite_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CandidateSite" ADD CONSTRAINT "CandidateSite_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Remove old relationship
ALTER TABLE "Candidate" DROP CONSTRAINT IF EXISTS "Candidate_siteId_fkey";
ALTER TABLE "Candidate" DROP COLUMN "siteId";
-- This migration adds functions to help with alphabetic auto-incrementing candidate codes
-- The implementation is done in the application code, but we can add indexes to improve performance
-- Add index on candidateCode to make queries faster
CREATE INDEX IF NOT EXISTS "Candidate_candidateCode_idx" ON "Candidate"("candidateCode");
-- Add function to get next alphabetic code (for reference, actual implementation is in the app)
CREATE OR REPLACE FUNCTION next_alphabetic_code(current_code TEXT)
RETURNS TEXT AS $$
DECLARE
chars TEXT[];
i INTEGER;
BEGIN
-- If no code provided, start with 'A'
IF current_code IS NULL OR current_code = '' THEN
RETURN 'A';
END IF;
-- Convert to array of characters
chars := regexp_split_to_array(current_code, '');
i := array_length(chars, 1);
-- Start from the last character and try to increment
WHILE i > 0 LOOP
-- If current character is not 'Z', increment it
IF chars[i] <> 'Z' THEN
chars[i] := chr(ascii(chars[i]) + 1);
RETURN array_to_string(chars, '');
END IF;
-- Current character is 'Z', set it to 'A' and move to previous position
chars[i] := 'A';
i := i - 1;
END LOOP;
-- If we're here, we've carried over beyond the first character
-- (e.g., incrementing 'ZZ' to 'AAA')
RETURN 'A' || array_to_string(chars, '');
END;
$$ LANGUAGE plpgsql;
\ No newline at end of file
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
......@@ -26,6 +26,8 @@ model User {
refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
inspectionsCreated Inspection[] @relation("InspectionCreator")
inspectionsUpdated Inspection[] @relation("InspectionUpdater")
associatedSites UserSite[] // New relation for PARTNER role
partner Partner? @relation(fields: [partnerId], references: [id])
partnerId Int?
......@@ -65,6 +67,7 @@ model Site {
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[] // New relation for PARTNER role
inspections Inspection[]
@@index([siteCode])
}
......@@ -189,3 +192,67 @@ model Partner {
@@index([name])
}
enum InspectionResponseOption {
YES
NO
NA
}
model InspectionQuestion {
id Int @id @default(autoincrement())
question String
orderIndex Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
responses InspectionResponse[]
}
model InspectionResponse {
id Int @id @default(autoincrement())
response InspectionResponseOption
comment String?
questionId Int
inspectionId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
question InspectionQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
@@index([questionId])
@@index([inspectionId])
}
model Inspection {
id Int @id @default(autoincrement())
date DateTime
comment String?
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
createdBy User? @relation("InspectionCreator", fields: [createdById], references: [id])
updatedBy User? @relation("InspectionUpdater", fields: [updatedById], references: [id])
responses InspectionResponse[]
photos InspectionPhoto[]
@@index([siteId])
@@index([createdById])
@@index([updatedById])
}
model InspectionPhoto {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inspectionId Int
inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
@@index([inspectionId])
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String
role Role @default(VIEWER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resetToken String?
resetTokenExpiry DateTime?
isActive Boolean @default(false)
candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater")
Comment Comment[]
refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
maintenancesCreated Maintenance[] @relation("MaintenanceCreator")
maintenancesUpdated Maintenance[] @relation("MaintenanceUpdater")
associatedSites UserSite[] // New relation for PARTNER role
partner Partner? @relation(fields: [partnerId], references: [id])
partnerId Int?
@@index([email])
@@index([role])
@@index([partnerId])
}
model RefreshToken {
id Int @id @default(autoincrement())
token String @unique
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
}
model Site {
id Int @id @default(autoincrement())
siteCode String @unique
siteName String
latitude Float
longitude Float
type String?
isDigi Boolean @default(false)
isReported Boolean @default(false)
companies CompanyName[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
candidates CandidateSite[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[] // New relation for PARTNER role
maintenances Maintenance[]
@@index([siteCode])
}
model Candidate {
id Int @id @default(autoincrement())
candidateCode String
latitude Float
longitude Float
type String
address String
currentStatus String
onGoing Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
sites CandidateSite[]
comments Comment[]
photos Photo[]
partnerId Int? // To track which partner created the candidate
partner Partner? @relation(fields: [partnerId], references: [id])
@@index([candidateCode])
@@index([currentStatus])
@@index([onGoing])
@@index([partnerId])
}
model CandidateSite {
id Int @id @default(autoincrement())
candidateId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([candidateId, siteId])
@@index([candidateId])
@@index([siteId])
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
createdById Int?
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id])
@@index([candidateId])
@@index([createdById])
}
model Photo {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
@@index([candidateId])
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model spatial_ref_sys {
srid Int @id
auth_name String? @db.VarChar(256)
auth_srid Int?
srtext String? @db.VarChar(2048)
proj4text String? @db.VarChar(2048)
}
model UserSite {
id Int @id @default(autoincrement())
userId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([userId, siteId])
@@index([userId])
@@index([siteId])
}
enum CompanyName {
VODAFONE
MEO
NOS
DIGI
}
enum Role {
ADMIN
MANAGER
OPERATOR
VIEWER
SUPERADMIN
PARTNER
}
model Partner {
id Int @id @default(autoincrement())
name String @unique
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
candidates Candidate[]
@@index([name])
}
enum MaintenanceResponseOption {
YES
NO
NA
}
model MaintenanceQuestion {
id Int @id @default(autoincrement())
question String
orderIndex Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
responses MaintenanceResponse[]
}
model MaintenanceResponse {
id Int @id @default(autoincrement())
response MaintenanceResponseOption
comment String?
questionId Int
maintenanceId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
question MaintenanceQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
maintenance Maintenance @relation(fields: [maintenanceId], references: [id], onDelete: Cascade)
@@index([questionId])
@@index([maintenanceId])
}
model Maintenance {
id Int @id @default(autoincrement())
date DateTime
comment String?
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
createdBy User? @relation("MaintenanceCreator", fields: [createdById], references: [id])
updatedBy User? @relation("MaintenanceUpdater", fields: [updatedById], references: [id])
responses MaintenanceResponse[]
photos MaintenancePhoto[]
@@index([siteId])
@@index([createdById])
@@index([updatedById])
}
model MaintenancePhoto {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
maintenanceId Int
maintenance Maintenance @relation(fields: [maintenanceId], references: [id], onDelete: Cascade)
@@index([maintenanceId])
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding maintenance questions...');
// Clear existing questions
await prisma.maintenanceQuestion.deleteMany({});
// Create questions
const questions = [
{
question: 'Site access condition',
orderIndex: 1,
},
{
question: 'Site infrastructure condition',
orderIndex: 2,
},
{
question: 'Equipment condition',
orderIndex: 3,
},
{
question: 'Power system condition',
orderIndex: 4,
},
{
question: 'Cooling system condition',
orderIndex: 5,
},
{
question: 'Security features condition',
orderIndex: 6,
},
{
question: 'Safety equipment presence and condition',
orderIndex: 7,
},
{
question: 'Site cleanliness',
orderIndex: 8,
},
{
question: 'Vegetation control',
orderIndex: 9,
},
{
question: 'Surrounding area condition',
orderIndex: 10,
},
];
for (const question of questions) {
await prisma.maintenanceQuestion.create({
data: question,
});
}
console.log('Maintenance questions have been seeded!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
\ No newline at end of file
......@@ -15,6 +15,7 @@ import { join } from 'path';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { PartnersModule } from './modules/partners/partners.module';
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
@Module({
imports: [
......@@ -54,6 +55,7 @@ import { PartnersModule } from './modules/partners/partners.module';
CommentsModule,
DashboardModule,
PartnersModule,
MaintenanceModule,
],
controllers: [AppController],
providers: [
......@@ -64,4 +66,4 @@ import { PartnersModule } from './modules/partners/partners.module';
},
],
})
export class AppModule { }
export class AppModule {}
......@@ -7,4 +7,4 @@ import { ConfigModule } from '@nestjs/config';
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule { }
\ No newline at end of file
export class EmailModule {}
......@@ -18,7 +18,11 @@ export class EmailService {
});
}
async sendPasswordResetEmail(email: string, resetToken: string, resetUrl: string): Promise<void> {
async sendPasswordResetEmail(
email: string,
resetToken: string,
resetUrl: string,
): Promise<void> {
const mailOptions = {
from: this.configService.get<string>('SMTP_FROM', 'noreply@cellnex.com'),
to: email,
......
......@@ -21,5 +21,5 @@ export const multerConfig = {
callback(error, false);
}
},
preservePath: true
preservePath: true,
};
......@@ -6,4 +6,4 @@ import { PrismaService } from './prisma.service';
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule { }
\ No newline at end of file
export class PrismaModule {}
......@@ -2,7 +2,10 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
......
......@@ -25,6 +25,10 @@ async function bootstrap() {
// Serve static files
app.use('/uploads', express.static('/home/api-cellnex/public_html/uploads'));
// In development, serve from local directory
if (process.env.NODE_ENV === 'development') {
app.use('/uploads', express.static(join(__dirname, '..', 'uploads')));
}
// Swagger configuration
const config = new DocumentBuilder()
......@@ -35,6 +39,7 @@ async function bootstrap() {
.addTag('users', 'User management endpoints')
.addTag('sites', 'Site management endpoints')
.addTag('candidates', 'Candidate management endpoints')
.addTag('maintenance', 'Site maintenance management endpoints')
.addBearerAuth(
{
type: 'http',
......@@ -49,11 +54,21 @@ async function bootstrap() {
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
// Configure Swagger to persist authentication
const swaggerOptions = {
swaggerOptions: {
persistAuthorization: true,
},
};
SwaggerModule.setup('docs', app, document, swaggerOptions);
const port = process.env.PORT ?? 3001;
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(`Swagger documentation is available at: http://localhost:${port}/docs`);
console.log(
`Swagger documentation is available at: http://localhost:${port}/docs`,
);
}
bootstrap();
import { Body, Controller, Get, Headers, Post, UnauthorizedException, HttpCode, HttpStatus } from '@nestjs/common';
import {
Body,
Controller,
Get,
Headers,
Post,
UnauthorizedException,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
......@@ -11,7 +20,7 @@ import { Public } from './decorators/public.decorator';
@Controller('auth')
@Public()
export class AuthController {
constructor(private readonly authService: AuthService) { }
constructor(private readonly authService: AuthService) {}
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
......@@ -45,7 +54,9 @@ export class AuthController {
})
@ApiResponse({ status: 400, description: 'User not found' })
@HttpCode(HttpStatus.OK)
async requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto) {
async requestPasswordReset(
@Body() requestPasswordResetDto: RequestPasswordResetDto,
) {
return this.authService.requestPasswordReset(requestPasswordResetDto);
}
......
......@@ -32,4 +32,4 @@ import { RolesGuard } from './guards/roles.guard';
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard],
})
export class AuthModule { }
\ No newline at end of file
export class AuthModule {}
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import {
Injectable,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { LoginDto } from './dto/login.dto';
......@@ -28,9 +32,11 @@ export class AuthService {
async validateUser(email: string, password: string): Promise<any> {
const user = await this.usersService.findByEmail(email);
if (user && await bcrypt.compare(password, user.password)) {
if (user && (await bcrypt.compare(password, user.password))) {
if (!user.isActive) {
throw new UnauthorizedException('Your account is not active. Please contact an administrator.');
throw new UnauthorizedException(
'Your account is not active. Please contact an administrator.',
);
}
const { password, ...result } = user;
return result;
......@@ -52,8 +58,8 @@ export class AuthService {
email: true,
name: true,
role: true,
partnerId: true
}
partnerId: true,
},
});
if (!userDetails) {
......@@ -65,7 +71,8 @@ export class AuthService {
email: user.email,
role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === Role.PARTNER && userDetails.partnerId && { partnerId: userDetails.partnerId })
...(user.role === Role.PARTNER &&
userDetails.partnerId && { partnerId: userDetails.partnerId }),
};
const [accessToken, refreshToken] = await Promise.all([
......@@ -93,14 +100,17 @@ export class AuthService {
email: user.email,
name: user.name,
role: user.role,
partnerId: userDetails.partnerId
partnerId: userDetails.partnerId,
},
client: 'verticalflow'
};
}
async refreshToken(refreshTokenDto: RefreshTokenDto) {
try {
const payload = await this.jwtService.verifyAsync(refreshTokenDto.refreshToken);
const payload = await this.jwtService.verifyAsync(
refreshTokenDto.refreshToken,
);
const user = await this.usersService.findOne(payload.sub);
if (!user) {
......@@ -133,8 +143,8 @@ export class AuthService {
id: true,
email: true,
role: true,
partnerId: true
}
partnerId: true,
},
});
if (!userDetails) {
......@@ -147,7 +157,8 @@ export class AuthService {
email: user.email,
role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === Role.PARTNER && userDetails.partnerId && { partnerId: userDetails.partnerId })
...(user.role === Role.PARTNER &&
userDetails.partnerId && { partnerId: userDetails.partnerId }),
};
const [newAccessToken, newRefreshToken] = await Promise.all([
......@@ -171,6 +182,7 @@ export class AuthService {
return {
access_token: newAccessToken,
refresh_token: newRefreshToken,
client: 'verticalflow'
};
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
......@@ -178,10 +190,15 @@ export class AuthService {
}
async requestPasswordReset(requestPasswordResetDto: RequestPasswordResetDto) {
const user = await this.usersService.findByEmail(requestPasswordResetDto.email);
const user = await this.usersService.findByEmail(
requestPasswordResetDto.email,
);
if (!user) {
// Return success even if user doesn't exist to prevent email enumeration
return { message: 'If your email is registered, you will receive a password reset link.' };
return {
message:
'If your email is registered, you will receive a password reset link.',
};
}
const resetToken = randomBytes(32).toString('hex');
......@@ -205,11 +222,16 @@ export class AuthService {
},
});
return { message: 'If your email is registered, you will receive a password reset link.' };
return {
message:
'If your email is registered, you will receive a password reset link.',
};
}
async resetPassword(resetPasswordDto: ResetPasswordDto) {
const user = await this.usersService.findByResetToken(resetPasswordDto.token);
const user = await this.usersService.findByResetToken(
resetPasswordDto.token,
);
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
throw new UnauthorizedException('Invalid or expired reset token');
}
......@@ -231,7 +253,8 @@ export class AuthService {
id: payload.sub,
email: payload.email,
role: payload.role,
partnerId: payload.partnerId || null
partnerId: payload.partnerId || null,
client: 'verticalflow'
};
} catch (error) {
throw new UnauthorizedException('Invalid token');
......
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
......
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Role } from '@prisma/client';
@Injectable()
......@@ -6,18 +11,24 @@ export class PartnerAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
const partnerId = request.params.partnerId ? parseInt(request.params.partnerId, 10) : null;
const partnerId = request.params.partnerId
? parseInt(request.params.partnerId, 10)
: null;
// If it's a PARTNER user, make sure they can only access their own partner data
if (user.role === Role.PARTNER) {
// Check if the user has a partnerId and if it matches the requested partnerId
if (!user.partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
throw new ForbiddenException(
'User does not have access to any partner resources',
);
}
// If a specific partnerId is requested in the URL, check if it matches the user's partnerId
if (partnerId && partnerId !== user.partnerId) {
throw new ForbiddenException('Access to this partner is not authorized');
throw new ForbiddenException(
'Access to this partner is not authorized',
);
}
}
......
......@@ -5,7 +5,7 @@ import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) { }
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
......
......@@ -19,6 +19,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
email: payload.email,
role: payload.role,
partnerId: payload.partnerId || null,
client: 'verticalflow'
};
}
}
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, UseGuards, UseInterceptors, UploadedFile, BadRequestException, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
Query,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { CandidatesService } from './candidates.service';
import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
......@@ -21,16 +43,24 @@ import { multerConfig } from '../../common/multer/multer.config';
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class CandidatesController {
constructor(private readonly candidatesService: CandidatesService) { }
constructor(private readonly candidatesService: CandidatesService) {}
@Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@ApiOperation({
summary: 'Create a new candidate',
description: 'Creates a new candidate with optional initial comment. If candidateCode is not provided, it will be automatically generated as an alphabetical code (A, B, C, ..., AA, AB, etc.) specific to the first site in the siteIds array.'
description:
'Creates a new candidate with optional initial comment. If candidateCode is not provided, it will be automatically generated as an alphabetical code (A, B, C, ..., AA, AB, etc.) specific to the first site in the siteIds array.',
})
@ApiResponse({ status: 201, description: 'The candidate has been successfully created.', type: CandidateResponseDto })
create(@Body() createCandidateDto: CreateCandidateDto, @User('id') userId: number) {
@ApiResponse({
status: 201,
description: 'The candidate has been successfully created.',
type: CandidateResponseDto,
})
create(
@Body() createCandidateDto: CreateCandidateDto,
@User('id') userId: number,
) {
return this.candidatesService.create(createCandidateDto, userId);
}
......@@ -43,7 +73,7 @@ export class CandidatesController {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/CandidateResponseDto' }
items: { $ref: '#/components/schemas/CandidateResponseDto' },
},
meta: {
type: 'object',
......@@ -51,11 +81,11 @@ export class CandidatesController {
total: { type: 'number' },
page: { type: 'number' },
limit: { type: 'number' },
totalPages: { type: 'number' }
}
}
}
}
totalPages: { type: 'number' },
},
},
},
},
})
findAll(@Query() query: QueryCandidateDto, @User('id') userId: number) {
return this.candidatesService.findAll(query, userId);
......@@ -66,17 +96,29 @@ export class CandidatesController {
@ApiResponse({
status: 200,
description: 'Return the candidates for the site.',
type: [CandidateResponseDto]
type: [CandidateResponseDto],
})
findBySiteId(@Param('siteId', ParseIntPipe) siteId: number, @User('id') userId: number) {
findBySiteId(
@Param('siteId', ParseIntPipe) siteId: number,
@User('id') userId: number,
) {
return this.candidatesService.findBySiteId(siteId, userId);
}
@Get(':id')
@ApiOperation({ summary: 'Get a candidate by id' })
@ApiResponse({ status: 200, description: 'Return the candidate.', type: CandidateResponseDto })
@ApiResponse({
status: 200,
description: 'Return the candidate.',
type: CandidateResponseDto,
})
@ApiResponse({ status: 404, description: 'Candidate not found.' })
findOne(@Param('id', ParseIntPipe) id: number, @User('id') userId: number, @Partner() partnerId: number | null, @User('role') role: Role) {
findOne(
@Param('id', ParseIntPipe) id: number,
@User('id') userId: number,
@Partner() partnerId: number | null,
@User('role') role: Role,
) {
// For PARTNER role, we restrict access based on the partnerId
if (role === Role.PARTNER) {
return this.candidatesService.findOneWithPartnerCheck(id, partnerId);
......@@ -87,18 +129,27 @@ export class CandidatesController {
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Update a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully updated.', type: CandidateResponseDto })
@ApiResponse({
status: 200,
description: 'The candidate has been successfully updated.',
type: CandidateResponseDto,
})
@ApiResponse({ status: 404, description: 'Candidate not found.' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateCandidateDto: UpdateCandidateDto,
@User('id') userId: number,
@Partner() partnerId: number | null,
@User('role') role: Role
@User('role') role: Role,
) {
// For PARTNER role, we restrict updates to candidates associated with their partner
if (role === Role.PARTNER) {
return this.candidatesService.updateWithPartnerCheck(id, updateCandidateDto, userId, partnerId);
return this.candidatesService.updateWithPartnerCheck(
id,
updateCandidateDto,
userId,
partnerId,
);
}
return this.candidatesService.update(id, updateCandidateDto);
}
......@@ -106,7 +157,11 @@ export class CandidatesController {
@Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.PARTNER, Role.MANAGER)
@ApiOperation({ summary: 'Delete a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully deleted.', type: CandidateResponseDto })
@ApiResponse({
status: 200,
description: 'The candidate has been successfully deleted.',
type: CandidateResponseDto,
})
@ApiResponse({ status: 404, description: 'Candidate not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.candidatesService.remove(id);
......@@ -118,18 +173,22 @@ export class CandidatesController {
@ApiResponse({
status: 200,
description: 'The sites have been successfully added to the candidate.',
type: CandidateResponseDto
type: CandidateResponseDto,
})
@ApiResponse({ status: 404, description: 'Candidate not found.' })
addSitesToCandidate(
@Param('id', ParseIntPipe) id: number,
@Body() addSitesDto: AddSitesToCandidateDto,
@Partner() partnerId: number | null,
@User('role') role: Role
@User('role') role: Role,
) {
// For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) {
return this.candidatesService.addSitesToCandidateWithPartnerCheck(id, addSitesDto, partnerId);
return this.candidatesService.addSitesToCandidateWithPartnerCheck(
id,
addSitesDto,
partnerId,
);
}
return this.candidatesService.addSitesToCandidate(id, addSitesDto);
}
......@@ -143,18 +202,18 @@ export class CandidatesController {
file: {
type: 'string',
format: 'binary',
description: 'The image file to upload'
}
description: 'The image file to upload',
},
},
required: ['file'],
},
required: ['file']
}
})
@UseInterceptors(FileInterceptor('file', multerConfig))
async uploadPhoto(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
@Partner() partnerId: number | null,
@User('role') role: Role
@User('role') role: Role,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
......@@ -168,7 +227,7 @@ export class CandidatesController {
return this.candidatesService.uploadPhoto(id, file, {
filename: file.originalname,
mimeType: file.mimetype,
size: file.size
size: file.size,
});
}
......@@ -176,7 +235,7 @@ export class CandidatesController {
async getCandidatePhotos(
@Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null,
@User('role') role: Role
@User('role') role: Role,
) {
// For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) {
......@@ -192,7 +251,7 @@ export class CandidatesController {
async deletePhoto(
@Param('photoId', ParseIntPipe) photoId: number,
@Partner() partnerId: number | null,
@User('role') role: Role
@User('role') role: Role,
) {
// For PARTNER role, check if the photo belongs to a candidate that belongs to their partner
if (role === Role.PARTNER) {
......
......@@ -10,4 +10,4 @@ import { AuthModule } from '../auth/auth.module';
providers: [CandidatesService],
exports: [CandidatesService],
})
export class CandidatesModule { }
\ No newline at end of file
export class CandidatesModule {}
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
......@@ -12,7 +16,7 @@ import { Prisma, Role } from '@prisma/client';
@Injectable()
export class CandidatesService {
constructor(private prisma: PrismaService) { }
constructor(private prisma: PrismaService) {}
/**
* Generates the next alphabetical code for a site
......@@ -23,8 +27,8 @@ export class CandidatesService {
const siteCandidates = await this.prisma.candidateSite.findMany({
where: { siteId },
include: {
candidate: true
}
candidate: true,
},
});
// If no candidates exist for this site, start with 'A'
......@@ -33,7 +37,9 @@ export class CandidatesService {
}
// Get all existing codes
const existingCodes = siteCandidates.map(sc => sc.candidate.candidateCode);
const existingCodes = siteCandidates.map(
(sc) => sc.candidate.candidateCode,
);
// Find the highest code
// Sort alphabetically with longer strings coming after shorter ones
......@@ -81,7 +87,7 @@ export class CandidatesService {
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { partnerId: true, role: true }
select: { partnerId: true, role: true },
});
// If user is a PARTNER, assign the candidate to their partner
......@@ -93,8 +99,11 @@ export class CandidatesService {
// Create the candidate with a transaction to ensure both operations succeed or fail together
return this.prisma.$transaction(async (prisma) => {
// If candidateCode is not provided, generate it for the first site
const finalCandidateCode = candidateData.candidateCode ||
(siteIds.length > 0 ? await this.generateNextCandidateCode(siteIds[0]) : 'A');
const finalCandidateCode =
candidateData.candidateCode ||
(siteIds.length > 0
? await this.generateNextCandidateCode(siteIds[0])
: 'A');
// Create candidate data with the basic properties
const data: any = {
......@@ -106,12 +115,12 @@ export class CandidatesService {
currentStatus: candidateData.currentStatus,
onGoing: candidateData.onGoing,
sites: {
create: siteIds.map(siteId => ({
create: siteIds.map((siteId) => ({
site: {
connect: { id: siteId }
}
}))
}
connect: { id: siteId },
},
})),
},
};
// Add relations for creator/updater
......@@ -131,8 +140,8 @@ export class CandidatesService {
include: {
sites: {
include: {
site: true
}
site: true,
},
},
comments: {
include: {
......@@ -167,8 +176,8 @@ export class CandidatesService {
include: {
sites: {
include: {
site: true
}
site: true,
},
},
comments: {
include: {
......@@ -193,35 +202,48 @@ export class CandidatesService {
}
async findAll(query: QueryCandidateDto, userId?: number) {
const { candidateCode, type, currentStatus, onGoing, siteId, page = 1, limit = 10 } = query;
const {
candidateCode,
type,
currentStatus,
onGoing,
siteId,
page = 1,
limit = 10,
} = query;
// Check if user is a PARTNER and get their partnerId
let partnerFilter = {};
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { partnerId: true, role: true }
select: { partnerId: true, role: true },
});
// If user is a PARTNER, only show candidates from their partner
if (user?.role === Role.PARTNER && user.partnerId) {
partnerFilter = {
partnerId: user.partnerId
partnerId: user.partnerId,
};
}
}
const where: Prisma.CandidateWhereInput = {
...(candidateCode && { candidateCode: { contains: candidateCode, mode: Prisma.QueryMode.insensitive } }),
...(candidateCode && {
candidateCode: {
contains: candidateCode,
mode: Prisma.QueryMode.insensitive,
},
}),
...(type && { type }),
...(currentStatus && { currentStatus }),
...(onGoing !== undefined && { onGoing }),
...(siteId && {
sites: {
some: {
siteId: siteId
}
}
siteId: siteId,
},
},
}),
...partnerFilter, // Add partner filtering
};
......@@ -236,8 +258,8 @@ export class CandidatesService {
include: {
sites: {
include: {
site: true
}
site: true,
},
},
comments: {
take: 1,
......@@ -257,16 +279,16 @@ export class CandidatesService {
partner: {
select: {
id: true,
name: true
}
name: true,
},
},
createdBy: {
select: {
id: true,
name: true,
email: true
}
}
email: true,
},
},
},
}),
]);
......@@ -288,8 +310,8 @@ export class CandidatesService {
include: {
sites: {
include: {
site: true
}
site: true,
},
},
comments: {
take: 1,
......@@ -327,19 +349,19 @@ export class CandidatesService {
...(siteIds && {
sites: {
deleteMany: {}, // Remove all existing site associations
create: siteIds.map(siteId => ({
create: siteIds.map((siteId) => ({
site: {
connect: { id: siteId }
}
}))
}
})
connect: { id: siteId },
},
})),
},
}),
},
include: {
sites: {
include: {
site: true
}
site: true,
},
},
comments: {
include: {
......@@ -394,13 +416,13 @@ export class CandidatesService {
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { partnerId: true, role: true }
select: { partnerId: true, role: true },
});
// If user is a PARTNER, only show candidates from their partner
if (user?.role === Role.PARTNER && user.partnerId) {
partnerFilter = {
partnerId: user.partnerId
partnerId: user.partnerId,
};
}
}
......@@ -409,16 +431,16 @@ export class CandidatesService {
where: {
sites: {
some: {
siteId: siteId
}
siteId: siteId,
},
...partnerFilter
},
...partnerFilter,
},
include: {
sites: {
include: {
site: true
}
site: true,
},
},
comments: {
include: {
......@@ -449,13 +471,17 @@ export class CandidatesService {
}
// Get existing site relationships to avoid duplicates
const existingSiteIds = await this.prisma.candidateSite.findMany({
const existingSiteIds = await this.prisma.candidateSite
.findMany({
where: { candidateId: id },
select: { siteId: true }
}).then(relations => relations.map(rel => rel.siteId));
select: { siteId: true },
})
.then((relations) => relations.map((rel) => rel.siteId));
// Filter out sites that are already associated with this candidate
const newSiteIds = siteIds.filter(siteId => !existingSiteIds.includes(siteId));
const newSiteIds = siteIds.filter(
(siteId) => !existingSiteIds.includes(siteId),
);
// Create the candidate-site relationships with transaction
await this.prisma.$transaction(async (prisma) => {
......@@ -468,7 +494,7 @@ export class CandidatesService {
data: {
candidateId: id,
siteId,
}
},
});
}
});
......@@ -500,14 +526,18 @@ export class CandidatesService {
});
}
async uploadPhoto(candidateId: number, file: Express.Multer.File, dto: UploadPhotoDto) {
async uploadPhoto(
candidateId: number,
file: Express.Multer.File,
dto: UploadPhotoDto,
) {
try {
console.log('Starting photo upload process...');
console.log('File details:', {
originalname: file?.originalname,
mimetype: file?.mimetype,
size: file?.size,
buffer: file?.buffer ? 'Buffer exists' : 'No buffer'
buffer: file?.buffer ? 'Buffer exists' : 'No buffer',
});
const candidate = await this.prisma.candidate.findUnique({
......@@ -515,11 +545,18 @@ export class CandidatesService {
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${candidateId} not found`);
throw new NotFoundException(
`Candidate with ID ${candidateId} not found`,
);
}
// Create uploads directory if it doesn't exist
const uploadDir = path.join(process.cwd(), 'uploads', 'candidates', candidateId.toString());
const uploadDir = path.join(
process.cwd(),
'uploads',
'candidates',
candidateId.toString(),
);
console.log('Upload directory:', uploadDir);
if (!fs.existsSync(uploadDir)) {
......@@ -528,7 +565,7 @@ export class CandidatesService {
}
// Generate unique filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const filename = `${uniqueSuffix}-${file.originalname}`;
const filePath = path.join(uploadDir, filename);
console.log('File path:', filePath);
......@@ -548,7 +585,9 @@ export class CandidatesService {
// Reduce quality until under 2MB or minimum quality
while (fileSize > maxSize && quality > 30) {
const tempBuffer = await image.quality(quality).getBufferAsync(mimeType);
const tempBuffer = await image
.quality(quality)
.getBufferAsync(mimeType);
if (tempBuffer.length <= maxSize) {
fileBuffer = tempBuffer;
fileSize = tempBuffer.length;
......@@ -632,8 +671,8 @@ export class CandidatesService {
include: {
sites: {
include: {
site: true
}
site: true,
},
},
comments: {
take: 1,
......@@ -659,18 +698,25 @@ export class CandidatesService {
// Check if this candidate belongs to the user's partner
if (partnerId && candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`);
throw new ForbiddenException(
`Access to candidate with ID ${id} is not authorized`,
);
}
return candidate;
}
// New method for updating a candidate with partner check
async updateWithPartnerCheck(id: number, updateCandidateDto: UpdateCandidateDto, userId?: number, partnerId?: number | null) {
async updateWithPartnerCheck(
id: number,
updateCandidateDto: UpdateCandidateDto,
userId?: number,
partnerId?: number | null,
) {
// Check if candidate exists and belongs to the partner
const candidate = await this.prisma.candidate.findUnique({
where: { id },
select: { partnerId: true }
select: { partnerId: true },
});
if (!candidate) {
......@@ -679,7 +725,9 @@ export class CandidatesService {
// Enforce partner check for PARTNER role
if (partnerId && candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`);
throw new ForbiddenException(
`Access to candidate with ID ${id} is not authorized`,
);
}
try {
......@@ -693,19 +741,19 @@ export class CandidatesService {
...(siteIds && {
sites: {
deleteMany: {}, // Remove all existing site associations
create: siteIds.map(siteId => ({
create: siteIds.map((siteId) => ({
site: {
connect: { id: siteId }
}
}))
}
})
connect: { id: siteId },
},
})),
},
}),
},
include: {
sites: {
include: {
site: true
}
site: true,
},
},
comments: {
include: {
......@@ -729,11 +777,15 @@ export class CandidatesService {
}
// New method for adding sites to a candidate with partner check
async addSitesToCandidateWithPartnerCheck(id: number, addSitesDto: AddSitesToCandidateDto, partnerId: number | null) {
async addSitesToCandidateWithPartnerCheck(
id: number,
addSitesDto: AddSitesToCandidateDto,
partnerId: number | null,
) {
// Check if candidate exists and belongs to the partner
const candidate = await this.prisma.candidate.findUnique({
where: { id },
select: { partnerId: true }
select: { partnerId: true },
});
if (!candidate) {
......@@ -742,7 +794,9 @@ export class CandidatesService {
// Enforce partner check for PARTNER role
if (partnerId && candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`);
throw new ForbiddenException(
`Access to candidate with ID ${id} is not authorized`,
);
}
return this.addSitesToCandidate(id, addSitesDto);
......@@ -751,12 +805,14 @@ export class CandidatesService {
// New method to check if a candidate belongs to a partner
async checkCandidatePartner(id: number, partnerId: number | null) {
if (!partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
throw new ForbiddenException(
'User does not have access to any partner resources',
);
}
const candidate = await this.prisma.candidate.findUnique({
where: { id },
select: { partnerId: true }
select: { partnerId: true },
});
if (!candidate) {
......@@ -764,7 +820,9 @@ export class CandidatesService {
}
if (candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`);
throw new ForbiddenException(
`Access to candidate with ID ${id} is not authorized`,
);
}
return true;
......@@ -773,16 +831,18 @@ export class CandidatesService {
// New method to check if a photo belongs to a candidate that belongs to a partner
async checkPhotoPartner(photoId: number, partnerId: number | null) {
if (!partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
throw new ForbiddenException(
'User does not have access to any partner resources',
);
}
const photo = await this.prisma.photo.findUnique({
where: { id: photoId },
include: {
candidate: {
select: { partnerId: true }
}
}
select: { partnerId: true },
},
},
});
if (!photo) {
......@@ -790,7 +850,9 @@ export class CandidatesService {
}
if (!photo.candidate || photo.candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to photo with ID ${photoId} is not authorized`);
throw new ForbiddenException(
`Access to photo with ID ${photoId} is not authorized`,
);
}
return true;
......
......@@ -36,13 +36,19 @@ export class CandidateResponseDto {
@ApiProperty({ description: 'Address of the candidate' })
address: string;
@ApiProperty({ enum: CandidateStatus, description: 'Current status of the candidate' })
@ApiProperty({
enum: CandidateStatus,
description: 'Current status of the candidate',
})
currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' })
onGoing: boolean;
@ApiProperty({ description: 'Sites associated with this candidate', type: [CandidateSiteDto] })
@ApiProperty({
description: 'Sites associated with this candidate',
type: [CandidateSiteDto],
})
sites: CandidateSiteDto[];
@ApiProperty({ description: 'Creation timestamp' })
......@@ -51,6 +57,9 @@ export class CandidateResponseDto {
@ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date;
@ApiProperty({ description: 'Comments associated with this candidate', type: [CommentResponseDto] })
@ApiProperty({
description: 'Comments associated with this candidate',
type: [CommentResponseDto],
})
comments: CommentResponseDto[];
}
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validator';
import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CandidateType {
......@@ -17,7 +23,7 @@ export enum CandidateStatus {
MNO_VALIDATION = 'MNO_VALIDATION',
CLOSING = 'CLOSING',
SEARCH_AREA = 'SEARCH_AREA',
PAM = 'PAM'
PAM = 'PAM',
}
export class CreateCandidateDto {
......@@ -50,7 +56,10 @@ export class CreateCandidateDto {
@IsBoolean()
onGoing: boolean;
@ApiProperty({ description: 'IDs of the sites this candidate belongs to', type: [Number] })
@ApiProperty({
description: 'IDs of the sites this candidate belongs to',
type: [Number],
})
@IsNumber({}, { each: true })
siteIds: number[];
......
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validator';
import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { CandidateType, CandidateStatus } from './create-candidate.dto';
......@@ -9,12 +15,20 @@ export class QueryCandidateDto {
@IsString()
candidateCode?: string;
@ApiProperty({ description: 'Filter by type', required: false, enum: CandidateType })
@ApiProperty({
description: 'Filter by type',
required: false,
enum: CandidateType,
})
@IsOptional()
@IsEnum(CandidateType)
type?: CandidateType;
@ApiProperty({ description: 'Filter by current status', required: false, enum: CandidateStatus })
@ApiProperty({
description: 'Filter by current status',
required: false,
enum: CandidateStatus,
})
@IsOptional()
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
......@@ -31,12 +45,20 @@ export class QueryCandidateDto {
@Transform(({ value }) => parseInt(value))
siteId?: number;
@ApiProperty({ description: 'Page number for pagination', required: false, default: 1 })
@ApiProperty({
description: 'Page number for pagination',
required: false,
default: 1,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
page?: number = 1;
@ApiProperty({ description: 'Number of items per page', required: false, default: 10 })
@ApiProperty({
description: 'Number of items per page',
required: false,
default: 10,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
limit?: number = 10;
......
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validator';
import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { CandidateType, CandidateStatus } from './create-candidate.dto';
export class UpdateCandidateDto {
......@@ -18,7 +24,10 @@ export class UpdateCandidateDto {
@IsNumber()
longitude?: number;
@ApiPropertyOptional({ enum: CandidateType, description: 'Type of candidate' })
@ApiPropertyOptional({
enum: CandidateType,
description: 'Type of candidate',
})
@IsOptional()
@IsEnum(CandidateType)
type?: CandidateType;
......@@ -28,7 +37,10 @@ export class UpdateCandidateDto {
@IsString()
address?: string;
@ApiPropertyOptional({ enum: CandidateStatus, description: 'Current status of the candidate' })
@ApiPropertyOptional({
enum: CandidateStatus,
description: 'Current status of the candidate',
})
@IsOptional()
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
......@@ -38,7 +50,10 @@ export class UpdateCandidateDto {
@IsBoolean()
onGoing?: boolean;
@ApiPropertyOptional({ description: 'IDs of the sites this candidate belongs to', type: [Number] })
@ApiPropertyOptional({
description: 'IDs of the sites this candidate belongs to',
type: [Number],
})
@IsOptional()
@IsNumber({}, { each: true })
siteIds?: number[];
......
......@@ -4,7 +4,7 @@ import { IsString, IsNumber, IsOptional } from 'class-validator';
export class UploadPhotoDto {
@ApiProperty({
required: false,
description: 'Optional: The filename to use'
description: 'Optional: The filename to use',
})
@IsOptional()
@IsString()
......@@ -12,7 +12,7 @@ export class UploadPhotoDto {
@ApiProperty({
required: false,
description: 'Optional: The MIME type of the file'
description: 'Optional: The MIME type of the file',
})
@IsOptional()
@IsString()
......@@ -20,7 +20,7 @@ export class UploadPhotoDto {
@ApiProperty({
required: false,
description: 'Optional: The size of the file in bytes'
description: 'Optional: The size of the file in bytes',
})
@IsOptional()
@IsNumber()
......
import { Controller, Get, Post, Body, Param, Delete, UseGuards, ParseIntPipe, Req, Put } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
UseGuards,
ParseIntPipe,
Req,
Put,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiBody,
} from '@nestjs/swagger';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
......@@ -15,13 +32,17 @@ import { Request } from 'express';
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) { }
constructor(private readonly commentsService: CommentsService) {}
@Post()
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Create a new comment' })
@ApiBody({ type: CreateCommentDto })
@ApiResponse({ status: 201, description: 'The comment has been successfully created.', type: CommentResponseDto })
@ApiResponse({
status: 201,
description: 'The comment has been successfully created.',
type: CommentResponseDto,
})
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createCommentDto: CreateCommentDto, @Req() req: Request) {
const user = req.user as any;
......@@ -34,7 +55,7 @@ export class CommentsController {
@ApiResponse({
status: 200,
description: 'Return all comments for the candidate.',
type: [CommentResponseDto]
type: [CommentResponseDto],
})
findAll(@Param('candidateId', ParseIntPipe) candidateId: number) {
return this.commentsService.findAll(candidateId);
......@@ -42,7 +63,11 @@ export class CommentsController {
@Get(':id')
@ApiOperation({ summary: 'Get a comment by id' })
@ApiResponse({ status: 200, description: 'Return the comment.', type: CommentResponseDto })
@ApiResponse({
status: 200,
description: 'Return the comment.',
type: CommentResponseDto,
})
@ApiResponse({ status: 404, description: 'Comment not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.commentsService.findOne(id);
......@@ -51,7 +76,11 @@ export class CommentsController {
@Delete(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Delete a comment' })
@ApiResponse({ status: 200, description: 'The comment has been successfully deleted.', type: CommentResponseDto })
@ApiResponse({
status: 200,
description: 'The comment has been successfully deleted.',
type: CommentResponseDto,
})
@ApiResponse({ status: 404, description: 'Comment not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.commentsService.remove(id);
......@@ -61,9 +90,16 @@ export class CommentsController {
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Update a comment' })
@ApiBody({ type: UpdateCommentDto })
@ApiResponse({ status: 200, description: 'The comment has been successfully updated.', type: CommentResponseDto })
@ApiResponse({
status: 200,
description: 'The comment has been successfully updated.',
type: CommentResponseDto,
})
@ApiResponse({ status: 404, description: 'Comment not found.' })
update(@Param('id', ParseIntPipe) id: number, @Body() updateCommentDto: UpdateCommentDto) {
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateCommentDto: UpdateCommentDto,
) {
return this.commentsService.update(id, updateCommentDto);
}
}
......@@ -8,4 +8,4 @@ import { PrismaService } from '../../common/prisma/prisma.service';
providers: [CommentsService, PrismaService],
exports: [CommentsService],
})
export class CommentsModule { }
\ No newline at end of file
export class CommentsModule {}
......@@ -5,7 +5,7 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
@Injectable()
export class CommentsService {
constructor(private prisma: PrismaService) { }
constructor(private prisma: PrismaService) {}
async create(createCommentDto: CreateCommentDto) {
return this.prisma.comment.create({
......
......@@ -27,6 +27,9 @@ export class CommentResponseDto {
@ApiProperty({ description: 'ID of the candidate this comment belongs to' })
candidateId: number;
@ApiProperty({ description: 'User who created the comment', type: UserResponseDto })
@ApiProperty({
description: 'User who created the comment',
type: UserResponseDto,
})
createdBy: UserResponseDto;
}
......@@ -4,7 +4,7 @@ import { ApiProperty } from '@nestjs/swagger';
export class CreateCommentDto {
@ApiProperty({
description: 'The content of the comment',
example: 'This is a comment about the candidate'
example: 'This is a comment about the candidate',
})
@IsString()
@IsNotEmpty()
......@@ -12,16 +12,17 @@ export class CreateCommentDto {
@ApiProperty({
description: 'The ID of the candidate this comment is for',
example: 64
example: 64,
})
@IsInt()
@IsNotEmpty()
candidateId: number;
@ApiProperty({
description: 'The ID of the user creating the comment (optional, will be set automatically)',
description:
'The ID of the user creating the comment (optional, will be set automatically)',
example: 1,
required: false
required: false,
})
@IsInt()
@IsOptional()
......
......@@ -4,7 +4,7 @@ import { ApiProperty } from '@nestjs/swagger';
export class UpdateCommentDto {
@ApiProperty({
description: 'The updated content of the comment',
example: 'This is an updated comment about the candidate'
example: 'This is an updated comment about the candidate',
})
@IsString()
@IsNotEmpty()
......
import { Controller, Get, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { DashboardStatsDto } from './dto/dashboard.dto';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
......@@ -12,7 +17,7 @@ import { Role } from '@prisma/client';
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) { }
constructor(private readonly dashboardService: DashboardService) {}
@Get()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
......
......@@ -9,4 +9,4 @@ import { PrismaModule } from '../../common/prisma/prisma.module';
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule { }
\ No newline at end of file
export class DashboardModule {}
......@@ -5,7 +5,7 @@ import { Prisma } from '@prisma/client';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) { }
constructor(private prisma: PrismaService) {}
async getDashboardStats(): Promise<DashboardStatsDto> {
// Get total counts
......@@ -104,8 +104,8 @@ export class DashboardService {
acc[curr.currentStatus] = curr._count;
return acc;
}, {}),
candidatesPerSite: convertBigIntToNumber(candidatesPerSite) as any,
recentActivity: convertBigIntToNumber(recentActivity) as any,
candidatesPerSite: convertBigIntToNumber(candidatesPerSite),
recentActivity: convertBigIntToNumber(recentActivity),
userStats: {
totalUsers,
usersByRole: usersByRole.reduce((acc, curr) => {
......
import {
IsDateString,
IsInt,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { MaintenanceResponseOption } from './maintenance-response-option.enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateMaintenanceResponseDto {
@ApiProperty({
description: 'The ID of the maintenance question being answered',
example: 1,
type: Number,
})
@IsInt()
questionId: number;
@ApiProperty({
description: 'The response to the maintenance question',
enum: MaintenanceResponseOption,
example: MaintenanceResponseOption.YES,
enumName: 'MaintenanceResponseOption',
})
@IsString()
response: MaintenanceResponseOption;
@ApiPropertyOptional({
description:
'Optional comment providing additional details about the response',
example:
'Equipment is in good working condition, but some minor rust was observed',
type: String,
})
@IsString()
@IsOptional()
comment?: string;
}
export class CreateMaintenanceDto {
@ApiProperty({
description: 'Date when the maintenance was performed',
example: '2025-05-21T13:00:00.000Z',
type: String,
})
@IsDateString()
date: string;
@ApiProperty({
description: 'ID of the site where the maintenance was performed',
example: 1,
type: Number,
})
@IsInt()
siteId: number;
@ApiPropertyOptional({
description: 'Optional general comment about the maintenance',
example: 'Regular annual maintenance. Site is in good overall condition.',
type: String,
})
@IsString()
@IsOptional()
comment?: string;
@ApiProperty({
description: 'Responses to maintenance questions',
type: [CreateMaintenanceResponseDto],
example: [
{
questionId: 1,
response: 'YES',
comment: 'Access is clear and well-maintained',
},
{
questionId: 2,
response: 'NO',
comment: 'Infrastructure needs some repairs',
},
{
questionId: 3,
response: 'YES',
comment: null,
},
],
})
@ValidateNested({ each: true })
@Type(() => CreateMaintenanceResponseDto)
responses: CreateMaintenanceResponseDto[];
}
import { IsDateString, IsInt, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class FindMaintenanceDto {
@ApiPropertyOptional({
description: 'Filter maintenance records by site ID',
example: 1,
type: Number,
})
@IsInt()
@IsOptional()
siteId?: number;
@ApiPropertyOptional({
description:
'Filter maintenance records with date greater than or equal to this date',
example: '2025-01-01T00:00:00.000Z',
type: String,
})
@IsDateString()
@IsOptional()
startDate?: string;
@ApiPropertyOptional({
description:
'Filter maintenance records with date less than or equal to this date',
example: '2025-12-31T23:59:59.999Z',
type: String,
})
@IsDateString()
@IsOptional()
endDate?: string;
}
export * from './create-maintenance.dto';
export * from './find-maintenance.dto';
export * from './maintenance-response.dto';
export * from './maintenance-response-option.enum';
/**
* Response options for maintenance questions
*
* YES - Item is in good condition/working properly
* NO - Item needs attention/repair
* NA - Not applicable for this site
*/
export enum MaintenanceResponseOption {
YES = 'YES',
NO = 'NO',
NA = 'NA',
}
import { MaintenanceResponseOption } from './maintenance-response-option.enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class MaintenanceQuestionDto {
@ApiProperty({
description: 'Unique identifier of the maintenance question',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'Text of the maintenance question',
example: 'Site access condition',
type: String,
})
question: string;
@ApiProperty({
description: 'Order index for sorting questions',
example: 1,
type: Number,
})
orderIndex: number;
}
export class MaintenanceResponseDto {
@ApiProperty({
description: 'Unique identifier of the maintenance response',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'Response option selected for the question',
enum: MaintenanceResponseOption,
example: MaintenanceResponseOption.YES,
enumName: 'MaintenanceResponseOption',
})
response: MaintenanceResponseOption;
@ApiPropertyOptional({
description: 'Optional comment providing additional details',
example: 'Access road is well maintained but gate lock needs lubrication',
type: String,
})
comment?: string;
@ApiProperty({
description: 'The question this response answers',
type: MaintenanceQuestionDto,
})
question: MaintenanceQuestionDto;
}
export class MaintenancePhotoDto {
@ApiProperty({
description: 'Unique identifier of the maintenance photo',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'URL to access the photo',
example: '/uploads/maintenance/1/photo1.jpg',
type: String,
})
url: string;
@ApiProperty({
description: 'Original filename of the photo',
example: 'photo1.jpg',
type: String,
})
filename: string;
}
export class MaintenanceDto {
@ApiProperty({
description: 'Unique identifier of the maintenance record',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'Date when the maintenance was performed',
example: '2025-05-21T13:00:00.000Z',
type: Date,
})
date: Date;
@ApiPropertyOptional({
description: 'Optional general comment about the maintenance',
example: 'Annual preventive maintenance completed with minor issues noted',
type: String,
})
comment?: string;
@ApiProperty({
description: 'ID of the site where maintenance was performed',
example: 1,
type: Number,
})
siteId: number;
@ApiProperty({
description: 'Date and time when the record was created',
example: '2025-05-21T13:15:30.000Z',
type: Date,
})
createdAt: Date;
@ApiProperty({
description: 'Date and time when the record was last updated',
example: '2025-05-21T13:15:30.000Z',
type: Date,
})
updatedAt: Date;
@ApiProperty({
description: 'Responses to maintenance questions',
type: [MaintenanceResponseDto],
isArray: true,
})
responses: MaintenanceResponseDto[];
@ApiProperty({
description: 'Photos attached to the maintenance record',
type: [MaintenancePhotoDto],
isArray: true,
})
photos: MaintenancePhotoDto[];
}
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
Req,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } 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 { MaintenanceService } from './maintenance.service';
import {
CreateMaintenanceDto,
CreateMaintenanceResponseDto,
} from './dto/create-maintenance.dto';
import { FindMaintenanceDto } from './dto/find-maintenance.dto';
import { multerConfig } from '../../common/multer/multer.config';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
ApiParam,
ApiQuery,
getSchemaPath,
} from '@nestjs/swagger';
import {
MaintenanceDto,
MaintenanceQuestionDto,
MaintenanceResponseDto,
} from './dto/maintenance-response.dto';
@ApiTags('maintenance')
@Controller('maintenance')
@ApiBearerAuth('access-token')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@UseInterceptors(FilesInterceptor('photos', 10, multerConfig))
@ApiOperation({
summary: 'Create a new maintenance record',
description:
'Creates a new maintenance record for a site with responses to maintenance questions and optional photos. Only users with ADMIN, MANAGER, OPERATOR, or PARTNER roles can create maintenance records.',
})
@ApiResponse({
status: 201,
description: 'The maintenance record has been successfully created.',
type: MaintenanceDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Site not found.' })
@ApiBearerAuth('access-token')
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Maintenance data with optional photos',
schema: {
type: 'object',
required: ['date', 'siteId', 'responses'],
properties: {
date: {
type: 'string',
format: 'date-time',
example: '2025-05-21T13:00:00.000Z',
description: 'Date when the maintenance was performed',
},
siteId: {
type: 'integer',
example: 1,
description: 'ID of the site where the maintenance was performed',
},
comment: {
type: 'string',
example:
'Regular annual maintenance. Site is in good overall condition.',
description: 'Optional general comment about the maintenance',
},
responses: {
type: 'array',
items: {
$ref: getSchemaPath(CreateMaintenanceDto),
},
description: 'Responses to maintenance questions',
},
photos: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
description:
'Photos documenting the site condition (max 10 photos, max 5MB each)',
},
},
},
})
async createMaintenance(
@Body() createMaintenanceDto: CreateMaintenanceDto,
@UploadedFiles() files: Express.Multer.File[],
@Req() req,
) {
return this.maintenanceService.createMaintenance(
createMaintenanceDto,
req.user.id,
files,
);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Find all maintenance records with optional filters',
description:
'Retrieves a list of maintenance records. Can be filtered by site ID and date range.',
})
@ApiResponse({
status: 200,
description: 'List of maintenance records.',
type: [MaintenanceDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiQuery({
name: 'siteId',
required: false,
type: Number,
description: 'Filter by site ID',
example: 1,
})
@ApiQuery({
name: 'startDate',
required: false,
type: String,
description: 'Filter by start date (inclusive)',
example: '2025-01-01T00:00:00.000Z',
})
@ApiQuery({
name: 'endDate',
required: false,
type: String,
description: 'Filter by end date (inclusive)',
example: '2025-12-31T23:59:59.999Z',
})
async findAllMaintenance(@Query() findMaintenanceDto: FindMaintenanceDto) {
return this.maintenanceService.findAllMaintenance(findMaintenanceDto);
}
@Get('questions')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Get all maintenance questions',
description:
'Retrieves the list of predefined maintenance questions that need to be answered during maintenance.',
})
@ApiResponse({
status: 200,
description: 'List of maintenance questions.',
type: [MaintenanceQuestionDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
async getMaintenanceQuestions() {
return this.maintenanceService.getMaintenanceQuestions();
}
@Get('questions/:id')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Get a specific maintenance question by ID',
description: 'Retrieves a specific maintenance question by its ID.',
})
@ApiResponse({
status: 200,
description: 'The maintenance question.',
type: MaintenanceQuestionDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 404, description: 'Question not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the maintenance question to retrieve',
example: 1,
})
async getMaintenanceQuestionById(@Param('id', ParseIntPipe) id: number) {
return this.maintenanceService.getMaintenanceQuestionById(id);
}
@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Find maintenance record by ID',
description:
'Retrieves a specific maintenance record by its ID, including all responses and photos.',
})
@ApiResponse({
status: 200,
description: 'The maintenance record.',
type: MaintenanceDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 404, description: 'Maintenance record not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the maintenance record to retrieve',
example: 1,
})
async findMaintenanceById(@Param('id', ParseIntPipe) id: number) {
return this.maintenanceService.findMaintenanceById(id);
}
@Get(':id/responses')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Get responses for a specific maintenance record',
description:
'Retrieves all responses for a specific maintenance record including the associated questions.',
})
@ApiResponse({
status: 200,
description: 'List of maintenance responses.',
type: [MaintenanceResponseDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 404, description: 'Maintenance record not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the maintenance record',
example: 1,
})
async getResponsesByMaintenanceId(@Param('id', ParseIntPipe) id: number) {
return this.maintenanceService.getResponsesByMaintenanceId(id);
}
@Post(':id/responses')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@ApiOperation({
summary: 'Add responses to an existing maintenance record',
description:
'Adds or updates responses for a specific maintenance record. Can be used to complete a partially filled maintenance record.',
})
@ApiResponse({
status: 201,
description: 'The responses have been successfully added.',
type: [MaintenanceResponseDto],
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({
status: 404,
description: 'Maintenance record or question not found.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the maintenance record',
example: 1,
})
@ApiBody({
description: 'Array of maintenance responses to add',
type: [CreateMaintenanceResponseDto],
})
async addResponsesToMaintenance(
@Param('id', ParseIntPipe) id: number,
@Body() responses: CreateMaintenanceResponseDto[],
@Req() req,
) {
return this.maintenanceService.addResponsesToMaintenance(
id,
responses,
req.user.id,
);
}
}
/**
* Example requests and responses for the Maintenance API
* This file is for documentation purposes only
*/
/**
* Example request for creating a maintenance record
*/
export const createMaintenanceExample = {
date: '2025-05-21T13:00:00.000Z',
siteId: 1,
comment: 'Regular annual maintenance. Site is in good overall condition.',
responses: [
{
questionId: 1,
response: 'YES',
comment: 'Access is clear and well-maintained',
},
{
questionId: 2,
response: 'NO',
comment:
'Infrastructure needs some repairs - fence has multiple damaged sections',
},
{
questionId: 3,
response: 'YES',
comment: 'Equipment is functioning properly',
},
{
questionId: 4,
response: 'YES',
comment: 'Power systems are operational',
},
{
questionId: 5,
response: 'NA',
comment: 'No cooling system at this site',
},
],
};
/**
* Example response for a maintenance record
*/
export const maintenanceResponseExample = {
id: 1,
date: '2025-05-21T13:00:00.000Z',
comment: 'Regular annual maintenance. Site is in good overall condition.',
siteId: 1,
createdAt: '2025-05-21T13:15:30.000Z',
updatedAt: '2025-05-21T13:15:30.000Z',
responses: [
{
id: 1,
response: 'YES',
comment: 'Access is clear and well-maintained',
question: {
id: 1,
question: 'Site access condition',
orderIndex: 1,
},
},
{
id: 2,
response: 'NO',
comment:
'Infrastructure needs some repairs - fence has multiple damaged sections',
question: {
id: 2,
question: 'Site infrastructure condition',
orderIndex: 2,
},
},
{
id: 3,
response: 'YES',
comment: 'Equipment is functioning properly',
question: {
id: 3,
question: 'Equipment condition',
orderIndex: 3,
},
},
{
id: 4,
response: 'YES',
comment: 'Power systems are operational',
question: {
id: 4,
question: 'Power system condition',
orderIndex: 4,
},
},
{
id: 5,
response: 'NA',
comment: 'No cooling system at this site',
question: {
id: 5,
question: 'Cooling system condition',
orderIndex: 5,
},
},
],
photos: [
{
id: 1,
url: '/uploads/maintenance/1/entrance.jpg',
filename: 'entrance.jpg',
},
{
id: 2,
url: '/uploads/maintenance/1/damaged_fence.jpg',
filename: 'damaged_fence.jpg',
},
{
id: 3,
url: '/uploads/maintenance/1/equipment.jpg',
filename: 'equipment.jpg',
},
],
};
/**
* Example response for maintenance questions
*/
export const maintenanceQuestionsExample = [
{
id: 1,
question: 'Site access condition',
orderIndex: 1,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 2,
question: 'Site infrastructure condition',
orderIndex: 2,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 3,
question: 'Equipment condition',
orderIndex: 3,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 4,
question: 'Power system condition',
orderIndex: 4,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 5,
question: 'Cooling system condition',
orderIndex: 5,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 6,
question: 'Security features condition',
orderIndex: 6,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 7,
question: 'Safety equipment presence and condition',
orderIndex: 7,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 8,
question: 'Site cleanliness',
orderIndex: 8,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 9,
question: 'Vegetation control',
orderIndex: 9,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 10,
question: 'Surrounding area condition',
orderIndex: 10,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
];
import { Module } from '@nestjs/common';
import { MaintenanceController } from './maintenance.controller';
import { MaintenanceService } from './maintenance.service';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [MaintenanceController],
providers: [MaintenanceService],
exports: [MaintenanceService],
})
export class MaintenanceModule {}
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import {
CreateMaintenanceDto,
CreateMaintenanceResponseDto,
} from './dto/create-maintenance.dto';
import { FindMaintenanceDto } from './dto/find-maintenance.dto';
import {
MaintenanceDto,
MaintenanceResponseDto,
MaintenancePhotoDto,
} from './dto/maintenance-response.dto';
import { MaintenanceResponseOption } from './dto/maintenance-response-option.enum';
import { saveMaintenancePhotos } from './maintenance.utils';
@Injectable()
export class MaintenanceService {
constructor(private prisma: PrismaService) { }
async createMaintenance(
dto: CreateMaintenanceDto,
userId: number,
files?: Express.Multer.File[],
): Promise<MaintenanceDto> {
// 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`);
}
// Create maintenance record
const maintenance = await this.prisma.maintenance.create({
data: {
date: new Date(dto.date),
comment: dto.comment,
site: { connect: { id: dto.siteId } },
createdBy: userId ? { connect: { id: userId } } : undefined,
responses: {
create: dto.responses.map((response) => ({
response: response.response,
comment: response.comment,
question: { connect: { id: response.questionId } },
})),
},
},
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
// Process and upload files if any
if (files && files.length > 0) {
try {
// Save files to disk
const filePaths = await saveMaintenancePhotos(files, maintenance.id);
// Create photo records in database
const photoPromises = files.map((file, index) => {
return this.prisma.maintenancePhoto.create({
data: {
filename: file.originalname,
mimeType: file.mimetype,
size: file.size,
url: filePaths[index],
maintenance: { connect: { id: maintenance.id } },
},
});
});
await Promise.all(photoPromises);
// Fetch the updated maintenance record with photos
const updatedMaintenance = await this.prisma.maintenance.findUnique({
where: { id: maintenance.id },
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
return this.mapToDto(updatedMaintenance);
} catch (error) {
console.error('Error processing maintenance photos:', error);
// Continue without photos if there's an error
}
}
return this.mapToDto(maintenance);
}
async findAllMaintenance(dto: FindMaintenanceDto): Promise<MaintenanceDto[]> {
const filter: any = {};
if (dto.siteId) {
filter.siteId = dto.siteId;
}
if (dto.startDate || dto.endDate) {
filter.date = {};
if (dto.startDate) {
filter.date.gte = new Date(dto.startDate);
}
if (dto.endDate) {
filter.date.lte = new Date(dto.endDate);
}
}
const maintenances = await this.prisma.maintenance.findMany({
where: filter,
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
orderBy: {
date: 'desc',
},
});
return maintenances.map(this.mapToDto);
}
async findMaintenanceById(id: number): Promise<MaintenanceDto> {
const maintenance = await this.prisma.maintenance.findUnique({
where: { id },
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
if (!maintenance) {
throw new NotFoundException(`Maintenance with ID ${id} not found`);
}
return this.mapToDto(maintenance);
}
async getMaintenanceQuestions() {
return this.prisma.maintenanceQuestion.findMany({
orderBy: {
orderIndex: 'asc',
},
});
}
async getMaintenanceQuestionById(id: number) {
const question = await this.prisma.maintenanceQuestion.findUnique({
where: { id },
});
if (!question) {
throw new NotFoundException(
`Maintenance question with ID ${id} not found`,
);
}
return question;
}
async getResponsesByMaintenanceId(
maintenanceId: number,
): Promise<MaintenanceResponseDto[]> {
const maintenance = await this.prisma.maintenance.findUnique({
where: { id: maintenanceId },
include: {
responses: {
include: {
question: true,
},
},
},
});
if (!maintenance) {
throw new NotFoundException(
`Maintenance with ID ${maintenanceId} not found`,
);
}
return maintenance.responses.map((response) => ({
id: response.id,
response: response.response as unknown as MaintenanceResponseOption,
comment: response.comment ?? undefined,
question: {
id: response.question.id,
question: response.question.question,
orderIndex: response.question.orderIndex,
},
}));
}
async addResponsesToMaintenance(
maintenanceId: number,
responses: CreateMaintenanceResponseDto[],
userId: number,
): Promise<MaintenanceResponseDto[]> {
// Check if maintenance exists
const maintenance = await this.prisma.maintenance.findUnique({
where: { id: maintenanceId },
});
if (!maintenance) {
throw new NotFoundException(
`Maintenance with ID ${maintenanceId} not found`,
);
}
// Create responses
type ResponseWithQuestion = {
id: number;
response: any;
comment: string | null;
question: {
id: number;
question: string;
orderIndex: number;
};
};
const createdResponses: ResponseWithQuestion[] = [];
for (const response of responses) {
// Check if question exists
const question = await this.prisma.maintenanceQuestion.findUnique({
where: { id: response.questionId },
});
if (!question) {
throw new NotFoundException(
`Question with ID ${response.questionId} not found`,
);
}
// Check if a response already exists for this question
const existingResponse = await this.prisma.maintenanceResponse.findFirst({
where: {
maintenanceId,
questionId: response.questionId,
},
});
let result;
if (existingResponse) {
// Update existing response
result = await this.prisma.maintenanceResponse.update({
where: { id: existingResponse.id },
data: {
response: response.response,
comment: response.comment,
},
include: {
question: true,
},
});
} else {
// Create new response
result = await this.prisma.maintenanceResponse.create({
data: {
response: response.response,
comment: response.comment,
question: { connect: { id: response.questionId } },
maintenance: { connect: { id: maintenanceId } },
},
include: {
question: true,
},
});
}
createdResponses.push(result);
}
// Update the maintenance record's updatedBy and updatedAt
await this.prisma.maintenance.update({
where: { id: maintenanceId },
data: {
updatedBy: { connect: { id: userId } },
},
});
// Map responses to DTOs
return createdResponses.map((response) => ({
id: response.id,
response: response.response as unknown as MaintenanceResponseOption,
comment: response.comment ?? undefined,
question: {
id: response.question.id,
question: response.question.question,
orderIndex: response.question.orderIndex,
},
}));
}
private mapToDto(maintenance: any): MaintenanceDto {
return {
id: maintenance.id,
date: maintenance.date,
comment: maintenance.comment,
siteId: maintenance.siteId,
createdAt: maintenance.createdAt,
updatedAt: maintenance.updatedAt,
responses: maintenance.responses.map((response) => ({
id: response.id,
response: response.response as unknown as MaintenanceResponseOption,
comment: response.comment ?? undefined,
question: {
id: response.question.id,
question: response.question.question,
orderIndex: response.question.orderIndex,
},
})),
photos: maintenance.photos.map((photo) => ({
id: photo.id,
url: photo.url,
filename: photo.filename,
})),
};
}
}
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
const mkdir = promisify(fs.mkdir);
const writeFile = promisify(fs.writeFile);
/**
* Saves uploaded maintenance photos to the file system
* @param files Array of uploaded files
* @param maintenanceId The ID of the maintenance record
* @returns Array of saved file paths
*/
export async function saveMaintenancePhotos(
files: Express.Multer.File[],
maintenanceId: number,
): Promise<string[]> {
if (!files || files.length === 0) {
return [];
}
const uploadDir =
process.env.NODE_ENV === 'production'
? `/home/api-cellnex/public_html/uploads/maintenance/${maintenanceId}`
: path.join(
process.cwd(),
'uploads',
'maintenance',
maintenanceId.toString(),
);
// Create directory if it doesn't exist
try {
await mkdir(uploadDir, { recursive: true });
} catch (error) {
console.error(`Error creating directory ${uploadDir}:`, error);
throw new Error(`Failed to create upload directory: ${error.message}`);
}
// Save files
const savedPaths: string[] = [];
const savePromises = files.map(async (file) => {
const filename = file.originalname;
const filePath = path.join(uploadDir, filename);
try {
await writeFile(filePath, file.buffer);
savedPaths.push(`/uploads/maintenance/${maintenanceId}/${filename}`);
} catch (error) {
console.error(`Error saving file ${filename}:`, error);
throw new Error(`Failed to save file ${filename}: ${error.message}`);
}
});
await Promise.all(savePromises);
return savedPaths;
}
......@@ -5,7 +5,7 @@ export class CreatePartnerDto {
@ApiProperty({
description: 'The name of the partner organization',
example: 'PROEF Telco Services',
required: true
required: true,
})
@IsNotEmpty()
@IsString()
......@@ -14,7 +14,7 @@ export class CreatePartnerDto {
@ApiProperty({
description: 'Additional information about the partner',
example: 'Professional telecommunications and network service provider',
required: false
required: false,
})
@IsOptional()
@IsString()
......@@ -23,7 +23,7 @@ export class CreatePartnerDto {
@ApiProperty({
description: 'Whether the partner is active',
default: true,
required: false
required: false,
})
@IsOptional()
@IsBoolean()
......
......@@ -15,7 +15,10 @@ export class PartnerUserDto {
}
export class PartnerCountDto {
@ApiProperty({ description: 'Number of candidates associated with this partner', example: 42 })
@ApiProperty({
description: 'Number of candidates associated with this partner',
example: 42,
})
candidates: number;
}
......@@ -29,22 +32,34 @@ export class PartnerResponseDto {
@ApiProperty({
description: 'Partner description',
example: 'Professional telecommunications and network service provider',
required: false
required: false,
})
description?: string;
@ApiProperty({ description: 'Partner active status', example: true })
isActive: boolean;
@ApiProperty({ description: 'Partner creation timestamp', example: '2023-05-13T15:25:41.358Z' })
@ApiProperty({
description: 'Partner creation timestamp',
example: '2023-05-13T15:25:41.358Z',
})
createdAt: Date;
@ApiProperty({ description: 'Partner last update timestamp', example: '2023-05-13T15:25:41.358Z' })
@ApiProperty({
description: 'Partner last update timestamp',
example: '2023-05-13T15:25:41.358Z',
})
updatedAt: Date;
@ApiProperty({ type: [PartnerUserDto], description: 'Users associated with this partner' })
@ApiProperty({
type: [PartnerUserDto],
description: 'Users associated with this partner',
})
users?: PartnerUserDto[];
@ApiProperty({ type: PartnerCountDto, description: 'Associated entity counts' })
@ApiProperty({
type: PartnerCountDto,
description: 'Associated entity counts',
})
_count?: PartnerCountDto;
}
......@@ -6,21 +6,21 @@ export class UpdatePartnerDto extends PartialType(CreatePartnerDto) {
@ApiProperty({
description: 'The name of the partner organization',
example: 'PROEF Telco Services',
required: false
required: false,
})
name?: string;
@ApiProperty({
description: 'Additional information about the partner',
example: 'Professional telecommunications and network service provider',
required: false
required: false,
})
description?: string;
@ApiProperty({
description: 'Whether the partner is active',
example: true,
required: false
required: false,
})
isActive?: boolean;
}
......@@ -7,9 +7,15 @@ import {
Param,
Delete,
ParseIntPipe,
UseGuards
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { PartnersService } from './partners.service';
import { CreatePartnerDto, UpdatePartnerDto, PartnerResponseDto } from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
......@@ -25,7 +31,7 @@ import { Role } from '@prisma/client';
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class PartnersController {
constructor(private readonly partnersService: PartnersService) { }
constructor(private readonly partnersService: PartnersService) {}
@Post()
@Roles(Role.ADMIN, Role.SUPERADMIN)
......@@ -33,7 +39,7 @@ export class PartnersController {
@ApiResponse({
status: 201,
description: 'The partner has been successfully created.',
type: PartnerResponseDto
type: PartnerResponseDto,
})
create(@Body() createPartnerDto: CreatePartnerDto) {
return this.partnersService.create(createPartnerDto);
......@@ -45,7 +51,7 @@ export class PartnersController {
@ApiResponse({
status: 200,
description: 'Return all partners.',
type: [PartnerResponseDto]
type: [PartnerResponseDto],
})
findAll(@Partner() partnerId: number | null, @User('role') role: Role) {
// For PARTNER users, we'll only return their own partner
......@@ -62,7 +68,11 @@ export class PartnersController {
@UseGuards(PartnerAuthGuard)
@ApiOperation({ summary: 'Get a partner by id' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({ status: 200, description: 'Return the partner.', type: PartnerResponseDto })
@ApiResponse({
status: 200,
description: 'Return the partner.',
type: PartnerResponseDto,
})
@ApiResponse({ status: 404, description: 'Partner not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.findOne(id);
......@@ -72,7 +82,11 @@ export class PartnersController {
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Update a partner' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({ status: 200, description: 'The partner has been successfully updated.', type: PartnerResponseDto })
@ApiResponse({
status: 200,
description: 'The partner has been successfully updated.',
type: PartnerResponseDto,
})
@ApiResponse({ status: 404, description: 'Partner not found.' })
update(
@Param('id', ParseIntPipe) id: number,
......@@ -85,7 +99,11 @@ export class PartnersController {
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Delete a partner' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({ status: 200, description: 'The partner has been successfully deleted.', type: PartnerResponseDto })
@ApiResponse({
status: 200,
description: 'The partner has been successfully deleted.',
type: PartnerResponseDto,
})
@ApiResponse({ status: 404, description: 'Partner not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.remove(id);
......@@ -96,7 +114,10 @@ export class PartnersController {
@ApiOperation({ summary: 'Add a user to a partner' })
@ApiParam({ name: 'partnerId', description: 'Partner ID', type: 'number' })
@ApiParam({ name: 'userId', description: 'User ID', type: 'number' })
@ApiResponse({ status: 200, description: 'The user has been successfully added to the partner.' })
@ApiResponse({
status: 200,
description: 'The user has been successfully added to the partner.',
})
@ApiResponse({ status: 404, description: 'Partner or user not found.' })
addUserToPartner(
@Param('partnerId', ParseIntPipe) partnerId: number,
......@@ -110,8 +131,14 @@ export class PartnersController {
@ApiOperation({ summary: 'Remove a user from a partner' })
@ApiParam({ name: 'partnerId', description: 'Partner ID', type: 'number' })
@ApiParam({ name: 'userId', description: 'User ID', type: 'number' })
@ApiResponse({ status: 200, description: 'The user has been successfully removed from the partner.' })
@ApiResponse({ status: 404, description: 'Partner, user, or association not found.' })
@ApiResponse({
status: 200,
description: 'The user has been successfully removed from the partner.',
})
@ApiResponse({
status: 404,
description: 'Partner, user, or association not found.',
})
removeUserFromPartner(
@Param('partnerId', ParseIntPipe) partnerId: number,
@Param('userId', ParseIntPipe) userId: number,
......@@ -124,7 +151,10 @@ export class PartnersController {
@UseGuards(PartnerAuthGuard)
@ApiOperation({ summary: 'Get all candidates for a partner' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({ status: 200, description: 'Return all candidates for the partner.' })
@ApiResponse({
status: 200,
description: 'Return all candidates for the partner.',
})
@ApiResponse({ status: 404, description: 'Partner not found.' })
getPartnerCandidates(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.getPartnerCandidates(id);
......
......@@ -8,4 +8,4 @@ import { PrismaService } from '../../common/prisma/prisma.service';
providers: [PartnersService, PrismaService],
exports: [PartnersService],
})
export class PartnersModule { }
\ No newline at end of file
export class PartnersModule {}
......@@ -4,7 +4,7 @@ import { CreatePartnerDto, UpdatePartnerDto } from './dto';
@Injectable()
export class PartnersService {
constructor(private prisma: PrismaService) { }
constructor(private prisma: PrismaService) {}
async create(createPartnerDto: CreatePartnerDto) {
return this.prisma.partner.create({
......@@ -110,7 +110,9 @@ export class PartnersService {
}
if (user.partnerId !== partnerId) {
throw new NotFoundException(`User with ID ${userId} is not associated with Partner ID ${partnerId}`);
throw new NotFoundException(
`User with ID ${userId} is not associated with Partner ID ${partnerId}`,
);
}
return this.prisma.user.update({
......
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsNumber, IsOptional, Min, Max, IsBoolean, IsArray, IsEnum } from 'class-validator';
import {
IsString,
IsNotEmpty,
IsNumber,
IsOptional,
Min,
Max,
IsBoolean,
IsArray,
IsEnum,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CompanyName } from './company.dto';
......@@ -33,7 +43,7 @@ export class CreateSiteDto {
@ApiProperty({
description: 'Longitude coordinate of the site',
example: -74.0060,
example: -74.006,
minimum: -180,
maximum: 180,
})
......
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsEnum, IsInt, Min, IsBoolean } from 'class-validator';
import {
IsOptional,
IsString,
IsEnum,
IsInt,
Min,
IsBoolean,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export enum OrderDirection {
......
......@@ -43,7 +43,10 @@ export class SiteResponseDto {
@ApiProperty({ description: 'Type of the site' })
type: string;
@ApiProperty({ description: 'Whether the site is a Digi site', default: false })
@ApiProperty({
description: 'Whether the site is a Digi site',
default: false,
})
isDigi: boolean;
@ApiProperty({ description: 'Whether the site is reported', default: false })
......@@ -55,13 +58,21 @@ export class SiteResponseDto {
@ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date;
@ApiProperty({ description: 'User who created the site', type: UserResponseDto })
@ApiProperty({
description: 'User who created the site',
type: UserResponseDto,
})
createdBy: UserResponseDto;
@ApiProperty({ description: 'User who last updated the site', type: UserResponseDto })
@ApiProperty({
description: 'User who last updated the site',
type: UserResponseDto,
})
updatedBy: UserResponseDto;
@ApiProperty({ description: 'Number of candidates associated with this site' })
@ApiProperty({
description: 'Number of candidates associated with this site',
})
_count?: {
candidates: number;
};
......@@ -77,9 +88,16 @@ export class SiteResponseDto {
@ApiProperty({
description: 'Highest priority candidate status for this site',
enum: ['SEARCH_AREA', 'REJECTED', 'NEGOTIATION_ONGOING', 'MNO_VALIDATION', 'CLOSING', 'APPROVED'],
enum: [
'SEARCH_AREA',
'REJECTED',
'NEGOTIATION_ONGOING',
'MNO_VALIDATION',
'CLOSING',
'APPROVED',
],
required: false,
nullable: true
nullable: true,
})
highestCandidateStatus?: string | null;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsBoolean, IsArray } from 'class-validator';
import {
IsString,
IsNumber,
IsOptional,
IsBoolean,
IsArray,
} from 'class-validator';
import { CompanyName } from '@prisma/client';
export class UpdateSiteDto {
......
......@@ -36,7 +36,7 @@ import { Partner } from '../auth/decorators/partner.decorator';
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class SitesController {
constructor(private readonly sitesService: SitesService) { }
constructor(private readonly sitesService: SitesService) {}
@Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
......@@ -44,7 +44,7 @@ export class SitesController {
@ApiResponse({
status: 201,
description: 'The site has been successfully created.',
type: SiteResponseDto
type: SiteResponseDto,
})
@ApiResponse({ status: 400, description: 'Bad Request.' })
@ApiResponse({ status: 409, description: 'Site code already exists.' })
......@@ -55,12 +55,13 @@ export class SitesController {
@Get('map')
@ApiOperation({
summary: 'Get all sites for map view (without pagination)',
description: 'Returns all sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).'
description:
'Returns all sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).',
})
@ApiResponse({
status: 200,
description: 'Return all sites with applied filters and ordering.',
type: [SiteResponseDto]
type: [SiteResponseDto],
})
findAllForMap(@Query() findSitesDto: FindSitesDto) {
return this.sitesService.findAllForMap(findSitesDto);
......@@ -71,7 +72,8 @@ export class SitesController {
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({
summary: 'Get all sites for list view (with pagination)',
description: 'Returns paginated sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).'
description:
'Returns paginated sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).',
})
@ApiResponse({
status: 200,
......@@ -80,7 +82,7 @@ export class SitesController {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/SiteResponseDto' }
items: { $ref: '#/components/schemas/SiteResponseDto' },
},
meta: {
type: 'object',
......@@ -88,17 +90,18 @@ export class SitesController {
total: { type: 'number', description: 'Total number of records' },
page: { type: 'number', description: 'Current page number' },
limit: { type: 'number', description: 'Number of items per page' },
totalPages: { type: 'number', description: 'Total number of pages' }
}
}
}
}
totalPages: {
type: 'number',
description: 'Total number of pages',
},
},
},
},
},
})
async findAll(
@Query() findSitesDto: FindSitesDto,
@Request() req,
) {
const partnerId = req.user.role === Role.PARTNER ? req.user.partnerId : null;
async findAll(@Query() findSitesDto: FindSitesDto, @Request() req) {
const partnerId =
req.user.role === Role.PARTNER ? req.user.partnerId : null;
return this.sitesService.findAll(findSitesDto, partnerId);
}
......@@ -107,7 +110,7 @@ export class SitesController {
@ApiResponse({
status: 200,
description: 'Return the site.',
type: SiteResponseDto
type: SiteResponseDto,
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findByCode(@Param('siteCode') siteCode: string) {
......@@ -119,13 +122,13 @@ export class SitesController {
@ApiResponse({
status: 200,
description: 'Return the site.',
type: SiteResponseDto
type: SiteResponseDto,
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findOne(
@Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null,
@User('role') role: Role
@User('role') role: Role,
) {
// For PARTNER role, we restrict access to only see sites associated with their partnerId
if (role === Role.PARTNER) {
......@@ -140,19 +143,19 @@ export class SitesController {
name: 'partnerId',
required: false,
type: Number,
description: 'Filter candidates by specific partner ID'
description: 'Filter candidates by specific partner ID',
})
@ApiResponse({
status: 200,
description: 'Return the site with its candidates.',
type: SiteResponseDto
type: SiteResponseDto,
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findOneWithCandidates(
@Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null,
@User('role') role: Role,
@Query('partnerId') filterPartnerId?: number
@Query('partnerId') filterPartnerId?: number,
) {
// For PARTNER role, we restrict access to only see candidates created with their partnerId
if (role === Role.PARTNER) {
......@@ -162,14 +165,13 @@ export class SitesController {
return this.sitesService.findOneWithCandidates(id, filterPartnerId);
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Update a site' })
@ApiResponse({
status: 200,
description: 'The site has been successfully updated.',
type: SiteResponseDto
type: SiteResponseDto,
})
@ApiResponse({ status: 404, description: 'Site not found.' })
@ApiResponse({ status: 409, description: 'Site code already exists.' })
......@@ -202,9 +204,9 @@ export class SitesController {
type: 'array',
items: {
type: 'string',
enum: ['VODAFONE', 'MEO', 'NOS', 'DIGI']
}
}
enum: ['VODAFONE', 'MEO', 'NOS', 'DIGI'],
},
},
})
findAllCompanies() {
return this.sitesService.findAllCompanies();
......
......@@ -10,4 +10,4 @@ import { AuthModule } from '../auth/auth.module';
providers: [SitesService],
exports: [SitesService],
})
export class SitesModule { }
\ No newline at end of file
export class SitesModule {}
import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaClient, Prisma, CompanyName } from '@prisma/client';
import { CreateSiteDto } from './dto/create-site.dto';
import { UpdateSiteDto } from './dto/update-site.dto';
......@@ -28,14 +33,22 @@ export class SitesService {
return null;
}
return candidates
.map(candidate => candidate.candidate?.currentStatus)
return (
candidates
.map((candidate) => candidate.candidate?.currentStatus)
.filter(Boolean)
.sort((a, b) => {
const priorityA = CandidateStatusPriority[a] !== undefined ? Number(CandidateStatusPriority[a]) : -1;
const priorityB = CandidateStatusPriority[b] !== undefined ? Number(CandidateStatusPriority[b]) : -1;
const priorityA =
CandidateStatusPriority[a] !== undefined
? Number(CandidateStatusPriority[a])
: -1;
const priorityB =
CandidateStatusPriority[b] !== undefined
? Number(CandidateStatusPriority[b])
: -1;
return priorityB - priorityA;
})[0] || null;
})[0] || null
);
}
async create(createSiteDto: CreateSiteDto, userId: number) {
......@@ -71,7 +84,9 @@ export class SitesService {
});
} catch (error) {
if (error.code === 'P2002') {
throw new ConflictException(`Site with code ${createSiteDto.siteCode} already exists`);
throw new ConflictException(
`Site with code ${createSiteDto.siteCode} already exists`,
);
}
throw error;
}
......@@ -98,13 +113,27 @@ export class SitesService {
const skip = (page - 1) * limit;
const where: Prisma.SiteWhereInput = {
...(siteCode && { siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive } }),
...(siteName && { siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive } }),
...(address && { address: { contains: address, mode: Prisma.QueryMode.insensitive } }),
...(city && { city: { contains: city, mode: Prisma.QueryMode.insensitive } }),
...(state && { state: { contains: state, mode: Prisma.QueryMode.insensitive } }),
...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }),
...(type && { type: { contains: type, mode: Prisma.QueryMode.insensitive } }),
...(siteCode && {
siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive },
}),
...(siteName && {
siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive },
}),
...(address && {
address: { contains: address, mode: Prisma.QueryMode.insensitive },
}),
...(city && {
city: { contains: city, mode: Prisma.QueryMode.insensitive },
}),
...(state && {
state: { contains: state, mode: Prisma.QueryMode.insensitive },
}),
...(country && {
country: { contains: country, mode: Prisma.QueryMode.insensitive },
}),
...(type && {
type: { contains: type, mode: Prisma.QueryMode.insensitive },
}),
...(isDigi !== undefined && { isDigi }),
...(isReported !== undefined && { isReported }),
};
......@@ -129,18 +158,20 @@ export class SitesService {
},
},
candidates: {
where: partnerId ? {
where: partnerId
? {
candidate: {
partnerId: partnerId
partnerId: partnerId,
},
}
} : undefined,
: undefined,
include: {
candidate: {
select: {
currentStatus: true
}
}
}
currentStatus: true,
},
},
},
},
},
}),
......@@ -148,8 +179,10 @@ export class SitesService {
]);
// Add highest priority status to all sites
const sitesWithHighestStatus = sites.map(site => {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
const sitesWithHighestStatus = sites.map((site) => {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
......@@ -222,7 +255,9 @@ export class SitesService {
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
......@@ -234,7 +269,9 @@ export class SitesService {
async findOneFilteredByPartner(id: number, partnerId: number | null) {
if (!partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
throw new ForbiddenException(
'User does not have access to any partner resources',
);
}
const site = await this.prisma.site.findUnique({
......@@ -257,8 +294,8 @@ export class SitesService {
candidates: {
where: {
candidate: {
partnerId: partnerId
}
partnerId: partnerId,
},
},
include: {
candidate: {
......@@ -295,7 +332,9 @@ export class SitesService {
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
......@@ -310,11 +349,13 @@ export class SitesService {
where: { id },
include: {
candidates: {
where: partnerId ? {
where: partnerId
? {
candidate: {
partnerId: partnerId
partnerId: partnerId,
},
}
} : undefined,
: undefined,
include: {
candidate: {
include: {
......@@ -366,7 +407,9 @@ export class SitesService {
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
......@@ -376,9 +419,14 @@ export class SitesService {
return site;
}
async findOneWithCandidatesFilteredByPartner(id: number, partnerId: number | null) {
async findOneWithCandidatesFilteredByPartner(
id: number,
partnerId: number | null,
) {
if (!partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
throw new ForbiddenException(
'User does not have access to any partner resources',
);
}
// First, fetch the site with all candidates
......@@ -432,14 +480,16 @@ export class SitesService {
// Filter the candidates to only include those with the partner's ID
const filteredSite = {
...site,
candidates: site.candidates.filter(candidateSite =>
candidateSite.candidate.partnerId === partnerId
)
candidates: site.candidates.filter(
(candidateSite) => candidateSite.candidate.partnerId === partnerId,
),
};
// Add highest priority status if the site has filtered candidates
if (filteredSite.candidates && filteredSite.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(filteredSite.candidates);
const highestCandidateStatus = this.getHighestPriorityStatus(
filteredSite.candidates,
);
return {
...filteredSite,
highestCandidateStatus,
......@@ -461,7 +511,10 @@ export class SitesService {
type: updateSiteDto.type,
isDigi: updateSiteDto.isDigi,
isReported: updateSiteDto.isReported,
companies: updateSiteDto.companies !== undefined ? updateSiteDto.companies : undefined,
companies:
updateSiteDto.companies !== undefined
? updateSiteDto.companies
: undefined,
updatedById: userId,
},
include: {
......@@ -486,7 +539,9 @@ export class SitesService {
throw new NotFoundException(`Site with ID ${id} not found`);
}
if (error.code === 'P2002') {
throw new ConflictException(`Site with code ${updateSiteDto.siteCode} already exists`);
throw new ConflictException(
`Site with code ${updateSiteDto.siteCode} already exists`,
);
}
throw error;
}
......@@ -560,7 +615,9 @@ export class SitesService {
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
......@@ -587,20 +644,34 @@ export class SitesService {
} = findSitesDto;
const where: Prisma.SiteWhereInput = {
...(siteCode && { siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive } }),
...(siteName && { siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive } }),
...(address && { address: { contains: address, mode: Prisma.QueryMode.insensitive } }),
...(city && { city: { contains: city, mode: Prisma.QueryMode.insensitive } }),
...(state && { state: { contains: state, mode: Prisma.QueryMode.insensitive } }),
...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }),
...(type && { type: { contains: type, mode: Prisma.QueryMode.insensitive } }),
...(siteCode && {
siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive },
}),
...(siteName && {
siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive },
}),
...(address && {
address: { contains: address, mode: Prisma.QueryMode.insensitive },
}),
...(city && {
city: { contains: city, mode: Prisma.QueryMode.insensitive },
}),
...(state && {
state: { contains: state, mode: Prisma.QueryMode.insensitive },
}),
...(country && {
country: { contains: country, mode: Prisma.QueryMode.insensitive },
}),
...(type && {
type: { contains: type, mode: Prisma.QueryMode.insensitive },
}),
...(isDigi !== undefined && { isDigi }),
...(isReported !== undefined && { isReported }),
...(withCandidates === true && {
candidates: {
some: {},
}
})
},
}),
};
const sites = await this.prisma.site.findMany({
......@@ -616,17 +687,19 @@ export class SitesService {
include: {
candidate: {
select: {
currentStatus: true
}
}
}
currentStatus: true,
},
},
},
},
},
});
// Add highest priority status to all sites
const sitesWithHighestStatus = sites.map(site => {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
const sitesWithHighestStatus = sites.map((site) => {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
......
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsNotEmpty, IsString, MinLength } from 'class-validator';
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsString,
MinLength,
} from 'class-validator';
import { Role } from '@prisma/client';
export class CreateUserDto {
......
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, IsInt, Min, IsEnum } from 'class-validator';
import {
IsBoolean,
IsOptional,
IsString,
IsInt,
Min,
IsEnum,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export enum UserOrderBy {
......@@ -29,7 +36,8 @@ export class FindUsersDto {
active?: boolean;
@ApiProperty({
description: 'Get only active users with PARTNER role who don\'t have a partner assigned',
description:
"Get only active users with PARTNER role who don't have a partner assigned",
example: 'true',
required: false,
})
......
......@@ -9,7 +9,10 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
@IsString()
resetToken?: string | null;
@ApiProperty({ required: false, description: 'Password reset token expiry date' })
@ApiProperty({
required: false,
description: 'Password reset token expiry date',
})
@IsOptional()
@IsDate()
resetTokenExpiry?: Date | null;
......
......@@ -32,7 +32,7 @@ import { User } from '../auth/decorators/user.decorator';
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class UsersController {
constructor(private readonly usersService: UsersService) { }
constructor(private readonly usersService: UsersService) {}
@Post()
@Roles(Role.SUPERADMIN, Role.ADMIN)
......@@ -62,7 +62,8 @@ export class UsersController {
name: 'unassignedPartners',
required: false,
type: Boolean,
description: 'Get only active users with PARTNER role who don\'t have a partner assigned (true/false)',
description:
"Get only active users with PARTNER role who don't have a partner assigned (true/false)",
})
@ApiQuery({
name: 'page',
......@@ -149,7 +150,10 @@ export class UsersController {
description: 'The user has been successfully activated.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
@ApiResponse({ status: 403, description: 'Forbidden - Insufficient permissions.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
activateUser(
@Param('id', ParseIntPipe) id: number,
@User('role') role: Role,
......@@ -165,7 +169,10 @@ export class UsersController {
description: 'The user has been successfully deactivated.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
@ApiResponse({ status: 403, description: 'Forbidden - Insufficient permissions.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
deactivateUser(
@Param('id', ParseIntPipe) id: number,
@User('role') role: Role,
......
......@@ -9,4 +9,4 @@ import { PrismaModule } from '../../common/prisma/prisma.module';
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule { }
\ No newline at end of file
export class UsersModule {}
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
......@@ -8,7 +12,7 @@ import { Role } from '@prisma/client';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) { }
constructor(private prisma: PrismaService) {}
async create(createUserDto: CreateUserDto) {
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
......@@ -34,8 +38,8 @@ export class UsersService {
async findAll(query?: FindUsersDto) {
let where: any = {
NOT: {
role: Role.SUPERADMIN
}
role: Role.SUPERADMIN,
},
};
if (query?.active !== undefined) {
......@@ -48,7 +52,7 @@ export class UsersService {
where = {
isActive: true,
role: Role.PARTNER,
partnerId: null
partnerId: null,
};
}
......@@ -83,7 +87,6 @@ export class UsersService {
// Special handling for role field if needed
orderBy = { [field]: direction };
}
// Get total count for pagination metadata
......@@ -197,7 +200,9 @@ export class UsersService {
async activateUser(id: number, currentUserRole: Role) {
// Only SUPERADMIN and ADMIN can activate users
if (currentUserRole !== Role.SUPERADMIN && currentUserRole !== Role.ADMIN) {
throw new ForbiddenException('Only SUPERADMIN and ADMIN can activate users');
throw new ForbiddenException(
'Only SUPERADMIN and ADMIN can activate users',
);
}
try {
......@@ -222,7 +227,9 @@ export class UsersService {
async deactivateUser(id: number, currentUserRole: Role) {
// Only SUPERADMIN and ADMIN can deactivate users
if (currentUserRole !== Role.SUPERADMIN && currentUserRole !== Role.ADMIN) {
throw new ForbiddenException('Only SUPERADMIN and ADMIN can deactivate users');
throw new ForbiddenException(
'Only SUPERADMIN and ADMIN can deactivate users',
);
}
try {
......
......@@ -3,7 +3,7 @@ import { PrismaService } from '../../common/prisma/prisma.service';
@Injectable()
export class CodeGeneratorService {
constructor(private prisma: PrismaService) { }
constructor(private prisma: PrismaService) {}
/**
* Generates the next alphabetical code for a site
......@@ -19,7 +19,7 @@ export class CodeGeneratorService {
orderBy: {
candidate: {
candidateCode: 'desc',
}
},
},
});
......
......@@ -7,4 +7,4 @@ import { CodeGeneratorService } from './code-generator.service';
providers: [CodeGeneratorService],
exports: [CodeGeneratorService],
})
export class UtilsModule { }
\ No newline at end of file
export class UtilsModule {}
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