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