Commit c8c5d592 by Augusto

Settling

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