Commit 023e6ff8 by Augusto

0.0.2

parent 2698094f
......@@ -23,6 +23,7 @@
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"csv-parser": "^3.2.0",
"date-fns": "^4.1.0",
"jimp": "^0.22.12",
"multer": "^1.4.5-lts.2",
......@@ -33,7 +34,8 @@
"rxjs": "^7.8.1",
"sharp": "^0.34.1",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
......@@ -5972,6 +5974,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
......@@ -6869,6 +6880,19 @@
"follow-redirects": "^1.15.6"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
......@@ -7180,6 +7204,15 @@
"node": ">= 0.12.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
......@@ -7445,6 +7478,18 @@
}
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
......@@ -7518,6 +7563,18 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csv-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==",
"license": "MIT",
"bin": {
"csv-parser": "bin/csv-parser"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
......@@ -9093,6 +9150,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
......@@ -14899,6 +14965,18 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
......@@ -16626,6 +16704,24 @@
"node": ">= 10.0.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
......@@ -16759,6 +16855,27 @@
"xtend": "^4.0.0"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
......
{
"name": "api-cellnex",
"version": "0.0.1",
"version": "0.0.2",
"description": "",
"author": "",
"private": true,
......@@ -35,6 +35,7 @@
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"csv-parser": "^3.2.0",
"date-fns": "^4.1.0",
"jimp": "^0.22.12",
"multer": "^1.4.5-lts.2",
......@@ -45,7 +46,8 @@
"rxjs": "^7.8.1",
"sharp": "^0.34.1",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
......
# Site Data Import Instructions
This guide explains how to import site data from an Excel file into the database.
## Prerequisites
- Node.js installed
- Access to the project and database
- An Excel file (.xlsx) with site data
## Excel File Format
Your Excel file should have the following columns:
- `Site Code` - Unique identifier for the site (required)
- `Site Name` - Name of the site (required)
- `Lat` - Latitude coordinate (numeric)
- `Long` - Longitude coordinate (numeric)
- `Type` - Site type (optional)
## Running the Import
1. Place your Excel file in an accessible location
2. Run the import script using the following command:
```
npx ts-node prisma/import-sites.ts /path/to/your/file.xlsx
```
Replace `/path/to/your/file.xlsx` with the actual path to your Excel file.
3. The script will process each row in the file:
- If a site with the same `Site Code` already exists, it will be updated
- If the site doesn't exist, a new record will be created
- Validation errors will be logged but won't stop the import process
4. After completion, a summary will be displayed showing:
- Total records processed
- Number of sites created
- Number of sites updated
- Number of errors encountered
## Error Handling
Common errors to watch for:
- Missing required fields (Site Code, Site Name)
- Invalid coordinates (non-numeric values)
- Duplicate Site Codes in the Excel file
The script logs detailed error information to help diagnose issues.
## Tips for Large Files
Since your file contains approximately 7,000 records:
- The import may take several minutes to complete
- Progress updates are displayed every 100 records
- Consider running the import during off-peak hours
- Keep the terminal open until the process completes
## After Import
After importing, you may want to:
1. Verify the data in the database
2. Check the error logs for any records that failed to import
3. Run any necessary data validation or cleanup procedures
\ No newline at end of file
-- Add PARTNER to the Role enum
ALTER TYPE "Role" ADD VALUE IF NOT EXISTS 'PARTNER';
-- Create UserSite table
CREATE TABLE IF NOT EXISTS "UserSite" (
"id" SERIAL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"siteId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserSite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserSite_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Create indexes
CREATE UNIQUE INDEX IF NOT EXISTS "UserSite_userId_siteId_key" ON "UserSite"("userId", "siteId");
CREATE INDEX IF NOT EXISTS "UserSite_userId_idx" ON "UserSite"("userId");
CREATE INDEX IF NOT EXISTS "UserSite_siteId_idx" ON "UserSite"("siteId");
\ No newline at end of file
import { PrismaClient } from '@prisma/client';
import * as xlsx from 'xlsx';
import * as path from 'path';
import * as fs from 'fs';
const prisma = new PrismaClient();
// Define interface for Excel row data
interface SiteRow {
'Site Code': string;
'Site Name': string;
'Lat': string | number;
'Long': string | number;
'Type': string;
[key: string]: any; // For any additional columns
}
async function importSitesFromExcel(filePath: string) {
try {
console.log(`Importing sites from ${filePath}...`);
// Check if file exists
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
// Read the Excel file
const workbook = xlsx.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const data = xlsx.utils.sheet_to_json<SiteRow>(worksheet);
console.log(`Found ${data.length} records to import`);
// Track results
let created = 0;
let updated = 0;
let errors = 0;
// Process each row
for (const row of data) {
try {
// Map Excel columns to database fields
const siteCode = row['Site Code'];
const siteName = row['Site Name'];
const latitude = parseFloat(row['Lat'].toString());
const longitude = parseFloat(row['Long'].toString());
const type = row['Type'];
// Validate required fields
if (!siteCode || !siteName) {
console.error(`Missing required fields in row:`, row);
errors++;
continue;
}
// Validate numeric coordinates
if (isNaN(latitude) || isNaN(longitude)) {
console.error(`Invalid coordinates in row:`, row);
errors++;
continue;
}
// Check if site already exists
const existingSite = await prisma.site.findUnique({
where: { siteCode },
});
if (existingSite) {
// Update existing site
await prisma.site.update({
where: { id: existingSite.id },
data: {
siteName,
latitude,
longitude,
type,
updatedAt: new Date(),
},
});
updated++;
} else {
// Create new site
await prisma.site.create({
data: {
siteCode,
siteName,
latitude,
longitude,
type,
},
});
created++;
}
} catch (err) {
console.error(`Error processing row:`, row, err);
errors++;
}
// Log progress periodically
if ((created + updated + errors) % 100 === 0) {
console.log(`Progress: ${created + updated + errors}/${data.length} records processed`);
}
}
console.log(`
Import completed:
- Total records: ${data.length}
- Created: ${created}
- Updated: ${updated}
- Errors: ${errors}
`);
} catch (error) {
console.error('Import failed:', error);
} finally {
await prisma.$disconnect();
}
}
// Get file path from command line argument
const filePath = process.argv[2];
if (!filePath) {
console.error('Please provide the path to the Excel file as an argument');
console.error('Example: npx ts-node prisma/import-sites.ts /path/to/sites.xlsx');
process.exit(1);
}
importSitesFromExcel(filePath);
\ No newline at end of file
-- 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
......@@ -26,9 +26,13 @@ model User {
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?
@@index([email])
@@index([role])
@@index([partnerId])
}
model RefreshToken {
......@@ -49,6 +53,9 @@ model Site {
siteName String
latitude Float
longitude Float
type String?
isDigi Boolean @default(false)
companies CompanyName[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById Int?
......@@ -56,6 +63,7 @@ model Site {
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
@@index([siteCode])
}
......@@ -78,10 +86,13 @@ model Candidate {
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 {
......@@ -135,10 +146,45 @@ model spatial_ref_sys {
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])
}
......@@ -13,6 +13,8 @@ import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
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';
@Module({
imports: [
......@@ -50,6 +52,8 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
SitesModule,
CandidatesModule,
CommentsModule,
DashboardModule,
PartnersModule,
],
controllers: [AppController],
providers: [
......
......@@ -44,10 +44,28 @@ export class AuthService {
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
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([
......@@ -75,6 +93,7 @@ export class AuthService {
email: user.email,
name: user.name,
role: user.role,
partnerId: userDetails.partnerId
},
};
}
......@@ -107,11 +126,28 @@ export class AuthService {
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
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([
......@@ -195,6 +231,7 @@ export class AuthService {
id: payload.sub,
email: payload.email,
role: payload.role,
partnerId: payload.partnerId || null
};
} catch (error) {
throw new UnauthorizedException('Invalid token');
......
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Partner = createParamDecorator(
(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
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;
// 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;
}
}
\ No newline at end of file
......@@ -18,6 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
id: payload.sub,
email: payload.email,
role: payload.role,
partnerId: payload.partnerId || null,
};
}
}
\ No newline at end of file
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, UseGuards, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
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';
......@@ -10,6 +10,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 { Partner } from '../auth/decorators/partner.decorator';
import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { UploadPhotoDto } from './dto/upload-photo.dto';
......@@ -23,10 +24,12 @@ export class CandidatesController {
constructor(private readonly candidatesService: CandidatesService) { }
@Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Create a new candidate' })
@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 })
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createCandidateDto: CreateCandidateDto, @User('id') userId: number) {
return this.candidatesService.create(createCandidateDto, userId);
}
......@@ -54,8 +57,8 @@ export class CandidatesController {
}
}
})
findAll(@Query() query: QueryCandidateDto) {
return this.candidatesService.findAll(query);
findAll(@Query() query: QueryCandidateDto, @User('id') userId: number) {
return this.candidatesService.findAll(query, userId);
}
@Get('site/:siteId')
......@@ -65,20 +68,24 @@ export class CandidatesController {
description: 'Return the candidates for the site.',
type: [CandidateResponseDto]
})
findBySiteId(@Param('siteId', ParseIntPipe) siteId: number) {
return this.candidatesService.findBySiteId(siteId);
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) {
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)
@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.' })
......@@ -86,7 +93,13 @@ export class CandidatesController {
@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);
}
......@@ -100,6 +113,7 @@ export class CandidatesController {
}
@Post(':id/sites')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Add multiple sites to a candidate' })
@ApiResponse({
status: 200,
......@@ -110,7 +124,13 @@ export class CandidatesController {
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);
}
......@@ -133,16 +153,17 @@ export class CandidatesController {
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');
}
console.log('Received file:', {
originalname: 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);
}
return this.candidatesService.uploadPhoto(id, file, {
filename: file.originalname,
......@@ -152,7 +173,15 @@ export class CandidatesController {
}
@Get(':id/photos')
async getCandidatePhotos(@Param('id', ParseIntPipe) id: number) {
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);
}
......@@ -160,7 +189,15 @@ export class CandidatesController {
@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) {
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);
}
}
\ No newline at end of file
......@@ -22,7 +22,8 @@ export enum CandidateStatus {
export class CreateCandidateDto {
@ApiProperty({ description: 'Candidate code' })
@IsString()
candidateCode: string;
@IsOptional()
candidateCode?: string;
@ApiProperty({ description: 'Latitude coordinate' })
@IsNumber()
......
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 { 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('dashboard')
@Controller('dashboard')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
export class DashboardController {
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
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { PrismaModule } from '../../common/prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule { }
\ No newline at end of file
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { DashboardStatsDto } from './dto/dashboard.dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class DashboardService {
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(),
]);
// 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 per site with BigInt count conversion to Number
const candidatesPerSite = await this.prisma.$queryRaw`
SELECT
"Site"."id" as "siteId",
"Site"."siteCode",
"Site"."siteName",
CAST(COUNT("CandidateSite"."candidateId") AS INTEGER) as count
FROM "Site"
LEFT JOIN "CandidateSite" ON "CandidateSite"."siteId" = "Site"."id"
GROUP BY "Site"."id", "Site"."siteCode", "Site"."siteName"
ORDER BY count DESC
LIMIT 10
`;
// Get recent activity
const recentActivity = await this.prisma.$queryRaw`
SELECT
'site' as type,
"Site"."id" as id,
'created' as action,
"Site"."createdAt" as timestamp,
"Site"."createdById" as "userId",
u.name as "userName"
FROM "Site"
JOIN "User" u ON u.id = "Site"."createdById"
UNION ALL
SELECT
'candidate' as type,
"Candidate"."id" as id,
'created' as action,
"Candidate"."createdAt" as timestamp,
"Candidate"."createdById" as "userId",
u.name as "userName"
FROM "Candidate"
JOIN "User" u ON u.id = "Candidate"."createdById"
ORDER BY timestamp DESC
LIMIT 10
`;
// 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;
}
if (typeof obj === 'bigint') {
return Number(obj);
}
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;
}
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
import { ApiProperty } from '@nestjs/swagger';
export class DashboardStatsDto {
@ApiProperty({ description: 'Total number of sites' })
totalSites: number;
@ApiProperty({ description: 'Total number of candidates' })
totalCandidates: 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 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: 'User statistics' })
userStats: {
totalUsers: number;
usersByRole: {
[key: string]: number;
};
};
}
\ No newline at end of file
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: '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
export * from './create-partner.dto';
export * from './update-partner.dto';
export * from './partner-response.dto';
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
export class PartnerUserDto {
@ApiProperty({ description: 'User ID', example: 1 })
id: number;
@ApiProperty({ description: 'User name', example: 'John Doe' })
name: string;
@ApiProperty({ description: 'User email', example: 'john.doe@example.com' })
email: 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;
}
export class PartnerResponseDto {
@ApiProperty({ description: 'Partner ID', example: 1 })
id: number;
@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 active status', example: true })
isActive: boolean;
@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({ 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
import { PartialType } from '@nestjs/mapped-types';
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: '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
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
UseGuards
} from '@nestjs/common';
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';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PartnerAuthGuard } from '../auth/guards/partner-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { User } from '../auth/decorators/user.decorator';
import { Partner } from '../auth/decorators/partner.decorator';
import { Role } from '@prisma/client';
@ApiTags('partners')
@Controller('partners')
@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);
}
@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);
}
// For other roles, return all partners
return this.partnersService.findAll();
}
@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);
}
@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);
}
@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);
}
@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(':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);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { PartnersController } from './partners.controller';
import { PartnersService } from './partners.service';
import { PrismaService } from '../../common/prisma/prisma.service';
@Module({
controllers: [PartnersController],
providers: [PartnersService, PrismaService],
exports: [PartnersService],
})
export class PartnersModule { }
\ No newline at end of file
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
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;
}
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`);
}
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`);
}
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
import { ApiProperty } from '@nestjs/swagger';
export enum CompanyName {
VODAFONE = 'VODAFONE',
MEO = 'MEO',
NOS = 'NOS',
DIGI = 'DIGI',
}
\ No newline at end of file
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsNumber, IsOptional, Min, Max } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
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({
......@@ -39,4 +41,32 @@ export class CreateSiteDto {
@Min(-180)
@Max(180)
longitude: number;
@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: '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
import { ApiProperty } from '@nestjs/swagger';
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: '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
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsEnum } from 'class-validator';
import { IsOptional, IsString, IsEnum, IsInt, Min, IsBoolean } from 'class-validator';
import { Transform, Type } from 'class-transformer';
export enum OrderDirection {
ASC = 'asc',
......@@ -7,6 +8,20 @@ export enum OrderDirection {
}
export class FindSitesDto {
@ApiPropertyOptional({ description: 'Page number (1-based)', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'Number of items per page', default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 10;
@ApiPropertyOptional({ description: 'Filter by site code' })
@IsOptional()
@IsString()
......@@ -17,6 +32,11 @@ export class FindSitesDto {
@IsString()
siteName?: string;
@ApiPropertyOptional({ description: 'Filter by site type' })
@IsOptional()
@IsString()
type?: string;
@ApiPropertyOptional({ description: 'Filter by site address' })
@IsOptional()
@IsString()
......@@ -37,6 +57,26 @@ export class FindSitesDto {
@IsString()
country?: string;
@ApiPropertyOptional({ description: 'Filter by isDigi status', default: undefined })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
isDigi?: boolean;
@ApiPropertyOptional({ description: 'Filter sites that have candidates', default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
withCandidates?: boolean;
@ApiPropertyOptional({ description: 'Order by field (e.g., siteName, siteCode, address, city, state, country)' })
@IsOptional()
@IsString()
......
import { ApiProperty } from '@nestjs/swagger';
import { CompanyName } from './company.dto';
export class UserResponseDto {
@ApiProperty({ description: 'User ID' })
id: number;
@ApiProperty({ description: 'User name' })
name: string;
@ApiProperty({ description: 'User email' })
email: string;
}
export class SiteResponseDto {
@ApiProperty({ description: 'Site ID' })
id: number;
@ApiProperty({ description: 'Site code' })
code: string;
siteCode: string;
@ApiProperty({ description: 'Site name' })
name: string;
siteName: string;
@ApiProperty({ description: 'Latitude coordinate' })
latitude: number;
......@@ -19,9 +31,52 @@ export class SiteResponseDto {
@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: '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
......@@ -25,6 +25,7 @@ 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';
import { SiteResponseDto } from './dto/site-response.dto';
@ApiTags('sites')
@Controller('sites')
......@@ -39,6 +40,7 @@ export class SitesController {
@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.' })
......@@ -46,11 +48,45 @@ export class SitesController {
return this.sitesService.create(createSiteDto, userId);
}
@Get()
@ApiOperation({ summary: 'Get all sites with filtering and ordering options' })
@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()
@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' }
}
}
}
}
})
findAll(@Query() findSitesDto: FindSitesDto) {
return this.sitesService.findAll(findSitesDto);
......@@ -61,6 +97,7 @@ export class SitesController {
@ApiResponse({
status: 200,
description: 'Return the site.',
type: SiteResponseDto
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findByCode(@Param('siteCode') siteCode: string) {
......@@ -72,6 +109,7 @@ export class SitesController {
@ApiResponse({
status: 200,
description: 'Return the site.',
type: SiteResponseDto
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
......@@ -83,22 +121,7 @@ export class SitesController {
@ApiResponse({
status: 200,
description: 'Return the site with its candidates.',
schema: {
properties: {
data: {
type: 'object',
properties: {
id: { type: 'number' },
code: { type: 'string' },
name: { type: 'string' },
candidates: {
type: 'array',
items: { $ref: '#/components/schemas/CandidateResponseDto' }
}
}
}
}
}
type: SiteResponseDto
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findOneWithCandidates(@Param('id', ParseIntPipe) id: number) {
......@@ -111,6 +134,7 @@ export class SitesController {
@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.' })
......@@ -133,4 +157,21 @@ export class SitesController {
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
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { Transform } from 'class-transformer';
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: '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;
}
\ No newline at end of file
......@@ -8,15 +8,18 @@ import {
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 } from './dto/find-users.dto';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator';
......@@ -49,8 +52,20 @@ export class UsersController {
status: 200,
description: 'Return all users.',
})
findAll() {
return this.usersService.findAll();
@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)',
})
findAll(@Query() query: FindUsersDto) {
return this.usersService.findAll(query);
}
@Get('inactive')
......
......@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/commo
import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { FindUsersDto } from './dto/find-users.dto';
import * as bcrypt from 'bcrypt';
import { Role } from '@prisma/client';
......@@ -30,8 +31,22 @@ export class UsersService {
});
}
async findAll() {
async findAll(query?: FindUsersDto) {
const where = {};
if (query?.active !== undefined) {
where['isActive'] = query.active;
}
// If unassignedPartners is true, filter for active users with PARTNER role and no partner
if (query?.unassignedPartners === true) {
where['isActive'] = true;
where['role'] = Role.PARTNER;
where['partnerId'] = null;
}
return this.prisma.user.findMany({
where,
select: {
id: true,
email: true,
......@@ -40,6 +55,7 @@ export class UsersService {
isActive: true,
createdAt: true,
updatedAt: true,
partnerId: true,
},
});
}
......
import { Injectable } from '@nestjs/common';
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);
}
/**
* 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('');
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma/prisma.module';
import { CodeGeneratorService } from './code-generator.service';
@Module({
imports: [PrismaModule],
providers: [CodeGeneratorService],
exports: [CodeGeneratorService],
})
export class UtilsModule { }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment