Commit 65bf6dc7 by Augusto

Database All updated

parent 1941a82d
......@@ -17,7 +17,8 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
......@@ -89,5 +90,8 @@
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
/*
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";
......@@ -33,6 +33,7 @@ model User {
refreshTokens RefreshToken[]
resetToken String? // For password reset
resetTokenExpiry DateTime? // Expiry time for reset token
Comment Comment[]
@@index([email])
@@index([role])
......@@ -58,7 +59,7 @@ model Site {
longitude Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidates Candidate[]
candidates CandidateSite[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
createdById Int?
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
......@@ -74,21 +75,46 @@ model Candidate {
longitude Float
type String
address String
comments String?
currentStatus String
onGoing Boolean @default(false)
site Site @relation(fields: [siteId], references: [id])
siteId Int
sites CandidateSite[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
createdById Int?
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
updatedById Int?
comments Comment[]
@@unique([siteId, candidateCode])
@@index([candidateCode])
@@index([currentStatus])
@@index([siteId])
@@index([onGoing])
}
model CandidateSite {
id Int @id @default(autoincrement())
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
candidateId Int
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([candidateId, siteId])
@@index([candidateId])
@@index([siteId])
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade)
candidateId Int
createdBy User? @relation(fields: [createdById], references: [id])
createdById Int?
@@index([candidateId])
@@index([createdById])
}
import { PrismaClient, Role } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const hashedPassword = await bcrypt.hash('brandit123465', 10);
const superadmin = await prisma.user.upsert({
where: { email: 'augusto.fonte@brandit.pt' },
update: {
isActive: true,
role: Role.SUPERADMIN,
password: hashedPassword,
},
create: {
email: 'augusto.fonte@brandit.pt',
name: 'Augusto Fonte',
password: hashedPassword,
role: Role.SUPERADMIN,
isActive: true,
},
});
console.log('Created/Updated superadmin user:', superadmin);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
\ No newline at end of file
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Starting CandidateSite seeding...');
// Get all candidates
const candidates = await prisma.candidate.findMany({
select: {
id: true,
latitude: true,
longitude: true,
},
});
console.log(`Found ${candidates.length} candidates`);
// Get all sites
const sites = await prisma.site.findMany({
select: {
id: true,
},
});
console.log(`Found ${sites.length} sites`);
// Create a map of candidates by their coordinates for easy lookup
const candidateMap = new Map(
candidates.map(c => [`${c.latitude},${c.longitude}`, c])
);
// Get the seed data from seedCandidates.ts
const seedData = [
// Site ID 1 candidates
{
candidateCode: 'A',
latitude: 38.726545,
longitude: -9.419486,
siteId: 1
},
{
candidateCode: 'B',
latitude: 38.725969,
longitude: -9.423131,
siteId: 1
},
{
candidateCode: 'C',
latitude: 38.730217,
longitude: -9.418326,
siteId: 1
},
{
candidateCode: 'D',
latitude: 38.725250,
longitude: -9.424500,
siteId: 1
},
{
candidateCode: 'E',
latitude: 38.726738,
longitude: -9.419803,
siteId: 1
},
{
candidateCode: 'F',
latitude: 38.726868,
longitude: -9.419859,
siteId: 1
},
{
candidateCode: 'G',
latitude: 38.726508,
longitude: -9.419673,
siteId: 1
},
// Site ID 2 candidates
{
candidateCode: 'A',
latitude: 38.644019,
longitude: -9.237759,
siteId: 2
},
{
candidateCode: 'C',
latitude: 38.644997,
longitude: -9.238472,
siteId: 2
},
{
candidateCode: 'D',
latitude: 38.644873,
longitude: -9.238874,
siteId: 2
},
{
candidateCode: 'E',
latitude: 38.644799,
longitude: -9.239149,
siteId: 2
},
{
candidateCode: 'F',
latitude: 38.644422,
longitude: -9.239560,
siteId: 2
},
{
candidateCode: 'G',
latitude: 38.644416,
longitude: -9.240363,
siteId: 2
},
{
candidateCode: 'H',
latitude: 38.644703,
longitude: -9.237950,
siteId: 2
},
{
candidateCode: 'I',
latitude: 38.645030,
longitude: -9.238106,
siteId: 2
},
{
candidateCode: 'J',
latitude: 38.644929,
longitude: -9.237269,
siteId: 2
},
{
candidateCode: 'L',
latitude: 38.645336,
longitude: -9.237468,
siteId: 2
},
{
candidateCode: 'M',
latitude: 38.645248,
longitude: -9.237779,
siteId: 2
},
{
candidateCode: 'N',
latitude: 38.644829,
longitude: -9.237575,
siteId: 2
},
{
candidateCode: 'O',
latitude: 38.643395,
longitude: -9.237417,
siteId: 2
},
{
candidateCode: 'P',
latitude: 38.643972,
longitude: -9.237750,
siteId: 2
},
{
candidateCode: 'Q',
latitude: 38.641333,
longitude: -9.236250,
siteId: 2
},
// Site ID 3 candidates
{
candidateCode: 'A',
latitude: 38.703867,
longitude: -9.409794,
siteId: 3
},
{
candidateCode: 'B',
latitude: 38.705352,
longitude: -9.409230,
siteId: 3
},
{
candidateCode: 'C',
latitude: 38.705252,
longitude: -9.408116,
siteId: 3
},
{
candidateCode: 'D',
latitude: 38.705479,
longitude: -9.410360,
siteId: 3
},
{
candidateCode: 'E',
latitude: 38.706143,
longitude: -9.408228,
siteId: 3
},
{
candidateCode: 'F',
latitude: 38.707038,
longitude: -9.408328,
siteId: 3
},
{
candidateCode: 'G',
latitude: 38.706369,
longitude: -9.408576,
siteId: 3
},
{
candidateCode: 'H',
latitude: 38.716133,
longitude: -9.240754,
siteId: 3
},
{
candidateCode: 'I',
latitude: 38.715948,
longitude: -9.240704,
siteId: 3
},
{
candidateCode: 'J',
latitude: 38.716057,
longitude: -9.241199,
siteId: 3
},
{
candidateCode: 'L',
latitude: 38.716237,
longitude: -9.241284,
siteId: 3
},
{
candidateCode: 'M',
latitude: 38.704317,
longitude: -9.404516,
siteId: 3
},
// Site ID 5 candidates
{
candidateCode: 'A',
latitude: 38.721221,
longitude: -9.154731,
siteId: 5
},
{
candidateCode: 'B',
latitude: 38.720636,
longitude: -9.152795,
siteId: 5
},
{
candidateCode: 'C',
latitude: 38.721725,
longitude: -9.151891,
siteId: 5
},
// Site ID 6 candidates
{
candidateCode: 'A',
latitude: 37.075038,
longitude: -8.125136,
siteId: 6
},
{
candidateCode: 'B',
latitude: 37.074373,
longitude: -8.120280,
siteId: 6
},
{
candidateCode: 'C',
latitude: 37.076107,
longitude: -8.117430,
siteId: 6
},
{
candidateCode: 'D',
latitude: 37.074852,
longitude: -8.118025,
siteId: 6
},
{
candidateCode: 'E',
latitude: 37.075541,
longitude: -8.125299,
siteId: 6
},
{
candidateCode: 'F',
latitude: 37.076201,
longitude: -8.124550,
siteId: 6
},
{
candidateCode: 'G',
latitude: 37.075943,
longitude: -8.124319,
siteId: 6
},
// Site ID 7 candidates
{
candidateCode: 'A',
latitude: 39.494514,
longitude: -8.613901,
siteId: 7
},
{
candidateCode: 'B',
latitude: 39.495646,
longitude: -8.610714,
siteId: 7
},
{
candidateCode: 'C',
latitude: 39.495916,
longitude: -8.610367,
siteId: 7
},
{
candidateCode: 'D',
latitude: 39.495205,
longitude: -8.612732,
siteId: 7
},
// Site ID 9 candidates
{
candidateCode: 'A',
latitude: 38.717448,
longitude: -9.149730,
siteId: 9
},
{
candidateCode: 'B',
latitude: 38.717032,
longitude: -9.148821,
siteId: 9
},
{
candidateCode: 'C',
latitude: 38.717222,
longitude: -9.149457,
siteId: 9
},
{
candidateCode: 'D',
latitude: 38.717007,
longitude: -9.149382,
siteId: 9
},
{
candidateCode: 'E',
latitude: 38.715710,
longitude: -9.149208,
siteId: 9
},
{
candidateCode: 'F',
latitude: 38.716928,
longitude: -9.148390,
siteId: 9
},
{
candidateCode: 'G',
latitude: 38.715507,
longitude: -9.148643,
siteId: 9
},
{
candidateCode: 'H',
latitude: 38.715750,
longitude: -9.148213,
siteId: 9
},
{
candidateCode: 'I',
latitude: 38.716006,
longitude: -9.147743,
siteId: 9
},
{
candidateCode: 'J',
latitude: 38.716320,
longitude: -9.147463,
siteId: 9
},
{
candidateCode: 'L',
latitude: 38.715906,
longitude: -9.146578,
siteId: 9
},
{
candidateCode: 'M',
latitude: 38.715973,
longitude: -9.146910,
siteId: 9
},
{
candidateCode: 'N',
latitude: 38.715556,
longitude: -9.146083,
siteId: 9
},
{
candidateCode: 'O',
latitude: 38.716139,
longitude: -9.148722,
siteId: 9
}
];
// Create CandidateSite entries for each candidate
let createdCount = 0;
let skippedCount = 0;
for (const data of seedData) {
const key = `${data.latitude},${data.longitude}`;
const candidate = candidateMap.get(key);
if (candidate) {
try {
// Create the relationship for the original site
await prisma.candidateSite.create({
data: {
candidateId: candidate.id,
siteId: data.siteId,
},
});
createdCount++;
// If this is a site ID 3 candidate, also create a relationship for site ID 4
if (data.siteId === 3) {
await prisma.candidateSite.create({
data: {
candidateId: candidate.id,
siteId: 4, // Site ID 4
},
});
createdCount++;
}
} catch (error) {
if (error.code === 'P2002') {
// Unique constraint violation - relationship already exists
skippedCount++;
} else {
console.error(`Error creating relationship for candidate ${candidate.id} and site ${data.siteId}:`, error);
}
}
} else {
console.warn(`Could not find matching candidate for coordinates: ${key}`);
}
}
console.log(`CandidateSite seeding completed: ${createdCount} relationships created, ${skippedCount} skipped (already existed)`);
}
main()
.catch((error) => {
console.error('Seeding failed:', error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
\ No newline at end of file
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Get the superadmin user
const superadmin = await prisma.user.findUnique({
where: { email: 'augusto.fonte@brandit.pt' },
});
if (!superadmin) {
console.error('Superadmin user not found. Please run the main seed.ts first.');
return;
}
// Get all sites to associate candidates with
const sites = await prisma.site.findMany();
if (sites.length === 0) {
console.error('No sites found. Please run the seedSites.ts first.');
return;
}
// Example candidates data - you can replace this with your actual data
const candidatesData = [
// Site ID 1 candidates
{
candidateCode: 'A',
latitude: 38.726545,
longitude: -9.419486,
type: 'GF',
address: 'Rua das Tojas, Alvide - Cascais',
comment: '05/08/2024: PD enviadas - Negociação Cellnex',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[0].id
},
{
candidateCode: 'B',
latitude: 38.725969,
longitude: -9.423131,
type: 'GF',
address: 'Site CLX 008U7_ALVIDE',
comment: '06/01/2025: MEO reprovou o local',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[0].id
},
{
candidateCode: 'C',
latitude: 38.730217,
longitude: -9.418326,
type: 'RT',
address: 'Hospital de Cascais',
comment: '10/02/2025: PD enviadas - Negociação Cellnex',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[0].id
},
{
candidateCode: 'D',
latitude: 38.725250,
longitude: -9.424500,
type: 'GF',
address: '',
comment: 'Terreno da brisa - possibilidade de construçao de Greenfield - em validaçao MNO',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[0].id
},
{
candidateCode: 'E',
latitude: 38.726738,
longitude: -9.419803,
type: 'GF',
address: '',
comment: 'Rejeitam a colocaçao do site',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[0].id
},
{
candidateCode: 'F',
latitude: 38.726868,
longitude: -9.419859,
type: 'GF',
address: '',
comment: 'Rejeitam a colocaçao do site',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[0].id
},
{
candidateCode: 'G',
latitude: 38.726508,
longitude: -9.419673,
type: 'GF',
address: '',
comment: 'Rejeitam a colocaçao do site',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[0].id
},
// Site ID 2 candidates
{
candidateCode: 'A',
latitude: 38.644019,
longitude: -9.237759,
type: 'RT',
address: 'Av General Humberto Delgado, 27, Costa Caparica',
comment: '22/01/2025: SS enviado - Negociação Cellnex',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[1].id
},
{
candidateCode: 'C',
latitude: 38.644997,
longitude: -9.238472,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'D',
latitude: 38.644873,
longitude: -9.238874,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'E',
latitude: 38.644799,
longitude: -9.239149,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'F',
latitude: 38.644422,
longitude: -9.239560,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'G',
latitude: 38.644416,
longitude: -9.240363,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'H',
latitude: 38.644703,
longitude: -9.237950,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'I',
latitude: 38.645030,
longitude: -9.238106,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'J',
latitude: 38.644929,
longitude: -9.237269,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'L',
latitude: 38.645336,
longitude: -9.237468,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'M',
latitude: 38.645248,
longitude: -9.237779,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'N',
latitude: 38.644829,
longitude: -9.237575,
type: 'RT',
address: '',
comment: 'REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'O',
latitude: 38.643395,
longitude: -9.237417,
type: 'RT',
address: '',
comment: 'REJEITADO PELO COND - NÃO TEM INTERESSE',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[1].id
},
{
candidateCode: 'P',
latitude: 38.643972,
longitude: -9.237750,
type: 'RT',
address: '',
comment: 'Interesse por parte do cand. - MEO diz que local não é o ideal mas estao a reconsiderar',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[1].id
},
{
candidateCode: 'Q',
latitude: 38.641333,
longitude: -9.236250,
type: 'RT',
address: '',
comment: 'MEO validou o local, mas não conseguimos fechar a negociaçao com o candidato - em novas negociaçoes',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[1].id
},
// Site ID 3 candidates
{
candidateCode: 'A',
latitude: 38.703867,
longitude: -9.409794,
type: 'RT',
address: 'R Dom António Guedes de Herédia, 68, Estoril',
comment: '14-01-2025 A parte superior foi cedida ao proprietário do ultimo piso, pelo que não será possível a instalação',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[2].id
},
{
candidateCode: 'B',
latitude: 38.705352,
longitude: -9.409230,
type: 'RT',
address: 'Av. São Pedro no1, Estoril',
comment: '12-02-2025: Condminio vai reanalisar uma eventual contra-proposta',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[2].id
},
{
candidateCode: 'C',
latitude: 38.705252,
longitude: -9.408116,
type: 'RT',
address: 'Av Estrangeiros, 175, 2765-410 Monte Estoril',
comment: '17-02-2025: Aguarda aprovação de local e condições',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[2].id
},
{
candidateCode: 'D',
latitude: 38.705479,
longitude: -9.410360,
type: 'RT',
address: 'Av Faial, 218, Estoril',
comment: '11/02/2025: Proposta rejeitada pelo condominio',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[2].id
},
{
candidateCode: 'E',
latitude: 38.706143,
longitude: -9.408228,
type: 'RT',
address: 'Rua Alegre, nº1, Estoril',
comment: '12-03-2025: Conseguimos falar com uma condómina que nos informou que o administrador do condomínio está ausente do país e que só regressa em junho. Não disponibilizou contactos, apesar da insistência, e também não mostrou qualquer interesse em receber/analisar uma proposta.',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[2].id
},
{
candidateCode: 'F',
latitude: 38.707038,
longitude: -9.408328,
type: 'RT',
address: 'Rua Conde Moser, Estoril',
comment: '',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[2].id
},
{
candidateCode: 'G',
latitude: 38.706369,
longitude: -9.408576,
type: 'RT',
address: 'Rua Conde Moser 284, Estoril',
comment: '21-03-2025 Proposta em analise no proprietário',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[2].id
},
{
candidateCode: 'H',
latitude: 38.716133,
longitude: -9.240754,
type: 'RT',
address: 'ADMIN INTERNA',
comment: 'REJEITADO pelo candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[2].id
},
{
candidateCode: 'I',
latitude: 38.715948,
longitude: -9.240704,
type: 'RT',
address: 'AD URBIS',
comment: 'REJEITADO pelo candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[2].id
},
{
candidateCode: 'J',
latitude: 38.716057,
longitude: -9.241199,
type: 'RT',
address: 'MCM CONDOMÍNIOS',
comment: 'REJEITADO pelo candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[2].id
},
{
candidateCode: 'L',
latitude: 38.716237,
longitude: -9.241284,
type: 'RT',
address: 'CERLENA - SOCIEDADE CIVIL DE INVESTIMENTOS IMOBILIÁRIOS S.A.R.L.',
comment: 'AGC 18-05 - REJEITADO',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[2].id
},
{
candidateCode: 'M',
latitude: 38.704317,
longitude: -9.404516,
type: 'GF',
address: '',
comment: 'REJEITADO pelo candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[2].id
},
// Site ID 5 candidates
{
candidateCode: 'A',
latitude: 38.721221,
longitude: -9.154731,
type: 'RT',
address: 'Rua de São Francisco Sales, 17, Lisboa',
comment: '12-03-2025 Falámos com moradores mas não disponibilizaram contactos do administrador de condominio',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[4].id
},
{
candidateCode: 'B',
latitude: 38.720636,
longitude: -9.152795,
type: 'RT',
address: 'Rua Alexanre Herculano, 53',
comment: 'Rejeitado por parte do candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[4].id
},
{
candidateCode: 'C',
latitude: 38.721725,
longitude: -9.151891,
type: 'RT',
address: 'Rua Alexanre Herculano, 50',
comment: 'Candidato com licenciamento feito - bloqueado pelo condominio em fase de construçao - Legal a rever',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[4].id
},
// Site ID 6 candidates
{
candidateCode: 'A',
latitude: 37.075038,
longitude: -8.125136,
type: 'GF',
address: 'Marina Vilamoura',
comment: '05-05-2023: PD enviadas- Negociação Cellnex',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[5].id
},
{
candidateCode: 'B',
latitude: 37.074373,
longitude: -8.120280,
type: 'RT',
address: 'Tivoli Marina Vilamoura Algarve Resort',
comment: '10-07-2024: Proposta de instalação enviada (Negociação Cellnex)',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[5].id
},
{
candidateCode: 'C',
latitude: 37.076107,
longitude: -8.117430,
type: 'RT',
address: 'Hotel Vila Galé Marina',
comment: '17-02-2025 proposta em analise no diretor do Hotel.',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[5].id
},
{
candidateCode: 'D',
latitude: 37.074852,
longitude: -8.118025,
type: 'RT',
address: 'Dom Pedro Marina Hotel - Vilamoura',
comment: '18-02-2025: Visita técnica - Aguarda validação do local',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[5].id
},
{
candidateCode: 'E',
latitude: 37.075541,
longitude: -8.125299,
type: 'GF',
address: 'Terreno junto à Marina Vilamoura (antigo posto da GNR)',
comment: '27-02-2025: Terreno do estado para aquisição em concurso mas não é compativel com a instalação de uma estção radiocom.',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[5].id
},
{
candidateCode: 'F',
latitude: 37.076201,
longitude: -8.124550,
type: 'GF',
address: 'Clube Vela Vilamoura',
comment: '26-02-2025: Clube tem um projeto a iniciar que irá ocupar todo o espaço, pelo que não é possível a instalação',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[5].id
},
{
candidateCode: 'G',
latitude: 37.075943,
longitude: -8.124319,
type: 'GF',
address: 'Etar Vilamoura',
comment: '18-03-2025: Inframoura respondeu que a ETAR está no espaço da Marina de Vilamoura',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[5].id
},
// Site ID 7 candidates
{
candidateCode: 'A',
latitude: 39.494514,
longitude: -8.613901,
type: 'GF',
address: '',
comment: 'Em validaçao de cand identificado que pretende 3.6k ano',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[6].id
},
{
candidateCode: 'B',
latitude: 39.495646,
longitude: -8.610714,
type: 'GF',
address: 'Zibreira, Torres Novas',
comment: 'Candidato validado - pretende 12k ano',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[6].id
},
{
candidateCode: 'C',
latitude: 39.495916,
longitude: -8.610367,
type: 'GF',
address: '',
comment: '02-04-2025: Pesquisa contactos',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[6].id
},
{
candidateCode: 'D',
latitude: 39.495205,
longitude: -8.612732,
type: 'GF',
address: '',
comment: '02-04-2025: Pesquisa contactos',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[6].id
},
// Site ID 9 candidates
{
candidateCode: 'A',
latitude: 38.717448,
longitude: -9.149730,
type: 'RT',
address: 'R Escola Politécnica, N9, Lisboa',
comment: '16-01-2025: PD enviadas (Negociação Cellnex)',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'B',
latitude: 38.717032,
longitude: -9.148821,
type: 'RT',
address: '',
comment: 'REJEITADO pelo candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'C',
latitude: 38.717222,
longitude: -9.149457,
type: 'RT',
address: '',
comment: 'REJEITADO pelo candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'D',
latitude: 38.717007,
longitude: -9.149382,
type: 'RT',
address: '',
comment: 'REJEITADO pelo candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'E',
latitude: 38.715710,
longitude: -9.149208,
type: 'RT',
address: '',
comment: 'Recusão a colocação de qualquer equipamento que não seja da embaixada',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'F',
latitude: 38.716928,
longitude: -9.148390,
type: 'RT',
address: '',
comment: 'Contato com a direção, no qual informam que pelo valor de 400€ não aceitam e dão resposta com o valor que acham justo para eles - Aceitam 2.500€ mês',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'G',
latitude: 38.715507,
longitude: -9.148643,
type: 'RT',
address: '',
comment: 'Contato com a direção, no qual informam que pelo valor de 400€ não aceitam e dão resposta com o valor que acham justo para eles - Aceitam 2.500€ mês',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'H',
latitude: 38.715750,
longitude: -9.148213,
type: 'RT',
address: '',
comment: 'Contato com a direção, no qual informam que pelo valor de 400€ não aceitam e dão resposta com o valor que acham justo para eles - Aceitam 2.500€ mês',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'I',
latitude: 38.716006,
longitude: -9.147743,
type: 'RT',
address: '',
comment: 'Cellnex não aceita local',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'J',
latitude: 38.716320,
longitude: -9.147463,
type: 'RT',
address: '',
comment: 'Contato com a direção, no qual informam que na empresa será dificil aceitar a colocação',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'L',
latitude: 38.715906,
longitude: -9.146578,
type: 'RT',
address: '',
comment: 'Recusada proposta de adaptaçao possivel pelo candidato',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'M',
latitude: 38.715973,
longitude: -9.146910,
type: 'RT',
address: '',
comment: 'Senhorio não aceita a antena no seu edificio',
currentStatus: 'REJECTED',
onGoing: false,
siteId: sites[7].id
},
{
candidateCode: 'N',
latitude: 38.715556,
longitude: -9.146083,
type: 'RT',
address: 'Dom Pedro V, 53, Lisboa, 1250 Portugal - PENSÃO LONDRES',
comment: 'Fizemos visita tecnica ao loca com a equipa de manutenção - em elaboração de proposta a apresentar',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[7].id
},
{
candidateCode: 'O',
latitude: 38.716139,
longitude: -9.148722,
type: 'RT',
address: 'Jardim do principe real',
comment: 'Em apresentação de proposta com a junta de freguesia',
currentStatus: 'NEGOTIATION_ONGOING',
onGoing: true,
siteId: sites[7].id
}
];
// Create candidates and their comments
for (const candidateData of candidatesData) {
const { comment, ...candidateFields } = candidateData;
// Create the candidate
const candidate = await prisma.candidate.create({
data: {
...candidateFields,
createdById: superadmin.id,
updatedById: superadmin.id,
},
});
// If there's a comment, create it
if (comment) {
await prisma.comment.create({
data: {
content: comment,
candidateId: candidate.id,
createdById: superadmin.id,
},
});
}
}
console.log('Candidates seeded successfully!');
}
// Run the seeding function directly
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
\ No newline at end of file
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Get the superadmin user
const superadmin = await prisma.user.findUnique({
where: { email: 'augusto.fonte@brandit.pt' },
});
if (!superadmin) {
console.error('Superadmin user not found. Please run the main seed.ts first.');
return;
}
// Sites data
const sitesData = [
{
siteCode: '04LO019',
siteName: 'CARRASCAL ALVIDE',
latitude: 38.7257,
longitude: -9.4229,
},
{
siteCode: '99LS120',
siteName: 'COSTA CAPARICA NORTE',
latitude: 38.6445,
longitude: -9.24,
},
{
siteCode: '06LO026',
siteName: 'MONTE ESTORIL OESTE',
latitude: 38.7042,
longitude: -9.4094,
},
{
siteCode: '047S2',
siteName: 'CASCAIS ESTORIL',
latitude: 38.7041,
longitude: -9.4078,
},
{
siteCode: '98LC129',
siteName: 'RATO',
latitude: 38.721,
longitude: -9.1531,
},
{
siteCode: '02AG024',
siteName: 'VILAMOURA FALESIA',
latitude: 37.0746,
longitude: -8.1234,
},
{
siteCode: '16RB005',
siteName: 'RENOVA2-ZIBREIRA',
latitude: 39.491,
longitude: -8.6121,
},
];
// Create sites
for (const siteData of sitesData) {
const site = await prisma.site.upsert({
where: { siteCode: siteData.siteCode },
update: {
siteName: siteData.siteName,
latitude: siteData.latitude,
longitude: siteData.longitude,
updatedById: superadmin.id,
},
create: {
siteCode: siteData.siteCode,
siteName: siteData.siteName,
latitude: siteData.latitude,
longitude: siteData.longitude,
createdById: superadmin.id,
updatedById: superadmin.id,
},
});
console.log(`Created/Updated site: ${site.siteCode} - ${site.siteName}`);
}
console.log('Sites seeding completed successfully!');
}
// Run the seeding function directly
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
\ No newline at end of file
-- Truncate all tables in the correct order due to foreign key constraints
TRUNCATE TABLE "Comment" CASCADE;
TRUNCATE TABLE "Candidate" CASCADE;
TRUNCATE TABLE "Site" CASCADE;
TRUNCATE TABLE "RefreshToken" CASCADE;
TRUNCATE TABLE "User" CASCADE;
-- Reset the sequences
ALTER SEQUENCE "User_id_seq" RESTART WITH 1;
ALTER SEQUENCE "RefreshToken_id_seq" RESTART WITH 1;
ALTER SEQUENCE "Site_id_seq" RESTART WITH 1;
ALTER SEQUENCE "Candidate_id_seq" RESTART WITH 1;
ALTER SEQUENCE "Comment_id_seq" RESTART WITH 1;
\ No newline at end of file
#!/bin/bash
# Execute the truncate SQL script
psql -d cellnex -f prisma/truncate.sql
# Seed script removed as it's only for creating a superadmin
\ No newline at end of file
......@@ -8,6 +8,7 @@ import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { SitesModule } from './modules/sites/sites.module';
import { CandidatesModule } from './modules/candidates/candidates.module';
import { CommentsModule } from './modules/comments/comments.module';
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { join } from 'path';
......@@ -48,6 +49,7 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
AuthModule,
SitesModule,
CandidatesModule,
CommentsModule,
],
controllers: [AppController],
providers: [
......
......@@ -52,7 +52,7 @@ export class AuthService {
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
expiresIn: '15m',
expiresIn: '24h',
}),
this.jwtService.signAsync(payload, {
expiresIn: '7d',
......@@ -116,7 +116,7 @@ export class AuthService {
const [newAccessToken, newRefreshToken] = await Promise.all([
this.jwtService.signAsync(newPayload, {
expiresIn: '15m',
expiresIn: '24h',
}),
this.jwtService.signAsync(newPayload, {
expiresIn: '7d',
......
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { CandidatesService } from './candidates.service';
import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
import { QueryCandidateDto } from './dto/query-candidate.dto';
import { CandidateResponseDto } from './dto/candidate-response.dto';
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 { User } from '../auth/decorators/user.decorator';
import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto';
@ApiTags('candidates')
@Controller('candidates')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class CandidatesController {
constructor(private readonly candidatesService: CandidatesService) { }
@Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Create a new candidate' })
@ApiResponse({ status: 201, description: 'The candidate has been successfully created.' })
@ApiResponse({ status: 201, description: 'The candidate has been successfully created.', type: CandidateResponseDto })
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createCandidateDto: CreateCandidateDto) {
return this.candidatesService.create(createCandidateDto);
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.' })
@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) {
return this.candidatesService.findAll(query);
}
@Get('site/:siteId')
@ApiOperation({ summary: 'Get candidates by site id' })
@ApiResponse({ status: 200, description: 'Return the candidates for the site.' })
@ApiResponse({
status: 200,
description: 'Return the candidates for the site.',
type: [CandidateResponseDto]
})
findBySiteId(@Param('siteId', ParseIntPipe) siteId: number) {
return this.candidatesService.findBySiteId(siteId);
}
@Get(':id')
@ApiOperation({ summary: 'Get a candidate by id' })
@ApiResponse({ status: 200, description: 'Return the candidate.' })
@ApiResponse({ status: 200, description: 'Return the candidate.', type: CandidateResponseDto })
@ApiResponse({ status: 404, description: 'Candidate not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.candidatesService.findOne(id);
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Update a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully updated.' })
@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,
) {
return this.candidatesService.update(id, updateCandidateDto);
}
@Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Delete a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully deleted.' })
@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')
@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,
) {
return this.candidatesService.addSitesToCandidate(id, addSitesDto);
}
}
\ No newline at end of file
......@@ -3,25 +3,94 @@ import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto';
import { QueryCandidateDto } from './dto/query-candidate.dto';
import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class CandidatesService {
constructor(private prisma: PrismaService) { }
async create(createCandidateDto: CreateCandidateDto) {
return this.prisma.candidate.create({
data: createCandidateDto,
async create(createCandidateDto: CreateCandidateDto, userId?: number) {
const { comment, ...candidateData } = createCandidateDto;
// Create the candidate with a transaction to ensure both operations succeed or fail together
return this.prisma.$transaction(async (prisma) => {
// Create the candidate
const candidate = await prisma.candidate.create({
data: {
...candidateData,
createdById: userId,
updatedById: userId,
},
include: {
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: {
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
}
return candidate;
});
}
async findAll(query: QueryCandidateDto) {
const { firstName, lastName, email, siteId, page = 1, limit = 10 } = query;
const { candidateCode, type, currentStatus, onGoing, siteId, page = 1, limit = 10 } = query;
const where = {
...(firstName && { firstName: { contains: firstName, mode: 'insensitive' } }),
...(lastName && { lastName: { contains: lastName, mode: 'insensitive' } }),
...(email && { email: { contains: email, mode: 'insensitive' } }),
...(siteId && { siteId }),
const where: Prisma.CandidateWhereInput = {
...(candidateCode && { candidateCode: { contains: candidateCode, mode: Prisma.QueryMode.insensitive } }),
...(type && { type }),
...(currentStatus && { currentStatus }),
...(onGoing !== undefined && { onGoing }),
...(siteId && {
sites: {
some: {
siteId: siteId
}
}
}),
};
const [total, data] = await Promise.all([
......@@ -31,6 +100,27 @@ export class CandidatesService {
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
sites: {
include: {
site: true
}
},
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
}),
]);
......@@ -48,6 +138,22 @@ export class CandidatesService {
async findOne(id: number) {
const candidate = await this.prisma.candidate.findUnique({
where: { id },
include: {
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!candidate) {
......@@ -62,6 +168,22 @@ export class CandidatesService {
return await this.prisma.candidate.update({
where: { id },
data: updateCandidateDto,
include: {
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
} catch (error) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
......@@ -72,6 +194,22 @@ export class CandidatesService {
try {
return await this.prisma.candidate.delete({
where: { id },
include: {
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
} catch (error) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
......@@ -80,7 +218,80 @@ export class CandidatesService {
async findBySiteId(siteId: number) {
return this.prisma.candidate.findMany({
where: { siteId },
where: {
sites: {
some: {
siteId: siteId
}
}
},
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`);
}
// Create the candidate-site relationships
const candidateSites = await this.prisma.candidateSite.createMany({
data: siteIds.map(siteId => ({
candidateId: id,
siteId,
})),
skipDuplicates: true, // Skip if relationship already exists
});
// 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',
},
},
},
});
}
}
\ No newline at end of file
import { IsArray, IsNumber } from 'class-validator';
export class AddSitesToCandidateDto {
@IsArray()
@IsNumber({}, { each: true })
siteIds: number[];
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { CandidateType, CandidateStatus } from './create-candidate.dto';
import { CommentResponseDto } from '../../comments/dto/comment-response.dto';
export class CandidateResponseDto {
@ApiProperty({ description: 'Candidate ID' })
id: number;
@ApiProperty({ description: 'Candidate code' })
candidateCode: string;
@ApiProperty({ description: 'Latitude coordinate' })
latitude: number;
@ApiProperty({ description: 'Longitude coordinate' })
longitude: number;
@ApiProperty({ enum: CandidateType, description: 'Type of candidate' })
type: CandidateType;
@ApiProperty({ description: 'Address of the candidate' })
address: string;
@ApiProperty({ enum: CandidateStatus, description: 'Current status of the candidate' })
currentStatus: CandidateStatus;
@ApiProperty({ description: 'Whether the candidate is ongoing' })
onGoing: boolean;
@ApiProperty({ description: 'ID of the site this candidate belongs to' })
siteId: number;
@ApiProperty({ description: 'Creation timestamp' })
createdAt: 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
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean, IsUUID, IsEmail, IsPhoneNumber } from 'class-validator';
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CandidateType {
......@@ -15,32 +15,6 @@ export enum CandidateStatus {
}
export class CreateCandidateDto {
@ApiProperty({ description: 'The first name of the candidate' })
@IsString()
firstName: string;
@ApiProperty({ description: 'The last name of the candidate' })
@IsString()
lastName: string;
@ApiProperty({ description: 'The email address of the candidate' })
@IsEmail()
email: string;
@ApiProperty({ description: 'The phone number of the candidate', required: false })
@IsOptional()
@IsPhoneNumber()
phone?: string;
@ApiProperty({ description: 'The ID of the site the candidate is associated with' })
@IsNumber()
siteId: number;
@ApiProperty({ description: 'Additional notes about the candidate', required: false })
@IsOptional()
@IsString()
notes?: string;
@ApiProperty({ description: 'Candidate code' })
@IsString()
candidateCode: string;
......@@ -61,11 +35,6 @@ export class CreateCandidateDto {
@IsString()
address: string;
@ApiPropertyOptional({ description: 'Additional comments' })
@IsString()
@IsOptional()
comments?: string;
@ApiProperty({ description: 'Current status of the candidate' })
@IsEnum(CandidateStatus)
currentStatus: CandidateStatus;
......@@ -73,4 +42,13 @@ export class CreateCandidateDto {
@ApiProperty({ description: 'Whether the candidate is ongoing' })
@IsBoolean()
onGoing: boolean;
@ApiProperty({ description: 'ID of the site this candidate belongs to' })
@IsNumber()
siteId: number;
@ApiPropertyOptional({ description: 'Initial comment for the candidate' })
@IsString()
@IsOptional()
comment?: string;
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEmail } 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 first name', required: false })
@ApiProperty({ description: 'Filter by candidate code', required: false })
@IsOptional()
@IsString()
firstName?: string;
candidateCode?: string;
@ApiProperty({ description: 'Filter by last name', required: false })
@ApiProperty({ description: 'Filter by type', required: false, enum: CandidateType })
@IsOptional()
@IsString()
lastName?: string;
@IsEnum(CandidateType)
type?: CandidateType;
@ApiProperty({ description: 'Filter by current status', required: false, enum: CandidateStatus })
@IsOptional()
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
@ApiProperty({ description: 'Filter by email', required: false })
@ApiProperty({ description: 'Filter by ongoing status', required: false })
@IsOptional()
@IsEmail()
email?: string;
@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: 'Page number for pagination', required: false, default: 1 })
......
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEmail, IsPhoneNumber } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validator';
import { CandidateType, CandidateStatus } from './create-candidate.dto';
export class UpdateCandidateDto {
@ApiProperty({ description: 'The first name of the candidate', required: false })
@ApiPropertyOptional({ description: 'Candidate code' })
@IsOptional()
@IsString()
firstName?: string;
candidateCode?: string;
@ApiProperty({ description: 'The last name of the candidate', required: false })
@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()
lastName?: string;
address?: string;
@ApiProperty({ description: 'The email address of the candidate', required: false })
@ApiPropertyOptional({ enum: CandidateStatus, description: 'Current status of the candidate' })
@IsOptional()
@IsEmail()
email?: string;
@IsEnum(CandidateStatus)
currentStatus?: CandidateStatus;
@ApiProperty({ description: 'The phone number of the candidate', required: false })
@ApiPropertyOptional({ description: 'Whether the candidate is ongoing' })
@IsOptional()
@IsPhoneNumber()
phone?: string;
@IsBoolean()
onGoing?: boolean;
@ApiProperty({ description: 'The ID of the site the candidate is associated with', required: false })
@ApiPropertyOptional({ description: 'ID of the site this candidate belongs to' })
@IsOptional()
@IsNumber()
siteId?: number;
@ApiProperty({ description: 'Additional notes about the candidate', required: false })
@IsOptional()
@IsString()
notes?: string;
}
\ No newline at end of file
import { Controller, Get, Post, Body, Param, Delete, UseGuards, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentResponseDto } from './dto/comment-response.dto';
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';
@ApiTags('comments')
@Controller('comments')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) { }
@Post()
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR)
@ApiOperation({ summary: 'Create a new comment' })
@ApiResponse({ status: 201, description: 'The comment has been successfully created.', type: CommentResponseDto })
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createCommentDto: CreateCommentDto) {
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(':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)
@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);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { CommentsService } from './comments.service';
import { CommentsController } from './comments.controller';
import { PrismaService } from '../../common/prisma/prisma.service';
@Module({
controllers: [CommentsController],
providers: [CommentsService, PrismaService],
exports: [CommentsService],
})
export class CommentsModule { }
\ No newline at end of file
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateCommentDto } from './dto/create-comment.dto';
@Injectable()
export class CommentsService {
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 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 remove(id: number) {
return this.prisma.comment.delete({
where: { id },
});
}
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
class UserResponseDto {
@ApiProperty({ description: 'User ID' })
id: number;
@ApiProperty({ description: 'User name' })
name: string;
@ApiProperty({ description: 'User email' })
email: string;
}
export class CommentResponseDto {
@ApiProperty({ description: 'Comment ID' })
id: number;
@ApiProperty({ description: 'Comment content' })
content: string;
@ApiProperty({ description: 'Creation timestamp' })
createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date;
@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
import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator';
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
content: string;
@IsInt()
@IsNotEmpty()
candidateId: number;
@IsInt()
@IsOptional()
createdById?: number;
}
\ No newline at end of file
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsEnum } from 'class-validator';
export enum OrderDirection {
ASC = 'asc',
DESC = 'desc',
}
export class FindSitesDto {
@ApiPropertyOptional({ description: 'Filter by site code' })
@IsOptional()
@IsString()
siteCode?: string;
@ApiPropertyOptional({ description: 'Filter by site name' })
@IsOptional()
@IsString()
siteName?: string;
@ApiPropertyOptional({ description: 'Filter by site address' })
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional({ description: 'Filter by site city' })
@IsOptional()
@IsString()
city?: string;
@ApiPropertyOptional({ description: 'Filter by site state' })
@IsOptional()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'Filter by site country' })
@IsOptional()
@IsString()
country?: string;
@ApiPropertyOptional({ description: 'Order by field (e.g., siteName, siteCode, address, city, state, country)' })
@IsOptional()
@IsString()
orderBy?: string;
@ApiPropertyOptional({ description: 'Order direction (asc or desc)', enum: OrderDirection })
@IsOptional()
@IsEnum(OrderDirection)
orderDirection?: OrderDirection;
}
\ No newline at end of file
......@@ -8,6 +8,7 @@ import {
Delete,
ParseIntPipe,
UseGuards,
Query,
} from '@nestjs/common';
import { SitesService } from './sites.service';
import { CreateSiteDto } from './dto/create-site.dto';
......@@ -23,6 +24,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator';
import { FindSitesDto } from './dto/find-sites.dto';
@ApiTags('sites')
@Controller('sites')
......@@ -45,13 +47,13 @@ export class SitesController {
}
@Get()
@ApiOperation({ summary: 'Get all sites' })
@ApiOperation({ summary: 'Get all sites with filtering and ordering options' })
@ApiResponse({
status: 200,
description: 'Return all sites.',
description: 'Return all sites with applied filters and ordering.',
})
findAll() {
return this.sitesService.findAll();
findAll(@Query() findSitesDto: FindSitesDto) {
return this.sitesService.findAll(findSitesDto);
}
@Get('code/:siteCode')
......@@ -76,6 +78,17 @@ export class SitesController {
return this.sitesService.findOne(id);
}
@Get(':id/with-candidates')
@ApiOperation({ summary: 'Get a site with its candidates' })
@ApiResponse({
status: 200,
description: 'Return the site with its candidates.',
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findOneWithCandidates(@Param('id', ParseIntPipe) id: number) {
return this.sitesService.findOneWithCandidates(id);
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Update a site' })
......
......@@ -2,6 +2,8 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateSiteDto } from './dto/create-site.dto';
import { UpdateSiteDto } from './dto/update-site.dto';
import { FindSitesDto, OrderDirection } from './dto/find-sites.dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class SitesService {
......@@ -40,8 +42,31 @@ export class SitesService {
}
}
async findAll() {
return this.prisma.site.findMany({
async findAll(findSitesDto: FindSitesDto) {
const {
siteCode,
siteName,
address,
city,
state,
country,
orderBy = 'siteName',
orderDirection = OrderDirection.ASC,
} = findSitesDto;
// Build where clause for filters
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 } }),
};
// Get all filtered results
const sites = await this.prisma.site.findMany({
where,
include: {
createdBy: {
select: {
......@@ -57,13 +82,40 @@ export class SitesService {
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,
},
},
},
orderBy: {
[orderBy]: orderDirection,
},
});
return sites;
}
async findOne(id: number) {
......@@ -86,6 +138,8 @@ export class SitesService {
},
candidates: {
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
......@@ -102,6 +156,54 @@ export class SitesService {
},
},
},
},
},
_count: {
select: {
candidates: true,
},
},
},
});
if (!site) {
throw new NotFoundException(`Site with ID ${id} not found`);
}
return site;
}
async findOneWithCandidates(id: number) {
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,
},
},
},
},
},
orderBy: {
candidate: {
candidateCode: 'asc',
},
},
},
_count: {
select: {
candidates: true,
......
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