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"
...@@ -10,25 +10,27 @@ datasource db { ...@@ -10,25 +10,27 @@ datasource db {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique email String @unique
name String name String
password String password String
role Role @default(VIEWER) role Role @default(VIEWER)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
resetToken String? resetToken String?
resetTokenExpiry DateTime? resetTokenExpiry DateTime?
isActive Boolean @default(false) isActive Boolean @default(false)
candidatesCreated Candidate[] @relation("CandidateCreator") candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater") candidatesUpdated Candidate[] @relation("CandidateUpdater")
Comment Comment[] Comment Comment[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator") sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater") sitesUpdated Site[] @relation("SiteUpdater")
associatedSites UserSite[] // New relation for PARTNER role inspectionsCreated Inspection[] @relation("InspectionCreator")
partner Partner? @relation(fields: [partnerId], references: [id]) inspectionsUpdated Inspection[] @relation("InspectionUpdater")
partnerId Int? associatedSites UserSite[] // New relation for PARTNER role
partner Partner? @relation(fields: [partnerId], references: [id])
partnerId Int?
@@index([email]) @@index([email])
@@index([role]) @@index([role])
...@@ -65,6 +67,7 @@ model Site { ...@@ -65,6 +67,7 @@ model Site {
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id]) createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id]) updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[] // New relation for PARTNER role partners UserSite[] // New relation for PARTNER role
inspections Inspection[]
@@index([siteCode]) @@index([siteCode])
} }
...@@ -189,3 +192,67 @@ model Partner { ...@@ -189,3 +192,67 @@ model Partner {
@@index([name]) @@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'; ...@@ -15,6 +15,7 @@ import { join } from 'path';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import { DashboardModule } from './modules/dashboard/dashboard.module'; import { DashboardModule } from './modules/dashboard/dashboard.module';
import { PartnersModule } from './modules/partners/partners.module'; import { PartnersModule } from './modules/partners/partners.module';
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
@Module({ @Module({
imports: [ imports: [
...@@ -54,6 +55,7 @@ import { PartnersModule } from './modules/partners/partners.module'; ...@@ -54,6 +55,7 @@ import { PartnersModule } from './modules/partners/partners.module';
CommentsModule, CommentsModule,
DashboardModule, DashboardModule,
PartnersModule, PartnersModule,
MaintenanceModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
...@@ -64,4 +66,4 @@ import { PartnersModule } from './modules/partners/partners.module'; ...@@ -64,4 +66,4 @@ import { PartnersModule } from './modules/partners/partners.module';
}, },
], ],
}) })
export class AppModule { } export class AppModule {}
...@@ -3,8 +3,8 @@ import { EmailService } from './email.service'; ...@@ -3,8 +3,8 @@ import { EmailService } from './email.service';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
providers: [EmailService], providers: [EmailService],
exports: [EmailService], exports: [EmailService],
}) })
export class EmailModule { } export class EmailModule {}
\ No newline at end of file
...@@ -4,26 +4,30 @@ import * as nodemailer from 'nodemailer'; ...@@ -4,26 +4,30 @@ import * as nodemailer from 'nodemailer';
@Injectable() @Injectable()
export class EmailService { export class EmailService {
private transporter: nodemailer.Transporter; private transporter: nodemailer.Transporter;
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
host: this.configService.get<string>('SMTP_HOST'), host: this.configService.get<string>('SMTP_HOST'),
port: this.configService.get<number>('SMTP_PORT'), port: this.configService.get<number>('SMTP_PORT'),
secure: this.configService.get<boolean>('SMTP_SECURE', false), secure: this.configService.get<boolean>('SMTP_SECURE', false),
auth: { auth: {
user: this.configService.get<string>('SMTP_USER'), user: this.configService.get<string>('SMTP_USER'),
pass: this.configService.get<string>('SMTP_PASS'), pass: this.configService.get<string>('SMTP_PASS'),
}, },
}); });
} }
async sendPasswordResetEmail(email: string, resetToken: string, resetUrl: string): Promise<void> { async sendPasswordResetEmail(
const mailOptions = { email: string,
from: this.configService.get<string>('SMTP_FROM', 'noreply@cellnex.com'), resetToken: string,
to: email, resetUrl: string,
subject: 'Password Reset Request', ): Promise<void> {
html: ` const mailOptions = {
from: this.configService.get<string>('SMTP_FROM', 'noreply@cellnex.com'),
to: email,
subject: 'Password Reset Request',
html: `
<h1>Password Reset Request</h1> <h1>Password Reset Request</h1>
<p>You have requested to reset your password. Click the link below to reset it:</p> <p>You have requested to reset your password. Click the link below to reset it:</p>
<p><a href="${resetUrl}?token=${resetToken}">Reset Password</a></p> <p><a href="${resetUrl}?token=${resetToken}">Reset Password</a></p>
...@@ -33,13 +37,13 @@ export class EmailService { ...@@ -33,13 +37,13 @@ export class EmailService {
<p>${resetUrl}?token=${resetToken}</p> <p>${resetUrl}?token=${resetToken}</p>
<p>Best regards,<br>Cellnex Team</p> <p>Best regards,<br>Cellnex Team</p>
`, `,
}; };
try { try {
await this.transporter.sendMail(mailOptions); await this.transporter.sendMail(mailOptions);
} catch (error) { } catch (error) {
console.error('Failed to send email:', error); console.error('Failed to send email:', error);
throw new Error('Failed to send password reset email'); throw new Error('Failed to send password reset email');
}
} }
} }
\ No newline at end of file }
...@@ -2,24 +2,24 @@ import { diskStorage, memoryStorage } from 'multer'; ...@@ -2,24 +2,24 @@ import { diskStorage, memoryStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
export const multerConfig = { export const multerConfig = {
storage: memoryStorage(), storage: memoryStorage(),
limits: { limits: {
fileSize: 5 * 1024 * 1024, // 5MB fileSize: 5 * 1024 * 1024, // 5MB
}, },
fileFilter: (req, file, callback) => { fileFilter: (req, file, callback) => {
try { try {
console.log('Checking file type:', file.mimetype); console.log('Checking file type:', file.mimetype);
// Accept only images // Accept only images
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
console.error('Invalid file type:', file.mimetype); console.error('Invalid file type:', file.mimetype);
return callback(new Error('Only image files are allowed!'), false); return callback(new Error('Only image files are allowed!'), false);
} }
callback(null, true); callback(null, true);
} catch (error) { } catch (error) {
console.error('Error in file filter:', error); console.error('Error in file filter:', error);
callback(error, false); callback(error, false);
} }
}, },
preservePath: true preservePath: true,
}; };
\ No newline at end of file
...@@ -3,7 +3,7 @@ import { PrismaService } from './prisma.service'; ...@@ -3,7 +3,7 @@ import { PrismaService } from './prisma.service';
@Global() @Global()
@Module({ @Module({
providers: [PrismaService], providers: [PrismaService],
exports: [PrismaService], exports: [PrismaService],
}) })
export class PrismaModule { } export class PrismaModule {}
\ No newline at end of file
...@@ -2,12 +2,15 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; ...@@ -2,12 +2,15 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService
async onModuleInit() { extends PrismaClient
await this.$connect(); implements OnModuleInit, OnModuleDestroy
} {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() { async onModuleDestroy() {
await this.$disconnect(); await this.$disconnect();
} }
} }
\ No newline at end of file
...@@ -25,6 +25,10 @@ async function bootstrap() { ...@@ -25,6 +25,10 @@ async function bootstrap() {
// Serve static files // Serve static files
app.use('/uploads', express.static('/home/api-cellnex/public_html/uploads')); 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 // Swagger configuration
const config = new DocumentBuilder() const config = new DocumentBuilder()
...@@ -35,6 +39,7 @@ async function bootstrap() { ...@@ -35,6 +39,7 @@ async function bootstrap() {
.addTag('users', 'User management endpoints') .addTag('users', 'User management endpoints')
.addTag('sites', 'Site management endpoints') .addTag('sites', 'Site management endpoints')
.addTag('candidates', 'Candidate management endpoints') .addTag('candidates', 'Candidate management endpoints')
.addTag('maintenance', 'Site maintenance management endpoints')
.addBearerAuth( .addBearerAuth(
{ {
type: 'http', type: 'http',
...@@ -49,11 +54,21 @@ async function bootstrap() { ...@@ -49,11 +54,21 @@ async function bootstrap() {
.build(); .build();
const document = SwaggerModule.createDocument(app, config); 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; const port = process.env.PORT ?? 3001;
await app.listen(port); await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`); console.log(`Application is running on: http://localhost:${port}`);
console.log(`Swagger documentation is available at: http://localhost:${port}/docs`); console.log(
`Swagger documentation is available at: http://localhost:${port}/docs`,
);
} }
bootstrap(); 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 { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto';
...@@ -11,69 +20,71 @@ import { Public } from './decorators/public.decorator'; ...@@ -11,69 +20,71 @@ import { Public } from './decorators/public.decorator';
@Controller('auth') @Controller('auth')
@Public() @Public()
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) { } constructor(private readonly authService: AuthService) {}
@Post('login') @Post('login')
@ApiOperation({ summary: 'Login with email and password' }) @ApiOperation({ summary: 'Login with email and password' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Returns JWT token, refresh token and user information', description: 'Returns JWT token, refresh token and user information',
}) })
@ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 401, description: 'Unauthorized' })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto) { async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto); return this.authService.login(loginDto);
} }
@Post('refresh')
@ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiResponse({
status: 200,
description: 'Returns new access token and refresh token',
})
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
@HttpCode(HttpStatus.OK)
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto);
}
@Post('password/reset-request') @Post('refresh')
@ApiOperation({ summary: 'Request password reset email' }) @ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Password reset email sent', description: 'Returns new access token and refresh token',
}) })
@ApiResponse({ status: 400, description: 'User not found' }) @ApiResponse({ status: 401, description: 'Invalid refresh token' })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto) { async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.requestPasswordReset(requestPasswordResetDto); return this.authService.refreshToken(refreshTokenDto);
} }
@Post('password/reset') @Post('password/reset-request')
@ApiOperation({ summary: 'Reset password using reset token' }) @ApiOperation({ summary: 'Request password reset email' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Password successfully reset', description: 'Password reset email sent',
}) })
@ApiResponse({ status: 400, description: 'Invalid or expired reset token' }) @ApiResponse({ status: 400, description: 'User not found' })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { async requestPasswordReset(
return this.authService.resetPassword(resetPasswordDto); @Body() requestPasswordResetDto: RequestPasswordResetDto,
} ) {
return this.authService.requestPasswordReset(requestPasswordResetDto);
}
@Get('validate') @Post('password/reset')
@ApiOperation({ summary: 'Validate JWT token' }) @ApiOperation({ summary: 'Reset password using reset token' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Returns user information from token', description: 'Password successfully reset',
}) })
@ApiResponse({ status: 401, description: 'Invalid token' }) @ApiResponse({ status: 400, description: 'Invalid or expired reset token' })
async validateToken(@Headers('authorization') auth: string) { @HttpCode(HttpStatus.OK)
if (!auth || !auth.startsWith('Bearer ')) { async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
throw new UnauthorizedException('No token provided'); return this.authService.resetPassword(resetPasswordDto);
} }
const token = auth.split(' ')[1]; @Get('validate')
return this.authService.validateToken(token); @ApiOperation({ summary: 'Validate JWT token' })
@ApiResponse({
status: 200,
description: 'Returns user information from token',
})
@ApiResponse({ status: 401, description: 'Invalid token' })
async validateToken(@Headers('authorization') auth: string) {
if (!auth || !auth.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
} }
}
\ No newline at end of file const token = auth.split(' ')[1];
return this.authService.validateToken(token);
}
}
...@@ -12,24 +12,24 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard'; ...@@ -12,24 +12,24 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard'; import { RolesGuard } from './guards/roles.guard';
@Module({ @Module({
imports: [ imports: [
UsersModule, UsersModule,
EmailModule, EmailModule,
PassportModule, PassportModule,
MailerModule, MailerModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({ useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') ?? 'your-secret-key', secret: configService.get<string>('JWT_SECRET') ?? 'your-secret-key',
signOptions: { signOptions: {
expiresIn: '24h', expiresIn: '24h',
}, },
}), }),
inject: [ConfigService], inject: [ConfigService],
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard], providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard], exports: [AuthService, JwtAuthGuard, RolesGuard],
}) })
export class AuthModule { } export class AuthModule {}
\ No newline at end of file
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Partner = createParamDecorator( export const Partner = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => { (data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();
// Return the partnerId from the user object on the request // Return the partnerId from the user object on the request
return request.user?.partnerId; return request.user?.partnerId;
}, },
); );
\ No newline at end of file
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic'; export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
\ No newline at end of file
...@@ -2,4 +2,4 @@ import { SetMetadata } from '@nestjs/common'; ...@@ -2,4 +2,4 @@ import { SetMetadata } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
export const ROLES_KEY = 'roles'; export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
\ No newline at end of file
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator( export const User = createParamDecorator(
(data: string, ctx: ExecutionContext) => { (data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();
const user = request.user; const user = request.user;
return data ? user?.[data] : user; return data ? user?.[data] : user;
}, },
); );
\ No newline at end of file
...@@ -2,19 +2,19 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,19 +2,19 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto { export class LoginDto {
@ApiProperty({ @ApiProperty({
description: 'The email of the user', description: 'The email of the user',
example: 'augusto.fonte@brandit.pt', example: 'augusto.fonte@brandit.pt',
}) })
@IsEmail() @IsEmail()
@IsNotEmpty() @IsNotEmpty()
email: string; email: string;
@ApiProperty({ @ApiProperty({
description: 'The password of the user', description: 'The password of the user',
example: 'passsword', example: 'passsword',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
password: string; password: string;
} }
\ No newline at end of file
...@@ -2,11 +2,11 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,11 +2,11 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
export class RefreshTokenDto { export class RefreshTokenDto {
@ApiProperty({ @ApiProperty({
description: 'The refresh token', description: 'The refresh token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
refreshToken: string; refreshToken: string;
} }
\ No newline at end of file
...@@ -2,11 +2,11 @@ import { IsEmail, IsNotEmpty } from 'class-validator'; ...@@ -2,11 +2,11 @@ import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class RequestPasswordResetDto { export class RequestPasswordResetDto {
@ApiProperty({ @ApiProperty({
description: 'Email address of the user requesting password reset', description: 'Email address of the user requesting password reset',
example: 'user@example.com', example: 'user@example.com',
}) })
@IsEmail() @IsEmail()
@IsNotEmpty() @IsNotEmpty()
email: string; email: string;
} }
\ No newline at end of file
...@@ -2,21 +2,21 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,21 +2,21 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator'; import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ResetPasswordDto { export class ResetPasswordDto {
@ApiProperty({ @ApiProperty({
description: 'Reset token received via email', description: 'Reset token received via email',
example: 'abc123def456', example: 'abc123def456',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
token: string; token: string;
@ApiProperty({ @ApiProperty({
description: 'New password', description: 'New password',
example: 'newPassword123', example: 'newPassword123',
minLength: 8, minLength: 8,
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
newPassword: string; newPassword: string;
} }
\ No newline at end of file
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) { constructor(private reflector: Reflector) {
super(); super();
} }
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) { canActivate(context: ExecutionContext) {
return true; const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
} context.getHandler(),
context.getClass(),
]);
return super.canActivate(context); if (isPublic) {
return true;
} }
handleRequest(err: any, user: any) { return super.canActivate(context);
if (err || !user) { }
throw err || new UnauthorizedException('Authentication required');
} handleRequest(err: any, user: any) {
return user; if (err || !user) {
throw err || new UnauthorizedException('Authentication required');
} }
} return user;
\ No newline at end of file }
}
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
@Injectable() @Injectable()
export class PartnerAuthGuard implements CanActivate { export class PartnerAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const user = request.user; 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 it's a PARTNER user, make sure they can only access their own partner data
if (user.role === Role.PARTNER) { if (user.role === Role.PARTNER) {
// Check if the user has a partnerId and if it matches the requested partnerId // Check if the user has a partnerId and if it matches the requested partnerId
if (!user.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 a specific partnerId is requested in the URL, check if it matches the user's partnerId
if (partnerId && partnerId !== user.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',
} );
}
// Non-PARTNER roles have general access
return true;
} }
}
\ No newline at end of file // Non-PARTNER roles have general access
return true;
}
}
...@@ -5,19 +5,19 @@ import { ROLES_KEY } from '../decorators/roles.decorator'; ...@@ -5,19 +5,19 @@ import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable() @Injectable()
export class RolesGuard implements CanActivate { export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) { } constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [ const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
if (!requiredRoles) { if (!requiredRoles) {
return true; return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
} }
}
\ No newline at end of file const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}
...@@ -5,20 +5,21 @@ import { ConfigService } from '@nestjs/config'; ...@@ -5,20 +5,21 @@ import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET') ?? 'your-secret-key', secretOrKey: configService.get<string>('JWT_SECRET') ?? 'your-secret-key',
}); });
} }
async validate(payload: any) { async validate(payload: any) {
return { return {
id: payload.sub, id: payload.sub,
email: payload.email, email: payload.email,
role: payload.role, role: payload.role,
partnerId: payload.partnerId || null, partnerId: payload.partnerId || null,
}; client: 'verticalflow'
} };
} }
\ No newline at end of file }
...@@ -5,9 +5,9 @@ import { PrismaModule } from '../../common/prisma/prisma.module'; ...@@ -5,9 +5,9 @@ import { PrismaModule } from '../../common/prisma/prisma.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [PrismaModule, AuthModule], imports: [PrismaModule, AuthModule],
controllers: [CandidatesController], controllers: [CandidatesController],
providers: [CandidatesService], providers: [CandidatesService],
exports: [CandidatesService], exports: [CandidatesService],
}) })
export class CandidatesModule { } export class CandidatesModule {}
\ No newline at end of file
import { IsArray, IsNumber } from 'class-validator'; import { IsArray, IsNumber } from 'class-validator';
export class AddSitesToCandidateDto { export class AddSitesToCandidateDto {
@IsArray() @IsArray()
@IsNumber({}, { each: true }) @IsNumber({}, { each: true })
siteIds: number[]; siteIds: number[];
} }
\ No newline at end of file
...@@ -4,53 +4,62 @@ import { CommentResponseDto } from '../../comments/dto/comment-response.dto'; ...@@ -4,53 +4,62 @@ import { CommentResponseDto } from '../../comments/dto/comment-response.dto';
import { SiteResponseDto } from '../../sites/dto/site-response.dto'; import { SiteResponseDto } from '../../sites/dto/site-response.dto';
export class CandidateSiteDto { export class CandidateSiteDto {
@ApiProperty({ description: 'CandidateSite ID' }) @ApiProperty({ description: 'CandidateSite ID' })
id: number; id: number;
@ApiProperty({ description: 'Site associated with this candidate' }) @ApiProperty({ description: 'Site associated with this candidate' })
site: SiteResponseDto; site: SiteResponseDto;
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
createdAt: Date; createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' }) @ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date; updatedAt: Date;
} }
export class CandidateResponseDto { export class CandidateResponseDto {
@ApiProperty({ description: 'Candidate ID' }) @ApiProperty({ description: 'Candidate ID' })
id: number; id: number;
@ApiProperty({ description: 'Candidate code' }) @ApiProperty({ description: 'Candidate code' })
candidateCode: string; candidateCode: string;
@ApiProperty({ description: 'Latitude coordinate' }) @ApiProperty({ description: 'Latitude coordinate' })
latitude: number; latitude: number;
@ApiProperty({ description: 'Longitude coordinate' }) @ApiProperty({ description: 'Longitude coordinate' })
longitude: number; longitude: number;
@ApiProperty({ enum: CandidateType, description: 'Type of candidate' }) @ApiProperty({ enum: CandidateType, description: 'Type of candidate' })
type: CandidateType; type: CandidateType;
@ApiProperty({ description: 'Address of the candidate' }) @ApiProperty({ description: 'Address of the candidate' })
address: string; address: string;
@ApiProperty({ enum: CandidateStatus, description: 'Current status of the candidate' }) @ApiProperty({
currentStatus: CandidateStatus; enum: CandidateStatus,
description: 'Current status of the candidate',
})
currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' }) @ApiProperty({ description: 'Whether the candidate is ongoing' })
onGoing: boolean; onGoing: boolean;
@ApiProperty({ description: 'Sites associated with this candidate', type: [CandidateSiteDto] }) @ApiProperty({
sites: CandidateSiteDto[]; description: 'Sites associated with this candidate',
type: [CandidateSiteDto],
})
sites: CandidateSiteDto[];
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
createdAt: Date; createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' }) @ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date; updatedAt: Date;
@ApiProperty({ description: 'Comments associated with this candidate', type: [CommentResponseDto] }) @ApiProperty({
comments: CommentResponseDto[]; description: 'Comments associated with this candidate',
} type: [CommentResponseDto],
\ No newline at end of file })
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'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CandidateType { export enum CandidateType {
Greenfield = 'Greenfield', Greenfield = 'Greenfield',
Indoor = 'Indoor', Indoor = 'Indoor',
Micro = 'Micro', Micro = 'Micro',
Rooftop = 'Rooftop', Rooftop = 'Rooftop',
Tunel = 'Tunel', Tunel = 'Tunel',
} }
export enum CandidateStatus { export enum CandidateStatus {
PENDING = 'PENDING', PENDING = 'PENDING',
APPROVED = 'APPROVED', APPROVED = 'APPROVED',
REJECTED = 'REJECTED', REJECTED = 'REJECTED',
NEGOTIATION_ONGOING = 'NEGOTIATION_ONGOING', NEGOTIATION_ONGOING = 'NEGOTIATION_ONGOING',
MNO_VALIDATION = 'MNO_VALIDATION', MNO_VALIDATION = 'MNO_VALIDATION',
CLOSING = 'CLOSING', CLOSING = 'CLOSING',
SEARCH_AREA = 'SEARCH_AREA', SEARCH_AREA = 'SEARCH_AREA',
PAM = 'PAM' PAM = 'PAM',
} }
export class CreateCandidateDto { export class CreateCandidateDto {
@ApiProperty({ description: 'Candidate code' }) @ApiProperty({ description: 'Candidate code' })
@IsString() @IsString()
@IsOptional() @IsOptional()
candidateCode?: string; candidateCode?: string;
@ApiProperty({ description: 'Latitude coordinate' }) @ApiProperty({ description: 'Latitude coordinate' })
@IsNumber() @IsNumber()
latitude: number; latitude: number;
@ApiProperty({ description: 'Longitude coordinate' }) @ApiProperty({ description: 'Longitude coordinate' })
@IsNumber() @IsNumber()
longitude: number; longitude: number;
@ApiProperty({ enum: CandidateType, description: 'Type of candidate' }) @ApiProperty({ enum: CandidateType, description: 'Type of candidate' })
@IsEnum(CandidateType) @IsEnum(CandidateType)
type: CandidateType; type: CandidateType;
@ApiProperty({ description: 'Address of the candidate' }) @ApiProperty({ description: 'Address of the candidate' })
@IsString() @IsString()
address: string; address: string;
@ApiProperty({ description: 'Current status of the candidate' }) @ApiProperty({ description: 'Current status of the candidate' })
@IsEnum(CandidateStatus) @IsEnum(CandidateStatus)
currentStatus: CandidateStatus; currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' }) @ApiProperty({ description: 'Whether the candidate is ongoing' })
@IsBoolean() @IsBoolean()
onGoing: boolean; onGoing: boolean;
@ApiProperty({ description: 'IDs of the sites this candidate belongs to', type: [Number] }) @ApiProperty({
@IsNumber({}, { each: true }) description: 'IDs of the sites this candidate belongs to',
siteIds: number[]; type: [Number],
})
@ApiPropertyOptional({ description: 'Initial comment for the candidate' }) @IsNumber({}, { each: true })
@IsString() siteIds: number[];
@IsOptional()
comment?: string; @ApiPropertyOptional({ description: 'Initial comment for the candidate' })
} @IsString()
\ No newline at end of file @IsOptional()
comment?: string;
}
import { ApiProperty } from '@nestjs/swagger'; 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 { Transform } from 'class-transformer';
import { CandidateType, CandidateStatus } from './create-candidate.dto'; import { CandidateType, CandidateStatus } from './create-candidate.dto';
export class QueryCandidateDto { export class QueryCandidateDto {
@ApiProperty({ description: 'Filter by candidate code', required: false }) @ApiProperty({ description: 'Filter by candidate code', required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
candidateCode?: string; candidateCode?: string;
@ApiProperty({ description: 'Filter by type', required: false, enum: CandidateType }) @ApiProperty({
@IsOptional() description: 'Filter by type',
@IsEnum(CandidateType) required: false,
type?: CandidateType; enum: CandidateType,
})
@IsOptional()
@IsEnum(CandidateType)
type?: CandidateType;
@ApiProperty({ description: 'Filter by current status', required: false, enum: CandidateStatus }) @ApiProperty({
@IsOptional() description: 'Filter by current status',
@IsEnum(CandidateStatus) required: false,
currentStatus?: CandidateStatus; enum: CandidateStatus,
})
@IsOptional()
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
@ApiProperty({ description: 'Filter by ongoing status', required: false }) @ApiProperty({ description: 'Filter by ongoing status', required: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@Transform(({ value }) => value === 'true') @Transform(({ value }) => value === 'true')
onGoing?: boolean; onGoing?: boolean;
@ApiProperty({ description: 'Filter by site ID', required: false }) @ApiProperty({ description: 'Filter by site ID', required: false })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Transform(({ value }) => parseInt(value)) @Transform(({ value }) => parseInt(value))
siteId?: number; siteId?: number;
@ApiProperty({ description: 'Page number for pagination', required: false, default: 1 }) @ApiProperty({
@IsOptional() description: 'Page number for pagination',
@Transform(({ value }) => parseInt(value)) required: false,
page?: number = 1; default: 1,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
page?: number = 1;
@ApiProperty({ description: 'Number of items per page', required: false, default: 10 }) @ApiProperty({
@IsOptional() description: 'Number of items per page',
@Transform(({ value }) => parseInt(value)) required: false,
limit?: number = 10; default: 10,
} })
\ No newline at end of file @IsOptional()
@Transform(({ value }) => parseInt(value))
limit?: number = 10;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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'; import { CandidateType, CandidateStatus } from './create-candidate.dto';
export class UpdateCandidateDto { export class UpdateCandidateDto {
@ApiPropertyOptional({ description: 'Candidate code' }) @ApiPropertyOptional({ description: 'Candidate code' })
@IsOptional() @IsOptional()
@IsString() @IsString()
candidateCode?: string; candidateCode?: string;
@ApiPropertyOptional({ description: 'Latitude coordinate' }) @ApiPropertyOptional({ description: 'Latitude coordinate' })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
latitude?: number; latitude?: number;
@ApiPropertyOptional({ description: 'Longitude coordinate' }) @ApiPropertyOptional({ description: 'Longitude coordinate' })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
longitude?: number; longitude?: number;
@ApiPropertyOptional({ enum: CandidateType, description: 'Type of candidate' }) @ApiPropertyOptional({
@IsOptional() enum: CandidateType,
@IsEnum(CandidateType) description: 'Type of candidate',
type?: CandidateType; })
@IsOptional()
@ApiPropertyOptional({ description: 'Address of the candidate' }) @IsEnum(CandidateType)
@IsOptional() type?: CandidateType;
@IsString()
address?: string; @ApiPropertyOptional({ description: 'Address of the candidate' })
@IsOptional()
@ApiPropertyOptional({ enum: CandidateStatus, description: 'Current status of the candidate' }) @IsString()
@IsOptional() address?: string;
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus; @ApiPropertyOptional({
enum: CandidateStatus,
@ApiPropertyOptional({ description: 'Whether the candidate is ongoing' }) description: 'Current status of the candidate',
@IsOptional() })
@IsBoolean() @IsOptional()
onGoing?: boolean; @IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
@ApiPropertyOptional({ description: 'IDs of the sites this candidate belongs to', type: [Number] })
@IsOptional() @ApiPropertyOptional({ description: 'Whether the candidate is ongoing' })
@IsNumber({}, { each: true }) @IsOptional()
siteIds?: number[]; @IsBoolean()
} onGoing?: boolean;
\ No newline at end of file
@ApiPropertyOptional({
description: 'IDs of the sites this candidate belongs to',
type: [Number],
})
@IsOptional()
@IsNumber({}, { each: true })
siteIds?: number[];
}
...@@ -2,27 +2,27 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,27 +2,27 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional } from 'class-validator'; import { IsString, IsNumber, IsOptional } from 'class-validator';
export class UploadPhotoDto { export class UploadPhotoDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Optional: The filename to use' description: 'Optional: The filename to use',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
filename?: string; filename?: string;
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Optional: The MIME type of the file' description: 'Optional: The MIME type of the file',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
mimeType?: string; mimeType?: string;
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Optional: The size of the file in bytes' description: 'Optional: The size of the file in bytes',
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
size?: number; size?: number;
} }
\ No newline at end of file
import { Controller, Get, Post, Body, Param, Delete, UseGuards, ParseIntPipe, Req, Put } from '@nestjs/common'; import {
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; 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 { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto';
...@@ -15,55 +32,74 @@ import { Request } from 'express'; ...@@ -15,55 +32,74 @@ import { Request } from 'express';
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
export class CommentsController { export class CommentsController {
constructor(private readonly commentsService: CommentsService) { } constructor(private readonly commentsService: CommentsService) {}
@Post() @Post()
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER) @Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Create a new comment' }) @ApiOperation({ summary: 'Create a new comment' })
@ApiBody({ type: CreateCommentDto }) @ApiBody({ type: CreateCommentDto })
@ApiResponse({ status: 201, description: 'The comment has been successfully created.', type: CommentResponseDto }) @ApiResponse({
@ApiResponse({ status: 400, description: 'Bad Request.' }) status: 201,
create(@Body() createCommentDto: CreateCommentDto, @Req() req: Request) { description: 'The comment has been successfully created.',
const user = req.user as any; type: CommentResponseDto,
createCommentDto.createdById = user.id; })
return this.commentsService.create(createCommentDto); @ApiResponse({ status: 400, description: 'Bad Request.' })
} create(@Body() createCommentDto: CreateCommentDto, @Req() req: Request) {
const user = req.user as any;
createCommentDto.createdById = user.id;
return this.commentsService.create(createCommentDto);
}
@Get('candidate/:candidateId') @Get('candidate/:candidateId')
@ApiOperation({ summary: 'Get all comments for a candidate' }) @ApiOperation({ summary: 'Get all comments for a candidate' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return all comments for the candidate.', description: 'Return all comments for the candidate.',
type: [CommentResponseDto] type: [CommentResponseDto],
}) })
findAll(@Param('candidateId', ParseIntPipe) candidateId: number) { findAll(@Param('candidateId', ParseIntPipe) candidateId: number) {
return this.commentsService.findAll(candidateId); return this.commentsService.findAll(candidateId);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get a comment by id' }) @ApiOperation({ summary: 'Get a comment by id' })
@ApiResponse({ status: 200, description: 'Return the comment.', type: CommentResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Comment not found.' }) status: 200,
findOne(@Param('id', ParseIntPipe) id: number) { description: 'Return the comment.',
return this.commentsService.findOne(id); type: CommentResponseDto,
} })
@ApiResponse({ status: 404, description: 'Comment not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.commentsService.findOne(id);
}
@Delete(':id') @Delete(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER) @Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Delete a comment' }) @ApiOperation({ summary: 'Delete a comment' })
@ApiResponse({ status: 200, description: 'The comment has been successfully deleted.', type: CommentResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Comment not found.' }) status: 200,
remove(@Param('id', ParseIntPipe) id: number) { description: 'The comment has been successfully deleted.',
return this.commentsService.remove(id); type: CommentResponseDto,
} })
@ApiResponse({ status: 404, description: 'Comment not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.commentsService.remove(id);
}
@Put(':id') @Put(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER) @Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Update a comment' }) @ApiOperation({ summary: 'Update a comment' })
@ApiBody({ type: UpdateCommentDto }) @ApiBody({ type: UpdateCommentDto })
@ApiResponse({ status: 200, description: 'The comment has been successfully updated.', type: CommentResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Comment not found.' }) status: 200,
update(@Param('id', ParseIntPipe) id: number, @Body() updateCommentDto: UpdateCommentDto) { description: 'The comment has been successfully updated.',
return this.commentsService.update(id, updateCommentDto); type: CommentResponseDto,
} })
} @ApiResponse({ status: 404, description: 'Comment not found.' })
\ No newline at end of file update(
@Param('id', ParseIntPipe) id: number,
@Body() updateCommentDto: UpdateCommentDto,
) {
return this.commentsService.update(id, updateCommentDto);
}
}
...@@ -4,8 +4,8 @@ import { CommentsController } from './comments.controller'; ...@@ -4,8 +4,8 @@ import { CommentsController } from './comments.controller';
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaService } from '../../common/prisma/prisma.service';
@Module({ @Module({
controllers: [CommentsController], controllers: [CommentsController],
providers: [CommentsService, PrismaService], providers: [CommentsService, PrismaService],
exports: [CommentsService], exports: [CommentsService],
}) })
export class CommentsModule { } export class CommentsModule {}
\ No newline at end of file
...@@ -5,88 +5,88 @@ import { UpdateCommentDto } from './dto/update-comment.dto'; ...@@ -5,88 +5,88 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
async create(createCommentDto: CreateCommentDto) { async create(createCommentDto: CreateCommentDto) {
return this.prisma.comment.create({ return this.prisma.comment.create({
data: { data: {
content: createCommentDto.content, content: createCommentDto.content,
candidateId: createCommentDto.candidateId, candidateId: createCommentDto.candidateId,
createdById: createCommentDto.createdById, createdById: createCommentDto.createdById,
}, },
include: { include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
}, },
}); });
} }
async findAll(candidateId: number) { async findAll(candidateId: number) {
return this.prisma.comment.findMany({ return this.prisma.comment.findMany({
where: { where: {
candidateId, candidateId,
}, },
include: { include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
}); });
} }
async findOne(id: number) { async findOne(id: number) {
return this.prisma.comment.findUnique({ return this.prisma.comment.findUnique({
where: { id }, where: { id },
include: { include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
}, },
}); });
} }
async remove(id: number) { async remove(id: number) {
return this.prisma.comment.delete({ return this.prisma.comment.delete({
where: { id }, where: { id },
}); });
} }
async update(id: number, updateCommentDto: UpdateCommentDto) { async update(id: number, updateCommentDto: UpdateCommentDto) {
try { try {
return await this.prisma.comment.update({ return await this.prisma.comment.update({
where: { id }, where: { id },
data: { data: {
content: updateCommentDto.content, content: updateCommentDto.content,
updatedAt: new Date(), updatedAt: new Date(),
}, },
include: { include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
}, },
}); });
} catch (error) { } catch (error) {
throw new NotFoundException(`Comment with ID ${id} not found`); throw new NotFoundException(`Comment with ID ${id} not found`);
}
} }
} }
\ No newline at end of file }
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
class UserResponseDto { class UserResponseDto {
@ApiProperty({ description: 'User ID' }) @ApiProperty({ description: 'User ID' })
id: number; id: number;
@ApiProperty({ description: 'User name' }) @ApiProperty({ description: 'User name' })
name: string; name: string;
@ApiProperty({ description: 'User email' }) @ApiProperty({ description: 'User email' })
email: string; email: string;
} }
export class CommentResponseDto { export class CommentResponseDto {
@ApiProperty({ description: 'Comment ID' }) @ApiProperty({ description: 'Comment ID' })
id: number; id: number;
@ApiProperty({ description: 'Comment content' }) @ApiProperty({ description: 'Comment content' })
content: string; content: string;
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
createdAt: Date; createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' }) @ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date; updatedAt: Date;
@ApiProperty({ description: 'ID of the candidate this comment belongs to' }) @ApiProperty({ description: 'ID of the candidate this comment belongs to' })
candidateId: number; candidateId: number;
@ApiProperty({ description: 'User who created the comment', type: UserResponseDto }) @ApiProperty({
createdBy: UserResponseDto; description: 'User who created the comment',
} type: UserResponseDto,
\ No newline at end of file })
createdBy: UserResponseDto;
}
...@@ -2,28 +2,29 @@ import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator'; ...@@ -2,28 +2,29 @@ import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class CreateCommentDto { export class CreateCommentDto {
@ApiProperty({ @ApiProperty({
description: 'The content of the comment', description: 'The content of the comment',
example: 'This is a comment about the candidate' example: 'This is a comment about the candidate',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
content: string; content: string;
@ApiProperty({ @ApiProperty({
description: 'The ID of the candidate this comment is for', description: 'The ID of the candidate this comment is for',
example: 64 example: 64,
}) })
@IsInt() @IsInt()
@IsNotEmpty() @IsNotEmpty()
candidateId: number; candidateId: number;
@ApiProperty({ @ApiProperty({
description: 'The ID of the user creating the comment (optional, will be set automatically)', description:
example: 1, 'The ID of the user creating the comment (optional, will be set automatically)',
required: false example: 1,
}) required: false,
@IsInt() })
@IsOptional() @IsInt()
createdById?: number; @IsOptional()
} createdById?: number;
\ No newline at end of file }
...@@ -2,11 +2,11 @@ import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; ...@@ -2,11 +2,11 @@ import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class UpdateCommentDto { export class UpdateCommentDto {
@ApiProperty({ @ApiProperty({
description: 'The updated content of the comment', 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() @IsString()
@IsNotEmpty() @IsNotEmpty()
content: string; content: string;
} }
\ No newline at end of file
import { Controller, Get, UseGuards } from '@nestjs/common'; import { Controller, Get, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { DashboardStatsDto } from './dto/dashboard.dto'; 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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
...@@ -12,17 +17,17 @@ import { Role } from '@prisma/client'; ...@@ -12,17 +17,17 @@ import { Role } from '@prisma/client';
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
export class DashboardController { export class DashboardController {
constructor(private readonly dashboardService: DashboardService) { } constructor(private readonly dashboardService: DashboardService) {}
@Get() @Get()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Get dashboard statistics and data' }) @ApiOperation({ summary: 'Get dashboard statistics and data' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return dashboard statistics and data', description: 'Return dashboard statistics and data',
type: DashboardStatsDto, type: DashboardStatsDto,
}) })
getDashboard() { getDashboard() {
return this.dashboardService.getDashboardStats(); return this.dashboardService.getDashboardStats();
} }
} }
\ No newline at end of file
...@@ -4,9 +4,9 @@ import { DashboardService } from './dashboard.service'; ...@@ -4,9 +4,9 @@ import { DashboardService } from './dashboard.service';
import { PrismaModule } from '../../common/prisma/prisma.module'; import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule],
controllers: [DashboardController], controllers: [DashboardController],
providers: [DashboardService], providers: [DashboardService],
exports: [DashboardService], exports: [DashboardService],
}) })
export class DashboardModule { } export class DashboardModule {}
\ No newline at end of file
...@@ -5,29 +5,29 @@ import { Prisma } from '@prisma/client'; ...@@ -5,29 +5,29 @@ import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
async getDashboardStats(): Promise<DashboardStatsDto> { async getDashboardStats(): Promise<DashboardStatsDto> {
// Get total counts // Get total counts
const [totalSites, totalCandidates, totalUsers] = await Promise.all([ const [totalSites, totalCandidates, totalUsers] = await Promise.all([
this.prisma.site.count(), this.prisma.site.count(),
this.prisma.candidate.count(), this.prisma.candidate.count(),
this.prisma.user.count(), this.prisma.user.count(),
]); ]);
// Get ongoing candidates count // Get ongoing candidates count
const ongoingCandidates = await this.prisma.candidate.count({ const ongoingCandidates = await this.prisma.candidate.count({
where: { onGoing: true }, where: { onGoing: true },
}); });
// Get candidates by status // Get candidates by status
const candidatesByStatus = await this.prisma.candidate.groupBy({ const candidatesByStatus = await this.prisma.candidate.groupBy({
by: ['currentStatus'], by: ['currentStatus'],
_count: true, _count: true,
}); });
// Get candidates per site with BigInt count conversion to Number // Get candidates per site with BigInt count conversion to Number
const candidatesPerSite = await this.prisma.$queryRaw` const candidatesPerSite = await this.prisma.$queryRaw`
SELECT SELECT
"Site"."id" as "siteId", "Site"."id" as "siteId",
"Site"."siteCode", "Site"."siteCode",
...@@ -40,8 +40,8 @@ export class DashboardService { ...@@ -40,8 +40,8 @@ export class DashboardService {
LIMIT 10 LIMIT 10
`; `;
// Get recent activity // Get recent activity
const recentActivity = await this.prisma.$queryRaw` const recentActivity = await this.prisma.$queryRaw`
SELECT SELECT
'site' as type, 'site' as type,
"Site"."id" as id, "Site"."id" as id,
...@@ -65,54 +65,54 @@ export class DashboardService { ...@@ -65,54 +65,54 @@ export class DashboardService {
LIMIT 10 LIMIT 10
`; `;
// Get users by role // Get users by role
const usersByRole = await this.prisma.user.groupBy({ const usersByRole = await this.prisma.user.groupBy({
by: ['role'], by: ['role'],
_count: true, _count: true,
}); });
// Helper function to convert BigInt values to numbers // Helper function to convert BigInt values to numbers
const convertBigIntToNumber = (obj: any): any => { const convertBigIntToNumber = (obj: any): any => {
if (obj === null || obj === undefined) { if (obj === null || obj === undefined) {
return obj; return obj;
} }
if (typeof obj === 'bigint') { if (typeof obj === 'bigint') {
return Number(obj); return Number(obj);
} }
if (typeof obj === 'object') { if (typeof obj === 'object') {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(convertBigIntToNumber); return obj.map(convertBigIntToNumber);
} }
const result = {}; const result = {};
for (const key in obj) { for (const key in obj) {
result[key] = convertBigIntToNumber(obj[key]); result[key] = convertBigIntToNumber(obj[key]);
} }
return result; return result;
} }
return obj; return obj;
}; };
return { return {
totalSites, totalSites,
totalCandidates, totalCandidates,
ongoingCandidates, ongoingCandidates,
candidatesByStatus: candidatesByStatus.reduce((acc, curr) => { candidatesByStatus: candidatesByStatus.reduce((acc, curr) => {
acc[curr.currentStatus] = curr._count; acc[curr.currentStatus] = curr._count;
return acc; return acc;
}, {}), }, {}),
candidatesPerSite: convertBigIntToNumber(candidatesPerSite) as any, candidatesPerSite: convertBigIntToNumber(candidatesPerSite),
recentActivity: convertBigIntToNumber(recentActivity) as any, recentActivity: convertBigIntToNumber(recentActivity),
userStats: { userStats: {
totalUsers, totalUsers,
usersByRole: usersByRole.reduce((acc, curr) => { usersByRole: usersByRole.reduce((acc, curr) => {
acc[curr.role] = curr._count; acc[curr.role] = curr._count;
return acc; return acc;
}, {}), }, {}),
}, },
}; };
} }
} }
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class DashboardStatsDto { export class DashboardStatsDto {
@ApiProperty({ description: 'Total number of sites' }) @ApiProperty({ description: 'Total number of sites' })
totalSites: number; totalSites: number;
@ApiProperty({ description: 'Total number of candidates' }) @ApiProperty({ description: 'Total number of candidates' })
totalCandidates: number; totalCandidates: number;
@ApiProperty({ description: 'Number of ongoing candidates' }) @ApiProperty({ description: 'Number of ongoing candidates' })
ongoingCandidates: number; ongoingCandidates: number;
@ApiProperty({ description: 'Number of candidates by status' }) @ApiProperty({ description: 'Number of candidates by status' })
candidatesByStatus: { candidatesByStatus: {
[key: string]: number; [key: string]: number;
}; };
@ApiProperty({ description: 'Number of candidates per site' }) @ApiProperty({ description: 'Number of candidates per site' })
candidatesPerSite: { candidatesPerSite: {
siteId: number; siteId: number;
siteCode: string; siteCode: string;
siteName: string; siteName: string;
count: number; count: number;
}[]; }[];
@ApiProperty({ description: 'Recent activity' }) @ApiProperty({ description: 'Recent activity' })
recentActivity: { recentActivity: {
id: number; id: number;
type: 'site' | 'candidate'; type: 'site' | 'candidate';
action: 'created' | 'updated'; action: 'created' | 'updated';
timestamp: Date; timestamp: Date;
userId: number; userId: number;
userName: string; userName: string;
}[]; }[];
@ApiProperty({ description: 'User statistics' }) @ApiProperty({ description: 'User statistics' })
userStats: { userStats: {
totalUsers: number; totalUsers: number;
usersByRole: { usersByRole: {
[key: string]: number; [key: string]: number;
};
}; };
} };
\ No newline at end of file }
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 * 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;
}
...@@ -2,30 +2,30 @@ import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; ...@@ -2,30 +2,30 @@ import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class CreatePartnerDto { export class CreatePartnerDto {
@ApiProperty({ @ApiProperty({
description: 'The name of the partner organization', description: 'The name of the partner organization',
example: 'PROEF Telco Services', example: 'PROEF Telco Services',
required: true required: true,
}) })
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
name: string; name: string;
@ApiProperty({ @ApiProperty({
description: 'Additional information about the partner', description: 'Additional information about the partner',
example: 'Professional telecommunications and network service provider', example: 'Professional telecommunications and network service provider',
required: false required: false,
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@ApiProperty({ @ApiProperty({
description: 'Whether the partner is active', description: 'Whether the partner is active',
default: true, default: true,
required: false required: false,
}) })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isActive?: boolean = true; isActive?: boolean = true;
} }
\ No newline at end of file
export * from './create-partner.dto'; export * from './create-partner.dto';
export * from './update-partner.dto'; export * from './update-partner.dto';
export * from './partner-response.dto'; export * from './partner-response.dto';
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class PartnerUserDto { export class PartnerUserDto {
@ApiProperty({ description: 'User ID', example: 1 }) @ApiProperty({ description: 'User ID', example: 1 })
id: number; id: number;
@ApiProperty({ description: 'User name', example: 'John Doe' }) @ApiProperty({ description: 'User name', example: 'John Doe' })
name: string; name: string;
@ApiProperty({ description: 'User email', example: 'john.doe@example.com' }) @ApiProperty({ description: 'User email', example: 'john.doe@example.com' })
email: string; email: string;
@ApiProperty({ description: 'User role', example: 'PARTNER' }) @ApiProperty({ description: 'User role', example: 'PARTNER' })
role: string; role: string;
} }
export class PartnerCountDto { export class PartnerCountDto {
@ApiProperty({ description: 'Number of candidates associated with this partner', example: 42 }) @ApiProperty({
candidates: number; description: 'Number of candidates associated with this partner',
example: 42,
})
candidates: number;
} }
export class PartnerResponseDto { export class PartnerResponseDto {
@ApiProperty({ description: 'Partner ID', example: 1 }) @ApiProperty({ description: 'Partner ID', example: 1 })
id: number; id: number;
@ApiProperty({ description: 'Partner name', example: 'PROEF Telco Services' }) @ApiProperty({ description: 'Partner name', example: 'PROEF Telco Services' })
name: string; name: string;
@ApiProperty({ @ApiProperty({
description: 'Partner description', description: 'Partner description',
example: 'Professional telecommunications and network service provider', example: 'Professional telecommunications and network service provider',
required: false required: false,
}) })
description?: string; description?: string;
@ApiProperty({ description: 'Partner active status', example: true }) @ApiProperty({ description: 'Partner active status', example: true })
isActive: boolean; isActive: boolean;
@ApiProperty({ description: 'Partner creation timestamp', example: '2023-05-13T15:25:41.358Z' }) @ApiProperty({
createdAt: Date; 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({
updatedAt: Date; description: 'Partner last update timestamp',
example: '2023-05-13T15:25:41.358Z',
})
updatedAt: Date;
@ApiProperty({ type: [PartnerUserDto], description: 'Users associated with this partner' }) @ApiProperty({
users?: PartnerUserDto[]; type: [PartnerUserDto],
description: 'Users associated with this partner',
})
users?: PartnerUserDto[];
@ApiProperty({ type: PartnerCountDto, description: 'Associated entity counts' }) @ApiProperty({
_count?: PartnerCountDto; type: PartnerCountDto,
} description: 'Associated entity counts',
\ No newline at end of file })
_count?: PartnerCountDto;
}
...@@ -3,24 +3,24 @@ import { CreatePartnerDto } from './create-partner.dto'; ...@@ -3,24 +3,24 @@ import { CreatePartnerDto } from './create-partner.dto';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class UpdatePartnerDto extends PartialType(CreatePartnerDto) { export class UpdatePartnerDto extends PartialType(CreatePartnerDto) {
@ApiProperty({ @ApiProperty({
description: 'The name of the partner organization', description: 'The name of the partner organization',
example: 'PROEF Telco Services', example: 'PROEF Telco Services',
required: false required: false,
}) })
name?: string; name?: string;
@ApiProperty({ @ApiProperty({
description: 'Additional information about the partner', description: 'Additional information about the partner',
example: 'Professional telecommunications and network service provider', example: 'Professional telecommunications and network service provider',
required: false required: false,
}) })
description?: string; description?: string;
@ApiProperty({ @ApiProperty({
description: 'Whether the partner is active', description: 'Whether the partner is active',
example: true, example: true,
required: false required: false,
}) })
isActive?: boolean; isActive?: boolean;
} }
\ No newline at end of file
...@@ -4,8 +4,8 @@ import { PartnersService } from './partners.service'; ...@@ -4,8 +4,8 @@ import { PartnersService } from './partners.service';
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaService } from '../../common/prisma/prisma.service';
@Module({ @Module({
controllers: [PartnersController], controllers: [PartnersController],
providers: [PartnersService, PrismaService], providers: [PartnersService, PrismaService],
exports: [PartnersService], exports: [PartnersService],
}) })
export class PartnersModule { } export class PartnersModule {}
\ No newline at end of file
...@@ -4,145 +4,147 @@ import { CreatePartnerDto, UpdatePartnerDto } from './dto'; ...@@ -4,145 +4,147 @@ import { CreatePartnerDto, UpdatePartnerDto } from './dto';
@Injectable() @Injectable()
export class PartnersService { export class PartnersService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
async create(createPartnerDto: CreatePartnerDto) { async create(createPartnerDto: CreatePartnerDto) {
return this.prisma.partner.create({ return this.prisma.partner.create({
data: createPartnerDto, data: createPartnerDto,
}); });
} }
async findAll() { async findAll() {
return this.prisma.partner.findMany({ return this.prisma.partner.findMany({
include: { include: {
users: { users: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
role: true, role: true,
}, },
}, },
_count: { _count: {
select: { select: {
candidates: true, candidates: true,
}, },
}, },
}, },
}); });
} }
async findOne(id: number) { async findOne(id: number) {
const partner = await this.prisma.partner.findUnique({ const partner = await this.prisma.partner.findUnique({
where: { id }, where: { id },
include: { include: {
users: { users: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
role: true, role: true,
}, },
}, },
_count: { _count: {
select: { select: {
candidates: true, candidates: true,
}, },
}, },
}, },
}); });
if (!partner) { if (!partner) {
throw new NotFoundException(`Partner with ID ${id} not found`); throw new NotFoundException(`Partner with ID ${id} not found`);
}
return partner;
} }
async update(id: number, updatePartnerDto: UpdatePartnerDto) { return partner;
// Check if partner exists }
await this.findOne(id);
async update(id: number, updatePartnerDto: UpdatePartnerDto) {
return this.prisma.partner.update({ // Check if partner exists
where: { id }, await this.findOne(id);
data: updatePartnerDto,
}); return this.prisma.partner.update({
where: { id },
data: updatePartnerDto,
});
}
async remove(id: number) {
// Check if partner exists
await this.findOne(id);
return this.prisma.partner.delete({
where: { id },
});
}
async addUserToPartner(partnerId: number, userId: number) {
// Check if both partner and user exist
const partner = await this.findOne(partnerId);
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
} }
async remove(id: number) { return this.prisma.user.update({
// Check if partner exists where: { id: userId },
await this.findOne(id); data: {
partnerId,
return this.prisma.partner.delete({ role: 'PARTNER', // Set role to PARTNER automatically
where: { id }, },
}); });
}
async removeUserFromPartner(partnerId: number, userId: number) {
// Check if both partner and user exist
await this.findOne(partnerId);
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
} }
async addUserToPartner(partnerId: number, userId: number) { if (user.partnerId !== partnerId) {
// Check if both partner and user exist throw new NotFoundException(
const partner = await this.findOne(partnerId); `User with ID ${userId} is not associated with Partner ID ${partnerId}`,
const user = await this.prisma.user.findUnique({ );
where: { id: userId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
return this.prisma.user.update({
where: { id: userId },
data: {
partnerId,
role: 'PARTNER', // Set role to PARTNER automatically
},
});
} }
async removeUserFromPartner(partnerId: number, userId: number) { return this.prisma.user.update({
// Check if both partner and user exist where: { id: userId },
await this.findOne(partnerId); data: {
const user = await this.prisma.user.findUnique({ partnerId: null,
where: { id: userId }, // Note: We don't change the role back automatically, that should be a separate operation
}); },
});
if (!user) { }
throw new NotFoundException(`User with ID ${userId} not found`);
} async getPartnerCandidates(partnerId: number) {
await this.findOne(partnerId);
if (user.partnerId !== partnerId) {
throw new NotFoundException(`User with ID ${userId} is not associated with Partner ID ${partnerId}`); return this.prisma.candidate.findMany({
} where: {
partnerId,
return this.prisma.user.update({ },
where: { id: userId }, include: {
data: { sites: {
partnerId: null, include: {
// Note: We don't change the role back automatically, that should be a separate operation site: true,
}, },
}); },
} createdBy: {
select: {
async getPartnerCandidates(partnerId: number) { id: true,
await this.findOne(partnerId); name: true,
email: true,
return this.prisma.candidate.findMany({ },
where: { },
partnerId, },
}, });
include: { }
sites: { }
include: {
site: true,
},
},
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export enum CompanyName { export enum CompanyName {
VODAFONE = 'VODAFONE', VODAFONE = 'VODAFONE',
MEO = 'MEO', MEO = 'MEO',
NOS = 'NOS', NOS = 'NOS',
DIGI = 'DIGI', DIGI = 'DIGI',
} }
\ No newline at end of file
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { Type } from 'class-transformer';
import { CompanyName } from './company.dto'; import { CompanyName } from './company.dto';
export class CreateSiteDto { export class CreateSiteDto {
@ApiProperty({ @ApiProperty({
description: 'Unique code for the site', description: 'Unique code for the site',
example: 'SITE001', example: 'SITE001',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
siteCode: string; siteCode: string;
@ApiProperty({ @ApiProperty({
description: 'Name of the site', description: 'Name of the site',
example: 'Downtown Tower', example: 'Downtown Tower',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
siteName: string; siteName: string;
@ApiProperty({ @ApiProperty({
description: 'Latitude coordinate of the site', description: 'Latitude coordinate of the site',
example: 40.7128, example: 40.7128,
minimum: -90, minimum: -90,
maximum: 90, maximum: 90,
}) })
@IsNumber() @IsNumber()
@Min(-90) @Min(-90)
@Max(90) @Max(90)
latitude: number; latitude: number;
@ApiProperty({ @ApiProperty({
description: 'Longitude coordinate of the site', description: 'Longitude coordinate of the site',
example: -74.0060, example: -74.006,
minimum: -180, minimum: -180,
maximum: 180, maximum: 180,
}) })
@IsNumber() @IsNumber()
@Min(-180) @Min(-180)
@Max(180) @Max(180)
longitude: number; longitude: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Type of site', description: 'Type of site',
example: 'Tower', example: 'Tower',
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
type?: string; type?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Whether the site is a Digi site', description: 'Whether the site is a Digi site',
example: false, example: false,
default: false, default: false,
}) })
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isDigi?: boolean = false; isDigi?: boolean = false;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Companies operating at this site', description: 'Companies operating at this site',
type: [String], type: [String],
enum: CompanyName, enum: CompanyName,
example: [CompanyName.VODAFONE, CompanyName.MEO], example: [CompanyName.VODAFONE, CompanyName.MEO],
}) })
@IsArray() @IsArray()
@IsEnum(CompanyName, { each: true }) @IsEnum(CompanyName, { each: true })
@IsOptional() @IsOptional()
companies?: CompanyName[]; companies?: CompanyName[];
} }
\ No newline at end of file
...@@ -3,33 +3,33 @@ import { IsOptional, IsInt, Min, IsString } from 'class-validator'; ...@@ -3,33 +3,33 @@ import { IsOptional, IsInt, Min, IsString } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
export class FindSitesPaginatedDto { export class FindSitesPaginatedDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Page number (1-based)', description: 'Page number (1-based)',
default: 1, default: 1,
}) })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
page?: number = 1; page?: number = 1;
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Number of items per page', description: 'Number of items per page',
default: 10, default: 10,
}) })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
limit?: number = 10; limit?: number = 10;
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Search term for site code or name', description: 'Search term for site code or name',
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
search?: string; search?: string;
} }
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; 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'; import { Transform, Type } from 'class-transformer';
export enum OrderDirection { export enum OrderDirection {
ASC = 'asc', ASC = 'asc',
DESC = 'desc', DESC = 'desc',
} }
export class FindSitesDto { export class FindSitesDto {
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
page?: number = 1; page?: number = 1;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
limit?: number = 10; limit?: number = 10;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
siteCode?: string; siteCode?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
siteName?: string; siteName?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
type?: string; type?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
address?: string; address?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
city?: string; city?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
state?: string; state?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
country?: string; country?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@Transform(({ value }) => { @Transform(({ value }) => {
if (value === 'true') return true; if (value === 'true') return true;
if (value === 'false') return false; if (value === 'false') return false;
return value; return value;
}) })
isDigi?: boolean; isDigi?: boolean;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@Transform(({ value }) => { @Transform(({ value }) => {
if (value === 'true') return true; if (value === 'true') return true;
if (value === 'false') return false; if (value === 'false') return false;
return value; return value;
}) })
withCandidates?: boolean; withCandidates?: boolean;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
orderBy?: string; orderBy?: string;
@ApiProperty({ required: false, enum: OrderDirection }) @ApiProperty({ required: false, enum: OrderDirection })
@IsOptional() @IsOptional()
@IsEnum(OrderDirection) @IsEnum(OrderDirection)
orderDirection?: OrderDirection; orderDirection?: OrderDirection;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isReported?: boolean; isReported?: boolean;
} }
\ No newline at end of file
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