Commit c8c5d592 by Augusto

Settling

parent 5b4fb32a
# Site Maintenance Feature
This feature allows for tracking and managing site maintenance records, including form responses and photos.
## Setup Instructions for New Client
1. Initialize the database with the maintenance models:
```bash
# Run the clean setup script
./scripts/clean-setup-for-new-client.sh
```
This script will:
- Back up the current schema
- Remove existing migration history
- Generate a fresh Prisma client
- Create a new initial migration with all models
- Seed the database with maintenance questions
2. If you encounter type errors after changing the schema, run:
```bash
# Update the Prisma client
./scripts/fix-prisma-client.sh
```
## Maintenance Model
Each maintenance record includes:
- Date of maintenance
- Site association
- Optional general comment
- Set of responses to predefined questions
- Optional photos documenting the site condition
## Response Options
The maintenance form uses three standard response options:
- YES - The item is in good condition/working properly
- NO - The item needs attention/repair
- NA - Not applicable for this site
## API Endpoints
- `POST /maintenance` - Create a new maintenance record
- `GET /maintenance` - List all maintenance records with filtering options
- `GET /maintenance/:id` - Get details of a specific maintenance record
- `GET /maintenance/questions` - Get the list of maintenance questions
## Roles and Permissions
The following roles can create maintenance records:
- ADMIN
- MANAGER
- OPERATOR
- PARTNER
All authenticated users can view maintenance records.
## Photos
Maintenance photos are stored in the `uploads/maintenance/{maintenanceId}` directory.
Each photo is associated with a specific maintenance record.
\ No newline at end of file
# New Client Setup Guide
This guide provides step-by-step instructions for setting up the Cellnex API for a new client with the Site Maintenance feature.
## Initial Setup
1. Create a new branch for the client:
```bash
git checkout -b client-name
```
2. Set up the database and environment variables:
```bash
# Run the setup script with your database details
./scripts/setup-new-client-db.sh --name client_db --user client_user --password your_password
```
This script will:
- Create a .env file with database configuration
- Create the database and user (if PostgreSQL is available locally)
- Enable the PostGIS extension
- Run initial migrations
- Seed maintenance questions
## Manual Database Setup (if automatic setup doesn't work)
If you need to set up the database manually:
1. Create a .env file with your database connection string:
```
DATABASE_URL=postgresql://username:password@localhost:5432/database_name?schema=public
```
2. Create the database and enable the PostGIS extension:
```sql
CREATE DATABASE database_name;
CREATE USER username WITH ENCRYPTED PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE database_name TO username;
\c database_name
CREATE EXTENSION IF NOT EXISTS postgis;
```
3. Run the migration and seed scripts:
```bash
# Generate Prisma client
npx prisma generate
# Run migrations
npx prisma migrate dev --name init_with_maintenance_models
# Seed maintenance questions
npx ts-node prisma/seed-maintenance.ts
```
## File Structure
The maintenance feature consists of the following components:
- **Models**: Defined in `prisma/schema.prisma`
- `Maintenance`: Main maintenance record
- `MaintenanceResponse`: Responses to maintenance questions
- `MaintenanceQuestion`: Predefined maintenance questions
- `MaintenancePhoto`: Photos attached to maintenance records
- **Module**: Located in `src/modules/maintenance/`
- Controllers
- Services
- DTOs
- Utilities for file handling
- **Uploads**: Photos are stored in `uploads/maintenance/{maintenanceId}/`
## API Endpoints
The maintenance feature provides the following endpoints:
- `POST /api/maintenance` - Create a new maintenance record
- `GET /api/maintenance` - List all maintenance records with filtering options
- `GET /api/maintenance/:id` - Get details of a specific maintenance record
- `GET /api/maintenance/questions` - Get the list of maintenance questions
## Development
To run the application in development mode:
```bash
# Install dependencies
npm install
# Start the development server
npm run start:dev
```
The API will be available at http://localhost:3001/api/
Swagger documentation is available at http://localhost:3001/docs
## Troubleshooting
### Prisma Client Type Errors
If you encounter type errors with the Prisma client:
```bash
# Update the Prisma client
./scripts/fix-prisma-client.sh
```
### File Upload Issues
If you encounter issues with file uploads:
1. Ensure the uploads directory exists and has proper permissions:
```bash
mkdir -p uploads/maintenance
chmod 755 uploads uploads/maintenance
```
2. Check if the environment variable `NODE_ENV` is set correctly (development/production).
### Database Connection Issues
If you can't connect to the database:
1. Verify the DATABASE_URL in your .env file
2. Ensure the database server is running
3. Check that the user has proper permissions
## Deployment
For production deployment:
1. Set the NODE_ENV environment variable to "production"
2. Update the DATABASE_URL in .env
3. Build the application:
```bash
npm run build
```
4. Start the production server:
```bash
npm run start:prod
```
5. Ensure the uploads directory in production has proper permissions.
\ No newline at end of file
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'MANAGER', 'OPERATOR', 'VIEWER');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'VIEWER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Site" (
"id" SERIAL NOT NULL,
"siteCode" TEXT NOT NULL,
"siteName" TEXT NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" INTEGER,
"updatedById" INTEGER,
CONSTRAINT "Site_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Candidate" (
"id" SERIAL NOT NULL,
"candidateCode" TEXT NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"type" TEXT NOT NULL,
"address" TEXT NOT NULL,
"comments" TEXT,
"currentStatus" TEXT NOT NULL,
"onGoing" BOOLEAN NOT NULL DEFAULT false,
"siteId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" INTEGER,
"updatedById" INTEGER,
CONSTRAINT "Candidate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE UNIQUE INDEX "Site_siteCode_key" ON "Site"("siteCode");
-- CreateIndex
CREATE INDEX "Site_siteCode_idx" ON "Site"("siteCode");
-- CreateIndex
CREATE INDEX "Candidate_candidateCode_idx" ON "Candidate"("candidateCode");
-- CreateIndex
CREATE INDEX "Candidate_currentStatus_idx" ON "Candidate"("currentStatus");
-- CreateIndex
CREATE INDEX "Candidate_siteId_idx" ON "Candidate"("siteId");
-- CreateIndex
CREATE INDEX "Candidate_onGoing_idx" ON "Candidate"("onGoing");
-- CreateIndex
CREATE UNIQUE INDEX "Candidate_siteId_candidateCode_key" ON "Candidate"("siteId", "candidateCode");
-- AddForeignKey
ALTER TABLE "Site" ADD CONSTRAINT "Site_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Site" ADD CONSTRAINT "Site_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterEnum
ALTER TYPE "Role" ADD VALUE 'SUPERADMIN';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "resetToken" TEXT,
ADD COLUMN "resetTokenExpiry" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
-- CreateIndex
CREATE INDEX "RefreshToken_token_idx" ON "RefreshToken"("token");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT false;
/*
Warnings:
- You are about to drop the column `comments` on the `Candidate` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Candidate" DROP COLUMN "comments";
-- CreateTable
CREATE TABLE "Comment" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"candidateId" INTEGER NOT NULL,
"createdById" INTEGER,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Comment_candidateId_idx" ON "Comment"("candidateId");
-- CreateIndex
CREATE INDEX "Comment_createdById_idx" ON "Comment"("createdById");
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
/*
Warnings:
- You are about to drop the column `siteId` on the `Candidate` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "Candidate" DROP CONSTRAINT "Candidate_siteId_fkey";
-- DropIndex
DROP INDEX "Candidate_siteId_candidateCode_key";
-- DropIndex
DROP INDEX "Candidate_siteId_idx";
-- AlterTable
ALTER TABLE "Candidate" DROP COLUMN "siteId";
-- CreateTable
CREATE TABLE "CandidateSite" (
"id" SERIAL NOT NULL,
"candidateId" INTEGER NOT NULL,
"siteId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CandidateSite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CandidateSite_candidateId_siteId_key" ON "CandidateSite"("candidateId", "siteId");
-- CreateIndex
CREATE INDEX "CandidateSite_candidateId_idx" ON "CandidateSite"("candidateId");
-- CreateIndex
CREATE INDEX "CandidateSite_siteId_idx" ON "CandidateSite"("siteId");
-- Migrate existing data
INSERT INTO "CandidateSite" ("candidateId", "siteId", "createdAt", "updatedAt")
SELECT id, "siteId", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Candidate"
WHERE "siteId" IS NOT NULL;
-- AddForeignKey
ALTER TABLE "CandidateSite" ADD CONSTRAINT "CandidateSite_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CandidateSite" ADD CONSTRAINT "CandidateSite_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Remove old relationship
ALTER TABLE "Candidate" DROP CONSTRAINT IF EXISTS "Candidate_siteId_fkey";
ALTER TABLE "Candidate" DROP COLUMN "siteId";
-- This migration adds functions to help with alphabetic auto-incrementing candidate codes
-- The implementation is done in the application code, but we can add indexes to improve performance
-- Add index on candidateCode to make queries faster
CREATE INDEX IF NOT EXISTS "Candidate_candidateCode_idx" ON "Candidate"("candidateCode");
-- Add function to get next alphabetic code (for reference, actual implementation is in the app)
CREATE OR REPLACE FUNCTION next_alphabetic_code(current_code TEXT)
RETURNS TEXT AS $$
DECLARE
chars TEXT[];
i INTEGER;
BEGIN
-- If no code provided, start with 'A'
IF current_code IS NULL OR current_code = '' THEN
RETURN 'A';
END IF;
-- Convert to array of characters
chars := regexp_split_to_array(current_code, '');
i := array_length(chars, 1);
-- Start from the last character and try to increment
WHILE i > 0 LOOP
-- If current character is not 'Z', increment it
IF chars[i] <> 'Z' THEN
chars[i] := chr(ascii(chars[i]) + 1);
RETURN array_to_string(chars, '');
END IF;
-- Current character is 'Z', set it to 'A' and move to previous position
chars[i] := 'A';
i := i - 1;
END LOOP;
-- If we're here, we've carried over beyond the first character
-- (e.g., incrementing 'ZZ' to 'AAA')
RETURN 'A' || array_to_string(chars, '');
END;
$$ LANGUAGE plpgsql;
\ No newline at end of file
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
...@@ -10,25 +10,27 @@ datasource db { ...@@ -10,25 +10,27 @@ datasource db {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique email String @unique
name String name String
password String password String
role Role @default(VIEWER) role Role @default(VIEWER)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
resetToken String? resetToken String?
resetTokenExpiry DateTime? resetTokenExpiry DateTime?
isActive Boolean @default(false) isActive Boolean @default(false)
candidatesCreated Candidate[] @relation("CandidateCreator") candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater") candidatesUpdated Candidate[] @relation("CandidateUpdater")
Comment Comment[] Comment Comment[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator") sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater") sitesUpdated Site[] @relation("SiteUpdater")
associatedSites UserSite[] // New relation for PARTNER role inspectionsCreated Inspection[] @relation("InspectionCreator")
partner Partner? @relation(fields: [partnerId], references: [id]) inspectionsUpdated Inspection[] @relation("InspectionUpdater")
partnerId Int? associatedSites UserSite[] // New relation for PARTNER role
partner Partner? @relation(fields: [partnerId], references: [id])
partnerId Int?
@@index([email]) @@index([email])
@@index([role]) @@index([role])
...@@ -65,6 +67,7 @@ model Site { ...@@ -65,6 +67,7 @@ model Site {
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id]) createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id]) updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[] // New relation for PARTNER role partners UserSite[] // New relation for PARTNER role
inspections Inspection[]
@@index([siteCode]) @@index([siteCode])
} }
...@@ -189,3 +192,67 @@ model Partner { ...@@ -189,3 +192,67 @@ model Partner {
@@index([name]) @@index([name])
} }
enum InspectionResponseOption {
YES
NO
NA
}
model InspectionQuestion {
id Int @id @default(autoincrement())
question String
orderIndex Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
responses InspectionResponse[]
}
model InspectionResponse {
id Int @id @default(autoincrement())
response InspectionResponseOption
comment String?
questionId Int
inspectionId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
question InspectionQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
@@index([questionId])
@@index([inspectionId])
}
model Inspection {
id Int @id @default(autoincrement())
date DateTime
comment String?
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
createdBy User? @relation("InspectionCreator", fields: [createdById], references: [id])
updatedBy User? @relation("InspectionUpdater", fields: [updatedById], references: [id])
responses InspectionResponse[]
photos InspectionPhoto[]
@@index([siteId])
@@index([createdById])
@@index([updatedById])
}
model InspectionPhoto {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inspectionId Int
inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
@@index([inspectionId])
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String
role Role @default(VIEWER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resetToken String?
resetTokenExpiry DateTime?
isActive Boolean @default(false)
candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater")
Comment Comment[]
refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
maintenancesCreated Maintenance[] @relation("MaintenanceCreator")
maintenancesUpdated Maintenance[] @relation("MaintenanceUpdater")
associatedSites UserSite[] // New relation for PARTNER role
partner Partner? @relation(fields: [partnerId], references: [id])
partnerId Int?
@@index([email])
@@index([role])
@@index([partnerId])
}
model RefreshToken {
id Int @id @default(autoincrement())
token String @unique
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
}
model Site {
id Int @id @default(autoincrement())
siteCode String @unique
siteName String
latitude Float
longitude Float
type String?
isDigi Boolean @default(false)
isReported Boolean @default(false)
companies CompanyName[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
candidates CandidateSite[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[] // New relation for PARTNER role
maintenances Maintenance[]
@@index([siteCode])
}
model Candidate {
id Int @id @default(autoincrement())
candidateCode String
latitude Float
longitude Float
type String
address String
currentStatus String
onGoing Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
sites CandidateSite[]
comments Comment[]
photos Photo[]
partnerId Int? // To track which partner created the candidate
partner Partner? @relation(fields: [partnerId], references: [id])
@@index([candidateCode])
@@index([currentStatus])
@@index([onGoing])
@@index([partnerId])
}
model CandidateSite {
id Int @id @default(autoincrement())
candidateId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([candidateId, siteId])
@@index([candidateId])
@@index([siteId])
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
createdById Int?
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id])
@@index([candidateId])
@@index([createdById])
}
model Photo {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidateId Int
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
@@index([candidateId])
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model spatial_ref_sys {
srid Int @id
auth_name String? @db.VarChar(256)
auth_srid Int?
srtext String? @db.VarChar(2048)
proj4text String? @db.VarChar(2048)
}
model UserSite {
id Int @id @default(autoincrement())
userId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([userId, siteId])
@@index([userId])
@@index([siteId])
}
enum CompanyName {
VODAFONE
MEO
NOS
DIGI
}
enum Role {
ADMIN
MANAGER
OPERATOR
VIEWER
SUPERADMIN
PARTNER
}
model Partner {
id Int @id @default(autoincrement())
name String @unique
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
candidates Candidate[]
@@index([name])
}
enum MaintenanceResponseOption {
YES
NO
NA
}
model MaintenanceQuestion {
id Int @id @default(autoincrement())
question String
orderIndex Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
responses MaintenanceResponse[]
}
model MaintenanceResponse {
id Int @id @default(autoincrement())
response MaintenanceResponseOption
comment String?
questionId Int
maintenanceId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
question MaintenanceQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
maintenance Maintenance @relation(fields: [maintenanceId], references: [id], onDelete: Cascade)
@@index([questionId])
@@index([maintenanceId])
}
model Maintenance {
id Int @id @default(autoincrement())
date DateTime
comment String?
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
createdBy User? @relation("MaintenanceCreator", fields: [createdById], references: [id])
updatedBy User? @relation("MaintenanceUpdater", fields: [updatedById], references: [id])
responses MaintenanceResponse[]
photos MaintenancePhoto[]
@@index([siteId])
@@index([createdById])
@@index([updatedById])
}
model MaintenancePhoto {
id Int @id @default(autoincrement())
url String
filename String
mimeType String
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
maintenanceId Int
maintenance Maintenance @relation(fields: [maintenanceId], references: [id], onDelete: Cascade)
@@index([maintenanceId])
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding maintenance questions...');
// Clear existing questions
await prisma.maintenanceQuestion.deleteMany({});
// Create questions
const questions = [
{
question: 'Site access condition',
orderIndex: 1,
},
{
question: 'Site infrastructure condition',
orderIndex: 2,
},
{
question: 'Equipment condition',
orderIndex: 3,
},
{
question: 'Power system condition',
orderIndex: 4,
},
{
question: 'Cooling system condition',
orderIndex: 5,
},
{
question: 'Security features condition',
orderIndex: 6,
},
{
question: 'Safety equipment presence and condition',
orderIndex: 7,
},
{
question: 'Site cleanliness',
orderIndex: 8,
},
{
question: 'Vegetation control',
orderIndex: 9,
},
{
question: 'Surrounding area condition',
orderIndex: 10,
},
];
for (const question of questions) {
await prisma.maintenanceQuestion.create({
data: question,
});
}
console.log('Maintenance questions have been seeded!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
\ No newline at end of file
...@@ -15,6 +15,7 @@ import { join } from 'path'; ...@@ -15,6 +15,7 @@ import { join } from 'path';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import { DashboardModule } from './modules/dashboard/dashboard.module'; import { DashboardModule } from './modules/dashboard/dashboard.module';
import { PartnersModule } from './modules/partners/partners.module'; import { PartnersModule } from './modules/partners/partners.module';
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
@Module({ @Module({
imports: [ imports: [
...@@ -54,6 +55,7 @@ import { PartnersModule } from './modules/partners/partners.module'; ...@@ -54,6 +55,7 @@ import { PartnersModule } from './modules/partners/partners.module';
CommentsModule, CommentsModule,
DashboardModule, DashboardModule,
PartnersModule, PartnersModule,
MaintenanceModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
...@@ -64,4 +66,4 @@ import { PartnersModule } from './modules/partners/partners.module'; ...@@ -64,4 +66,4 @@ import { PartnersModule } from './modules/partners/partners.module';
}, },
], ],
}) })
export class AppModule { } export class AppModule {}
...@@ -3,8 +3,8 @@ import { EmailService } from './email.service'; ...@@ -3,8 +3,8 @@ import { EmailService } from './email.service';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
providers: [EmailService], providers: [EmailService],
exports: [EmailService], exports: [EmailService],
}) })
export class EmailModule { } export class EmailModule {}
\ No newline at end of file
...@@ -4,26 +4,30 @@ import * as nodemailer from 'nodemailer'; ...@@ -4,26 +4,30 @@ import * as nodemailer from 'nodemailer';
@Injectable() @Injectable()
export class EmailService { export class EmailService {
private transporter: nodemailer.Transporter; private transporter: nodemailer.Transporter;
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
host: this.configService.get<string>('SMTP_HOST'), host: this.configService.get<string>('SMTP_HOST'),
port: this.configService.get<number>('SMTP_PORT'), port: this.configService.get<number>('SMTP_PORT'),
secure: this.configService.get<boolean>('SMTP_SECURE', false), secure: this.configService.get<boolean>('SMTP_SECURE', false),
auth: { auth: {
user: this.configService.get<string>('SMTP_USER'), user: this.configService.get<string>('SMTP_USER'),
pass: this.configService.get<string>('SMTP_PASS'), pass: this.configService.get<string>('SMTP_PASS'),
}, },
}); });
} }
async sendPasswordResetEmail(email: string, resetToken: string, resetUrl: string): Promise<void> { async sendPasswordResetEmail(
const mailOptions = { email: string,
from: this.configService.get<string>('SMTP_FROM', 'noreply@cellnex.com'), resetToken: string,
to: email, resetUrl: string,
subject: 'Password Reset Request', ): Promise<void> {
html: ` const mailOptions = {
from: this.configService.get<string>('SMTP_FROM', 'noreply@cellnex.com'),
to: email,
subject: 'Password Reset Request',
html: `
<h1>Password Reset Request</h1> <h1>Password Reset Request</h1>
<p>You have requested to reset your password. Click the link below to reset it:</p> <p>You have requested to reset your password. Click the link below to reset it:</p>
<p><a href="${resetUrl}?token=${resetToken}">Reset Password</a></p> <p><a href="${resetUrl}?token=${resetToken}">Reset Password</a></p>
...@@ -33,13 +37,13 @@ export class EmailService { ...@@ -33,13 +37,13 @@ export class EmailService {
<p>${resetUrl}?token=${resetToken}</p> <p>${resetUrl}?token=${resetToken}</p>
<p>Best regards,<br>Cellnex Team</p> <p>Best regards,<br>Cellnex Team</p>
`, `,
}; };
try { try {
await this.transporter.sendMail(mailOptions); await this.transporter.sendMail(mailOptions);
} catch (error) { } catch (error) {
console.error('Failed to send email:', error); console.error('Failed to send email:', error);
throw new Error('Failed to send password reset email'); throw new Error('Failed to send password reset email');
}
} }
} }
\ No newline at end of file }
...@@ -2,24 +2,24 @@ import { diskStorage, memoryStorage } from 'multer'; ...@@ -2,24 +2,24 @@ import { diskStorage, memoryStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
export const multerConfig = { export const multerConfig = {
storage: memoryStorage(), storage: memoryStorage(),
limits: { limits: {
fileSize: 5 * 1024 * 1024, // 5MB fileSize: 5 * 1024 * 1024, // 5MB
}, },
fileFilter: (req, file, callback) => { fileFilter: (req, file, callback) => {
try { try {
console.log('Checking file type:', file.mimetype); console.log('Checking file type:', file.mimetype);
// Accept only images // Accept only images
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
console.error('Invalid file type:', file.mimetype); console.error('Invalid file type:', file.mimetype);
return callback(new Error('Only image files are allowed!'), false); return callback(new Error('Only image files are allowed!'), false);
} }
callback(null, true); callback(null, true);
} catch (error) { } catch (error) {
console.error('Error in file filter:', error); console.error('Error in file filter:', error);
callback(error, false); callback(error, false);
} }
}, },
preservePath: true preservePath: true,
}; };
\ No newline at end of file
...@@ -3,7 +3,7 @@ import { PrismaService } from './prisma.service'; ...@@ -3,7 +3,7 @@ import { PrismaService } from './prisma.service';
@Global() @Global()
@Module({ @Module({
providers: [PrismaService], providers: [PrismaService],
exports: [PrismaService], exports: [PrismaService],
}) })
export class PrismaModule { } export class PrismaModule {}
\ No newline at end of file
...@@ -2,12 +2,15 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; ...@@ -2,12 +2,15 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService
async onModuleInit() { extends PrismaClient
await this.$connect(); implements OnModuleInit, OnModuleDestroy
} {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() { async onModuleDestroy() {
await this.$disconnect(); await this.$disconnect();
} }
} }
\ No newline at end of file
...@@ -25,6 +25,10 @@ async function bootstrap() { ...@@ -25,6 +25,10 @@ async function bootstrap() {
// Serve static files // Serve static files
app.use('/uploads', express.static('/home/api-cellnex/public_html/uploads')); app.use('/uploads', express.static('/home/api-cellnex/public_html/uploads'));
// In development, serve from local directory
if (process.env.NODE_ENV === 'development') {
app.use('/uploads', express.static(join(__dirname, '..', 'uploads')));
}
// Swagger configuration // Swagger configuration
const config = new DocumentBuilder() const config = new DocumentBuilder()
...@@ -35,6 +39,7 @@ async function bootstrap() { ...@@ -35,6 +39,7 @@ async function bootstrap() {
.addTag('users', 'User management endpoints') .addTag('users', 'User management endpoints')
.addTag('sites', 'Site management endpoints') .addTag('sites', 'Site management endpoints')
.addTag('candidates', 'Candidate management endpoints') .addTag('candidates', 'Candidate management endpoints')
.addTag('maintenance', 'Site maintenance management endpoints')
.addBearerAuth( .addBearerAuth(
{ {
type: 'http', type: 'http',
...@@ -49,11 +54,21 @@ async function bootstrap() { ...@@ -49,11 +54,21 @@ async function bootstrap() {
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
// Configure Swagger to persist authentication
const swaggerOptions = {
swaggerOptions: {
persistAuthorization: true,
},
};
SwaggerModule.setup('docs', app, document, swaggerOptions);
const port = process.env.PORT ?? 3001; const port = process.env.PORT ?? 3001;
await app.listen(port); await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`); console.log(`Application is running on: http://localhost:${port}`);
console.log(`Swagger documentation is available at: http://localhost:${port}/docs`); console.log(
`Swagger documentation is available at: http://localhost:${port}/docs`,
);
} }
bootstrap(); bootstrap();
import { Body, Controller, Get, Headers, Post, UnauthorizedException, HttpCode, HttpStatus } from '@nestjs/common'; import {
Body,
Controller,
Get,
Headers,
Post,
UnauthorizedException,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto';
...@@ -11,69 +20,71 @@ import { Public } from './decorators/public.decorator'; ...@@ -11,69 +20,71 @@ import { Public } from './decorators/public.decorator';
@Controller('auth') @Controller('auth')
@Public() @Public()
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) { } constructor(private readonly authService: AuthService) {}
@Post('login') @Post('login')
@ApiOperation({ summary: 'Login with email and password' }) @ApiOperation({ summary: 'Login with email and password' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Returns JWT token, refresh token and user information', description: 'Returns JWT token, refresh token and user information',
}) })
@ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 401, description: 'Unauthorized' })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto) { async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto); return this.authService.login(loginDto);
} }
@Post('refresh')
@ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiResponse({
status: 200,
description: 'Returns new access token and refresh token',
})
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
@HttpCode(HttpStatus.OK)
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto);
}
@Post('password/reset-request') @Post('refresh')
@ApiOperation({ summary: 'Request password reset email' }) @ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Password reset email sent', description: 'Returns new access token and refresh token',
}) })
@ApiResponse({ status: 400, description: 'User not found' }) @ApiResponse({ status: 401, description: 'Invalid refresh token' })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto) { async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.requestPasswordReset(requestPasswordResetDto); return this.authService.refreshToken(refreshTokenDto);
} }
@Post('password/reset') @Post('password/reset-request')
@ApiOperation({ summary: 'Reset password using reset token' }) @ApiOperation({ summary: 'Request password reset email' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Password successfully reset', description: 'Password reset email sent',
}) })
@ApiResponse({ status: 400, description: 'Invalid or expired reset token' }) @ApiResponse({ status: 400, description: 'User not found' })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { async requestPasswordReset(
return this.authService.resetPassword(resetPasswordDto); @Body() requestPasswordResetDto: RequestPasswordResetDto,
} ) {
return this.authService.requestPasswordReset(requestPasswordResetDto);
}
@Get('validate') @Post('password/reset')
@ApiOperation({ summary: 'Validate JWT token' }) @ApiOperation({ summary: 'Reset password using reset token' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Returns user information from token', description: 'Password successfully reset',
}) })
@ApiResponse({ status: 401, description: 'Invalid token' }) @ApiResponse({ status: 400, description: 'Invalid or expired reset token' })
async validateToken(@Headers('authorization') auth: string) { @HttpCode(HttpStatus.OK)
if (!auth || !auth.startsWith('Bearer ')) { async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
throw new UnauthorizedException('No token provided'); return this.authService.resetPassword(resetPasswordDto);
} }
const token = auth.split(' ')[1]; @Get('validate')
return this.authService.validateToken(token); @ApiOperation({ summary: 'Validate JWT token' })
@ApiResponse({
status: 200,
description: 'Returns user information from token',
})
@ApiResponse({ status: 401, description: 'Invalid token' })
async validateToken(@Headers('authorization') auth: string) {
if (!auth || !auth.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
} }
}
\ No newline at end of file const token = auth.split(' ')[1];
return this.authService.validateToken(token);
}
}
...@@ -12,24 +12,24 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard'; ...@@ -12,24 +12,24 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard'; import { RolesGuard } from './guards/roles.guard';
@Module({ @Module({
imports: [ imports: [
UsersModule, UsersModule,
EmailModule, EmailModule,
PassportModule, PassportModule,
MailerModule, MailerModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({ useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') ?? 'your-secret-key', secret: configService.get<string>('JWT_SECRET') ?? 'your-secret-key',
signOptions: { signOptions: {
expiresIn: '24h', expiresIn: '24h',
}, },
}), }),
inject: [ConfigService], inject: [ConfigService],
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard], providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard], exports: [AuthService, JwtAuthGuard, RolesGuard],
}) })
export class AuthModule { } export class AuthModule {}
\ No newline at end of file
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; import {
Injectable,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
...@@ -17,224 +21,243 @@ import { randomBytes } from 'crypto'; ...@@ -17,224 +21,243 @@ import { randomBytes } from 'crypto';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
private jwtService: JwtService, private jwtService: JwtService,
private prisma: PrismaService, private prisma: PrismaService,
private emailService: EmailService, private emailService: EmailService,
private configService: ConfigService, private configService: ConfigService,
private mailerService: MailerService, private mailerService: MailerService,
) { } ) { }
async validateUser(email: string, password: string): Promise<any> { async validateUser(email: string, password: string): Promise<any> {
const user = await this.usersService.findByEmail(email); const user = await this.usersService.findByEmail(email);
if (user && await bcrypt.compare(password, user.password)) { if (user && (await bcrypt.compare(password, user.password))) {
if (!user.isActive) { if (!user.isActive) {
throw new UnauthorizedException('Your account is not active. Please contact an administrator.'); throw new UnauthorizedException(
} 'Your account is not active. Please contact an administrator.',
const { password, ...result } = user; );
return result; }
} const { password, ...result } = user;
return null; return result;
} }
return null;
}
async login(loginDto: LoginDto) { async login(loginDto: LoginDto) {
const user = await this.validateUser(loginDto.email, loginDto.password); const user = await this.validateUser(loginDto.email, loginDto.password);
if (!user) { if (!user) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
}
// Get detailed user information including partnerId
const userDetails = await this.prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
email: true,
name: true,
role: true,
partnerId: true
}
});
if (!userDetails) {
throw new UnauthorizedException('User not found');
}
const payload = {
sub: user.id,
email: user.email,
role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === Role.PARTNER && userDetails.partnerId && { partnerId: userDetails.partnerId })
};
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
expiresIn: '24h',
}),
this.jwtService.signAsync(payload, {
expiresIn: '7d',
}),
]);
await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return {
access_token: accessToken,
refresh_token: refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
partnerId: userDetails.partnerId
},
};
} }
async refreshToken(refreshTokenDto: RefreshTokenDto) { // Get detailed user information including partnerId
try { const userDetails = await this.prisma.user.findUnique({
const payload = await this.jwtService.verifyAsync(refreshTokenDto.refreshToken); where: { id: user.id },
const user = await this.usersService.findOne(payload.sub); select: {
id: true,
if (!user) { email: true,
throw new UnauthorizedException('User not found'); name: true,
} role: true,
partnerId: true,
const storedToken = await this.prisma.refreshToken.findFirst({ },
where: { });
token: refreshTokenDto.refreshToken,
userId: user.id, if (!userDetails) {
expiresAt: { throw new UnauthorizedException('User not found');
gt: new Date(),
},
},
});
if (!storedToken) {
throw new UnauthorizedException('Invalid refresh token');
}
// Delete the used refresh token
await this.prisma.refreshToken.delete({
where: { id: storedToken.id },
});
// Get detailed user information including partnerId
const userDetails = await this.prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
email: true,
role: true,
partnerId: true
}
});
if (!userDetails) {
throw new UnauthorizedException('User not found');
}
// Generate new tokens
const newPayload = {
sub: user.id,
email: user.email,
role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === Role.PARTNER && userDetails.partnerId && { partnerId: userDetails.partnerId })
};
const [newAccessToken, newRefreshToken] = await Promise.all([
this.jwtService.signAsync(newPayload, {
expiresIn: '24h',
}),
this.jwtService.signAsync(newPayload, {
expiresIn: '7d',
}),
]);
// Store new refresh token
await this.prisma.refreshToken.create({
data: {
token: newRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return {
access_token: newAccessToken,
refresh_token: newRefreshToken,
};
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
} }
async requestPasswordReset(requestPasswordResetDto: RequestPasswordResetDto) { const payload = {
const user = await this.usersService.findByEmail(requestPasswordResetDto.email); sub: user.id,
if (!user) { email: user.email,
// Return success even if user doesn't exist to prevent email enumeration role: user.role,
return { message: 'If your email is registered, you will receive a password reset link.' }; // Include partnerId in the payload if user is a PARTNER and has a partnerId
} ...(user.role === Role.PARTNER &&
userDetails.partnerId && { partnerId: userDetails.partnerId }),
const resetToken = randomBytes(32).toString('hex'); };
const resetTokenExpiry = new Date();
resetTokenExpiry.setHours(resetTokenExpiry.getHours() + 1); // Token expires in 1 hour const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
await this.usersService.update(user.id, { expiresIn: '24h',
resetToken, }),
resetTokenExpiry, this.jwtService.signAsync(payload, {
}); expiresIn: '7d',
}),
const resetUrl = `${this.configService.get('FRONTEND_URL')}/reset-password?token=${resetToken}`; ]);
await this.mailerService.sendMail({ await this.prisma.refreshToken.create({
to: user.email, data: {
subject: 'Password Reset Request', token: refreshToken,
template: 'password-reset', userId: user.id,
context: { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
name: user.name, },
resetUrl, });
},
}); return {
access_token: accessToken,
return { message: 'If your email is registered, you will receive a password reset link.' }; refresh_token: refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
partnerId: userDetails.partnerId,
},
client: 'verticalflow'
};
}
async refreshToken(refreshTokenDto: RefreshTokenDto) {
try {
const payload = await this.jwtService.verifyAsync(
refreshTokenDto.refreshToken,
);
const user = await this.usersService.findOne(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
const storedToken = await this.prisma.refreshToken.findFirst({
where: {
token: refreshTokenDto.refreshToken,
userId: user.id,
expiresAt: {
gt: new Date(),
},
},
});
if (!storedToken) {
throw new UnauthorizedException('Invalid refresh token');
}
// Delete the used refresh token
await this.prisma.refreshToken.delete({
where: { id: storedToken.id },
});
// Get detailed user information including partnerId
const userDetails = await this.prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
email: true,
role: true,
partnerId: true,
},
});
if (!userDetails) {
throw new UnauthorizedException('User not found');
}
// Generate new tokens
const newPayload = {
sub: user.id,
email: user.email,
role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === Role.PARTNER &&
userDetails.partnerId && { partnerId: userDetails.partnerId }),
};
const [newAccessToken, newRefreshToken] = await Promise.all([
this.jwtService.signAsync(newPayload, {
expiresIn: '24h',
}),
this.jwtService.signAsync(newPayload, {
expiresIn: '7d',
}),
]);
// Store new refresh token
await this.prisma.refreshToken.create({
data: {
token: newRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return {
access_token: newAccessToken,
refresh_token: newRefreshToken,
client: 'verticalflow'
};
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
}
async requestPasswordReset(requestPasswordResetDto: RequestPasswordResetDto) {
const user = await this.usersService.findByEmail(
requestPasswordResetDto.email,
);
if (!user) {
// Return success even if user doesn't exist to prevent email enumeration
return {
message:
'If your email is registered, you will receive a password reset link.',
};
} }
async resetPassword(resetPasswordDto: ResetPasswordDto) { const resetToken = randomBytes(32).toString('hex');
const user = await this.usersService.findByResetToken(resetPasswordDto.token); const resetTokenExpiry = new Date();
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) { resetTokenExpiry.setHours(resetTokenExpiry.getHours() + 1); // Token expires in 1 hour
throw new UnauthorizedException('Invalid or expired reset token');
}
const hashedPassword = await bcrypt.hash(resetPasswordDto.newPassword, 10); await this.usersService.update(user.id, {
await this.usersService.update(user.id, { resetToken,
password: hashedPassword, resetTokenExpiry,
resetToken: null, });
resetTokenExpiry: null,
});
return { message: 'Password has been reset successfully' }; const resetUrl = `${this.configService.get('FRONTEND_URL')}/reset-password?token=${resetToken}`;
await this.mailerService.sendMail({
to: user.email,
subject: 'Password Reset Request',
template: 'password-reset',
context: {
name: user.name,
resetUrl,
},
});
return {
message:
'If your email is registered, you will receive a password reset link.',
};
}
async resetPassword(resetPasswordDto: ResetPasswordDto) {
const user = await this.usersService.findByResetToken(
resetPasswordDto.token,
);
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
throw new UnauthorizedException('Invalid or expired reset token');
} }
async validateToken(token: string) { const hashedPassword = await bcrypt.hash(resetPasswordDto.newPassword, 10);
try { await this.usersService.update(user.id, {
const payload = this.jwtService.verify(token); password: hashedPassword,
return { resetToken: null,
id: payload.sub, resetTokenExpiry: null,
email: payload.email, });
role: payload.role,
partnerId: payload.partnerId || null return { message: 'Password has been reset successfully' };
}; }
} catch (error) {
throw new UnauthorizedException('Invalid token'); async validateToken(token: string) {
} try {
const payload = this.jwtService.verify(token);
return {
id: payload.sub,
email: payload.email,
role: payload.role,
partnerId: payload.partnerId || null,
client: 'verticalflow'
};
} catch (error) {
throw new UnauthorizedException('Invalid token');
} }
} }
\ No newline at end of file }
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Partner = createParamDecorator( export const Partner = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => { (data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();
// Return the partnerId from the user object on the request // Return the partnerId from the user object on the request
return request.user?.partnerId; return request.user?.partnerId;
}, },
); );
\ No newline at end of file
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic'; export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
\ No newline at end of file
...@@ -2,4 +2,4 @@ import { SetMetadata } from '@nestjs/common'; ...@@ -2,4 +2,4 @@ import { SetMetadata } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
export const ROLES_KEY = 'roles'; export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
\ No newline at end of file
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator( export const User = createParamDecorator(
(data: string, ctx: ExecutionContext) => { (data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();
const user = request.user; const user = request.user;
return data ? user?.[data] : user; return data ? user?.[data] : user;
}, },
); );
\ No newline at end of file
...@@ -2,19 +2,19 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,19 +2,19 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto { export class LoginDto {
@ApiProperty({ @ApiProperty({
description: 'The email of the user', description: 'The email of the user',
example: 'augusto.fonte@brandit.pt', example: 'augusto.fonte@brandit.pt',
}) })
@IsEmail() @IsEmail()
@IsNotEmpty() @IsNotEmpty()
email: string; email: string;
@ApiProperty({ @ApiProperty({
description: 'The password of the user', description: 'The password of the user',
example: 'passsword', example: 'passsword',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
password: string; password: string;
} }
\ No newline at end of file
...@@ -2,11 +2,11 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,11 +2,11 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
export class RefreshTokenDto { export class RefreshTokenDto {
@ApiProperty({ @ApiProperty({
description: 'The refresh token', description: 'The refresh token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
refreshToken: string; refreshToken: string;
} }
\ No newline at end of file
...@@ -2,11 +2,11 @@ import { IsEmail, IsNotEmpty } from 'class-validator'; ...@@ -2,11 +2,11 @@ import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class RequestPasswordResetDto { export class RequestPasswordResetDto {
@ApiProperty({ @ApiProperty({
description: 'Email address of the user requesting password reset', description: 'Email address of the user requesting password reset',
example: 'user@example.com', example: 'user@example.com',
}) })
@IsEmail() @IsEmail()
@IsNotEmpty() @IsNotEmpty()
email: string; email: string;
} }
\ No newline at end of file
...@@ -2,21 +2,21 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,21 +2,21 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator'; import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ResetPasswordDto { export class ResetPasswordDto {
@ApiProperty({ @ApiProperty({
description: 'Reset token received via email', description: 'Reset token received via email',
example: 'abc123def456', example: 'abc123def456',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
token: string; token: string;
@ApiProperty({ @ApiProperty({
description: 'New password', description: 'New password',
example: 'newPassword123', example: 'newPassword123',
minLength: 8, minLength: 8,
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
newPassword: string; newPassword: string;
} }
\ No newline at end of file
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) { constructor(private reflector: Reflector) {
super(); super();
} }
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) { canActivate(context: ExecutionContext) {
return true; const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
} context.getHandler(),
context.getClass(),
]);
return super.canActivate(context); if (isPublic) {
return true;
} }
handleRequest(err: any, user: any) { return super.canActivate(context);
if (err || !user) { }
throw err || new UnauthorizedException('Authentication required');
} handleRequest(err: any, user: any) {
return user; if (err || !user) {
throw err || new UnauthorizedException('Authentication required');
} }
} return user;
\ No newline at end of file }
}
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
@Injectable() @Injectable()
export class PartnerAuthGuard implements CanActivate { export class PartnerAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const user = request.user; const user = request.user;
const partnerId = request.params.partnerId ? parseInt(request.params.partnerId, 10) : null; const partnerId = request.params.partnerId
? parseInt(request.params.partnerId, 10)
: null;
// If it's a PARTNER user, make sure they can only access their own partner data // If it's a PARTNER user, make sure they can only access their own partner data
if (user.role === Role.PARTNER) { if (user.role === Role.PARTNER) {
// Check if the user has a partnerId and if it matches the requested partnerId // Check if the user has a partnerId and if it matches the requested partnerId
if (!user.partnerId) { if (!user.partnerId) {
throw new ForbiddenException('User does not have access to any partner resources'); throw new ForbiddenException(
} 'User does not have access to any partner resources',
);
}
// If a specific partnerId is requested in the URL, check if it matches the user's partnerId // If a specific partnerId is requested in the URL, check if it matches the user's partnerId
if (partnerId && partnerId !== user.partnerId) { if (partnerId && partnerId !== user.partnerId) {
throw new ForbiddenException('Access to this partner is not authorized'); throw new ForbiddenException(
} 'Access to this partner is not authorized',
} );
}
// Non-PARTNER roles have general access
return true;
} }
}
\ No newline at end of file // Non-PARTNER roles have general access
return true;
}
}
...@@ -5,19 +5,19 @@ import { ROLES_KEY } from '../decorators/roles.decorator'; ...@@ -5,19 +5,19 @@ import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable() @Injectable()
export class RolesGuard implements CanActivate { export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) { } constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [ const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
if (!requiredRoles) { if (!requiredRoles) {
return true; return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
} }
}
\ No newline at end of file const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}
...@@ -5,20 +5,21 @@ import { ConfigService } from '@nestjs/config'; ...@@ -5,20 +5,21 @@ import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET') ?? 'your-secret-key', secretOrKey: configService.get<string>('JWT_SECRET') ?? 'your-secret-key',
}); });
} }
async validate(payload: any) { async validate(payload: any) {
return { return {
id: payload.sub, id: payload.sub,
email: payload.email, email: payload.email,
role: payload.role, role: payload.role,
partnerId: payload.partnerId || null, partnerId: payload.partnerId || null,
}; client: 'verticalflow'
} };
} }
\ No newline at end of file }
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, UseGuards, UseInterceptors, UploadedFile, BadRequestException, Request } from '@nestjs/common'; import {
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger'; Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
Query,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { CandidatesService } from './candidates.service'; import { CandidatesService } from './candidates.service';
import { CreateCandidateDto } from './dto/create-candidate.dto'; import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto'; import { UpdateCandidateDto } from './dto/update-candidate.dto';
...@@ -21,183 +43,220 @@ import { multerConfig } from '../../common/multer/multer.config'; ...@@ -21,183 +43,220 @@ import { multerConfig } from '../../common/multer/multer.config';
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
export class CandidatesController { export class CandidatesController {
constructor(private readonly candidatesService: CandidatesService) { } constructor(private readonly candidatesService: CandidatesService) {}
@Post() @Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@ApiOperation({ @ApiOperation({
summary: 'Create a new candidate', summary: 'Create a new candidate',
description: 'Creates a new candidate with optional initial comment. If candidateCode is not provided, it will be automatically generated as an alphabetical code (A, B, C, ..., AA, AB, etc.) specific to the first site in the siteIds array.' description:
}) 'Creates a new candidate with optional initial comment. If candidateCode is not provided, it will be automatically generated as an alphabetical code (A, B, C, ..., AA, AB, etc.) specific to the first site in the siteIds array.',
@ApiResponse({ status: 201, description: 'The candidate has been successfully created.', type: CandidateResponseDto }) })
create(@Body() createCandidateDto: CreateCandidateDto, @User('id') userId: number) { @ApiResponse({
return this.candidatesService.create(createCandidateDto, userId); status: 201,
} description: 'The candidate has been successfully created.',
type: CandidateResponseDto,
})
create(
@Body() createCandidateDto: CreateCandidateDto,
@User('id') userId: number,
) {
return this.candidatesService.create(createCandidateDto, userId);
}
@Get() @Get()
@ApiOperation({ summary: 'Get all candidates' }) @ApiOperation({ summary: 'Get all candidates' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return all candidates.', description: 'Return all candidates.',
schema: { schema: {
properties: { properties: {
data: { data: {
type: 'array', type: 'array',
items: { $ref: '#/components/schemas/CandidateResponseDto' } items: { $ref: '#/components/schemas/CandidateResponseDto' },
}, },
meta: { meta: {
type: 'object', type: 'object',
properties: { properties: {
total: { type: 'number' }, total: { type: 'number' },
page: { type: 'number' }, page: { type: 'number' },
limit: { type: 'number' }, limit: { type: 'number' },
totalPages: { type: 'number' } totalPages: { type: 'number' },
} },
} },
} },
} },
}) })
findAll(@Query() query: QueryCandidateDto, @User('id') userId: number) { findAll(@Query() query: QueryCandidateDto, @User('id') userId: number) {
return this.candidatesService.findAll(query, userId); return this.candidatesService.findAll(query, userId);
} }
@Get('site/:siteId') @Get('site/:siteId')
@ApiOperation({ summary: 'Get candidates by site id' }) @ApiOperation({ summary: 'Get candidates by site id' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return the candidates for the site.', description: 'Return the candidates for the site.',
type: [CandidateResponseDto] type: [CandidateResponseDto],
}) })
findBySiteId(@Param('siteId', ParseIntPipe) siteId: number, @User('id') userId: number) { findBySiteId(
return this.candidatesService.findBySiteId(siteId, userId); @Param('siteId', ParseIntPipe) siteId: number,
} @User('id') userId: number,
) {
return this.candidatesService.findBySiteId(siteId, userId);
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get a candidate by id' }) @ApiOperation({ summary: 'Get a candidate by id' })
@ApiResponse({ status: 200, description: 'Return the candidate.', type: CandidateResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Candidate not found.' }) status: 200,
findOne(@Param('id', ParseIntPipe) id: number, @User('id') userId: number, @Partner() partnerId: number | null, @User('role') role: Role) { description: 'Return the candidate.',
// For PARTNER role, we restrict access based on the partnerId type: CandidateResponseDto,
if (role === Role.PARTNER) { })
return this.candidatesService.findOneWithPartnerCheck(id, partnerId); @ApiResponse({ status: 404, description: 'Candidate not found.' })
} findOne(
return this.candidatesService.findOne(id); @Param('id', ParseIntPipe) id: number,
@User('id') userId: number,
@Partner() partnerId: number | null,
@User('role') role: Role,
) {
// For PARTNER role, we restrict access based on the partnerId
if (role === Role.PARTNER) {
return this.candidatesService.findOneWithPartnerCheck(id, partnerId);
} }
return this.candidatesService.findOne(id);
}
@Patch(':id') @Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Update a candidate' }) @ApiOperation({ summary: 'Update a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully updated.', type: CandidateResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Candidate not found.' }) status: 200,
update( description: 'The candidate has been successfully updated.',
@Param('id', ParseIntPipe) id: number, type: CandidateResponseDto,
@Body() updateCandidateDto: UpdateCandidateDto, })
@User('id') userId: number, @ApiResponse({ status: 404, description: 'Candidate not found.' })
@Partner() partnerId: number | null, update(
@User('role') role: Role @Param('id', ParseIntPipe) id: number,
) { @Body() updateCandidateDto: UpdateCandidateDto,
// For PARTNER role, we restrict updates to candidates associated with their partner @User('id') userId: number,
if (role === Role.PARTNER) { @Partner() partnerId: number | null,
return this.candidatesService.updateWithPartnerCheck(id, updateCandidateDto, userId, partnerId); @User('role') role: Role,
} ) {
return this.candidatesService.update(id, updateCandidateDto); // For PARTNER role, we restrict updates to candidates associated with their partner
if (role === Role.PARTNER) {
return this.candidatesService.updateWithPartnerCheck(
id,
updateCandidateDto,
userId,
partnerId,
);
} }
return this.candidatesService.update(id, updateCandidateDto);
}
@Delete(':id') @Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.PARTNER, Role.MANAGER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.PARTNER, Role.MANAGER)
@ApiOperation({ summary: 'Delete a candidate' }) @ApiOperation({ summary: 'Delete a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully deleted.', type: CandidateResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Candidate not found.' }) status: 200,
remove(@Param('id', ParseIntPipe) id: number) { description: 'The candidate has been successfully deleted.',
return this.candidatesService.remove(id); type: CandidateResponseDto,
} })
@ApiResponse({ status: 404, description: 'Candidate not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.candidatesService.remove(id);
}
@Post(':id/sites') @Post(':id/sites')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Add multiple sites to a candidate' }) @ApiOperation({ summary: 'Add multiple sites to a candidate' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'The sites have been successfully added to the candidate.', description: 'The sites have been successfully added to the candidate.',
type: CandidateResponseDto type: CandidateResponseDto,
}) })
@ApiResponse({ status: 404, description: 'Candidate not found.' }) @ApiResponse({ status: 404, description: 'Candidate not found.' })
addSitesToCandidate( addSitesToCandidate(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() addSitesDto: AddSitesToCandidateDto, @Body() addSitesDto: AddSitesToCandidateDto,
@Partner() partnerId: number | null, @Partner() partnerId: number | null,
@User('role') role: Role @User('role') role: Role,
) { ) {
// For PARTNER role, check if the candidate belongs to their partner // For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) { if (role === Role.PARTNER) {
return this.candidatesService.addSitesToCandidateWithPartnerCheck(id, addSitesDto, partnerId); return this.candidatesService.addSitesToCandidateWithPartnerCheck(
} id,
return this.candidatesService.addSitesToCandidate(id, addSitesDto); addSitesDto,
partnerId,
);
} }
return this.candidatesService.addSitesToCandidate(id, addSitesDto);
}
@Post(':id/photos') @Post(':id/photos')
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({ @ApiBody({
schema: { schema: {
type: 'object', type: 'object',
properties: { properties: {
file: { file: {
type: 'string', type: 'string',
format: 'binary', format: 'binary',
description: 'The image file to upload' description: 'The image file to upload',
} },
}, },
required: ['file'] required: ['file'],
} },
}) })
@UseInterceptors(FileInterceptor('file', multerConfig)) @UseInterceptors(FileInterceptor('file', multerConfig))
async uploadPhoto( async uploadPhoto(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Partner() partnerId: number | null, @Partner() partnerId: number | null,
@User('role') role: Role @User('role') role: Role,
) { ) {
if (!file) { if (!file) {
throw new BadRequestException('No file uploaded'); throw new BadRequestException('No file uploaded');
} }
// For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) {
await this.candidatesService.checkCandidatePartner(id, partnerId);
}
return this.candidatesService.uploadPhoto(id, file, { // For PARTNER role, check if the candidate belongs to their partner
filename: file.originalname, if (role === Role.PARTNER) {
mimeType: file.mimetype, await this.candidatesService.checkCandidatePartner(id, partnerId);
size: file.size
});
} }
@Get(':id/photos') return this.candidatesService.uploadPhoto(id, file, {
async getCandidatePhotos( filename: file.originalname,
@Param('id', ParseIntPipe) id: number, mimeType: file.mimetype,
@Partner() partnerId: number | null, size: file.size,
@User('role') role: Role });
) { }
// For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) { @Get(':id/photos')
await this.candidatesService.checkCandidatePartner(id, partnerId); async getCandidatePhotos(
} @Param('id', ParseIntPipe) id: number,
return this.candidatesService.getCandidatePhotos(id); @Partner() partnerId: number | null,
@User('role') role: Role,
) {
// For PARTNER role, check if the candidate belongs to their partner
if (role === Role.PARTNER) {
await this.candidatesService.checkCandidatePartner(id, partnerId);
} }
return this.candidatesService.getCandidatePhotos(id);
}
@Delete('photos/:photoId') @Delete('photos/:photoId')
@ApiOperation({ summary: 'Delete a photo by its ID' }) @ApiOperation({ summary: 'Delete a photo by its ID' })
@ApiResponse({ status: 200, description: 'Photo deleted successfully' }) @ApiResponse({ status: 200, description: 'Photo deleted successfully' })
@ApiResponse({ status: 404, description: 'Photo not found' }) @ApiResponse({ status: 404, description: 'Photo not found' })
async deletePhoto( async deletePhoto(
@Param('photoId', ParseIntPipe) photoId: number, @Param('photoId', ParseIntPipe) photoId: number,
@Partner() partnerId: number | null, @Partner() partnerId: number | null,
@User('role') role: Role @User('role') role: Role,
) { ) {
// For PARTNER role, check if the photo belongs to a candidate that belongs to their partner // For PARTNER role, check if the photo belongs to a candidate that belongs to their partner
if (role === Role.PARTNER) { if (role === Role.PARTNER) {
await this.candidatesService.checkPhotoPartner(photoId, partnerId); await this.candidatesService.checkPhotoPartner(photoId, partnerId);
}
return this.candidatesService.deletePhoto(photoId);
} }
} return this.candidatesService.deletePhoto(photoId);
\ No newline at end of file }
}
...@@ -5,9 +5,9 @@ import { PrismaModule } from '../../common/prisma/prisma.module'; ...@@ -5,9 +5,9 @@ import { PrismaModule } from '../../common/prisma/prisma.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [PrismaModule, AuthModule], imports: [PrismaModule, AuthModule],
controllers: [CandidatesController], controllers: [CandidatesController],
providers: [CandidatesService], providers: [CandidatesService],
exports: [CandidatesService], exports: [CandidatesService],
}) })
export class CandidatesModule { } export class CandidatesModule {}
\ No newline at end of file
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateCandidateDto } from './dto/create-candidate.dto'; import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto'; import { UpdateCandidateDto } from './dto/update-candidate.dto';
...@@ -12,787 +16,845 @@ import { Prisma, Role } from '@prisma/client'; ...@@ -12,787 +16,845 @@ import { Prisma, Role } from '@prisma/client';
@Injectable() @Injectable()
export class CandidatesService { export class CandidatesService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
/** /**
* Generates the next alphabetical code for a site * Generates the next alphabetical code for a site
* Codes start from A and progress to Z, then AA, AB, ..., ZZ, AAA, etc. * Codes start from A and progress to Z, then AA, AB, ..., ZZ, AAA, etc.
*/ */
private async generateNextCandidateCode(siteId: number): Promise<string> { private async generateNextCandidateCode(siteId: number): Promise<string> {
// Find all candidates associated with this site // Find all candidates associated with this site
const siteCandidates = await this.prisma.candidateSite.findMany({ const siteCandidates = await this.prisma.candidateSite.findMany({
where: { siteId }, where: { siteId },
include: { include: {
candidate: true candidate: true,
} },
}); });
// If no candidates exist for this site, start with 'A' // If no candidates exist for this site, start with 'A'
if (siteCandidates.length === 0) { if (siteCandidates.length === 0) {
return 'A'; return 'A';
}
// Get all existing codes
const existingCodes = siteCandidates.map(sc => sc.candidate.candidateCode);
// Find the highest code
// Sort alphabetically with longer strings coming after shorter ones
const sortedCodes = [...existingCodes].sort((a, b) => {
if (a.length !== b.length) return a.length - b.length;
return a.localeCompare(b);
});
const highestCode = sortedCodes[sortedCodes.length - 1];
return this.incrementAlphabeticCode(highestCode);
} }
/** // Get all existing codes
* Increments an alphabetic code (A->B, Z->AA, AA->AB, etc.) const existingCodes = siteCandidates.map(
*/ (sc) => sc.candidate.candidateCode,
private incrementAlphabeticCode(code: string): string { );
// Convert to array of characters for easier manipulation
const chars = code.split(''); // Find the highest code
// Sort alphabetically with longer strings coming after shorter ones
// Start from the last character and try to increment const sortedCodes = [...existingCodes].sort((a, b) => {
let i = chars.length - 1; if (a.length !== b.length) return a.length - b.length;
return a.localeCompare(b);
while (i >= 0) { });
// If current character is not 'Z', just increment it
if (chars[i] !== 'Z') { const highestCode = sortedCodes[sortedCodes.length - 1];
chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1); return this.incrementAlphabeticCode(highestCode);
return chars.join(''); }
}
/**
// Current character is 'Z', set it to 'A' and move to previous position * Increments an alphabetic code (A->B, Z->AA, AA->AB, etc.)
chars[i] = 'A'; */
i--; private incrementAlphabeticCode(code: string): string {
} // Convert to array of characters for easier manipulation
const chars = code.split('');
// If we're here, we've carried over beyond the first character
// (e.g., incrementing 'ZZ' to 'AAA') // Start from the last character and try to increment
return 'A' + chars.join(''); let i = chars.length - 1;
while (i >= 0) {
// If current character is not 'Z', just increment it
if (chars[i] !== 'Z') {
chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1);
return chars.join('');
}
// Current character is 'Z', set it to 'A' and move to previous position
chars[i] = 'A';
i--;
} }
async create(createCandidateDto: CreateCandidateDto, userId?: number) { // If we're here, we've carried over beyond the first character
const { comment, siteIds, ...candidateData } = createCandidateDto; // (e.g., incrementing 'ZZ' to 'AAA')
return 'A' + chars.join('');
// Get the user's partner if they have one (for PARTNER role) }
let userPartnerId: number | null = null;
if (userId) { async create(createCandidateDto: CreateCandidateDto, userId?: number) {
const user = await this.prisma.user.findUnique({ const { comment, siteIds, ...candidateData } = createCandidateDto;
where: { id: userId },
select: { partnerId: true, role: true } // Get the user's partner if they have one (for PARTNER role)
}); let userPartnerId: number | null = null;
if (userId) {
// If user is a PARTNER, assign the candidate to their partner const user = await this.prisma.user.findUnique({
if (user?.role === Role.PARTNER && user.partnerId) { where: { id: userId },
userPartnerId = user.partnerId; select: { partnerId: true, role: true },
} });
}
// If user is a PARTNER, assign the candidate to their partner
// Create the candidate with a transaction to ensure both operations succeed or fail together if (user?.role === Role.PARTNER && user.partnerId) {
return this.prisma.$transaction(async (prisma) => { userPartnerId = user.partnerId;
// If candidateCode is not provided, generate it for the first site }
const finalCandidateCode = candidateData.candidateCode ||
(siteIds.length > 0 ? await this.generateNextCandidateCode(siteIds[0]) : 'A');
// Create candidate data with the basic properties
const data: any = {
candidateCode: finalCandidateCode,
latitude: candidateData.latitude,
longitude: candidateData.longitude,
type: candidateData.type,
address: candidateData.address,
currentStatus: candidateData.currentStatus,
onGoing: candidateData.onGoing,
sites: {
create: siteIds.map(siteId => ({
site: {
connect: { id: siteId }
}
}))
}
};
// Add relations for creator/updater
if (userId) {
data.createdBy = { connect: { id: userId } };
data.updatedBy = { connect: { id: userId } };
}
// Add partner relation if applicable
if (userPartnerId) {
data.partner = { connect: { id: userPartnerId } };
}
// Create the candidate
const candidate = await prisma.candidate.create({
data,
include: {
sites: {
include: {
site: true
}
},
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
// If a comment was provided, create it
if (comment) {
await prisma.comment.create({
data: {
content: comment,
candidateId: candidate.id,
createdById: userId,
},
});
// Fetch the updated candidate with the new comment
return prisma.candidate.findUnique({
where: { id: candidate.id },
include: {
sites: {
include: {
site: true
}
},
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
}
return candidate;
});
} }
async findAll(query: QueryCandidateDto, userId?: number) { // Create the candidate with a transaction to ensure both operations succeed or fail together
const { candidateCode, type, currentStatus, onGoing, siteId, page = 1, limit = 10 } = query; return this.prisma.$transaction(async (prisma) => {
// If candidateCode is not provided, generate it for the first site
// Check if user is a PARTNER and get their partnerId const finalCandidateCode =
let partnerFilter = {}; candidateData.candidateCode ||
if (userId) { (siteIds.length > 0
const user = await this.prisma.user.findUnique({ ? await this.generateNextCandidateCode(siteIds[0])
where: { id: userId }, : 'A');
select: { partnerId: true, role: true }
}); // Create candidate data with the basic properties
const data: any = {
// If user is a PARTNER, only show candidates from their partner candidateCode: finalCandidateCode,
if (user?.role === Role.PARTNER && user.partnerId) { latitude: candidateData.latitude,
partnerFilter = { longitude: candidateData.longitude,
partnerId: user.partnerId type: candidateData.type,
}; address: candidateData.address,
} currentStatus: candidateData.currentStatus,
} onGoing: candidateData.onGoing,
sites: {
const where: Prisma.CandidateWhereInput = { create: siteIds.map((siteId) => ({
...(candidateCode && { candidateCode: { contains: candidateCode, mode: Prisma.QueryMode.insensitive } }), site: {
...(type && { type }), connect: { id: siteId },
...(currentStatus && { currentStatus }),
...(onGoing !== undefined && { onGoing }),
...(siteId && {
sites: {
some: {
siteId: siteId
}
}
}),
...partnerFilter, // Add partner filtering
};
const [total, data] = await Promise.all([
this.prisma.candidate.count({ where }),
this.prisma.candidate.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
sites: {
include: {
site: true
}
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
partner: {
select: {
id: true,
name: true
}
},
createdBy: {
select: {
id: true,
name: true,
email: true
}
}
},
}),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
}, },
}; })),
} },
};
async findOne(id: number) {
const candidate = await this.prisma.candidate.findUnique({ // Add relations for creator/updater
where: { id }, if (userId) {
data.createdBy = { connect: { id: userId } };
data.updatedBy = { connect: { id: userId } };
}
// Add partner relation if applicable
if (userPartnerId) {
data.partner = { connect: { id: userPartnerId } };
}
// Create the candidate
const candidate = await prisma.candidate.create({
data,
include: {
sites: {
include: { include: {
sites: { site: true,
include: { },
site: true },
} comments: {
}, include: {
comments: { createdBy: {
take: 1, select: {
include: { id: true,
createdBy: { name: true,
select: { email: true,
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
}, },
},
},
orderBy: {
createdAt: 'desc',
}, },
},
},
});
// If a comment was provided, create it
if (comment) {
await prisma.comment.create({
data: {
content: comment,
candidateId: candidate.id,
createdById: userId,
},
}); });
if (!candidate) { // Fetch the updated candidate with the new comment
throw new NotFoundException(`Candidate with ID ${id} not found`); return prisma.candidate.findUnique({
} where: { id: candidate.id },
include: {
return candidate; sites: {
} include: {
site: true,
async update(id: number, updateCandidateDto: UpdateCandidateDto) { },
try { },
const { siteIds, ...candidateData } = updateCandidateDto; comments: {
include: {
return await this.prisma.candidate.update({ createdBy: {
where: { id }, select: {
data: { id: true,
...candidateData, name: true,
...(siteIds && { email: true,
sites: { },
deleteMany: {}, // Remove all existing site associations
create: siteIds.map(siteId => ({
site: {
connect: { id: siteId }
}
}))
}
})
},
include: {
sites: {
include: {
site: true
}
},
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
}, },
}); },
} catch (error) { orderBy: {
throw new NotFoundException(`Candidate with ID ${id} not found`); createdAt: 'desc',
} },
},
},
});
}
return candidate;
});
}
async findAll(query: QueryCandidateDto, userId?: number) {
const {
candidateCode,
type,
currentStatus,
onGoing,
siteId,
page = 1,
limit = 10,
} = query;
// Check if user is a PARTNER and get their partnerId
let partnerFilter = {};
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { partnerId: true, role: true },
});
// If user is a PARTNER, only show candidates from their partner
if (user?.role === Role.PARTNER && user.partnerId) {
partnerFilter = {
partnerId: user.partnerId,
};
}
} }
async remove(id: number) { const where: Prisma.CandidateWhereInput = {
try { ...(candidateCode && {
return await this.prisma.candidate.delete({ candidateCode: {
where: { id }, contains: candidateCode,
include: { mode: Prisma.QueryMode.insensitive,
comments: { },
include: { }),
createdBy: { ...(type && { type }),
select: { ...(currentStatus && { currentStatus }),
id: true, ...(onGoing !== undefined && { onGoing }),
name: true, ...(siteId && {
email: true, sites: {
}, some: {
}, siteId: siteId,
}, },
orderBy: { },
createdAt: 'desc', }),
}, ...partnerFilter, // Add partner filtering
}, };
const [total, data] = await Promise.all([
this.prisma.candidate.count({ where }),
this.prisma.candidate.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
sites: {
include: {
site: true,
},
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
}, },
}); },
} catch (error) { },
throw new NotFoundException(`Candidate with ID ${id} not found`); orderBy: {
} createdAt: 'desc',
},
},
partner: {
select: {
id: true,
name: true,
},
},
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
}),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findOne(id: number) {
const candidate = await this.prisma.candidate.findUnique({
where: { id },
include: {
sites: {
include: {
site: true,
},
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
} }
async findBySiteId(siteId: number, userId?: number) { return candidate;
// Check if user is a PARTNER and get their partnerId }
let partnerFilter = {};
if (userId) { async update(id: number, updateCandidateDto: UpdateCandidateDto) {
const user = await this.prisma.user.findUnique({ try {
where: { id: userId }, const { siteIds, ...candidateData } = updateCandidateDto;
select: { partnerId: true, role: true }
}); return await this.prisma.candidate.update({
where: { id },
// If user is a PARTNER, only show candidates from their partner data: {
if (user?.role === Role.PARTNER && user.partnerId) { ...candidateData,
partnerFilter = { ...(siteIds && {
partnerId: user.partnerId sites: {
}; deleteMany: {}, // Remove all existing site associations
} create: siteIds.map((siteId) => ({
} site: {
connect: { id: siteId },
return this.prisma.candidate.findMany({
where: {
sites: {
some: {
siteId: siteId
}
}, },
...partnerFilter })),
}, },
}),
},
include: {
sites: {
include: { include: {
sites: { site: true,
include: { },
site: true },
} comments: {
}, include: {
comments: { createdBy: {
include: { select: {
createdBy: { id: true,
select: { name: true,
id: true, email: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
}, },
},
}, },
}); orderBy: {
createdAt: 'desc',
},
},
},
});
} catch (error) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
} }
}
async addSitesToCandidate(id: number, { siteIds }: AddSitesToCandidateDto) {
// First check if the candidate exists async remove(id: number) {
const candidate = await this.prisma.candidate.findUnique({ try {
where: { id }, return await this.prisma.candidate.delete({
}); where: { id },
include: {
if (!candidate) { comments: {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
// Get existing site relationships to avoid duplicates
const existingSiteIds = await this.prisma.candidateSite.findMany({
where: { candidateId: id },
select: { siteId: true }
}).then(relations => relations.map(rel => rel.siteId));
// Filter out sites that are already associated with this candidate
const newSiteIds = siteIds.filter(siteId => !existingSiteIds.includes(siteId));
// Create the candidate-site relationships with transaction
await this.prisma.$transaction(async (prisma) => {
for (const siteId of newSiteIds) {
// Generate a new alphabetical code for this site
const nextCode = await this.generateNextCandidateCode(siteId);
// Create the relationship
await prisma.candidateSite.create({
data: {
candidateId: id,
siteId,
}
});
}
});
// Return the updated candidate with all its sites
return this.prisma.candidate.findUnique({
where: { id },
include: { include: {
sites: { createdBy: {
include: { select: {
site: true, id: true,
}, name: true,
}, email: true,
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
}, },
},
}, },
}); orderBy: {
createdAt: 'desc',
},
},
},
});
} catch (error) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
}
async findBySiteId(siteId: number, userId?: number) {
// Check if user is a PARTNER and get their partnerId
let partnerFilter = {};
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { partnerId: true, role: true },
});
// If user is a PARTNER, only show candidates from their partner
if (user?.role === Role.PARTNER && user.partnerId) {
partnerFilter = {
partnerId: user.partnerId,
};
}
} }
async uploadPhoto(candidateId: number, file: Express.Multer.File, dto: UploadPhotoDto) { return this.prisma.candidate.findMany({
try { where: {
console.log('Starting photo upload process...'); sites: {
console.log('File details:', { some: {
originalname: file?.originalname, siteId: siteId,
mimetype: file?.mimetype, },
size: file?.size, },
buffer: file?.buffer ? 'Buffer exists' : 'No buffer' ...partnerFilter,
}); },
include: {
const candidate = await this.prisma.candidate.findUnique({ sites: {
where: { id: candidateId }, include: {
}); site: true,
},
if (!candidate) { },
throw new NotFoundException(`Candidate with ID ${candidateId} not found`); comments: {
} include: {
createdBy: {
// Create uploads directory if it doesn't exist select: {
const uploadDir = path.join(process.cwd(), 'uploads', 'candidates', candidateId.toString()); id: true,
console.log('Upload directory:', uploadDir); name: true,
email: true,
if (!fs.existsSync(uploadDir)) { },
console.log('Creating upload directory...'); },
fs.mkdirSync(uploadDir, { recursive: true }); },
} orderBy: {
createdAt: 'desc',
// Generate unique filename },
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); },
const filename = `${uniqueSuffix}-${file.originalname}`; },
const filePath = path.join(uploadDir, filename); });
console.log('File path:', filePath); }
// Initialize variables for image processing async addSitesToCandidate(id: number, { siteIds }: AddSitesToCandidateDto) {
let fileBuffer = file.buffer; // First check if the candidate exists
let fileSize = file.size; const candidate = await this.prisma.candidate.findUnique({
const maxSize = 2 * 1024 * 1024; // 2MB where: { id },
const mimeType = file.mimetype; });
const isImage = mimeType.startsWith('image/');
if (!candidate) {
if (isImage && fileSize > maxSize) { throw new NotFoundException(`Candidate with ID ${id} not found`);
try { }
console.log('Compressing image...');
const image = await Jimp.read(fileBuffer);
let quality = 80;
// Reduce quality until under 2MB or minimum quality
while (fileSize > maxSize && quality > 30) {
const tempBuffer = await image.quality(quality).getBufferAsync(mimeType);
if (tempBuffer.length <= maxSize) {
fileBuffer = tempBuffer;
fileSize = tempBuffer.length;
break;
}
quality -= 10;
}
} catch (error) {
console.error('Error processing image:', error);
// If image processing fails, continue with original file
}
}
// Save file // Get existing site relationships to avoid duplicates
console.log('Saving file...'); const existingSiteIds = await this.prisma.candidateSite
if (!fileBuffer) { .findMany({
throw new Error('File buffer is missing'); where: { candidateId: id },
select: { siteId: true },
})
.then((relations) => relations.map((rel) => rel.siteId));
// Filter out sites that are already associated with this candidate
const newSiteIds = siteIds.filter(
(siteId) => !existingSiteIds.includes(siteId),
);
// Create the candidate-site relationships with transaction
await this.prisma.$transaction(async (prisma) => {
for (const siteId of newSiteIds) {
// Generate a new alphabetical code for this site
const nextCode = await this.generateNextCandidateCode(siteId);
// Create the relationship
await prisma.candidateSite.create({
data: {
candidateId: id,
siteId,
},
});
}
});
// Return the updated candidate with all its sites
return this.prisma.candidate.findUnique({
where: { id },
include: {
sites: {
include: {
site: true,
},
},
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
}
async uploadPhoto(
candidateId: number,
file: Express.Multer.File,
dto: UploadPhotoDto,
) {
try {
console.log('Starting photo upload process...');
console.log('File details:', {
originalname: file?.originalname,
mimetype: file?.mimetype,
size: file?.size,
buffer: file?.buffer ? 'Buffer exists' : 'No buffer',
});
const candidate = await this.prisma.candidate.findUnique({
where: { id: candidateId },
});
if (!candidate) {
throw new NotFoundException(
`Candidate with ID ${candidateId} not found`,
);
}
// Create uploads directory if it doesn't exist
const uploadDir = path.join(
process.cwd(),
'uploads',
'candidates',
candidateId.toString(),
);
console.log('Upload directory:', uploadDir);
if (!fs.existsSync(uploadDir)) {
console.log('Creating upload directory...');
fs.mkdirSync(uploadDir, { recursive: true });
}
// Generate unique filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const filename = `${uniqueSuffix}-${file.originalname}`;
const filePath = path.join(uploadDir, filename);
console.log('File path:', filePath);
// Initialize variables for image processing
let fileBuffer = file.buffer;
let fileSize = file.size;
const maxSize = 2 * 1024 * 1024; // 2MB
const mimeType = file.mimetype;
const isImage = mimeType.startsWith('image/');
if (isImage && fileSize > maxSize) {
try {
console.log('Compressing image...');
const image = await Jimp.read(fileBuffer);
let quality = 80;
// Reduce quality until under 2MB or minimum quality
while (fileSize > maxSize && quality > 30) {
const tempBuffer = await image
.quality(quality)
.getBufferAsync(mimeType);
if (tempBuffer.length <= maxSize) {
fileBuffer = tempBuffer;
fileSize = tempBuffer.length;
break;
} }
fs.writeFileSync(filePath, fileBuffer); quality -= 10;
}
// Create photo record in database with relative URL
console.log('Creating database record...');
const photo = await this.prisma.photo.create({
data: {
url: `/uploads/candidates/${candidateId}/${filename}`,
filename: dto.filename || file.originalname,
mimeType: dto.mimeType || file.mimetype,
size: dto.size || fileSize,
candidateId: candidateId,
},
});
console.log('Photo upload completed successfully');
return photo;
} catch (error) { } catch (error) {
console.error('Error in uploadPhoto:', error); console.error('Error processing image:', error);
throw error; // If image processing fails, continue with original file
} }
}
// Save file
console.log('Saving file...');
if (!fileBuffer) {
throw new Error('File buffer is missing');
}
fs.writeFileSync(filePath, fileBuffer);
// Create photo record in database with relative URL
console.log('Creating database record...');
const photo = await this.prisma.photo.create({
data: {
url: `/uploads/candidates/${candidateId}/${filename}`,
filename: dto.filename || file.originalname,
mimeType: dto.mimeType || file.mimetype,
size: dto.size || fileSize,
candidateId: candidateId,
},
});
console.log('Photo upload completed successfully');
return photo;
} catch (error) {
console.error('Error in uploadPhoto:', error);
throw error;
} }
}
async getCandidatePhotos(candidateId: number) { async getCandidatePhotos(candidateId: number) {
const candidate = await this.prisma.candidate.findUnique({ const candidate = await this.prisma.candidate.findUnique({
where: { id: candidateId }, where: { id: candidateId },
include: { photos: true }, include: { photos: true },
}); });
if (!candidate) { if (!candidate) {
throw new NotFoundException(`Candidate with ID ${candidateId} not found`); throw new NotFoundException(`Candidate with ID ${candidateId} not found`);
}
return candidate.photos;
} }
async deletePhoto(photoId: number) { return candidate.photos;
const photo = await this.prisma.photo.findUnique({ }
where: { id: photoId },
});
if (!photo) {
throw new NotFoundException(`Photo with ID ${photoId} not found`);
}
// Delete file from disk async deletePhoto(photoId: number) {
const filePath = path.join(process.cwd(), photo.url); const photo = await this.prisma.photo.findUnique({
if (fs.existsSync(filePath)) { where: { id: photoId },
fs.unlinkSync(filePath); });
}
// Delete photo record from database if (!photo) {
await this.prisma.photo.delete({ throw new NotFoundException(`Photo with ID ${photoId} not found`);
where: { id: photoId }, }
});
return { message: 'Photo deleted successfully' }; // Delete file from disk
const filePath = path.join(process.cwd(), photo.url);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
} }
// New method for finding a candidate with partner check // Delete photo record from database
async findOneWithPartnerCheck(id: number, partnerId: number | null) { await this.prisma.photo.delete({
const candidate = await this.prisma.candidate.findUnique({ where: { id: photoId },
where: { id }, });
include: {
sites: { return { message: 'Photo deleted successfully' };
include: { }
site: true
} // New method for finding a candidate with partner check
}, async findOneWithPartnerCheck(id: number, partnerId: number | null) {
comments: { const candidate = await this.prisma.candidate.findUnique({
take: 1, where: { id },
include: { include: {
createdBy: { sites: {
select: { include: {
id: true, site: true,
name: true, },
email: true, },
}, comments: {
}, take: 1,
}, include: {
orderBy: { createdBy: {
createdAt: 'desc', select: {
}, id: true,
}, name: true,
email: true,
},
}, },
}); },
orderBy: {
if (!candidate) { createdAt: 'desc',
throw new NotFoundException(`Candidate with ID ${id} not found`); },
} },
},
// Check if this candidate belongs to the user's partner });
if (partnerId && candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`); if (!candidate) {
} throw new NotFoundException(`Candidate with ID ${id} not found`);
return candidate;
} }
// New method for updating a candidate with partner check // Check if this candidate belongs to the user's partner
async updateWithPartnerCheck(id: number, updateCandidateDto: UpdateCandidateDto, userId?: number, partnerId?: number | null) { if (partnerId && candidate.partnerId !== partnerId) {
// Check if candidate exists and belongs to the partner throw new ForbiddenException(
const candidate = await this.prisma.candidate.findUnique({ `Access to candidate with ID ${id} is not authorized`,
where: { id }, );
select: { partnerId: true } }
});
if (!candidate) { return candidate;
throw new NotFoundException(`Candidate with ID ${id} not found`); }
}
// New method for updating a candidate with partner check
async updateWithPartnerCheck(
id: number,
updateCandidateDto: UpdateCandidateDto,
userId?: number,
partnerId?: number | null,
) {
// Check if candidate exists and belongs to the partner
const candidate = await this.prisma.candidate.findUnique({
where: { id },
select: { partnerId: true },
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
// Enforce partner check for PARTNER role // Enforce partner check for PARTNER role
if (partnerId && candidate.partnerId !== partnerId) { if (partnerId && candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`); throw new ForbiddenException(
} `Access to candidate with ID ${id} is not authorized`,
);
}
try { try {
const { siteIds, ...candidateData } = updateCandidateDto; const { siteIds, ...candidateData } = updateCandidateDto;
return await this.prisma.candidate.update({ return await this.prisma.candidate.update({
where: { id }, where: { id },
data: { data: {
...candidateData, ...candidateData,
...(userId && { updatedById: userId }), // Update the updatedById if userId is provided ...(userId && { updatedById: userId }), // Update the updatedById if userId is provided
...(siteIds && { ...(siteIds && {
sites: { sites: {
deleteMany: {}, // Remove all existing site associations deleteMany: {}, // Remove all existing site associations
create: siteIds.map(siteId => ({ create: siteIds.map((siteId) => ({
site: { site: {
connect: { id: siteId } connect: { id: siteId },
}
}))
}
})
}, },
include: { })),
sites: { },
include: { }),
site: true },
} include: {
}, sites: {
comments: { include: {
include: { site: true,
createdBy: { },
select: { },
id: true, comments: {
name: true, include: {
email: true, createdBy: {
}, select: {
}, id: true,
}, name: true,
orderBy: { email: true,
createdAt: 'desc',
},
},
}, },
}); },
} catch (error) { },
throw new NotFoundException(`Candidate with ID ${id} not found`); orderBy: {
} createdAt: 'desc',
},
},
},
});
} catch (error) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
} }
}
// New method for adding sites to a candidate with partner check
async addSitesToCandidateWithPartnerCheck(id: number, addSitesDto: AddSitesToCandidateDto, partnerId: number | null) { // New method for adding sites to a candidate with partner check
// Check if candidate exists and belongs to the partner async addSitesToCandidateWithPartnerCheck(
const candidate = await this.prisma.candidate.findUnique({ id: number,
where: { id }, addSitesDto: AddSitesToCandidateDto,
select: { partnerId: true } partnerId: number | null,
}); ) {
// Check if candidate exists and belongs to the partner
if (!candidate) { const candidate = await this.prisma.candidate.findUnique({
throw new NotFoundException(`Candidate with ID ${id} not found`); where: { id },
} select: { partnerId: true },
});
// Enforce partner check for PARTNER role
if (partnerId && candidate.partnerId !== partnerId) { if (!candidate) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`); throw new NotFoundException(`Candidate with ID ${id} not found`);
}
return this.addSitesToCandidate(id, addSitesDto);
} }
// New method to check if a candidate belongs to a partner // Enforce partner check for PARTNER role
async checkCandidatePartner(id: number, partnerId: number | null) { if (partnerId && candidate.partnerId !== partnerId) {
if (!partnerId) { throw new ForbiddenException(
throw new ForbiddenException('User does not have access to any partner resources'); `Access to candidate with ID ${id} is not authorized`,
} );
}
const candidate = await this.prisma.candidate.findUnique({ return this.addSitesToCandidate(id, addSitesDto);
where: { id }, }
select: { partnerId: true }
});
if (!candidate) { // New method to check if a candidate belongs to a partner
throw new NotFoundException(`Candidate with ID ${id} not found`); async checkCandidatePartner(id: number, partnerId: number | null) {
} if (!partnerId) {
throw new ForbiddenException(
'User does not have access to any partner resources',
);
}
if (candidate.partnerId !== partnerId) { const candidate = await this.prisma.candidate.findUnique({
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`); where: { id },
} select: { partnerId: true },
});
return true; if (!candidate) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
} }
// New method to check if a photo belongs to a candidate that belongs to a partner if (candidate.partnerId !== partnerId) {
async checkPhotoPartner(photoId: number, partnerId: number | null) { throw new ForbiddenException(
if (!partnerId) { `Access to candidate with ID ${id} is not authorized`,
throw new ForbiddenException('User does not have access to any partner resources'); );
} }
const photo = await this.prisma.photo.findUnique({ return true;
where: { id: photoId }, }
include: {
candidate: {
select: { partnerId: true }
}
}
});
if (!photo) { // New method to check if a photo belongs to a candidate that belongs to a partner
throw new NotFoundException(`Photo with ID ${photoId} not found`); async checkPhotoPartner(photoId: number, partnerId: number | null) {
} if (!partnerId) {
throw new ForbiddenException(
'User does not have access to any partner resources',
);
}
if (!photo.candidate || photo.candidate.partnerId !== partnerId) { const photo = await this.prisma.photo.findUnique({
throw new ForbiddenException(`Access to photo with ID ${photoId} is not authorized`); where: { id: photoId },
} include: {
candidate: {
select: { partnerId: true },
},
},
});
if (!photo) {
throw new NotFoundException(`Photo with ID ${photoId} not found`);
}
return true; if (!photo.candidate || photo.candidate.partnerId !== partnerId) {
throw new ForbiddenException(
`Access to photo with ID ${photoId} is not authorized`,
);
} }
}
\ No newline at end of file return true;
}
}
import { IsArray, IsNumber } from 'class-validator'; import { IsArray, IsNumber } from 'class-validator';
export class AddSitesToCandidateDto { export class AddSitesToCandidateDto {
@IsArray() @IsArray()
@IsNumber({}, { each: true }) @IsNumber({}, { each: true })
siteIds: number[]; siteIds: number[];
} }
\ No newline at end of file
...@@ -4,53 +4,62 @@ import { CommentResponseDto } from '../../comments/dto/comment-response.dto'; ...@@ -4,53 +4,62 @@ import { CommentResponseDto } from '../../comments/dto/comment-response.dto';
import { SiteResponseDto } from '../../sites/dto/site-response.dto'; import { SiteResponseDto } from '../../sites/dto/site-response.dto';
export class CandidateSiteDto { export class CandidateSiteDto {
@ApiProperty({ description: 'CandidateSite ID' }) @ApiProperty({ description: 'CandidateSite ID' })
id: number; id: number;
@ApiProperty({ description: 'Site associated with this candidate' }) @ApiProperty({ description: 'Site associated with this candidate' })
site: SiteResponseDto; site: SiteResponseDto;
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
createdAt: Date; createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' }) @ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date; updatedAt: Date;
} }
export class CandidateResponseDto { export class CandidateResponseDto {
@ApiProperty({ description: 'Candidate ID' }) @ApiProperty({ description: 'Candidate ID' })
id: number; id: number;
@ApiProperty({ description: 'Candidate code' }) @ApiProperty({ description: 'Candidate code' })
candidateCode: string; candidateCode: string;
@ApiProperty({ description: 'Latitude coordinate' }) @ApiProperty({ description: 'Latitude coordinate' })
latitude: number; latitude: number;
@ApiProperty({ description: 'Longitude coordinate' }) @ApiProperty({ description: 'Longitude coordinate' })
longitude: number; longitude: number;
@ApiProperty({ enum: CandidateType, description: 'Type of candidate' }) @ApiProperty({ enum: CandidateType, description: 'Type of candidate' })
type: CandidateType; type: CandidateType;
@ApiProperty({ description: 'Address of the candidate' }) @ApiProperty({ description: 'Address of the candidate' })
address: string; address: string;
@ApiProperty({ enum: CandidateStatus, description: 'Current status of the candidate' }) @ApiProperty({
currentStatus: CandidateStatus; enum: CandidateStatus,
description: 'Current status of the candidate',
})
currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' }) @ApiProperty({ description: 'Whether the candidate is ongoing' })
onGoing: boolean; onGoing: boolean;
@ApiProperty({ description: 'Sites associated with this candidate', type: [CandidateSiteDto] }) @ApiProperty({
sites: CandidateSiteDto[]; description: 'Sites associated with this candidate',
type: [CandidateSiteDto],
})
sites: CandidateSiteDto[];
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
createdAt: Date; createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' }) @ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date; updatedAt: Date;
@ApiProperty({ description: 'Comments associated with this candidate', type: [CommentResponseDto] }) @ApiProperty({
comments: CommentResponseDto[]; description: 'Comments associated with this candidate',
} type: [CommentResponseDto],
\ No newline at end of file })
comments: CommentResponseDto[];
}
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validator'; import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CandidateType { export enum CandidateType {
Greenfield = 'Greenfield', Greenfield = 'Greenfield',
Indoor = 'Indoor', Indoor = 'Indoor',
Micro = 'Micro', Micro = 'Micro',
Rooftop = 'Rooftop', Rooftop = 'Rooftop',
Tunel = 'Tunel', Tunel = 'Tunel',
} }
export enum CandidateStatus { export enum CandidateStatus {
PENDING = 'PENDING', PENDING = 'PENDING',
APPROVED = 'APPROVED', APPROVED = 'APPROVED',
REJECTED = 'REJECTED', REJECTED = 'REJECTED',
NEGOTIATION_ONGOING = 'NEGOTIATION_ONGOING', NEGOTIATION_ONGOING = 'NEGOTIATION_ONGOING',
MNO_VALIDATION = 'MNO_VALIDATION', MNO_VALIDATION = 'MNO_VALIDATION',
CLOSING = 'CLOSING', CLOSING = 'CLOSING',
SEARCH_AREA = 'SEARCH_AREA', SEARCH_AREA = 'SEARCH_AREA',
PAM = 'PAM' PAM = 'PAM',
} }
export class CreateCandidateDto { export class CreateCandidateDto {
@ApiProperty({ description: 'Candidate code' }) @ApiProperty({ description: 'Candidate code' })
@IsString() @IsString()
@IsOptional() @IsOptional()
candidateCode?: string; candidateCode?: string;
@ApiProperty({ description: 'Latitude coordinate' }) @ApiProperty({ description: 'Latitude coordinate' })
@IsNumber() @IsNumber()
latitude: number; latitude: number;
@ApiProperty({ description: 'Longitude coordinate' }) @ApiProperty({ description: 'Longitude coordinate' })
@IsNumber() @IsNumber()
longitude: number; longitude: number;
@ApiProperty({ enum: CandidateType, description: 'Type of candidate' }) @ApiProperty({ enum: CandidateType, description: 'Type of candidate' })
@IsEnum(CandidateType) @IsEnum(CandidateType)
type: CandidateType; type: CandidateType;
@ApiProperty({ description: 'Address of the candidate' }) @ApiProperty({ description: 'Address of the candidate' })
@IsString() @IsString()
address: string; address: string;
@ApiProperty({ description: 'Current status of the candidate' }) @ApiProperty({ description: 'Current status of the candidate' })
@IsEnum(CandidateStatus) @IsEnum(CandidateStatus)
currentStatus: CandidateStatus; currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' }) @ApiProperty({ description: 'Whether the candidate is ongoing' })
@IsBoolean() @IsBoolean()
onGoing: boolean; onGoing: boolean;
@ApiProperty({ description: 'IDs of the sites this candidate belongs to', type: [Number] }) @ApiProperty({
@IsNumber({}, { each: true }) description: 'IDs of the sites this candidate belongs to',
siteIds: number[]; type: [Number],
})
@ApiPropertyOptional({ description: 'Initial comment for the candidate' }) @IsNumber({}, { each: true })
@IsString() siteIds: number[];
@IsOptional()
comment?: string; @ApiPropertyOptional({ description: 'Initial comment for the candidate' })
} @IsString()
\ No newline at end of file @IsOptional()
comment?: string;
}
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validator'; import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { CandidateType, CandidateStatus } from './create-candidate.dto'; import { CandidateType, CandidateStatus } from './create-candidate.dto';
export class QueryCandidateDto { export class QueryCandidateDto {
@ApiProperty({ description: 'Filter by candidate code', required: false }) @ApiProperty({ description: 'Filter by candidate code', required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
candidateCode?: string; candidateCode?: string;
@ApiProperty({ description: 'Filter by type', required: false, enum: CandidateType }) @ApiProperty({
@IsOptional() description: 'Filter by type',
@IsEnum(CandidateType) required: false,
type?: CandidateType; enum: CandidateType,
})
@IsOptional()
@IsEnum(CandidateType)
type?: CandidateType;
@ApiProperty({ description: 'Filter by current status', required: false, enum: CandidateStatus }) @ApiProperty({
@IsOptional() description: 'Filter by current status',
@IsEnum(CandidateStatus) required: false,
currentStatus?: CandidateStatus; enum: CandidateStatus,
})
@IsOptional()
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
@ApiProperty({ description: 'Filter by ongoing status', required: false }) @ApiProperty({ description: 'Filter by ongoing status', required: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@Transform(({ value }) => value === 'true') @Transform(({ value }) => value === 'true')
onGoing?: boolean; onGoing?: boolean;
@ApiProperty({ description: 'Filter by site ID', required: false }) @ApiProperty({ description: 'Filter by site ID', required: false })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Transform(({ value }) => parseInt(value)) @Transform(({ value }) => parseInt(value))
siteId?: number; siteId?: number;
@ApiProperty({ description: 'Page number for pagination', required: false, default: 1 }) @ApiProperty({
@IsOptional() description: 'Page number for pagination',
@Transform(({ value }) => parseInt(value)) required: false,
page?: number = 1; default: 1,
})
@IsOptional()
@Transform(({ value }) => parseInt(value))
page?: number = 1;
@ApiProperty({ description: 'Number of items per page', required: false, default: 10 }) @ApiProperty({
@IsOptional() description: 'Number of items per page',
@Transform(({ value }) => parseInt(value)) required: false,
limit?: number = 10; default: 10,
} })
\ No newline at end of file @IsOptional()
@Transform(({ value }) => parseInt(value))
limit?: number = 10;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validator'; import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { CandidateType, CandidateStatus } from './create-candidate.dto'; import { CandidateType, CandidateStatus } from './create-candidate.dto';
export class UpdateCandidateDto { export class UpdateCandidateDto {
@ApiPropertyOptional({ description: 'Candidate code' }) @ApiPropertyOptional({ description: 'Candidate code' })
@IsOptional() @IsOptional()
@IsString() @IsString()
candidateCode?: string; candidateCode?: string;
@ApiPropertyOptional({ description: 'Latitude coordinate' }) @ApiPropertyOptional({ description: 'Latitude coordinate' })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
latitude?: number; latitude?: number;
@ApiPropertyOptional({ description: 'Longitude coordinate' }) @ApiPropertyOptional({ description: 'Longitude coordinate' })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
longitude?: number; longitude?: number;
@ApiPropertyOptional({ enum: CandidateType, description: 'Type of candidate' }) @ApiPropertyOptional({
@IsOptional() enum: CandidateType,
@IsEnum(CandidateType) description: 'Type of candidate',
type?: CandidateType; })
@IsOptional()
@ApiPropertyOptional({ description: 'Address of the candidate' }) @IsEnum(CandidateType)
@IsOptional() type?: CandidateType;
@IsString()
address?: string; @ApiPropertyOptional({ description: 'Address of the candidate' })
@IsOptional()
@ApiPropertyOptional({ enum: CandidateStatus, description: 'Current status of the candidate' }) @IsString()
@IsOptional() address?: string;
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus; @ApiPropertyOptional({
enum: CandidateStatus,
@ApiPropertyOptional({ description: 'Whether the candidate is ongoing' }) description: 'Current status of the candidate',
@IsOptional() })
@IsBoolean() @IsOptional()
onGoing?: boolean; @IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
@ApiPropertyOptional({ description: 'IDs of the sites this candidate belongs to', type: [Number] })
@IsOptional() @ApiPropertyOptional({ description: 'Whether the candidate is ongoing' })
@IsNumber({}, { each: true }) @IsOptional()
siteIds?: number[]; @IsBoolean()
} onGoing?: boolean;
\ No newline at end of file
@ApiPropertyOptional({
description: 'IDs of the sites this candidate belongs to',
type: [Number],
})
@IsOptional()
@IsNumber({}, { each: true })
siteIds?: number[];
}
...@@ -2,27 +2,27 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,27 +2,27 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional } from 'class-validator'; import { IsString, IsNumber, IsOptional } from 'class-validator';
export class UploadPhotoDto { export class UploadPhotoDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Optional: The filename to use' description: 'Optional: The filename to use',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
filename?: string; filename?: string;
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Optional: The MIME type of the file' description: 'Optional: The MIME type of the file',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
mimeType?: string; mimeType?: string;
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Optional: The size of the file in bytes' description: 'Optional: The size of the file in bytes',
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
size?: number; size?: number;
} }
\ No newline at end of file
import { Controller, Get, Post, Body, Param, Delete, UseGuards, ParseIntPipe, Req, Put } from '@nestjs/common'; import {
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; Controller,
Get,
Post,
Body,
Param,
Delete,
UseGuards,
ParseIntPipe,
Req,
Put,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiBody,
} from '@nestjs/swagger';
import { CommentsService } from './comments.service'; import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto';
...@@ -15,55 +32,74 @@ import { Request } from 'express'; ...@@ -15,55 +32,74 @@ import { Request } from 'express';
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
export class CommentsController { export class CommentsController {
constructor(private readonly commentsService: CommentsService) { } constructor(private readonly commentsService: CommentsService) {}
@Post() @Post()
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER) @Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Create a new comment' }) @ApiOperation({ summary: 'Create a new comment' })
@ApiBody({ type: CreateCommentDto }) @ApiBody({ type: CreateCommentDto })
@ApiResponse({ status: 201, description: 'The comment has been successfully created.', type: CommentResponseDto }) @ApiResponse({
@ApiResponse({ status: 400, description: 'Bad Request.' }) status: 201,
create(@Body() createCommentDto: CreateCommentDto, @Req() req: Request) { description: 'The comment has been successfully created.',
const user = req.user as any; type: CommentResponseDto,
createCommentDto.createdById = user.id; })
return this.commentsService.create(createCommentDto); @ApiResponse({ status: 400, description: 'Bad Request.' })
} create(@Body() createCommentDto: CreateCommentDto, @Req() req: Request) {
const user = req.user as any;
createCommentDto.createdById = user.id;
return this.commentsService.create(createCommentDto);
}
@Get('candidate/:candidateId') @Get('candidate/:candidateId')
@ApiOperation({ summary: 'Get all comments for a candidate' }) @ApiOperation({ summary: 'Get all comments for a candidate' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return all comments for the candidate.', description: 'Return all comments for the candidate.',
type: [CommentResponseDto] type: [CommentResponseDto],
}) })
findAll(@Param('candidateId', ParseIntPipe) candidateId: number) { findAll(@Param('candidateId', ParseIntPipe) candidateId: number) {
return this.commentsService.findAll(candidateId); return this.commentsService.findAll(candidateId);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get a comment by id' }) @ApiOperation({ summary: 'Get a comment by id' })
@ApiResponse({ status: 200, description: 'Return the comment.', type: CommentResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Comment not found.' }) status: 200,
findOne(@Param('id', ParseIntPipe) id: number) { description: 'Return the comment.',
return this.commentsService.findOne(id); type: CommentResponseDto,
} })
@ApiResponse({ status: 404, description: 'Comment not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.commentsService.findOne(id);
}
@Delete(':id') @Delete(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER) @Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Delete a comment' }) @ApiOperation({ summary: 'Delete a comment' })
@ApiResponse({ status: 200, description: 'The comment has been successfully deleted.', type: CommentResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Comment not found.' }) status: 200,
remove(@Param('id', ParseIntPipe) id: number) { description: 'The comment has been successfully deleted.',
return this.commentsService.remove(id); type: CommentResponseDto,
} })
@ApiResponse({ status: 404, description: 'Comment not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.commentsService.remove(id);
}
@Put(':id') @Put(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER) @Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Update a comment' }) @ApiOperation({ summary: 'Update a comment' })
@ApiBody({ type: UpdateCommentDto }) @ApiBody({ type: UpdateCommentDto })
@ApiResponse({ status: 200, description: 'The comment has been successfully updated.', type: CommentResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Comment not found.' }) status: 200,
update(@Param('id', ParseIntPipe) id: number, @Body() updateCommentDto: UpdateCommentDto) { description: 'The comment has been successfully updated.',
return this.commentsService.update(id, updateCommentDto); type: CommentResponseDto,
} })
} @ApiResponse({ status: 404, description: 'Comment not found.' })
\ No newline at end of file update(
@Param('id', ParseIntPipe) id: number,
@Body() updateCommentDto: UpdateCommentDto,
) {
return this.commentsService.update(id, updateCommentDto);
}
}
...@@ -4,8 +4,8 @@ import { CommentsController } from './comments.controller'; ...@@ -4,8 +4,8 @@ import { CommentsController } from './comments.controller';
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaService } from '../../common/prisma/prisma.service';
@Module({ @Module({
controllers: [CommentsController], controllers: [CommentsController],
providers: [CommentsService, PrismaService], providers: [CommentsService, PrismaService],
exports: [CommentsService], exports: [CommentsService],
}) })
export class CommentsModule { } export class CommentsModule {}
\ No newline at end of file
...@@ -5,88 +5,88 @@ import { UpdateCommentDto } from './dto/update-comment.dto'; ...@@ -5,88 +5,88 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
async create(createCommentDto: CreateCommentDto) { async create(createCommentDto: CreateCommentDto) {
return this.prisma.comment.create({ return this.prisma.comment.create({
data: { data: {
content: createCommentDto.content, content: createCommentDto.content,
candidateId: createCommentDto.candidateId, candidateId: createCommentDto.candidateId,
createdById: createCommentDto.createdById, createdById: createCommentDto.createdById,
}, },
include: { include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
}, },
}); });
} }
async findAll(candidateId: number) { async findAll(candidateId: number) {
return this.prisma.comment.findMany({ return this.prisma.comment.findMany({
where: { where: {
candidateId, candidateId,
}, },
include: { include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
}); });
} }
async findOne(id: number) { async findOne(id: number) {
return this.prisma.comment.findUnique({ return this.prisma.comment.findUnique({
where: { id }, where: { id },
include: { include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
}, },
}); });
} }
async remove(id: number) { async remove(id: number) {
return this.prisma.comment.delete({ return this.prisma.comment.delete({
where: { id }, where: { id },
}); });
} }
async update(id: number, updateCommentDto: UpdateCommentDto) { async update(id: number, updateCommentDto: UpdateCommentDto) {
try { try {
return await this.prisma.comment.update({ return await this.prisma.comment.update({
where: { id }, where: { id },
data: { data: {
content: updateCommentDto.content, content: updateCommentDto.content,
updatedAt: new Date(), updatedAt: new Date(),
}, },
include: { include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
}, },
}); });
} catch (error) { } catch (error) {
throw new NotFoundException(`Comment with ID ${id} not found`); throw new NotFoundException(`Comment with ID ${id} not found`);
}
} }
} }
\ No newline at end of file }
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
class UserResponseDto { class UserResponseDto {
@ApiProperty({ description: 'User ID' }) @ApiProperty({ description: 'User ID' })
id: number; id: number;
@ApiProperty({ description: 'User name' }) @ApiProperty({ description: 'User name' })
name: string; name: string;
@ApiProperty({ description: 'User email' }) @ApiProperty({ description: 'User email' })
email: string; email: string;
} }
export class CommentResponseDto { export class CommentResponseDto {
@ApiProperty({ description: 'Comment ID' }) @ApiProperty({ description: 'Comment ID' })
id: number; id: number;
@ApiProperty({ description: 'Comment content' }) @ApiProperty({ description: 'Comment content' })
content: string; content: string;
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
createdAt: Date; createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' }) @ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date; updatedAt: Date;
@ApiProperty({ description: 'ID of the candidate this comment belongs to' }) @ApiProperty({ description: 'ID of the candidate this comment belongs to' })
candidateId: number; candidateId: number;
@ApiProperty({ description: 'User who created the comment', type: UserResponseDto }) @ApiProperty({
createdBy: UserResponseDto; description: 'User who created the comment',
} type: UserResponseDto,
\ No newline at end of file })
createdBy: UserResponseDto;
}
...@@ -2,28 +2,29 @@ import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator'; ...@@ -2,28 +2,29 @@ import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class CreateCommentDto { export class CreateCommentDto {
@ApiProperty({ @ApiProperty({
description: 'The content of the comment', description: 'The content of the comment',
example: 'This is a comment about the candidate' example: 'This is a comment about the candidate',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
content: string; content: string;
@ApiProperty({ @ApiProperty({
description: 'The ID of the candidate this comment is for', description: 'The ID of the candidate this comment is for',
example: 64 example: 64,
}) })
@IsInt() @IsInt()
@IsNotEmpty() @IsNotEmpty()
candidateId: number; candidateId: number;
@ApiProperty({ @ApiProperty({
description: 'The ID of the user creating the comment (optional, will be set automatically)', description:
example: 1, 'The ID of the user creating the comment (optional, will be set automatically)',
required: false example: 1,
}) required: false,
@IsInt() })
@IsOptional() @IsInt()
createdById?: number; @IsOptional()
} createdById?: number;
\ No newline at end of file }
...@@ -2,11 +2,11 @@ import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; ...@@ -2,11 +2,11 @@ import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class UpdateCommentDto { export class UpdateCommentDto {
@ApiProperty({ @ApiProperty({
description: 'The updated content of the comment', description: 'The updated content of the comment',
example: 'This is an updated comment about the candidate' example: 'This is an updated comment about the candidate',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
content: string; content: string;
} }
\ No newline at end of file
import { Controller, Get, UseGuards } from '@nestjs/common'; import { Controller, Get, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { DashboardStatsDto } from './dto/dashboard.dto'; import { DashboardStatsDto } from './dto/dashboard.dto';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
...@@ -12,17 +17,17 @@ import { Role } from '@prisma/client'; ...@@ -12,17 +17,17 @@ import { Role } from '@prisma/client';
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
export class DashboardController { export class DashboardController {
constructor(private readonly dashboardService: DashboardService) { } constructor(private readonly dashboardService: DashboardService) {}
@Get() @Get()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Get dashboard statistics and data' }) @ApiOperation({ summary: 'Get dashboard statistics and data' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return dashboard statistics and data', description: 'Return dashboard statistics and data',
type: DashboardStatsDto, type: DashboardStatsDto,
}) })
getDashboard() { getDashboard() {
return this.dashboardService.getDashboardStats(); return this.dashboardService.getDashboardStats();
} }
} }
\ No newline at end of file
...@@ -4,9 +4,9 @@ import { DashboardService } from './dashboard.service'; ...@@ -4,9 +4,9 @@ import { DashboardService } from './dashboard.service';
import { PrismaModule } from '../../common/prisma/prisma.module'; import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule],
controllers: [DashboardController], controllers: [DashboardController],
providers: [DashboardService], providers: [DashboardService],
exports: [DashboardService], exports: [DashboardService],
}) })
export class DashboardModule { } export class DashboardModule {}
\ No newline at end of file
...@@ -5,29 +5,29 @@ import { Prisma } from '@prisma/client'; ...@@ -5,29 +5,29 @@ import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
async getDashboardStats(): Promise<DashboardStatsDto> { async getDashboardStats(): Promise<DashboardStatsDto> {
// Get total counts // Get total counts
const [totalSites, totalCandidates, totalUsers] = await Promise.all([ const [totalSites, totalCandidates, totalUsers] = await Promise.all([
this.prisma.site.count(), this.prisma.site.count(),
this.prisma.candidate.count(), this.prisma.candidate.count(),
this.prisma.user.count(), this.prisma.user.count(),
]); ]);
// Get ongoing candidates count // Get ongoing candidates count
const ongoingCandidates = await this.prisma.candidate.count({ const ongoingCandidates = await this.prisma.candidate.count({
where: { onGoing: true }, where: { onGoing: true },
}); });
// Get candidates by status // Get candidates by status
const candidatesByStatus = await this.prisma.candidate.groupBy({ const candidatesByStatus = await this.prisma.candidate.groupBy({
by: ['currentStatus'], by: ['currentStatus'],
_count: true, _count: true,
}); });
// Get candidates per site with BigInt count conversion to Number // Get candidates per site with BigInt count conversion to Number
const candidatesPerSite = await this.prisma.$queryRaw` const candidatesPerSite = await this.prisma.$queryRaw`
SELECT SELECT
"Site"."id" as "siteId", "Site"."id" as "siteId",
"Site"."siteCode", "Site"."siteCode",
...@@ -40,8 +40,8 @@ export class DashboardService { ...@@ -40,8 +40,8 @@ export class DashboardService {
LIMIT 10 LIMIT 10
`; `;
// Get recent activity // Get recent activity
const recentActivity = await this.prisma.$queryRaw` const recentActivity = await this.prisma.$queryRaw`
SELECT SELECT
'site' as type, 'site' as type,
"Site"."id" as id, "Site"."id" as id,
...@@ -65,54 +65,54 @@ export class DashboardService { ...@@ -65,54 +65,54 @@ export class DashboardService {
LIMIT 10 LIMIT 10
`; `;
// Get users by role // Get users by role
const usersByRole = await this.prisma.user.groupBy({ const usersByRole = await this.prisma.user.groupBy({
by: ['role'], by: ['role'],
_count: true, _count: true,
}); });
// Helper function to convert BigInt values to numbers // Helper function to convert BigInt values to numbers
const convertBigIntToNumber = (obj: any): any => { const convertBigIntToNumber = (obj: any): any => {
if (obj === null || obj === undefined) { if (obj === null || obj === undefined) {
return obj; return obj;
} }
if (typeof obj === 'bigint') { if (typeof obj === 'bigint') {
return Number(obj); return Number(obj);
} }
if (typeof obj === 'object') { if (typeof obj === 'object') {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(convertBigIntToNumber); return obj.map(convertBigIntToNumber);
} }
const result = {}; const result = {};
for (const key in obj) { for (const key in obj) {
result[key] = convertBigIntToNumber(obj[key]); result[key] = convertBigIntToNumber(obj[key]);
} }
return result; return result;
} }
return obj; return obj;
}; };
return { return {
totalSites, totalSites,
totalCandidates, totalCandidates,
ongoingCandidates, ongoingCandidates,
candidatesByStatus: candidatesByStatus.reduce((acc, curr) => { candidatesByStatus: candidatesByStatus.reduce((acc, curr) => {
acc[curr.currentStatus] = curr._count; acc[curr.currentStatus] = curr._count;
return acc; return acc;
}, {}), }, {}),
candidatesPerSite: convertBigIntToNumber(candidatesPerSite) as any, candidatesPerSite: convertBigIntToNumber(candidatesPerSite),
recentActivity: convertBigIntToNumber(recentActivity) as any, recentActivity: convertBigIntToNumber(recentActivity),
userStats: { userStats: {
totalUsers, totalUsers,
usersByRole: usersByRole.reduce((acc, curr) => { usersByRole: usersByRole.reduce((acc, curr) => {
acc[curr.role] = curr._count; acc[curr.role] = curr._count;
return acc; return acc;
}, {}), }, {}),
}, },
}; };
} }
} }
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class DashboardStatsDto { export class DashboardStatsDto {
@ApiProperty({ description: 'Total number of sites' }) @ApiProperty({ description: 'Total number of sites' })
totalSites: number; totalSites: number;
@ApiProperty({ description: 'Total number of candidates' }) @ApiProperty({ description: 'Total number of candidates' })
totalCandidates: number; totalCandidates: number;
@ApiProperty({ description: 'Number of ongoing candidates' }) @ApiProperty({ description: 'Number of ongoing candidates' })
ongoingCandidates: number; ongoingCandidates: number;
@ApiProperty({ description: 'Number of candidates by status' }) @ApiProperty({ description: 'Number of candidates by status' })
candidatesByStatus: { candidatesByStatus: {
[key: string]: number; [key: string]: number;
}; };
@ApiProperty({ description: 'Number of candidates per site' }) @ApiProperty({ description: 'Number of candidates per site' })
candidatesPerSite: { candidatesPerSite: {
siteId: number; siteId: number;
siteCode: string; siteCode: string;
siteName: string; siteName: string;
count: number; count: number;
}[]; }[];
@ApiProperty({ description: 'Recent activity' }) @ApiProperty({ description: 'Recent activity' })
recentActivity: { recentActivity: {
id: number; id: number;
type: 'site' | 'candidate'; type: 'site' | 'candidate';
action: 'created' | 'updated'; action: 'created' | 'updated';
timestamp: Date; timestamp: Date;
userId: number; userId: number;
userName: string; userName: string;
}[]; }[];
@ApiProperty({ description: 'User statistics' }) @ApiProperty({ description: 'User statistics' })
userStats: { userStats: {
totalUsers: number; totalUsers: number;
usersByRole: { usersByRole: {
[key: string]: number; [key: string]: number;
};
}; };
} };
\ No newline at end of file }
import {
IsDateString,
IsInt,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { MaintenanceResponseOption } from './maintenance-response-option.enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateMaintenanceResponseDto {
@ApiProperty({
description: 'The ID of the maintenance question being answered',
example: 1,
type: Number,
})
@IsInt()
questionId: number;
@ApiProperty({
description: 'The response to the maintenance question',
enum: MaintenanceResponseOption,
example: MaintenanceResponseOption.YES,
enumName: 'MaintenanceResponseOption',
})
@IsString()
response: MaintenanceResponseOption;
@ApiPropertyOptional({
description:
'Optional comment providing additional details about the response',
example:
'Equipment is in good working condition, but some minor rust was observed',
type: String,
})
@IsString()
@IsOptional()
comment?: string;
}
export class CreateMaintenanceDto {
@ApiProperty({
description: 'Date when the maintenance was performed',
example: '2025-05-21T13:00:00.000Z',
type: String,
})
@IsDateString()
date: string;
@ApiProperty({
description: 'ID of the site where the maintenance was performed',
example: 1,
type: Number,
})
@IsInt()
siteId: number;
@ApiPropertyOptional({
description: 'Optional general comment about the maintenance',
example: 'Regular annual maintenance. Site is in good overall condition.',
type: String,
})
@IsString()
@IsOptional()
comment?: string;
@ApiProperty({
description: 'Responses to maintenance questions',
type: [CreateMaintenanceResponseDto],
example: [
{
questionId: 1,
response: 'YES',
comment: 'Access is clear and well-maintained',
},
{
questionId: 2,
response: 'NO',
comment: 'Infrastructure needs some repairs',
},
{
questionId: 3,
response: 'YES',
comment: null,
},
],
})
@ValidateNested({ each: true })
@Type(() => CreateMaintenanceResponseDto)
responses: CreateMaintenanceResponseDto[];
}
import { IsDateString, IsInt, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class FindMaintenanceDto {
@ApiPropertyOptional({
description: 'Filter maintenance records by site ID',
example: 1,
type: Number,
})
@IsInt()
@IsOptional()
siteId?: number;
@ApiPropertyOptional({
description:
'Filter maintenance records with date greater than or equal to this date',
example: '2025-01-01T00:00:00.000Z',
type: String,
})
@IsDateString()
@IsOptional()
startDate?: string;
@ApiPropertyOptional({
description:
'Filter maintenance records with date less than or equal to this date',
example: '2025-12-31T23:59:59.999Z',
type: String,
})
@IsDateString()
@IsOptional()
endDate?: string;
}
export * from './create-maintenance.dto';
export * from './find-maintenance.dto';
export * from './maintenance-response.dto';
export * from './maintenance-response-option.enum';
/**
* Response options for maintenance questions
*
* YES - Item is in good condition/working properly
* NO - Item needs attention/repair
* NA - Not applicable for this site
*/
export enum MaintenanceResponseOption {
YES = 'YES',
NO = 'NO',
NA = 'NA',
}
import { MaintenanceResponseOption } from './maintenance-response-option.enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class MaintenanceQuestionDto {
@ApiProperty({
description: 'Unique identifier of the maintenance question',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'Text of the maintenance question',
example: 'Site access condition',
type: String,
})
question: string;
@ApiProperty({
description: 'Order index for sorting questions',
example: 1,
type: Number,
})
orderIndex: number;
}
export class MaintenanceResponseDto {
@ApiProperty({
description: 'Unique identifier of the maintenance response',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'Response option selected for the question',
enum: MaintenanceResponseOption,
example: MaintenanceResponseOption.YES,
enumName: 'MaintenanceResponseOption',
})
response: MaintenanceResponseOption;
@ApiPropertyOptional({
description: 'Optional comment providing additional details',
example: 'Access road is well maintained but gate lock needs lubrication',
type: String,
})
comment?: string;
@ApiProperty({
description: 'The question this response answers',
type: MaintenanceQuestionDto,
})
question: MaintenanceQuestionDto;
}
export class MaintenancePhotoDto {
@ApiProperty({
description: 'Unique identifier of the maintenance photo',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'URL to access the photo',
example: '/uploads/maintenance/1/photo1.jpg',
type: String,
})
url: string;
@ApiProperty({
description: 'Original filename of the photo',
example: 'photo1.jpg',
type: String,
})
filename: string;
}
export class MaintenanceDto {
@ApiProperty({
description: 'Unique identifier of the maintenance record',
example: 1,
type: Number,
})
id: number;
@ApiProperty({
description: 'Date when the maintenance was performed',
example: '2025-05-21T13:00:00.000Z',
type: Date,
})
date: Date;
@ApiPropertyOptional({
description: 'Optional general comment about the maintenance',
example: 'Annual preventive maintenance completed with minor issues noted',
type: String,
})
comment?: string;
@ApiProperty({
description: 'ID of the site where maintenance was performed',
example: 1,
type: Number,
})
siteId: number;
@ApiProperty({
description: 'Date and time when the record was created',
example: '2025-05-21T13:15:30.000Z',
type: Date,
})
createdAt: Date;
@ApiProperty({
description: 'Date and time when the record was last updated',
example: '2025-05-21T13:15:30.000Z',
type: Date,
})
updatedAt: Date;
@ApiProperty({
description: 'Responses to maintenance questions',
type: [MaintenanceResponseDto],
isArray: true,
})
responses: MaintenanceResponseDto[];
@ApiProperty({
description: 'Photos attached to the maintenance record',
type: [MaintenancePhotoDto],
isArray: true,
})
photos: MaintenancePhotoDto[];
}
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
Req,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { MaintenanceService } from './maintenance.service';
import {
CreateMaintenanceDto,
CreateMaintenanceResponseDto,
} from './dto/create-maintenance.dto';
import { FindMaintenanceDto } from './dto/find-maintenance.dto';
import { multerConfig } from '../../common/multer/multer.config';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
ApiParam,
ApiQuery,
getSchemaPath,
} from '@nestjs/swagger';
import {
MaintenanceDto,
MaintenanceQuestionDto,
MaintenanceResponseDto,
} from './dto/maintenance-response.dto';
@ApiTags('maintenance')
@Controller('maintenance')
@ApiBearerAuth('access-token')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@UseInterceptors(FilesInterceptor('photos', 10, multerConfig))
@ApiOperation({
summary: 'Create a new maintenance record',
description:
'Creates a new maintenance record for a site with responses to maintenance questions and optional photos. Only users with ADMIN, MANAGER, OPERATOR, or PARTNER roles can create maintenance records.',
})
@ApiResponse({
status: 201,
description: 'The maintenance record has been successfully created.',
type: MaintenanceDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Site not found.' })
@ApiBearerAuth('access-token')
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Maintenance data with optional photos',
schema: {
type: 'object',
required: ['date', 'siteId', 'responses'],
properties: {
date: {
type: 'string',
format: 'date-time',
example: '2025-05-21T13:00:00.000Z',
description: 'Date when the maintenance was performed',
},
siteId: {
type: 'integer',
example: 1,
description: 'ID of the site where the maintenance was performed',
},
comment: {
type: 'string',
example:
'Regular annual maintenance. Site is in good overall condition.',
description: 'Optional general comment about the maintenance',
},
responses: {
type: 'array',
items: {
$ref: getSchemaPath(CreateMaintenanceDto),
},
description: 'Responses to maintenance questions',
},
photos: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
description:
'Photos documenting the site condition (max 10 photos, max 5MB each)',
},
},
},
})
async createMaintenance(
@Body() createMaintenanceDto: CreateMaintenanceDto,
@UploadedFiles() files: Express.Multer.File[],
@Req() req,
) {
return this.maintenanceService.createMaintenance(
createMaintenanceDto,
req.user.id,
files,
);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Find all maintenance records with optional filters',
description:
'Retrieves a list of maintenance records. Can be filtered by site ID and date range.',
})
@ApiResponse({
status: 200,
description: 'List of maintenance records.',
type: [MaintenanceDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiQuery({
name: 'siteId',
required: false,
type: Number,
description: 'Filter by site ID',
example: 1,
})
@ApiQuery({
name: 'startDate',
required: false,
type: String,
description: 'Filter by start date (inclusive)',
example: '2025-01-01T00:00:00.000Z',
})
@ApiQuery({
name: 'endDate',
required: false,
type: String,
description: 'Filter by end date (inclusive)',
example: '2025-12-31T23:59:59.999Z',
})
async findAllMaintenance(@Query() findMaintenanceDto: FindMaintenanceDto) {
return this.maintenanceService.findAllMaintenance(findMaintenanceDto);
}
@Get('questions')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Get all maintenance questions',
description:
'Retrieves the list of predefined maintenance questions that need to be answered during maintenance.',
})
@ApiResponse({
status: 200,
description: 'List of maintenance questions.',
type: [MaintenanceQuestionDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
async getMaintenanceQuestions() {
return this.maintenanceService.getMaintenanceQuestions();
}
@Get('questions/:id')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Get a specific maintenance question by ID',
description: 'Retrieves a specific maintenance question by its ID.',
})
@ApiResponse({
status: 200,
description: 'The maintenance question.',
type: MaintenanceQuestionDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 404, description: 'Question not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the maintenance question to retrieve',
example: 1,
})
async getMaintenanceQuestionById(@Param('id', ParseIntPipe) id: number) {
return this.maintenanceService.getMaintenanceQuestionById(id);
}
@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Find maintenance record by ID',
description:
'Retrieves a specific maintenance record by its ID, including all responses and photos.',
})
@ApiResponse({
status: 200,
description: 'The maintenance record.',
type: MaintenanceDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 404, description: 'Maintenance record not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the maintenance record to retrieve',
example: 1,
})
async findMaintenanceById(@Param('id', ParseIntPipe) id: number) {
return this.maintenanceService.findMaintenanceById(id);
}
@Get(':id/responses')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Get responses for a specific maintenance record',
description:
'Retrieves all responses for a specific maintenance record including the associated questions.',
})
@ApiResponse({
status: 200,
description: 'List of maintenance responses.',
type: [MaintenanceResponseDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({ status: 404, description: 'Maintenance record not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the maintenance record',
example: 1,
})
async getResponsesByMaintenanceId(@Param('id', ParseIntPipe) id: number) {
return this.maintenanceService.getResponsesByMaintenanceId(id);
}
@Post(':id/responses')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@ApiOperation({
summary: 'Add responses to an existing maintenance record',
description:
'Adds or updates responses for a specific maintenance record. Can be used to complete a partially filled maintenance record.',
})
@ApiResponse({
status: 201,
description: 'The responses have been successfully added.',
type: [MaintenanceResponseDto],
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({
status: 404,
description: 'Maintenance record or question not found.',
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the maintenance record',
example: 1,
})
@ApiBody({
description: 'Array of maintenance responses to add',
type: [CreateMaintenanceResponseDto],
})
async addResponsesToMaintenance(
@Param('id', ParseIntPipe) id: number,
@Body() responses: CreateMaintenanceResponseDto[],
@Req() req,
) {
return this.maintenanceService.addResponsesToMaintenance(
id,
responses,
req.user.id,
);
}
}
/**
* Example requests and responses for the Maintenance API
* This file is for documentation purposes only
*/
/**
* Example request for creating a maintenance record
*/
export const createMaintenanceExample = {
date: '2025-05-21T13:00:00.000Z',
siteId: 1,
comment: 'Regular annual maintenance. Site is in good overall condition.',
responses: [
{
questionId: 1,
response: 'YES',
comment: 'Access is clear and well-maintained',
},
{
questionId: 2,
response: 'NO',
comment:
'Infrastructure needs some repairs - fence has multiple damaged sections',
},
{
questionId: 3,
response: 'YES',
comment: 'Equipment is functioning properly',
},
{
questionId: 4,
response: 'YES',
comment: 'Power systems are operational',
},
{
questionId: 5,
response: 'NA',
comment: 'No cooling system at this site',
},
],
};
/**
* Example response for a maintenance record
*/
export const maintenanceResponseExample = {
id: 1,
date: '2025-05-21T13:00:00.000Z',
comment: 'Regular annual maintenance. Site is in good overall condition.',
siteId: 1,
createdAt: '2025-05-21T13:15:30.000Z',
updatedAt: '2025-05-21T13:15:30.000Z',
responses: [
{
id: 1,
response: 'YES',
comment: 'Access is clear and well-maintained',
question: {
id: 1,
question: 'Site access condition',
orderIndex: 1,
},
},
{
id: 2,
response: 'NO',
comment:
'Infrastructure needs some repairs - fence has multiple damaged sections',
question: {
id: 2,
question: 'Site infrastructure condition',
orderIndex: 2,
},
},
{
id: 3,
response: 'YES',
comment: 'Equipment is functioning properly',
question: {
id: 3,
question: 'Equipment condition',
orderIndex: 3,
},
},
{
id: 4,
response: 'YES',
comment: 'Power systems are operational',
question: {
id: 4,
question: 'Power system condition',
orderIndex: 4,
},
},
{
id: 5,
response: 'NA',
comment: 'No cooling system at this site',
question: {
id: 5,
question: 'Cooling system condition',
orderIndex: 5,
},
},
],
photos: [
{
id: 1,
url: '/uploads/maintenance/1/entrance.jpg',
filename: 'entrance.jpg',
},
{
id: 2,
url: '/uploads/maintenance/1/damaged_fence.jpg',
filename: 'damaged_fence.jpg',
},
{
id: 3,
url: '/uploads/maintenance/1/equipment.jpg',
filename: 'equipment.jpg',
},
],
};
/**
* Example response for maintenance questions
*/
export const maintenanceQuestionsExample = [
{
id: 1,
question: 'Site access condition',
orderIndex: 1,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 2,
question: 'Site infrastructure condition',
orderIndex: 2,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 3,
question: 'Equipment condition',
orderIndex: 3,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 4,
question: 'Power system condition',
orderIndex: 4,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 5,
question: 'Cooling system condition',
orderIndex: 5,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 6,
question: 'Security features condition',
orderIndex: 6,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 7,
question: 'Safety equipment presence and condition',
orderIndex: 7,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 8,
question: 'Site cleanliness',
orderIndex: 8,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 9,
question: 'Vegetation control',
orderIndex: 9,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
{
id: 10,
question: 'Surrounding area condition',
orderIndex: 10,
createdAt: '2025-05-20T10:00:00.000Z',
updatedAt: '2025-05-20T10:00:00.000Z',
},
];
import { Module } from '@nestjs/common';
import { MaintenanceController } from './maintenance.controller';
import { MaintenanceService } from './maintenance.service';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [MaintenanceController],
providers: [MaintenanceService],
exports: [MaintenanceService],
})
export class MaintenanceModule {}
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import {
CreateMaintenanceDto,
CreateMaintenanceResponseDto,
} from './dto/create-maintenance.dto';
import { FindMaintenanceDto } from './dto/find-maintenance.dto';
import {
MaintenanceDto,
MaintenanceResponseDto,
MaintenancePhotoDto,
} from './dto/maintenance-response.dto';
import { MaintenanceResponseOption } from './dto/maintenance-response-option.enum';
import { saveMaintenancePhotos } from './maintenance.utils';
@Injectable()
export class MaintenanceService {
constructor(private prisma: PrismaService) { }
async createMaintenance(
dto: CreateMaintenanceDto,
userId: number,
files?: Express.Multer.File[],
): Promise<MaintenanceDto> {
// Check if site exists
const site = await this.prisma.site.findUnique({
where: { id: dto.siteId },
});
if (!site) {
throw new NotFoundException(`Site with ID ${dto.siteId} not found`);
}
// Create maintenance record
const maintenance = await this.prisma.maintenance.create({
data: {
date: new Date(dto.date),
comment: dto.comment,
site: { connect: { id: dto.siteId } },
createdBy: userId ? { connect: { id: userId } } : undefined,
responses: {
create: dto.responses.map((response) => ({
response: response.response,
comment: response.comment,
question: { connect: { id: response.questionId } },
})),
},
},
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
// Process and upload files if any
if (files && files.length > 0) {
try {
// Save files to disk
const filePaths = await saveMaintenancePhotos(files, maintenance.id);
// Create photo records in database
const photoPromises = files.map((file, index) => {
return this.prisma.maintenancePhoto.create({
data: {
filename: file.originalname,
mimeType: file.mimetype,
size: file.size,
url: filePaths[index],
maintenance: { connect: { id: maintenance.id } },
},
});
});
await Promise.all(photoPromises);
// Fetch the updated maintenance record with photos
const updatedMaintenance = await this.prisma.maintenance.findUnique({
where: { id: maintenance.id },
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
return this.mapToDto(updatedMaintenance);
} catch (error) {
console.error('Error processing maintenance photos:', error);
// Continue without photos if there's an error
}
}
return this.mapToDto(maintenance);
}
async findAllMaintenance(dto: FindMaintenanceDto): Promise<MaintenanceDto[]> {
const filter: any = {};
if (dto.siteId) {
filter.siteId = dto.siteId;
}
if (dto.startDate || dto.endDate) {
filter.date = {};
if (dto.startDate) {
filter.date.gte = new Date(dto.startDate);
}
if (dto.endDate) {
filter.date.lte = new Date(dto.endDate);
}
}
const maintenances = await this.prisma.maintenance.findMany({
where: filter,
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
orderBy: {
date: 'desc',
},
});
return maintenances.map(this.mapToDto);
}
async findMaintenanceById(id: number): Promise<MaintenanceDto> {
const maintenance = await this.prisma.maintenance.findUnique({
where: { id },
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
if (!maintenance) {
throw new NotFoundException(`Maintenance with ID ${id} not found`);
}
return this.mapToDto(maintenance);
}
async getMaintenanceQuestions() {
return this.prisma.maintenanceQuestion.findMany({
orderBy: {
orderIndex: 'asc',
},
});
}
async getMaintenanceQuestionById(id: number) {
const question = await this.prisma.maintenanceQuestion.findUnique({
where: { id },
});
if (!question) {
throw new NotFoundException(
`Maintenance question with ID ${id} not found`,
);
}
return question;
}
async getResponsesByMaintenanceId(
maintenanceId: number,
): Promise<MaintenanceResponseDto[]> {
const maintenance = await this.prisma.maintenance.findUnique({
where: { id: maintenanceId },
include: {
responses: {
include: {
question: true,
},
},
},
});
if (!maintenance) {
throw new NotFoundException(
`Maintenance with ID ${maintenanceId} not found`,
);
}
return maintenance.responses.map((response) => ({
id: response.id,
response: response.response as unknown as MaintenanceResponseOption,
comment: response.comment ?? undefined,
question: {
id: response.question.id,
question: response.question.question,
orderIndex: response.question.orderIndex,
},
}));
}
async addResponsesToMaintenance(
maintenanceId: number,
responses: CreateMaintenanceResponseDto[],
userId: number,
): Promise<MaintenanceResponseDto[]> {
// Check if maintenance exists
const maintenance = await this.prisma.maintenance.findUnique({
where: { id: maintenanceId },
});
if (!maintenance) {
throw new NotFoundException(
`Maintenance with ID ${maintenanceId} not found`,
);
}
// Create responses
type ResponseWithQuestion = {
id: number;
response: any;
comment: string | null;
question: {
id: number;
question: string;
orderIndex: number;
};
};
const createdResponses: ResponseWithQuestion[] = [];
for (const response of responses) {
// Check if question exists
const question = await this.prisma.maintenanceQuestion.findUnique({
where: { id: response.questionId },
});
if (!question) {
throw new NotFoundException(
`Question with ID ${response.questionId} not found`,
);
}
// Check if a response already exists for this question
const existingResponse = await this.prisma.maintenanceResponse.findFirst({
where: {
maintenanceId,
questionId: response.questionId,
},
});
let result;
if (existingResponse) {
// Update existing response
result = await this.prisma.maintenanceResponse.update({
where: { id: existingResponse.id },
data: {
response: response.response,
comment: response.comment,
},
include: {
question: true,
},
});
} else {
// Create new response
result = await this.prisma.maintenanceResponse.create({
data: {
response: response.response,
comment: response.comment,
question: { connect: { id: response.questionId } },
maintenance: { connect: { id: maintenanceId } },
},
include: {
question: true,
},
});
}
createdResponses.push(result);
}
// Update the maintenance record's updatedBy and updatedAt
await this.prisma.maintenance.update({
where: { id: maintenanceId },
data: {
updatedBy: { connect: { id: userId } },
},
});
// Map responses to DTOs
return createdResponses.map((response) => ({
id: response.id,
response: response.response as unknown as MaintenanceResponseOption,
comment: response.comment ?? undefined,
question: {
id: response.question.id,
question: response.question.question,
orderIndex: response.question.orderIndex,
},
}));
}
private mapToDto(maintenance: any): MaintenanceDto {
return {
id: maintenance.id,
date: maintenance.date,
comment: maintenance.comment,
siteId: maintenance.siteId,
createdAt: maintenance.createdAt,
updatedAt: maintenance.updatedAt,
responses: maintenance.responses.map((response) => ({
id: response.id,
response: response.response as unknown as MaintenanceResponseOption,
comment: response.comment ?? undefined,
question: {
id: response.question.id,
question: response.question.question,
orderIndex: response.question.orderIndex,
},
})),
photos: maintenance.photos.map((photo) => ({
id: photo.id,
url: photo.url,
filename: photo.filename,
})),
};
}
}
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
const mkdir = promisify(fs.mkdir);
const writeFile = promisify(fs.writeFile);
/**
* Saves uploaded maintenance photos to the file system
* @param files Array of uploaded files
* @param maintenanceId The ID of the maintenance record
* @returns Array of saved file paths
*/
export async function saveMaintenancePhotos(
files: Express.Multer.File[],
maintenanceId: number,
): Promise<string[]> {
if (!files || files.length === 0) {
return [];
}
const uploadDir =
process.env.NODE_ENV === 'production'
? `/home/api-cellnex/public_html/uploads/maintenance/${maintenanceId}`
: path.join(
process.cwd(),
'uploads',
'maintenance',
maintenanceId.toString(),
);
// Create directory if it doesn't exist
try {
await mkdir(uploadDir, { recursive: true });
} catch (error) {
console.error(`Error creating directory ${uploadDir}:`, error);
throw new Error(`Failed to create upload directory: ${error.message}`);
}
// Save files
const savedPaths: string[] = [];
const savePromises = files.map(async (file) => {
const filename = file.originalname;
const filePath = path.join(uploadDir, filename);
try {
await writeFile(filePath, file.buffer);
savedPaths.push(`/uploads/maintenance/${maintenanceId}/${filename}`);
} catch (error) {
console.error(`Error saving file ${filename}:`, error);
throw new Error(`Failed to save file ${filename}: ${error.message}`);
}
});
await Promise.all(savePromises);
return savedPaths;
}
...@@ -2,30 +2,30 @@ import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; ...@@ -2,30 +2,30 @@ import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class CreatePartnerDto { export class CreatePartnerDto {
@ApiProperty({ @ApiProperty({
description: 'The name of the partner organization', description: 'The name of the partner organization',
example: 'PROEF Telco Services', example: 'PROEF Telco Services',
required: true required: true,
}) })
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
name: string; name: string;
@ApiProperty({ @ApiProperty({
description: 'Additional information about the partner', description: 'Additional information about the partner',
example: 'Professional telecommunications and network service provider', example: 'Professional telecommunications and network service provider',
required: false required: false,
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@ApiProperty({ @ApiProperty({
description: 'Whether the partner is active', description: 'Whether the partner is active',
default: true, default: true,
required: false required: false,
}) })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isActive?: boolean = true; isActive?: boolean = true;
} }
\ No newline at end of file
export * from './create-partner.dto'; export * from './create-partner.dto';
export * from './update-partner.dto'; export * from './update-partner.dto';
export * from './partner-response.dto'; export * from './partner-response.dto';
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class PartnerUserDto { export class PartnerUserDto {
@ApiProperty({ description: 'User ID', example: 1 }) @ApiProperty({ description: 'User ID', example: 1 })
id: number; id: number;
@ApiProperty({ description: 'User name', example: 'John Doe' }) @ApiProperty({ description: 'User name', example: 'John Doe' })
name: string; name: string;
@ApiProperty({ description: 'User email', example: 'john.doe@example.com' }) @ApiProperty({ description: 'User email', example: 'john.doe@example.com' })
email: string; email: string;
@ApiProperty({ description: 'User role', example: 'PARTNER' }) @ApiProperty({ description: 'User role', example: 'PARTNER' })
role: string; role: string;
} }
export class PartnerCountDto { export class PartnerCountDto {
@ApiProperty({ description: 'Number of candidates associated with this partner', example: 42 }) @ApiProperty({
candidates: number; description: 'Number of candidates associated with this partner',
example: 42,
})
candidates: number;
} }
export class PartnerResponseDto { export class PartnerResponseDto {
@ApiProperty({ description: 'Partner ID', example: 1 }) @ApiProperty({ description: 'Partner ID', example: 1 })
id: number; id: number;
@ApiProperty({ description: 'Partner name', example: 'PROEF Telco Services' }) @ApiProperty({ description: 'Partner name', example: 'PROEF Telco Services' })
name: string; name: string;
@ApiProperty({ @ApiProperty({
description: 'Partner description', description: 'Partner description',
example: 'Professional telecommunications and network service provider', example: 'Professional telecommunications and network service provider',
required: false required: false,
}) })
description?: string; description?: string;
@ApiProperty({ description: 'Partner active status', example: true }) @ApiProperty({ description: 'Partner active status', example: true })
isActive: boolean; isActive: boolean;
@ApiProperty({ description: 'Partner creation timestamp', example: '2023-05-13T15:25:41.358Z' }) @ApiProperty({
createdAt: Date; description: 'Partner creation timestamp',
example: '2023-05-13T15:25:41.358Z',
})
createdAt: Date;
@ApiProperty({ description: 'Partner last update timestamp', example: '2023-05-13T15:25:41.358Z' }) @ApiProperty({
updatedAt: Date; description: 'Partner last update timestamp',
example: '2023-05-13T15:25:41.358Z',
})
updatedAt: Date;
@ApiProperty({ type: [PartnerUserDto], description: 'Users associated with this partner' }) @ApiProperty({
users?: PartnerUserDto[]; type: [PartnerUserDto],
description: 'Users associated with this partner',
})
users?: PartnerUserDto[];
@ApiProperty({ type: PartnerCountDto, description: 'Associated entity counts' }) @ApiProperty({
_count?: PartnerCountDto; type: PartnerCountDto,
} description: 'Associated entity counts',
\ No newline at end of file })
_count?: PartnerCountDto;
}
...@@ -3,24 +3,24 @@ import { CreatePartnerDto } from './create-partner.dto'; ...@@ -3,24 +3,24 @@ import { CreatePartnerDto } from './create-partner.dto';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class UpdatePartnerDto extends PartialType(CreatePartnerDto) { export class UpdatePartnerDto extends PartialType(CreatePartnerDto) {
@ApiProperty({ @ApiProperty({
description: 'The name of the partner organization', description: 'The name of the partner organization',
example: 'PROEF Telco Services', example: 'PROEF Telco Services',
required: false required: false,
}) })
name?: string; name?: string;
@ApiProperty({ @ApiProperty({
description: 'Additional information about the partner', description: 'Additional information about the partner',
example: 'Professional telecommunications and network service provider', example: 'Professional telecommunications and network service provider',
required: false required: false,
}) })
description?: string; description?: string;
@ApiProperty({ @ApiProperty({
description: 'Whether the partner is active', description: 'Whether the partner is active',
example: true, example: true,
required: false required: false,
}) })
isActive?: boolean; isActive?: boolean;
} }
\ No newline at end of file
import { import {
Controller, Controller,
Get, Get,
Post, Post,
Body, Body,
Patch, Patch,
Param, Param,
Delete, Delete,
ParseIntPipe, ParseIntPipe,
UseGuards UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { PartnersService } from './partners.service'; import { PartnersService } from './partners.service';
import { CreatePartnerDto, UpdatePartnerDto, PartnerResponseDto } from './dto'; import { CreatePartnerDto, UpdatePartnerDto, PartnerResponseDto } from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
...@@ -25,108 +31,132 @@ import { Role } from '@prisma/client'; ...@@ -25,108 +31,132 @@ import { Role } from '@prisma/client';
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
export class PartnersController { export class PartnersController {
constructor(private readonly partnersService: PartnersService) { } constructor(private readonly partnersService: PartnersService) {}
@Post()
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Create a new partner' })
@ApiResponse({
status: 201,
description: 'The partner has been successfully created.',
type: PartnerResponseDto
})
create(@Body() createPartnerDto: CreatePartnerDto) {
return this.partnersService.create(createPartnerDto);
}
@Get() @Post()
@Roles(Role.ADMIN, Role.SUPERADMIN, Role.MANAGER, Role.PARTNER) @Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Get all partners' }) @ApiOperation({ summary: 'Create a new partner' })
@ApiResponse({ @ApiResponse({
status: 200, status: 201,
description: 'Return all partners.', description: 'The partner has been successfully created.',
type: [PartnerResponseDto] type: PartnerResponseDto,
}) })
findAll(@Partner() partnerId: number | null, @User('role') role: Role) { create(@Body() createPartnerDto: CreatePartnerDto) {
// For PARTNER users, we'll only return their own partner return this.partnersService.create(createPartnerDto);
if (role === Role.PARTNER && partnerId) { }
return this.partnersService.findOne(partnerId);
}
// For other roles, return all partners @Get()
return this.partnersService.findAll(); @Roles(Role.ADMIN, Role.SUPERADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Get all partners' })
@ApiResponse({
status: 200,
description: 'Return all partners.',
type: [PartnerResponseDto],
})
findAll(@Partner() partnerId: number | null, @User('role') role: Role) {
// For PARTNER users, we'll only return their own partner
if (role === Role.PARTNER && partnerId) {
return this.partnersService.findOne(partnerId);
} }
@Get(':id') // For other roles, return all partners
@Roles(Role.ADMIN, Role.SUPERADMIN, Role.MANAGER, Role.PARTNER) return this.partnersService.findAll();
@UseGuards(PartnerAuthGuard) }
@ApiOperation({ summary: 'Get a partner by id' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({ status: 200, description: 'Return the partner.', type: PartnerResponseDto })
@ApiResponse({ status: 404, description: 'Partner not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.findOne(id);
}
@Patch(':id') @Get(':id')
@Roles(Role.ADMIN, Role.SUPERADMIN) @Roles(Role.ADMIN, Role.SUPERADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Update a partner' }) @UseGuards(PartnerAuthGuard)
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' }) @ApiOperation({ summary: 'Get a partner by id' })
@ApiResponse({ status: 200, description: 'The partner has been successfully updated.', type: PartnerResponseDto }) @ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({ status: 404, description: 'Partner not found.' }) @ApiResponse({
update( status: 200,
@Param('id', ParseIntPipe) id: number, description: 'Return the partner.',
@Body() updatePartnerDto: UpdatePartnerDto, type: PartnerResponseDto,
) { })
return this.partnersService.update(id, updatePartnerDto); @ApiResponse({ status: 404, description: 'Partner not found.' })
} findOne(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.findOne(id);
}
@Delete(':id') @Patch(':id')
@Roles(Role.ADMIN, Role.SUPERADMIN) @Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Delete a partner' }) @ApiOperation({ summary: 'Update a partner' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' }) @ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({ status: 200, description: 'The partner has been successfully deleted.', type: PartnerResponseDto }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Partner not found.' }) status: 200,
remove(@Param('id', ParseIntPipe) id: number) { description: 'The partner has been successfully updated.',
return this.partnersService.remove(id); type: PartnerResponseDto,
} })
@ApiResponse({ status: 404, description: 'Partner not found.' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updatePartnerDto: UpdatePartnerDto,
) {
return this.partnersService.update(id, updatePartnerDto);
}
@Post(':partnerId/users/:userId') @Delete(':id')
@Roles(Role.ADMIN, Role.SUPERADMIN) @Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Add a user to a partner' }) @ApiOperation({ summary: 'Delete a partner' })
@ApiParam({ name: 'partnerId', description: 'Partner ID', type: 'number' }) @ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiParam({ name: 'userId', description: 'User ID', type: 'number' }) @ApiResponse({
@ApiResponse({ status: 200, description: 'The user has been successfully added to the partner.' }) status: 200,
@ApiResponse({ status: 404, description: 'Partner or user not found.' }) description: 'The partner has been successfully deleted.',
addUserToPartner( type: PartnerResponseDto,
@Param('partnerId', ParseIntPipe) partnerId: number, })
@Param('userId', ParseIntPipe) userId: number, @ApiResponse({ status: 404, description: 'Partner not found.' })
) { remove(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.addUserToPartner(partnerId, userId); return this.partnersService.remove(id);
} }
@Delete(':partnerId/users/:userId') @Post(':partnerId/users/:userId')
@Roles(Role.ADMIN, Role.SUPERADMIN) @Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Remove a user from a partner' }) @ApiOperation({ summary: 'Add a user to a partner' })
@ApiParam({ name: 'partnerId', description: 'Partner ID', type: 'number' }) @ApiParam({ name: 'partnerId', description: 'Partner ID', type: 'number' })
@ApiParam({ name: 'userId', description: 'User ID', type: 'number' }) @ApiParam({ name: 'userId', description: 'User ID', type: 'number' })
@ApiResponse({ status: 200, description: 'The user has been successfully removed from the partner.' }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Partner, user, or association not found.' }) status: 200,
removeUserFromPartner( description: 'The user has been successfully added to the partner.',
@Param('partnerId', ParseIntPipe) partnerId: number, })
@Param('userId', ParseIntPipe) userId: number, @ApiResponse({ status: 404, description: 'Partner or user not found.' })
) { addUserToPartner(
return this.partnersService.removeUserFromPartner(partnerId, userId); @Param('partnerId', ParseIntPipe) partnerId: number,
} @Param('userId', ParseIntPipe) userId: number,
) {
return this.partnersService.addUserToPartner(partnerId, userId);
}
@Get(':id/candidates') @Delete(':partnerId/users/:userId')
@Roles(Role.ADMIN, Role.SUPERADMIN, Role.MANAGER, Role.PARTNER) @Roles(Role.ADMIN, Role.SUPERADMIN)
@UseGuards(PartnerAuthGuard) @ApiOperation({ summary: 'Remove a user from a partner' })
@ApiOperation({ summary: 'Get all candidates for a partner' }) @ApiParam({ name: 'partnerId', description: 'Partner ID', type: 'number' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' }) @ApiParam({ name: 'userId', description: 'User ID', type: 'number' })
@ApiResponse({ status: 200, description: 'Return all candidates for the partner.' }) @ApiResponse({
@ApiResponse({ status: 404, description: 'Partner not found.' }) status: 200,
getPartnerCandidates(@Param('id', ParseIntPipe) id: number) { description: 'The user has been successfully removed from the partner.',
return this.partnersService.getPartnerCandidates(id); })
} @ApiResponse({
} status: 404,
\ No newline at end of file description: 'Partner, user, or association not found.',
})
removeUserFromPartner(
@Param('partnerId', ParseIntPipe) partnerId: number,
@Param('userId', ParseIntPipe) userId: number,
) {
return this.partnersService.removeUserFromPartner(partnerId, userId);
}
@Get(':id/candidates')
@Roles(Role.ADMIN, Role.SUPERADMIN, Role.MANAGER, Role.PARTNER)
@UseGuards(PartnerAuthGuard)
@ApiOperation({ summary: 'Get all candidates for a partner' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({
status: 200,
description: 'Return all candidates for the partner.',
})
@ApiResponse({ status: 404, description: 'Partner not found.' })
getPartnerCandidates(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.getPartnerCandidates(id);
}
}
...@@ -4,8 +4,8 @@ import { PartnersService } from './partners.service'; ...@@ -4,8 +4,8 @@ import { PartnersService } from './partners.service';
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaService } from '../../common/prisma/prisma.service';
@Module({ @Module({
controllers: [PartnersController], controllers: [PartnersController],
providers: [PartnersService, PrismaService], providers: [PartnersService, PrismaService],
exports: [PartnersService], exports: [PartnersService],
}) })
export class PartnersModule { } export class PartnersModule {}
\ No newline at end of file
...@@ -4,145 +4,147 @@ import { CreatePartnerDto, UpdatePartnerDto } from './dto'; ...@@ -4,145 +4,147 @@ import { CreatePartnerDto, UpdatePartnerDto } from './dto';
@Injectable() @Injectable()
export class PartnersService { export class PartnersService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
async create(createPartnerDto: CreatePartnerDto) { async create(createPartnerDto: CreatePartnerDto) {
return this.prisma.partner.create({ return this.prisma.partner.create({
data: createPartnerDto, data: createPartnerDto,
}); });
} }
async findAll() { async findAll() {
return this.prisma.partner.findMany({ return this.prisma.partner.findMany({
include: { include: {
users: { users: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
role: true, role: true,
}, },
}, },
_count: { _count: {
select: { select: {
candidates: true, candidates: true,
}, },
}, },
}, },
}); });
} }
async findOne(id: number) { async findOne(id: number) {
const partner = await this.prisma.partner.findUnique({ const partner = await this.prisma.partner.findUnique({
where: { id }, where: { id },
include: { include: {
users: { users: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
role: true, role: true,
}, },
}, },
_count: { _count: {
select: { select: {
candidates: true, candidates: true,
}, },
}, },
}, },
}); });
if (!partner) { if (!partner) {
throw new NotFoundException(`Partner with ID ${id} not found`); throw new NotFoundException(`Partner with ID ${id} not found`);
}
return partner;
} }
async update(id: number, updatePartnerDto: UpdatePartnerDto) { return partner;
// Check if partner exists }
await this.findOne(id);
async update(id: number, updatePartnerDto: UpdatePartnerDto) {
return this.prisma.partner.update({ // Check if partner exists
where: { id }, await this.findOne(id);
data: updatePartnerDto,
}); return this.prisma.partner.update({
where: { id },
data: updatePartnerDto,
});
}
async remove(id: number) {
// Check if partner exists
await this.findOne(id);
return this.prisma.partner.delete({
where: { id },
});
}
async addUserToPartner(partnerId: number, userId: number) {
// Check if both partner and user exist
const partner = await this.findOne(partnerId);
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
} }
async remove(id: number) { return this.prisma.user.update({
// Check if partner exists where: { id: userId },
await this.findOne(id); data: {
partnerId,
return this.prisma.partner.delete({ role: 'PARTNER', // Set role to PARTNER automatically
where: { id }, },
}); });
}
async removeUserFromPartner(partnerId: number, userId: number) {
// Check if both partner and user exist
await this.findOne(partnerId);
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
} }
async addUserToPartner(partnerId: number, userId: number) { if (user.partnerId !== partnerId) {
// Check if both partner and user exist throw new NotFoundException(
const partner = await this.findOne(partnerId); `User with ID ${userId} is not associated with Partner ID ${partnerId}`,
const user = await this.prisma.user.findUnique({ );
where: { id: userId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
return this.prisma.user.update({
where: { id: userId },
data: {
partnerId,
role: 'PARTNER', // Set role to PARTNER automatically
},
});
} }
async removeUserFromPartner(partnerId: number, userId: number) { return this.prisma.user.update({
// Check if both partner and user exist where: { id: userId },
await this.findOne(partnerId); data: {
const user = await this.prisma.user.findUnique({ partnerId: null,
where: { id: userId }, // Note: We don't change the role back automatically, that should be a separate operation
}); },
});
if (!user) { }
throw new NotFoundException(`User with ID ${userId} not found`);
} async getPartnerCandidates(partnerId: number) {
await this.findOne(partnerId);
if (user.partnerId !== partnerId) {
throw new NotFoundException(`User with ID ${userId} is not associated with Partner ID ${partnerId}`); return this.prisma.candidate.findMany({
} where: {
partnerId,
return this.prisma.user.update({ },
where: { id: userId }, include: {
data: { sites: {
partnerId: null, include: {
// Note: We don't change the role back automatically, that should be a separate operation site: true,
}, },
}); },
} createdBy: {
select: {
async getPartnerCandidates(partnerId: number) { id: true,
await this.findOne(partnerId); name: true,
email: true,
return this.prisma.candidate.findMany({ },
where: { },
partnerId, },
}, });
include: { }
sites: { }
include: {
site: true,
},
},
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export enum CompanyName { export enum CompanyName {
VODAFONE = 'VODAFONE', VODAFONE = 'VODAFONE',
MEO = 'MEO', MEO = 'MEO',
NOS = 'NOS', NOS = 'NOS',
DIGI = 'DIGI', DIGI = 'DIGI',
} }
\ No newline at end of file
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsNumber, IsOptional, Min, Max, IsBoolean, IsArray, IsEnum } from 'class-validator'; import {
IsString,
IsNotEmpty,
IsNumber,
IsOptional,
Min,
Max,
IsBoolean,
IsArray,
IsEnum,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { CompanyName } from './company.dto'; import { CompanyName } from './company.dto';
export class CreateSiteDto { export class CreateSiteDto {
@ApiProperty({ @ApiProperty({
description: 'Unique code for the site', description: 'Unique code for the site',
example: 'SITE001', example: 'SITE001',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
siteCode: string; siteCode: string;
@ApiProperty({ @ApiProperty({
description: 'Name of the site', description: 'Name of the site',
example: 'Downtown Tower', example: 'Downtown Tower',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
siteName: string; siteName: string;
@ApiProperty({ @ApiProperty({
description: 'Latitude coordinate of the site', description: 'Latitude coordinate of the site',
example: 40.7128, example: 40.7128,
minimum: -90, minimum: -90,
maximum: 90, maximum: 90,
}) })
@IsNumber() @IsNumber()
@Min(-90) @Min(-90)
@Max(90) @Max(90)
latitude: number; latitude: number;
@ApiProperty({ @ApiProperty({
description: 'Longitude coordinate of the site', description: 'Longitude coordinate of the site',
example: -74.0060, example: -74.006,
minimum: -180, minimum: -180,
maximum: 180, maximum: 180,
}) })
@IsNumber() @IsNumber()
@Min(-180) @Min(-180)
@Max(180) @Max(180)
longitude: number; longitude: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Type of site', description: 'Type of site',
example: 'Tower', example: 'Tower',
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
type?: string; type?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Whether the site is a Digi site', description: 'Whether the site is a Digi site',
example: false, example: false,
default: false, default: false,
}) })
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isDigi?: boolean = false; isDigi?: boolean = false;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Companies operating at this site', description: 'Companies operating at this site',
type: [String], type: [String],
enum: CompanyName, enum: CompanyName,
example: [CompanyName.VODAFONE, CompanyName.MEO], example: [CompanyName.VODAFONE, CompanyName.MEO],
}) })
@IsArray() @IsArray()
@IsEnum(CompanyName, { each: true }) @IsEnum(CompanyName, { each: true })
@IsOptional() @IsOptional()
companies?: CompanyName[]; companies?: CompanyName[];
} }
\ No newline at end of file
...@@ -3,33 +3,33 @@ import { IsOptional, IsInt, Min, IsString } from 'class-validator'; ...@@ -3,33 +3,33 @@ import { IsOptional, IsInt, Min, IsString } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
export class FindSitesPaginatedDto { export class FindSitesPaginatedDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Page number (1-based)', description: 'Page number (1-based)',
default: 1, default: 1,
}) })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
page?: number = 1; page?: number = 1;
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Number of items per page', description: 'Number of items per page',
default: 10, default: 10,
}) })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
limit?: number = 10; limit?: number = 10;
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'Search term for site code or name', description: 'Search term for site code or name',
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
search?: string; search?: string;
} }
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsEnum, IsInt, Min, IsBoolean } from 'class-validator'; import {
IsOptional,
IsString,
IsEnum,
IsInt,
Min,
IsBoolean,
} from 'class-validator';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
export enum OrderDirection { export enum OrderDirection {
ASC = 'asc', ASC = 'asc',
DESC = 'desc', DESC = 'desc',
} }
export class FindSitesDto { export class FindSitesDto {
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
page?: number = 1; page?: number = 1;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
limit?: number = 10; limit?: number = 10;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
siteCode?: string; siteCode?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
siteName?: string; siteName?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
type?: string; type?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
address?: string; address?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
city?: string; city?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
state?: string; state?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
country?: string; country?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@Transform(({ value }) => { @Transform(({ value }) => {
if (value === 'true') return true; if (value === 'true') return true;
if (value === 'false') return false; if (value === 'false') return false;
return value; return value;
}) })
isDigi?: boolean; isDigi?: boolean;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@Transform(({ value }) => { @Transform(({ value }) => {
if (value === 'true') return true; if (value === 'true') return true;
if (value === 'false') return false; if (value === 'false') return false;
return value; return value;
}) })
withCandidates?: boolean; withCandidates?: boolean;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsString() @IsString()
orderBy?: string; orderBy?: string;
@ApiProperty({ required: false, enum: OrderDirection }) @ApiProperty({ required: false, enum: OrderDirection })
@IsOptional() @IsOptional()
@IsEnum(OrderDirection) @IsEnum(OrderDirection)
orderDirection?: OrderDirection; orderDirection?: OrderDirection;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isReported?: boolean; isReported?: boolean;
} }
\ No newline at end of file
...@@ -2,84 +2,102 @@ import { ApiProperty } from '@nestjs/swagger'; ...@@ -2,84 +2,102 @@ import { ApiProperty } from '@nestjs/swagger';
import { CompanyName } from './company.dto'; import { CompanyName } from './company.dto';
export class UserResponseDto { export class UserResponseDto {
@ApiProperty({ description: 'User ID' }) @ApiProperty({ description: 'User ID' })
id: number; id: number;
@ApiProperty({ description: 'User name' }) @ApiProperty({ description: 'User name' })
name: string; name: string;
@ApiProperty({ description: 'User email' }) @ApiProperty({ description: 'User email' })
email: string; email: string;
} }
export class SiteResponseDto { export class SiteResponseDto {
@ApiProperty({ description: 'Site ID' }) @ApiProperty({ description: 'Site ID' })
id: number; id: number;
@ApiProperty({ description: 'Site code' }) @ApiProperty({ description: 'Site code' })
siteCode: string; siteCode: string;
@ApiProperty({ description: 'Site name' }) @ApiProperty({ description: 'Site name' })
siteName: string; siteName: string;
@ApiProperty({ description: 'Latitude coordinate' }) @ApiProperty({ description: 'Latitude coordinate' })
latitude: number; latitude: number;
@ApiProperty({ description: 'Longitude coordinate' }) @ApiProperty({ description: 'Longitude coordinate' })
longitude: number; longitude: number;
@ApiProperty({ description: 'Address of the site' }) @ApiProperty({ description: 'Address of the site' })
address: string; address: string;
@ApiProperty({ description: 'City where the site is located' }) @ApiProperty({ description: 'City where the site is located' })
city: string; city: string;
@ApiProperty({ description: 'State/Province where the site is located' }) @ApiProperty({ description: 'State/Province where the site is located' })
state: string; state: string;
@ApiProperty({ description: 'Country where the site is located' }) @ApiProperty({ description: 'Country where the site is located' })
country: string; country: string;
@ApiProperty({ description: 'Type of the site' }) @ApiProperty({ description: 'Type of the site' })
type: string; type: string;
@ApiProperty({ description: 'Whether the site is a Digi site', default: false }) @ApiProperty({
isDigi: boolean; description: 'Whether the site is a Digi site',
default: false,
@ApiProperty({ description: 'Whether the site is reported', default: false }) })
isReported: boolean; isDigi: boolean;
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Whether the site is reported', default: false })
createdAt: Date; isReported: boolean;
@ApiProperty({ description: 'Last update timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
updatedAt: Date; createdAt: Date;
@ApiProperty({ description: 'User who created the site', type: UserResponseDto }) @ApiProperty({ description: 'Last update timestamp' })
createdBy: UserResponseDto; updatedAt: Date;
@ApiProperty({ description: 'User who last updated the site', type: UserResponseDto }) @ApiProperty({
updatedBy: UserResponseDto; description: 'User who created the site',
type: UserResponseDto,
@ApiProperty({ description: 'Number of candidates associated with this site' }) })
_count?: { createdBy: UserResponseDto;
candidates: number;
}; @ApiProperty({
description: 'User who last updated the site',
@ApiProperty({ type: UserResponseDto,
description: 'Companies operating at this site', })
type: [String], updatedBy: UserResponseDto;
enum: CompanyName,
example: [CompanyName.VODAFONE, CompanyName.MEO], @ApiProperty({
default: [], description: 'Number of candidates associated with this site',
}) })
companies?: CompanyName[]; _count?: {
candidates: number;
@ApiProperty({ };
description: 'Highest priority candidate status for this site',
enum: ['SEARCH_AREA', 'REJECTED', 'NEGOTIATION_ONGOING', 'MNO_VALIDATION', 'CLOSING', 'APPROVED'], @ApiProperty({
required: false, description: 'Companies operating at this site',
nullable: true type: [String],
}) enum: CompanyName,
highestCandidateStatus?: string | null; example: [CompanyName.VODAFONE, CompanyName.MEO],
} default: [],
\ No newline at end of file })
companies?: CompanyName[];
@ApiProperty({
description: 'Highest priority candidate status for this site',
enum: [
'SEARCH_AREA',
'REJECTED',
'NEGOTIATION_ONGOING',
'MNO_VALIDATION',
'CLOSING',
'APPROVED',
],
required: false,
nullable: true,
})
highestCandidateStatus?: string | null;
}
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsBoolean, IsArray } from 'class-validator'; import {
IsString,
IsNumber,
IsOptional,
IsBoolean,
IsArray,
} from 'class-validator';
import { CompanyName } from '@prisma/client'; import { CompanyName } from '@prisma/client';
export class UpdateSiteDto { export class UpdateSiteDto {
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
siteCode?: string; siteCode?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
siteName?: string; siteName?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
latitude?: number; latitude?: number;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
longitude?: number; longitude?: number;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
type?: string; type?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isDigi?: boolean; isDigi?: boolean;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isReported?: boolean; isReported?: boolean;
@ApiProperty({ required: false, enum: CompanyName, isArray: true }) @ApiProperty({ required: false, enum: CompanyName, isArray: true })
@IsArray() @IsArray()
@IsOptional() @IsOptional()
companies?: CompanyName[]; companies?: CompanyName[];
} }
\ No newline at end of file
import { import {
Controller, Controller,
Get, Get,
Post, Post,
Body, Body,
Patch, Patch,
Param, Param,
Delete, Delete,
ParseIntPipe, ParseIntPipe,
UseGuards, UseGuards,
Query, Query,
Request, Request,
} from '@nestjs/common'; } from '@nestjs/common';
import { SitesService } from './sites.service'; import { SitesService } from './sites.service';
import { CreateSiteDto } from './dto/create-site.dto'; import { CreateSiteDto } from './dto/create-site.dto';
import { UpdateSiteDto } from './dto/update-site.dto'; import { UpdateSiteDto } from './dto/update-site.dto';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
...@@ -36,177 +36,179 @@ import { Partner } from '../auth/decorators/partner.decorator'; ...@@ -36,177 +36,179 @@ import { Partner } from '../auth/decorators/partner.decorator';
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
export class SitesController { export class SitesController {
constructor(private readonly sitesService: SitesService) { } constructor(private readonly sitesService: SitesService) {}
@Post() @Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Create a new site' }) @ApiOperation({ summary: 'Create a new site' })
@ApiResponse({ @ApiResponse({
status: 201, status: 201,
description: 'The site has been successfully created.', description: 'The site has been successfully created.',
type: SiteResponseDto type: SiteResponseDto,
}) })
@ApiResponse({ status: 400, description: 'Bad Request.' }) @ApiResponse({ status: 400, description: 'Bad Request.' })
@ApiResponse({ status: 409, description: 'Site code already exists.' }) @ApiResponse({ status: 409, description: 'Site code already exists.' })
create(@Body() createSiteDto: CreateSiteDto, @User('id') userId: number) { create(@Body() createSiteDto: CreateSiteDto, @User('id') userId: number) {
return this.sitesService.create(createSiteDto, userId); return this.sitesService.create(createSiteDto, userId);
} }
@Get('map') @Get('map')
@ApiOperation({ @ApiOperation({
summary: 'Get all sites for map view (without pagination)', summary: 'Get all sites for map view (without pagination)',
description: 'Returns all sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).' description:
}) 'Returns all sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).',
@ApiResponse({ })
status: 200, @ApiResponse({
description: 'Return all sites with applied filters and ordering.', status: 200,
type: [SiteResponseDto] description: 'Return all sites with applied filters and ordering.',
}) type: [SiteResponseDto],
findAllForMap(@Query() findSitesDto: FindSitesDto) { })
return this.sitesService.findAllForMap(findSitesDto); findAllForMap(@Query() findSitesDto: FindSitesDto) {
} return this.sitesService.findAllForMap(findSitesDto);
}
@Get() @Get()
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER) @Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ @ApiOperation({
summary: 'Get all sites for list view (with pagination)', summary: 'Get all sites for list view (with pagination)',
description: 'Returns paginated sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).' description:
}) 'Returns paginated sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).',
@ApiResponse({ })
status: 200, @ApiResponse({
description: 'Return paginated sites with applied filters and ordering.', status: 200,
schema: { description: 'Return paginated sites with applied filters and ordering.',
properties: { schema: {
data: { properties: {
type: 'array', data: {
items: { $ref: '#/components/schemas/SiteResponseDto' } type: 'array',
}, items: { $ref: '#/components/schemas/SiteResponseDto' },
meta: { },
type: 'object', meta: {
properties: { type: 'object',
total: { type: 'number', description: 'Total number of records' }, properties: {
page: { type: 'number', description: 'Current page number' }, total: { type: 'number', description: 'Total number of records' },
limit: { type: 'number', description: 'Number of items per page' }, page: { type: 'number', description: 'Current page number' },
totalPages: { type: 'number', description: 'Total number of pages' } limit: { type: 'number', description: 'Number of items per page' },
} totalPages: {
} type: 'number',
} description: 'Total number of pages',
} },
}) },
async findAll( },
@Query() findSitesDto: FindSitesDto, },
@Request() req, },
) { })
const partnerId = req.user.role === Role.PARTNER ? req.user.partnerId : null; async findAll(@Query() findSitesDto: FindSitesDto, @Request() req) {
return this.sitesService.findAll(findSitesDto, partnerId); const partnerId =
} req.user.role === Role.PARTNER ? req.user.partnerId : null;
return this.sitesService.findAll(findSitesDto, partnerId);
}
@Get('code/:siteCode') @Get('code/:siteCode')
@ApiOperation({ summary: 'Get a site by code' }) @ApiOperation({ summary: 'Get a site by code' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return the site.', description: 'Return the site.',
type: SiteResponseDto type: SiteResponseDto,
}) })
@ApiResponse({ status: 404, description: 'Site not found.' }) @ApiResponse({ status: 404, description: 'Site not found.' })
findByCode(@Param('siteCode') siteCode: string) { findByCode(@Param('siteCode') siteCode: string) {
return this.sitesService.findByCode(siteCode); return this.sitesService.findByCode(siteCode);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get a site by id' }) @ApiOperation({ summary: 'Get a site by id' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return the site.', description: 'Return the site.',
type: SiteResponseDto type: SiteResponseDto,
}) })
@ApiResponse({ status: 404, description: 'Site not found.' }) @ApiResponse({ status: 404, description: 'Site not found.' })
findOne( findOne(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null, @Partner() partnerId: number | null,
@User('role') role: Role @User('role') role: Role,
) { ) {
// For PARTNER role, we restrict access to only see sites associated with their partnerId // For PARTNER role, we restrict access to only see sites associated with their partnerId
if (role === Role.PARTNER) { if (role === Role.PARTNER) {
return this.sitesService.findOneFilteredByPartner(id, partnerId); return this.sitesService.findOneFilteredByPartner(id, partnerId);
}
return this.sitesService.findOne(id);
} }
return this.sitesService.findOne(id);
}
@Get(':id/with-candidates') @Get(':id/with-candidates')
@ApiOperation({ summary: 'Get a site with its candidates' }) @ApiOperation({ summary: 'Get a site with its candidates' })
@ApiQuery({ @ApiQuery({
name: 'partnerId', name: 'partnerId',
required: false, required: false,
type: Number, type: Number,
description: 'Filter candidates by specific partner ID' description: 'Filter candidates by specific partner ID',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return the site with its candidates.', description: 'Return the site with its candidates.',
type: SiteResponseDto type: SiteResponseDto,
}) })
@ApiResponse({ status: 404, description: 'Site not found.' }) @ApiResponse({ status: 404, description: 'Site not found.' })
findOneWithCandidates( findOneWithCandidates(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null, @Partner() partnerId: number | null,
@User('role') role: Role, @User('role') role: Role,
@Query('partnerId') filterPartnerId?: number @Query('partnerId') filterPartnerId?: number,
) { ) {
// For PARTNER role, we restrict access to only see candidates created with their partnerId // For PARTNER role, we restrict access to only see candidates created with their partnerId
if (role === Role.PARTNER) { if (role === Role.PARTNER) {
return this.sitesService.findOneWithCandidates(id, partnerId); return this.sitesService.findOneWithCandidates(id, partnerId);
}
// If a specific partnerId is provided in the query, use that for filtering
return this.sitesService.findOneWithCandidates(id, filterPartnerId);
} }
// If a specific partnerId is provided in the query, use that for filtering
return this.sitesService.findOneWithCandidates(id, filterPartnerId);
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Update a site' })
@ApiResponse({
status: 200,
description: 'The site has been successfully updated.',
type: SiteResponseDto,
})
@ApiResponse({ status: 404, description: 'Site not found.' })
@ApiResponse({ status: 409, description: 'Site code already exists.' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateSiteDto: UpdateSiteDto,
@User('id') userId: number,
) {
return this.sitesService.update(id, updateSiteDto, userId);
}
@Patch(':id') @Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER) @Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Update a site' }) @ApiOperation({ summary: 'Delete a site' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'The site has been successfully updated.', description: 'The site has been successfully deleted.',
type: SiteResponseDto })
}) @ApiResponse({ status: 404, description: 'Site not found.' })
@ApiResponse({ status: 404, description: 'Site not found.' }) remove(@Param('id', ParseIntPipe) id: number) {
@ApiResponse({ status: 409, description: 'Site code already exists.' }) return this.sitesService.remove(id);
update( }
@Param('id', ParseIntPipe) id: number,
@Body() updateSiteDto: UpdateSiteDto,
@User('id') userId: number,
) {
return this.sitesService.update(id, updateSiteDto, userId);
}
@Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Delete a site' })
@ApiResponse({
status: 200,
description: 'The site has been successfully deleted.',
})
@ApiResponse({ status: 404, description: 'Site not found.' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.sitesService.remove(id);
}
@Get('companies') @Get('companies')
@ApiOperation({ summary: 'Get all available company names' }) @ApiOperation({ summary: 'Get all available company names' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return all available company names for sites.', description: 'Return all available company names for sites.',
schema: { schema: {
type: 'array', type: 'array',
items: { items: {
type: 'string', type: 'string',
enum: ['VODAFONE', 'MEO', 'NOS', 'DIGI'] enum: ['VODAFONE', 'MEO', 'NOS', 'DIGI'],
} },
} },
}) })
findAllCompanies() { findAllCompanies() {
return this.sitesService.findAllCompanies(); return this.sitesService.findAllCompanies();
} }
} }
\ No newline at end of file
...@@ -5,9 +5,9 @@ import { PrismaModule } from '../../common/prisma/prisma.module'; ...@@ -5,9 +5,9 @@ import { PrismaModule } from '../../common/prisma/prisma.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [PrismaModule, AuthModule], imports: [PrismaModule, AuthModule],
controllers: [SitesController], controllers: [SitesController],
providers: [SitesService], providers: [SitesService],
exports: [SitesService], exports: [SitesService],
}) })
export class SitesModule { } export class SitesModule {}
\ No newline at end of file
import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaClient, Prisma, CompanyName } from '@prisma/client'; import { PrismaClient, Prisma, CompanyName } from '@prisma/client';
import { CreateSiteDto } from './dto/create-site.dto'; import { CreateSiteDto } from './dto/create-site.dto';
import { UpdateSiteDto } from './dto/update-site.dto'; import { UpdateSiteDto } from './dto/update-site.dto';
import { FindSitesDto, OrderDirection } from './dto/find-sites.dto'; import { FindSitesDto, OrderDirection } from './dto/find-sites.dto';
export enum CandidateStatusPriority { export enum CandidateStatusPriority {
SEARCH_AREA = 0, SEARCH_AREA = 0,
REJECTED = 1, REJECTED = 1,
NEGOTIATION_ONGOING = 2, NEGOTIATION_ONGOING = 2,
MNO_VALIDATION = 3, MNO_VALIDATION = 3,
CLOSING = 4, CLOSING = 4,
PAM = 5, PAM = 5,
APPROVED = 6, APPROVED = 6,
} }
@Injectable() @Injectable()
export class SitesService { export class SitesService {
private prisma: PrismaClient; private prisma: PrismaClient;
constructor() { constructor() {
this.prisma = new PrismaClient(); this.prisma = new PrismaClient();
}
// Helper method to get the highest priority status from a list of candidates
private getHighestPriorityStatus(candidates) {
if (!candidates || candidates.length === 0) {
return null;
} }
// Helper method to get the highest priority status from a list of candidates return (
private getHighestPriorityStatus(candidates) { candidates
if (!candidates || candidates.length === 0) { .map((candidate) => candidate.candidate?.currentStatus)
return null; .filter(Boolean)
} .sort((a, b) => {
const priorityA =
return candidates CandidateStatusPriority[a] !== undefined
.map(candidate => candidate.candidate?.currentStatus) ? Number(CandidateStatusPriority[a])
.filter(Boolean) : -1;
.sort((a, b) => { const priorityB =
const priorityA = CandidateStatusPriority[a] !== undefined ? Number(CandidateStatusPriority[a]) : -1; CandidateStatusPriority[b] !== undefined
const priorityB = CandidateStatusPriority[b] !== undefined ? Number(CandidateStatusPriority[b]) : -1; ? Number(CandidateStatusPriority[b])
return priorityB - priorityA; : -1;
})[0] || null; return priorityB - priorityA;
})[0] || null
);
}
async create(createSiteDto: CreateSiteDto, userId: number) {
try {
return await this.prisma.site.create({
data: {
siteCode: createSiteDto.siteCode,
siteName: createSiteDto.siteName,
latitude: createSiteDto.latitude,
longitude: createSiteDto.longitude,
type: createSiteDto.type,
isDigi: createSiteDto.isDigi || false,
companies: createSiteDto.companies || [],
createdById: userId,
updatedById: userId,
},
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
} catch (error) {
if (error.code === 'P2002') {
throw new ConflictException(
`Site with code ${createSiteDto.siteCode} already exists`,
);
}
throw error;
}
}
async findAll(findSitesDto: FindSitesDto, partnerId?: number | null) {
const {
page = 1,
limit = 10,
siteCode,
siteName,
address,
city,
state,
country,
orderBy,
orderDirection,
withCandidates,
type,
isDigi,
isReported,
} = findSitesDto;
const skip = (page - 1) * limit;
const where: Prisma.SiteWhereInput = {
...(siteCode && {
siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive },
}),
...(siteName && {
siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive },
}),
...(address && {
address: { contains: address, mode: Prisma.QueryMode.insensitive },
}),
...(city && {
city: { contains: city, mode: Prisma.QueryMode.insensitive },
}),
...(state && {
state: { contains: state, mode: Prisma.QueryMode.insensitive },
}),
...(country && {
country: { contains: country, mode: Prisma.QueryMode.insensitive },
}),
...(type && {
type: { contains: type, mode: Prisma.QueryMode.insensitive },
}),
...(isDigi !== undefined && { isDigi }),
...(isReported !== undefined && { isReported }),
};
// Only filter by candidates if withCandidates is true
if (withCandidates === true) {
where.candidates = {
some: {},
};
} }
async create(createSiteDto: CreateSiteDto, userId: number) { const [sites, total] = await Promise.all([
try { this.prisma.site.findMany({
return await this.prisma.site.create({ where,
data: { skip,
siteCode: createSiteDto.siteCode, take: limit,
siteName: createSiteDto.siteName, orderBy: orderBy ? { [orderBy]: orderDirection || 'asc' } : undefined,
latitude: createSiteDto.latitude, include: {
longitude: createSiteDto.longitude, _count: {
type: createSiteDto.type, select: {
isDigi: createSiteDto.isDigi || false, candidates: true,
companies: createSiteDto.companies || [], },
createdById: userId, },
updatedById: userId, candidates: {
where: partnerId
? {
candidate: {
partnerId: partnerId,
},
}
: undefined,
include: {
candidate: {
select: {
currentStatus: true,
}, },
include: { },
createdBy: { },
select: { },
id: true, },
name: true, }),
email: true, this.prisma.site.count({ where }),
}, ]);
},
updatedBy: { // Add highest priority status to all sites
select: { const sitesWithHighestStatus = sites.map((site) => {
id: true, const highestCandidateStatus = this.getHighestPriorityStatus(
name: true, site.candidates,
email: true, );
}, return {
}, ...site,
highestCandidateStatus,
candidates: withCandidates ? site.candidates : undefined, // Only include full candidates data if withCandidates is true
};
});
return {
data: sitesWithHighestStatus,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findOne(id: number) {
const site = await this.prisma.site.findUnique({
where: { id },
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
candidates: {
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
}, },
}); updatedBy: {
} catch (error) { select: {
if (error.code === 'P2002') { id: true,
throw new ConflictException(`Site with code ${createSiteDto.siteCode} already exists`); name: true,
} email: true,
throw error; },
}
}
async findAll(findSitesDto: FindSitesDto, partnerId?: number | null) {
const {
page = 1,
limit = 10,
siteCode,
siteName,
address,
city,
state,
country,
orderBy,
orderDirection,
withCandidates,
type,
isDigi,
isReported,
} = findSitesDto;
const skip = (page - 1) * limit;
const where: Prisma.SiteWhereInput = {
...(siteCode && { siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive } }),
...(siteName && { siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive } }),
...(address && { address: { contains: address, mode: Prisma.QueryMode.insensitive } }),
...(city && { city: { contains: city, mode: Prisma.QueryMode.insensitive } }),
...(state && { state: { contains: state, mode: Prisma.QueryMode.insensitive } }),
...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }),
...(type && { type: { contains: type, mode: Prisma.QueryMode.insensitive } }),
...(isDigi !== undefined && { isDigi }),
...(isReported !== undefined && { isReported }),
};
// Only filter by candidates if withCandidates is true
if (withCandidates === true) {
where.candidates = {
some: {},
};
}
const [sites, total] = await Promise.all([
this.prisma.site.findMany({
where,
skip,
take: limit,
orderBy: orderBy ? { [orderBy]: orderDirection || 'asc' } : undefined,
include: {
_count: {
select: {
candidates: true,
},
},
candidates: {
where: partnerId ? {
candidate: {
partnerId: partnerId
}
} : undefined,
include: {
candidate: {
select: {
currentStatus: true
}
}
}
},
}, },
}), },
this.prisma.site.count({ where }),
]);
// Add highest priority status to all sites
const sitesWithHighestStatus = sites.map(site => {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus,
candidates: withCandidates ? site.candidates : undefined, // Only include full candidates data if withCandidates is true
};
});
return {
data: sitesWithHighestStatus,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
}, },
}; },
},
_count: {
select: {
candidates: true,
},
},
},
});
if (!site) {
throw new NotFoundException(`Site with ID ${id} not found`);
} }
async findOne(id: number) { // Add highest priority status if the site has candidates
const site = await this.prisma.site.findUnique({ if (site.candidates && site.candidates.length > 0) {
where: { id }, const highestCandidateStatus = this.getHighestPriorityStatus(
include: { site.candidates,
);
return {
...site,
highestCandidateStatus,
};
}
return site;
}
async findOneFilteredByPartner(id: number, partnerId: number | null) {
if (!partnerId) {
throw new ForbiddenException(
'User does not have access to any partner resources',
);
}
const site = await this.prisma.site.findUnique({
where: { id },
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
candidates: {
where: {
candidate: {
partnerId: partnerId,
},
},
include: {
candidate: {
include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
updatedBy: { updatedBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
},
candidates: {
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
},
_count: {
select: {
candidates: true,
},
}, },
},
}, },
}); },
},
if (!site) { _count: {
throw new NotFoundException(`Site with ID ${id} not found`); select: {
} candidates: true,
},
// Add highest priority status if the site has candidates },
if (site.candidates && site.candidates.length > 0) { },
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates); });
return {
...site, if (!site) {
highestCandidateStatus, throw new NotFoundException(`Site with ID ${id} not found`);
};
}
return site;
} }
async findOneFilteredByPartner(id: number, partnerId: number | null) { // Add highest priority status if the site has candidates
if (!partnerId) { if (site.candidates && site.candidates.length > 0) {
throw new ForbiddenException('User does not have access to any partner resources'); const highestCandidateStatus = this.getHighestPriorityStatus(
} site.candidates,
);
return {
...site,
highestCandidateStatus,
};
}
const site = await this.prisma.site.findUnique({ return site;
where: { id }, }
include: {
async findOneWithCandidates(id: number, partnerId?: number | null) {
const site = await this.prisma.site.findUnique({
where: { id },
include: {
candidates: {
where: partnerId
? {
candidate: {
partnerId: partnerId,
},
}
: undefined,
include: {
candidate: {
include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
updatedBy: { updatedBy: {
select: { select: {
id: true,
name: true,
email: true,
},
},
partner: {
select: {
id: true,
name: true,
},
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
},
}, },
},
orderBy: {
createdAt: 'desc',
},
}, },
candidates: { },
where: {
candidate: {
partnerId: partnerId
}
},
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
},
_count: {
select: {
candidates: true,
},
},
}, },
}); },
},
if (!site) { },
throw new NotFoundException(`Site with ID ${id} not found`); });
}
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus,
};
}
return site;
}
async findOneWithCandidates(id: number, partnerId?: number | null) { if (!site) {
const site = await this.prisma.site.findUnique({ throw new NotFoundException(`Site with ID ${id} not found`);
where: { id },
include: {
candidates: {
where: partnerId ? {
candidate: {
partnerId: partnerId
}
} : undefined,
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
partner: {
select: {
id: true,
name: true,
},
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
},
},
},
},
});
if (!site) {
throw new NotFoundException(`Site with ID ${id} not found`);
}
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus,
};
}
return site;
} }
async findOneWithCandidatesFilteredByPartner(id: number, partnerId: number | null) { // Add highest priority status if the site has candidates
if (!partnerId) { if (site.candidates && site.candidates.length > 0) {
throw new ForbiddenException('User does not have access to any partner resources'); const highestCandidateStatus = this.getHighestPriorityStatus(
} site.candidates,
);
// First, fetch the site with all candidates return {
const site = await this.prisma.site.findUnique({ ...site,
where: { id }, highestCandidateStatus,
include: { };
candidates: {
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
},
},
},
},
});
if (!site) {
throw new NotFoundException(`Site with ID ${id} not found`);
}
// Filter the candidates to only include those with the partner's ID
const filteredSite = {
...site,
candidates: site.candidates.filter(candidateSite =>
candidateSite.candidate.partnerId === partnerId
)
};
// Add highest priority status if the site has filtered candidates
if (filteredSite.candidates && filteredSite.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(filteredSite.candidates);
return {
...filteredSite,
highestCandidateStatus,
};
}
return filteredSite;
} }
async update(id: number, updateSiteDto: UpdateSiteDto, userId: number) { return site;
try { }
return await this.prisma.site.update({
where: { id }, async findOneWithCandidatesFilteredByPartner(
data: { id: number,
siteCode: updateSiteDto.siteCode, partnerId: number | null,
siteName: updateSiteDto.siteName, ) {
latitude: updateSiteDto.latitude, if (!partnerId) {
longitude: updateSiteDto.longitude, throw new ForbiddenException(
type: updateSiteDto.type, 'User does not have access to any partner resources',
isDigi: updateSiteDto.isDigi, );
isReported: updateSiteDto.isReported,
companies: updateSiteDto.companies !== undefined ? updateSiteDto.companies : undefined,
updatedById: userId,
},
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Site with ID ${id} not found`);
}
if (error.code === 'P2002') {
throw new ConflictException(`Site with code ${updateSiteDto.siteCode} already exists`);
}
throw error;
}
}
async remove(id: number) {
try {
await this.prisma.site.delete({
where: { id },
});
return { message: `Site with ID ${id} has been deleted` };
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Site with ID ${id} not found`);
}
throw error;
}
} }
async findByCode(siteCode: string) { // First, fetch the site with all candidates
const site = await this.prisma.site.findFirst({ const site = await this.prisma.site.findUnique({
where: { siteCode }, where: { id },
include: { include: {
candidates: {
include: {
candidate: {
include: {
createdBy: { createdBy: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
}, },
}, },
updatedBy: { updatedBy: {
select: { select: {
id: true,
name: true,
email: true,
},
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
},
}, },
},
orderBy: {
createdAt: 'desc',
},
}, },
candidates: { },
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
},
_count: {
select: {
candidates: true,
},
},
}, },
}); },
},
if (!site) { },
throw new NotFoundException(`Site with code ${siteCode} not found`); });
}
if (!site) {
// Add highest priority status if the site has candidates throw new NotFoundException(`Site with ID ${id} not found`);
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus,
};
}
return site;
} }
async findAllForMap(findSitesDto: FindSitesDto) { // Filter the candidates to only include those with the partner's ID
const { const filteredSite = {
siteCode, ...site,
siteName, candidates: site.candidates.filter(
address, (candidateSite) => candidateSite.candidate.partnerId === partnerId,
city, ),
state, };
country,
orderBy, // Add highest priority status if the site has filtered candidates
orderDirection, if (filteredSite.candidates && filteredSite.candidates.length > 0) {
withCandidates, const highestCandidateStatus = this.getHighestPriorityStatus(
type, filteredSite.candidates,
isDigi, );
isReported, return {
} = findSitesDto; ...filteredSite,
highestCandidateStatus,
const where: Prisma.SiteWhereInput = { };
...(siteCode && { siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive } }), }
...(siteName && { siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive } }),
...(address && { address: { contains: address, mode: Prisma.QueryMode.insensitive } }),
...(city && { city: { contains: city, mode: Prisma.QueryMode.insensitive } }),
...(state && { state: { contains: state, mode: Prisma.QueryMode.insensitive } }),
...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }),
...(type && { type: { contains: type, mode: Prisma.QueryMode.insensitive } }),
...(isDigi !== undefined && { isDigi }),
...(isReported !== undefined && { isReported }),
...(withCandidates === true && {
candidates: {
some: {},
}
})
};
const sites = await this.prisma.site.findMany({ return filteredSite;
where, }
orderBy: orderBy ? { [orderBy]: orderDirection || 'asc' } : undefined,
include: { async update(id: number, updateSiteDto: UpdateSiteDto, userId: number) {
_count: { try {
select: { return await this.prisma.site.update({
candidates: true, where: { id },
}, data: {
siteCode: updateSiteDto.siteCode,
siteName: updateSiteDto.siteName,
latitude: updateSiteDto.latitude,
longitude: updateSiteDto.longitude,
type: updateSiteDto.type,
isDigi: updateSiteDto.isDigi,
isReported: updateSiteDto.isReported,
companies:
updateSiteDto.companies !== undefined
? updateSiteDto.companies
: undefined,
updatedById: userId,
},
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Site with ID ${id} not found`);
}
if (error.code === 'P2002') {
throw new ConflictException(
`Site with code ${updateSiteDto.siteCode} already exists`,
);
}
throw error;
}
}
async remove(id: number) {
try {
await this.prisma.site.delete({
where: { id },
});
return { message: `Site with ID ${id} has been deleted` };
} catch (error) {
if (error.code === 'P2025') {
throw new NotFoundException(`Site with ID ${id} not found`);
}
throw error;
}
}
async findByCode(siteCode: string) {
const site = await this.prisma.site.findFirst({
where: { siteCode },
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
candidates: {
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
}, },
candidates: { updatedBy: {
include: { select: {
candidate: { id: true,
select: { name: true,
currentStatus: true email: true,
} },
}
}
}, },
},
}, },
}); },
},
// Add highest priority status to all sites _count: {
const sitesWithHighestStatus = sites.map(site => { select: {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates); candidates: true,
return { },
...site, },
highestCandidateStatus, },
candidates: withCandidates ? site.candidates : undefined, // Only include full candidates data if withCandidates is true });
};
}); if (!site) {
throw new NotFoundException(`Site with code ${siteCode} not found`);
return sitesWithHighestStatus;
} }
async findAllCompanies() { // Add highest priority status if the site has candidates
// Return all values of the CompanyName enum if (site.candidates && site.candidates.length > 0) {
return Object.values(CompanyName); const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
};
} }
}
\ No newline at end of file return site;
}
async findAllForMap(findSitesDto: FindSitesDto) {
const {
siteCode,
siteName,
address,
city,
state,
country,
orderBy,
orderDirection,
withCandidates,
type,
isDigi,
isReported,
} = findSitesDto;
const where: Prisma.SiteWhereInput = {
...(siteCode && {
siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive },
}),
...(siteName && {
siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive },
}),
...(address && {
address: { contains: address, mode: Prisma.QueryMode.insensitive },
}),
...(city && {
city: { contains: city, mode: Prisma.QueryMode.insensitive },
}),
...(state && {
state: { contains: state, mode: Prisma.QueryMode.insensitive },
}),
...(country && {
country: { contains: country, mode: Prisma.QueryMode.insensitive },
}),
...(type && {
type: { contains: type, mode: Prisma.QueryMode.insensitive },
}),
...(isDigi !== undefined && { isDigi }),
...(isReported !== undefined && { isReported }),
...(withCandidates === true && {
candidates: {
some: {},
},
}),
};
const sites = await this.prisma.site.findMany({
where,
orderBy: orderBy ? { [orderBy]: orderDirection || 'asc' } : undefined,
include: {
_count: {
select: {
candidates: true,
},
},
candidates: {
include: {
candidate: {
select: {
currentStatus: true,
},
},
},
},
},
});
// Add highest priority status to all sites
const sitesWithHighestStatus = sites.map((site) => {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
candidates: withCandidates ? site.candidates : undefined, // Only include full candidates data if withCandidates is true
};
});
return sitesWithHighestStatus;
}
async findAllCompanies() {
// Return all values of the CompanyName enum
return Object.values(CompanyName);
}
}
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsNotEmpty, IsString, MinLength } from 'class-validator'; import {
IsEmail,
IsEnum,
IsNotEmpty,
IsString,
MinLength,
} from 'class-validator';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
export class CreateUserDto { export class CreateUserDto {
@ApiProperty({ @ApiProperty({
description: 'The email of the user', description: 'The email of the user',
example: 'john.doe@example.com', example: 'john.doe@example.com',
}) })
@IsEmail() @IsEmail()
@IsNotEmpty() @IsNotEmpty()
email: string; email: string;
@ApiProperty({ @ApiProperty({
description: 'The name of the user', description: 'The name of the user',
example: 'John Doe', example: 'John Doe',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name: string; name: string;
@ApiProperty({ @ApiProperty({
description: 'The password of the user', description: 'The password of the user',
example: 'password123', example: 'password123',
minLength: 8, minLength: 8,
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
password: string; password: string;
@ApiProperty({ @ApiProperty({
description: 'The role of the user', description: 'The role of the user',
enum: Role, enum: Role,
default: Role.VIEWER, default: Role.VIEWER,
example: Role.VIEWER, example: Role.VIEWER,
}) })
@IsEnum(Role) @IsEnum(Role)
role: Role = Role.VIEWER; role: Role = Role.VIEWER;
} }
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, IsInt, Min, IsEnum } from 'class-validator'; import {
IsBoolean,
IsOptional,
IsString,
IsInt,
Min,
IsEnum,
} from 'class-validator';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
export enum UserOrderBy { export enum UserOrderBy {
NAME_ASC = 'name_asc', NAME_ASC = 'name_asc',
NAME_DESC = 'name_desc', NAME_DESC = 'name_desc',
EMAIL_ASC = 'email_asc', EMAIL_ASC = 'email_asc',
EMAIL_DESC = 'email_desc', EMAIL_DESC = 'email_desc',
CREATED_AT_ASC = 'createdAt_asc', CREATED_AT_ASC = 'createdAt_asc',
CREATED_AT_DESC = 'createdAt_desc', CREATED_AT_DESC = 'createdAt_desc',
ROLE_ASC = 'role_asc', ROLE_ASC = 'role_asc',
ROLE_DESC = 'role_desc', ROLE_DESC = 'role_desc',
} }
export class FindUsersDto { export class FindUsersDto {
@ApiProperty({ @ApiProperty({
description: 'Filter users by their active status (true/false)', description: 'Filter users by their active status (true/false)',
example: 'true', example: 'true',
required: false, required: false,
}) })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@Transform(({ value }) => { @Transform(({ value }) => {
if (value === 'true') return true; if (value === 'true') return true;
if (value === 'false') return false; if (value === 'false') return false;
return value; return value;
}) })
active?: boolean; active?: boolean;
@ApiProperty({ @ApiProperty({
description: 'Get only active users with PARTNER role who don\'t have a partner assigned', description:
example: 'true', "Get only active users with PARTNER role who don't have a partner assigned",
required: false, example: 'true',
}) required: false,
@IsOptional() })
@IsBoolean() @IsOptional()
@Transform(({ value }) => { @IsBoolean()
if (value === 'true') return true; @Transform(({ value }) => {
if (value === 'false') return false; if (value === 'true') return true;
return value; if (value === 'false') return false;
}) return value;
unassignedPartners?: boolean; })
unassignedPartners?: boolean;
@ApiProperty({ @ApiProperty({
description: 'Page number for pagination (starts at 1)', description: 'Page number for pagination (starts at 1)',
example: 1, example: 1,
required: false, required: false,
default: 1, default: 1,
}) })
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1) @Min(1)
@Type(() => Number) @Type(() => Number)
page?: number = 1; page?: number = 1;
@ApiProperty({ @ApiProperty({
description: 'Number of items per page', description: 'Number of items per page',
example: 10, example: 10,
required: false, required: false,
default: 10, default: 10,
}) })
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1) @Min(1)
@Type(() => Number) @Type(() => Number)
limit?: number = 10; limit?: number = 10;
@ApiProperty({ @ApiProperty({
description: 'Search term to filter users by name or email', description: 'Search term to filter users by name or email',
example: 'john', example: 'john',
required: false, required: false,
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
search?: string; search?: string;
@ApiProperty({ @ApiProperty({
description: 'Order by field and direction', description: 'Order by field and direction',
enum: UserOrderBy, enum: UserOrderBy,
example: UserOrderBy.NAME_ASC, example: UserOrderBy.NAME_ASC,
required: false, required: false,
default: UserOrderBy.NAME_ASC, default: UserOrderBy.NAME_ASC,
}) })
@IsOptional() @IsOptional()
@IsEnum(UserOrderBy) @IsEnum(UserOrderBy)
orderBy?: UserOrderBy = UserOrderBy.NAME_ASC; orderBy?: UserOrderBy = UserOrderBy.NAME_ASC;
} }
\ No newline at end of file
...@@ -4,13 +4,16 @@ import { IsOptional, IsString, IsDate } from 'class-validator'; ...@@ -4,13 +4,16 @@ import { IsOptional, IsString, IsDate } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class UpdateUserDto extends PartialType(CreateUserDto) { export class UpdateUserDto extends PartialType(CreateUserDto) {
@ApiProperty({ required: false, description: 'Password reset token' }) @ApiProperty({ required: false, description: 'Password reset token' })
@IsOptional() @IsOptional()
@IsString() @IsString()
resetToken?: string | null; resetToken?: string | null;
@ApiProperty({ required: false, description: 'Password reset token expiry date' }) @ApiProperty({
@IsOptional() required: false,
@IsDate() description: 'Password reset token expiry date',
resetTokenExpiry?: Date | null; })
} @IsOptional()
\ No newline at end of file @IsDate()
resetTokenExpiry?: Date | null;
}
import { import {
Controller, Controller,
Get, Get,
Post, Post,
Body, Body,
Patch, Patch,
Param, Param,
Delete, Delete,
ParseIntPipe, ParseIntPipe,
UseGuards, UseGuards,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { FindUsersDto, UserOrderBy } from './dto/find-users.dto'; import { FindUsersDto, UserOrderBy } from './dto/find-users.dto';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
...@@ -32,144 +32,151 @@ import { User } from '../auth/decorators/user.decorator'; ...@@ -32,144 +32,151 @@ import { User } from '../auth/decorators/user.decorator';
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
export class UsersController { export class UsersController {
constructor(private readonly usersService: UsersService) { } constructor(private readonly usersService: UsersService) {}
@Post() @Post()
@Roles(Role.SUPERADMIN, Role.ADMIN) @Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Create a new user' }) @ApiOperation({ summary: 'Create a new user' })
@ApiResponse({ @ApiResponse({
status: 201, status: 201,
description: 'The user has been successfully created.', description: 'The user has been successfully created.',
}) })
@ApiResponse({ status: 400, description: 'Bad Request.' }) @ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createUserDto: CreateUserDto) { create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto); return this.usersService.create(createUserDto);
} }
@Get() @Get()
@ApiOperation({ summary: 'Get all users' }) @ApiOperation({ summary: 'Get all users' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return all users with pagination.', description: 'Return all users with pagination.',
}) })
@ApiQuery({ @ApiQuery({
name: 'active', name: 'active',
required: false, required: false,
type: Boolean, type: Boolean,
description: 'Filter users by active status (true/false)', description: 'Filter users by active status (true/false)',
}) })
@ApiQuery({ @ApiQuery({
name: 'unassignedPartners', name: 'unassignedPartners',
required: false, required: false,
type: Boolean, type: Boolean,
description: 'Get only active users with PARTNER role who don\'t have a partner assigned (true/false)', description:
}) "Get only active users with PARTNER role who don't have a partner assigned (true/false)",
@ApiQuery({ })
name: 'page', @ApiQuery({
required: false, name: 'page',
type: Number, required: false,
description: 'Page number for pagination (starts at 1)', type: Number,
}) description: 'Page number for pagination (starts at 1)',
@ApiQuery({ })
name: 'limit', @ApiQuery({
required: false, name: 'limit',
type: Number, required: false,
description: 'Number of items per page', type: Number,
}) description: 'Number of items per page',
@ApiQuery({ })
name: 'search', @ApiQuery({
required: false, name: 'search',
type: String, required: false,
description: 'Search term to filter users by name or email', type: String,
}) description: 'Search term to filter users by name or email',
@ApiQuery({ })
name: 'orderBy', @ApiQuery({
required: false, name: 'orderBy',
enum: UserOrderBy, required: false,
description: 'Order by field and direction', enum: UserOrderBy,
}) description: 'Order by field and direction',
findAll(@Query() query: FindUsersDto) { })
return this.usersService.findAll(query); findAll(@Query() query: FindUsersDto) {
} return this.usersService.findAll(query);
}
@Get('inactive') @Get('inactive')
@Roles(Role.SUPERADMIN, Role.ADMIN) @Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Get all inactive users' }) @ApiOperation({ summary: 'Get all inactive users' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return all inactive users.', description: 'Return all inactive users.',
}) })
findInactiveUsers() { findInactiveUsers() {
return this.usersService.findInactiveUsers(); return this.usersService.findInactiveUsers();
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get a user by id' }) @ApiOperation({ summary: 'Get a user by id' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return the user.', description: 'Return the user.',
}) })
@ApiResponse({ status: 404, description: 'User not found.' }) @ApiResponse({ status: 404, description: 'User not found.' })
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id); return this.usersService.findOne(id);
} }
@Patch(':id') @Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN) @Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Update a user' }) @ApiOperation({ summary: 'Update a user' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'The user has been successfully updated.', description: 'The user has been successfully updated.',
}) })
@ApiResponse({ status: 404, description: 'User not found.' }) @ApiResponse({ status: 404, description: 'User not found.' })
update( update(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto, @Body() updateUserDto: UpdateUserDto,
) { ) {
return this.usersService.update(id, updateUserDto); return this.usersService.update(id, updateUserDto);
} }
@Delete(':id') @Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN) @Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Delete a user' }) @ApiOperation({ summary: 'Delete a user' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'The user has been successfully deleted.', description: 'The user has been successfully deleted.',
}) })
@ApiResponse({ status: 404, description: 'User not found.' }) @ApiResponse({ status: 404, description: 'User not found.' })
remove(@Param('id', ParseIntPipe) id: number) { remove(@Param('id', ParseIntPipe) id: number) {
return this.usersService.remove(id); return this.usersService.remove(id);
} }
@Patch(':id/activate') @Patch(':id/activate')
@Roles(Role.SUPERADMIN, Role.ADMIN) @Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Activate a user' }) @ApiOperation({ summary: 'Activate a user' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'The user has been successfully activated.', description: 'The user has been successfully activated.',
}) })
@ApiResponse({ status: 404, description: 'User not found.' }) @ApiResponse({ status: 404, description: 'User not found.' })
@ApiResponse({ status: 403, description: 'Forbidden - Insufficient permissions.' }) @ApiResponse({
activateUser( status: 403,
@Param('id', ParseIntPipe) id: number, description: 'Forbidden - Insufficient permissions.',
@User('role') role: Role, })
) { activateUser(
return this.usersService.activateUser(id, role); @Param('id', ParseIntPipe) id: number,
} @User('role') role: Role,
) {
return this.usersService.activateUser(id, role);
}
@Patch(':id/deactivate') @Patch(':id/deactivate')
@Roles(Role.SUPERADMIN, Role.ADMIN) @Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Deactivate a user' }) @ApiOperation({ summary: 'Deactivate a user' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'The user has been successfully deactivated.', description: 'The user has been successfully deactivated.',
}) })
@ApiResponse({ status: 404, description: 'User not found.' }) @ApiResponse({ status: 404, description: 'User not found.' })
@ApiResponse({ status: 403, description: 'Forbidden - Insufficient permissions.' }) @ApiResponse({
deactivateUser( status: 403,
@Param('id', ParseIntPipe) id: number, description: 'Forbidden - Insufficient permissions.',
@User('role') role: Role, })
) { deactivateUser(
return this.usersService.deactivateUser(id, role); @Param('id', ParseIntPipe) id: number,
} @User('role') role: Role,
} ) {
\ No newline at end of file return this.usersService.deactivateUser(id, role);
}
}
...@@ -4,9 +4,9 @@ import { UsersController } from './users.controller'; ...@@ -4,9 +4,9 @@ import { UsersController } from './users.controller';
import { PrismaModule } from '../../common/prisma/prisma.module'; import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService], providers: [UsersService],
exports: [UsersService], exports: [UsersService],
}) })
export class UsersModule { } export class UsersModule {}
\ No newline at end of file
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
...@@ -8,254 +12,257 @@ import { Role } from '@prisma/client'; ...@@ -8,254 +12,257 @@ import { Role } from '@prisma/client';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
async create(createUserDto: CreateUserDto) { async create(createUserDto: CreateUserDto) {
const hashedPassword = await bcrypt.hash(createUserDto.password, 10); const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
return this.prisma.user.create({ return this.prisma.user.create({
data: { data: {
...createUserDto, ...createUserDto,
password: hashedPassword, password: hashedPassword,
isActive: false, // New users are inactive by default isActive: false, // New users are inactive by default
}, },
select: { select: {
id: true, id: true,
email: true, email: true,
name: true, name: true,
role: true, role: true,
isActive: true, isActive: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}); });
} }
async findAll(query?: FindUsersDto) {
let where: any = {
NOT: {
role: Role.SUPERADMIN
}
};
if (query?.active !== undefined) {
where['isActive'] = query.active;
}
// If unassignedPartners is true, filter for active users with PARTNER role and no partner async findAll(query?: FindUsersDto) {
if (query?.unassignedPartners === true) { let where: any = {
// Create a new where object for partner-specific query NOT: {
where = { role: Role.SUPERADMIN,
isActive: true, },
role: Role.PARTNER, };
partnerId: null
};
}
// Add search functionality for name or email if (query?.active !== undefined) {
if (query?.search) { where['isActive'] = query.active;
where.OR = [ }
{
name: {
contains: query.search,
mode: 'insensitive', // Case insensitive search
},
},
{
email: {
contains: query.search,
mode: 'insensitive', // Case insensitive search
},
},
];
}
// Set up pagination // If unassignedPartners is true, filter for active users with PARTNER role and no partner
const page = query?.page || 1; if (query?.unassignedPartners === true) {
const limit = query?.limit || 10; // Create a new where object for partner-specific query
const skip = (page - 1) * limit; where = {
isActive: true,
role: Role.PARTNER,
partnerId: null,
};
}
// Set up ordering // Add search functionality for name or email
let orderBy: any = { name: 'asc' }; // Default ordering if (query?.search) {
where.OR = [
{
name: {
contains: query.search,
mode: 'insensitive', // Case insensitive search
},
},
{
email: {
contains: query.search,
mode: 'insensitive', // Case insensitive search
},
},
];
}
if (query?.orderBy) { // Set up pagination
const [field, direction] = query.orderBy.split('_'); const page = query?.page || 1;
const limit = query?.limit || 10;
const skip = (page - 1) * limit;
// Special handling for role field if needed // Set up ordering
orderBy = { [field]: direction }; let orderBy: any = { name: 'asc' }; // Default ordering
} if (query?.orderBy) {
const [field, direction] = query.orderBy.split('_');
// Get total count for pagination metadata // Special handling for role field if needed
const totalCount = await this.prisma.user.count({ where }); orderBy = { [field]: direction };
}
// Fetch users with pagination and ordering // Get total count for pagination metadata
const users = await this.prisma.user.findMany({ const totalCount = await this.prisma.user.count({ where });
where,
skip,
take: limit,
orderBy,
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
partnerId: true,
},
});
// Return users with pagination metadata // Fetch users with pagination and ordering
return { const users = await this.prisma.user.findMany({
data: users, where,
meta: { skip,
totalCount, take: limit,
page, orderBy,
limit, select: {
totalPages: Math.ceil(totalCount / limit), id: true,
}, email: true,
}; name: true,
} role: true,
isActive: true,
createdAt: true,
updatedAt: true,
partnerId: true,
},
});
async findOne(id: number) { // Return users with pagination metadata
const user = await this.prisma.user.findUnique({ return {
where: { id }, data: users,
select: { meta: {
id: true, totalCount,
email: true, page,
name: true, limit,
role: true, totalPages: Math.ceil(totalCount / limit),
isActive: true, },
createdAt: true, };
updatedAt: true, }
},
});
if (!user) { async findOne(id: number) {
throw new NotFoundException(`User with ID ${id} not found`); const user = await this.prisma.user.findUnique({
} where: { id },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
return user; if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
} }
async update(id: number, updateUserDto: UpdateUserDto) { return user;
const data: any = { ...updateUserDto }; }
if (updateUserDto.password) { async update(id: number, updateUserDto: UpdateUserDto) {
data.password = await bcrypt.hash(updateUserDto.password, 10); const data: any = { ...updateUserDto };
}
try { if (updateUserDto.password) {
return await this.prisma.user.update({ data.password = await bcrypt.hash(updateUserDto.password, 10);
where: { id },
data,
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
}
} }
async remove(id: number) { try {
try { return await this.prisma.user.update({
await this.prisma.user.delete({ where: { id },
where: { id }, data,
}); select: {
return { message: `User with ID ${id} has been deleted` }; id: true,
} catch (error) { email: true,
throw new NotFoundException(`User with ID ${id} not found`); name: true,
} role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
} }
}
async findByEmail(email: string) { async remove(id: number) {
return this.prisma.user.findUnique({ try {
where: { email }, await this.prisma.user.delete({
}); where: { id },
});
return { message: `User with ID ${id} has been deleted` };
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
} }
}
async findByResetToken(resetToken: string) { async findByEmail(email: string) {
return this.prisma.user.findFirst({ return this.prisma.user.findUnique({
where: { where: { email },
resetToken, });
resetTokenExpiry: { }
gt: new Date(),
},
},
});
}
async activateUser(id: number, currentUserRole: Role) { async findByResetToken(resetToken: string) {
// Only SUPERADMIN and ADMIN can activate users return this.prisma.user.findFirst({
if (currentUserRole !== Role.SUPERADMIN && currentUserRole !== Role.ADMIN) { where: {
throw new ForbiddenException('Only SUPERADMIN and ADMIN can activate users'); resetToken,
} resetTokenExpiry: {
gt: new Date(),
},
},
});
}
try { async activateUser(id: number, currentUserRole: Role) {
return await this.prisma.user.update({ // Only SUPERADMIN and ADMIN can activate users
where: { id }, if (currentUserRole !== Role.SUPERADMIN && currentUserRole !== Role.ADMIN) {
data: { isActive: true }, throw new ForbiddenException(
select: { 'Only SUPERADMIN and ADMIN can activate users',
id: true, );
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
}
} }
async deactivateUser(id: number, currentUserRole: Role) { try {
// Only SUPERADMIN and ADMIN can deactivate users return await this.prisma.user.update({
if (currentUserRole !== Role.SUPERADMIN && currentUserRole !== Role.ADMIN) { where: { id },
throw new ForbiddenException('Only SUPERADMIN and ADMIN can deactivate users'); data: { isActive: true },
} select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
}
}
try { async deactivateUser(id: number, currentUserRole: Role) {
return await this.prisma.user.update({ // Only SUPERADMIN and ADMIN can deactivate users
where: { id }, if (currentUserRole !== Role.SUPERADMIN && currentUserRole !== Role.ADMIN) {
data: { isActive: false }, throw new ForbiddenException(
select: { 'Only SUPERADMIN and ADMIN can deactivate users',
id: true, );
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
}
} }
async findInactiveUsers() { try {
return this.prisma.user.findMany({ return await this.prisma.user.update({
where: { isActive: false }, where: { id },
select: { data: { isActive: false },
id: true, select: {
email: true, id: true,
name: true, email: true,
role: true, name: true,
isActive: true, role: true,
createdAt: true, isActive: true,
updatedAt: true, createdAt: true,
}, updatedAt: true,
}); },
});
} catch (error) {
throw new NotFoundException(`User with ID ${id} not found`);
} }
} }
\ No newline at end of file
async findInactiveUsers() {
return this.prisma.user.findMany({
where: { isActive: false },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
}
}
...@@ -3,60 +3,60 @@ import { PrismaService } from '../../common/prisma/prisma.service'; ...@@ -3,60 +3,60 @@ import { PrismaService } from '../../common/prisma/prisma.service';
@Injectable() @Injectable()
export class CodeGeneratorService { export class CodeGeneratorService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) {}
/** /**
* Generates the next alphabetical code for a site * Generates the next alphabetical code for a site
* Codes start from A and progress to Z, then AA, AB, ..., ZZ, AAA, etc. * Codes start from A and progress to Z, then AA, AB, ..., ZZ, AAA, etc.
*/ */
async generateNextCandidateCode(siteId: number): Promise<string> { async generateNextCandidateCode(siteId: number): Promise<string> {
// Find all candidates associated with this site // Find all candidates associated with this site
const siteCandidates = await this.prisma.candidateSite.findMany({ const siteCandidates = await this.prisma.candidateSite.findMany({
where: { siteId }, where: { siteId },
include: { include: {
candidate: true, candidate: true,
}, },
orderBy: { orderBy: {
candidate: { candidate: {
candidateCode: 'desc', candidateCode: 'desc',
} },
}, },
}); });
// If no codes exist yet, start with 'A' // If no codes exist yet, start with 'A'
if (siteCandidates.length === 0) { if (siteCandidates.length === 0) {
return 'A'; return 'A';
}
// Get the latest code and generate the next one
const latestCode = siteCandidates[0].candidate.candidateCode;
return this.incrementAlphabeticCode(latestCode);
} }
/** // Get the latest code and generate the next one
* Increments an alphabetic code (A->B, Z->AA, AA->AB, etc.) const latestCode = siteCandidates[0].candidate.candidateCode;
*/ return this.incrementAlphabeticCode(latestCode);
incrementAlphabeticCode(code: string): string { }
// Convert to array of characters for easier manipulation
const chars = code.split(''); /**
* Increments an alphabetic code (A->B, Z->AA, AA->AB, etc.)
// Start from the last character and try to increment */
let i = chars.length - 1; incrementAlphabeticCode(code: string): string {
// Convert to array of characters for easier manipulation
while (i >= 0) { const chars = code.split('');
// If current character is not 'Z', just increment it
if (chars[i] !== 'Z') { // Start from the last character and try to increment
chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1); let i = chars.length - 1;
return chars.join('');
} while (i >= 0) {
// If current character is not 'Z', just increment it
// Current character is 'Z', set it to 'A' and move to previous position if (chars[i] !== 'Z') {
chars[i] = 'A'; chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1);
i--; return chars.join('');
} }
// If we're here, we've carried over beyond the first character // Current character is 'Z', set it to 'A' and move to previous position
// (e.g., incrementing 'ZZ' to 'AAA') chars[i] = 'A';
return 'A' + chars.join(''); i--;
} }
}
\ No newline at end of file // If we're here, we've carried over beyond the first character
// (e.g., incrementing 'ZZ' to 'AAA')
return 'A' + chars.join('');
}
}
...@@ -3,8 +3,8 @@ import { PrismaModule } from '../../common/prisma/prisma.module'; ...@@ -3,8 +3,8 @@ import { PrismaModule } from '../../common/prisma/prisma.module';
import { CodeGeneratorService } from './code-generator.service'; import { CodeGeneratorService } from './code-generator.service';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule],
providers: [CodeGeneratorService], providers: [CodeGeneratorService],
exports: [CodeGeneratorService], exports: [CodeGeneratorService],
}) })
export class UtilsModule { } export class UtilsModule {}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment