Commit 023e6ff8 by Augusto

0.0.2

parent 2698094f
...@@ -23,6 +23,7 @@ ...@@ -23,6 +23,7 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"csv-parser": "^3.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jimp": "^0.22.12", "jimp": "^0.22.12",
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
...@@ -33,7 +34,8 @@ ...@@ -33,7 +34,8 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.1", "sharp": "^0.34.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0" "uuid": "^11.1.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
...@@ -5972,6 +5974,15 @@ ...@@ -5972,6 +5974,15 @@
"node": ">=0.4.0" "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": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
...@@ -6869,6 +6880,19 @@ ...@@ -6869,6 +6880,19 @@
"follow-redirects": "^1.15.6" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
...@@ -7180,6 +7204,15 @@ ...@@ -7180,6 +7204,15 @@
"node": ">= 0.12.0" "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": { "node_modules/collect-v8-coverage": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
...@@ -7445,6 +7478,18 @@ ...@@ -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": { "node_modules/create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
...@@ -7518,6 +7563,18 @@ ...@@ -7518,6 +7563,18 @@
"url": "https://github.com/sponsors/fb55" "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": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
...@@ -9093,6 +9150,15 @@ ...@@ -9093,6 +9150,15 @@
"node": ">= 0.6" "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": { "node_modules/fresh": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
...@@ -14899,6 +14965,18 @@ ...@@ -14899,6 +14965,18 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/stack-utils": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
...@@ -16626,6 +16704,24 @@ ...@@ -16626,6 +16704,24 @@
"node": ">= 10.0.0" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
...@@ -16759,6 +16855,27 @@ ...@@ -16759,6 +16855,27 @@
"xtend": "^4.0.0" "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": { "node_modules/xml-parse-from-string": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
......
{ {
"name": "api-cellnex", "name": "api-cellnex",
"version": "0.0.1", "version": "0.0.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
...@@ -35,6 +35,7 @@ ...@@ -35,6 +35,7 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"csv-parser": "^3.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jimp": "^0.22.12", "jimp": "^0.22.12",
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
...@@ -45,7 +46,8 @@ ...@@ -45,7 +46,8 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.1", "sharp": "^0.34.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0" "uuid": "^11.1.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@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 { ...@@ -26,9 +26,13 @@ model User {
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator") sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater") sitesUpdated Site[] @relation("SiteUpdater")
associatedSites UserSite[] // New relation for PARTNER role
partner Partner? @relation(fields: [partnerId], references: [id])
partnerId Int?
@@index([email]) @@index([email])
@@index([role]) @@index([role])
@@index([partnerId])
} }
model RefreshToken { model RefreshToken {
...@@ -49,6 +53,9 @@ model Site { ...@@ -49,6 +53,9 @@ model Site {
siteName String siteName String
latitude Float latitude Float
longitude Float longitude Float
type String?
isDigi Boolean @default(false)
companies CompanyName[] @default([])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdById Int? createdById Int?
...@@ -56,6 +63,7 @@ model Site { ...@@ -56,6 +63,7 @@ model Site {
candidates CandidateSite[] candidates CandidateSite[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id]) createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id]) updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[] // New relation for PARTNER role
@@index([siteCode]) @@index([siteCode])
} }
...@@ -78,10 +86,13 @@ model Candidate { ...@@ -78,10 +86,13 @@ model Candidate {
sites CandidateSite[] sites CandidateSite[]
comments Comment[] comments Comment[]
photos Photo[] photos Photo[]
partnerId Int? // To track which partner created the candidate
partner Partner? @relation(fields: [partnerId], references: [id])
@@index([candidateCode]) @@index([candidateCode])
@@index([currentStatus]) @@index([currentStatus])
@@index([onGoing]) @@index([onGoing])
@@index([partnerId])
} }
model CandidateSite { model CandidateSite {
...@@ -135,10 +146,45 @@ model spatial_ref_sys { ...@@ -135,10 +146,45 @@ model spatial_ref_sys {
proj4text String? @db.VarChar(2048) proj4text String? @db.VarChar(2048)
} }
model UserSite {
id Int @id @default(autoincrement())
userId Int
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
@@unique([userId, siteId])
@@index([userId])
@@index([siteId])
}
enum CompanyName {
VODAFONE
MEO
NOS
DIGI
}
enum Role { enum Role {
ADMIN ADMIN
MANAGER MANAGER
OPERATOR OPERATOR
VIEWER VIEWER
SUPERADMIN 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'; ...@@ -13,6 +13,8 @@ import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { join } from 'path'; import { join } from 'path';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { PartnersModule } from './modules/partners/partners.module';
@Module({ @Module({
imports: [ imports: [
...@@ -50,6 +52,8 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; ...@@ -50,6 +52,8 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
SitesModule, SitesModule,
CandidatesModule, CandidatesModule,
CommentsModule, CommentsModule,
DashboardModule,
PartnersModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
......
...@@ -44,10 +44,28 @@ export class AuthService { ...@@ -44,10 +44,28 @@ export class AuthService {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
// Get detailed user information including partnerId
const userDetails = await this.prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
email: true,
name: true,
role: true,
partnerId: true
}
});
if (!userDetails) {
throw new UnauthorizedException('User not found');
}
const payload = { const payload = {
sub: user.id, sub: user.id,
email: user.email, 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([ const [accessToken, refreshToken] = await Promise.all([
...@@ -75,6 +93,7 @@ export class AuthService { ...@@ -75,6 +93,7 @@ export class AuthService {
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role, role: user.role,
partnerId: userDetails.partnerId
}, },
}; };
} }
...@@ -107,11 +126,28 @@ export class AuthService { ...@@ -107,11 +126,28 @@ export class AuthService {
where: { id: storedToken.id }, 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 // Generate new tokens
const newPayload = { const newPayload = {
sub: user.id, sub: user.id,
email: user.email, 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([ const [newAccessToken, newRefreshToken] = await Promise.all([
...@@ -195,6 +231,7 @@ export class AuthService { ...@@ -195,6 +231,7 @@ export class AuthService {
id: payload.sub, id: payload.sub,
email: payload.email, email: payload.email,
role: payload.role, role: payload.role,
partnerId: payload.partnerId || null
}; };
} catch (error) { } catch (error) {
throw new UnauthorizedException('Invalid token'); 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) { ...@@ -18,6 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
id: payload.sub, id: payload.sub,
email: payload.email, email: payload.email,
role: payload.role, 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 { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger';
import { CandidatesService } from './candidates.service'; import { CandidatesService } from './candidates.service';
import { CreateCandidateDto } from './dto/create-candidate.dto'; import { CreateCandidateDto } from './dto/create-candidate.dto';
...@@ -10,6 +10,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; ...@@ -10,6 +10,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator'; import { User } from '../auth/decorators/user.decorator';
import { Partner } from '../auth/decorators/partner.decorator';
import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto'; import { AddSitesToCandidateDto } from './dto/add-sites-to-candidate.dto';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { UploadPhotoDto } from './dto/upload-photo.dto'; import { UploadPhotoDto } from './dto/upload-photo.dto';
...@@ -23,10 +24,12 @@ export class CandidatesController { ...@@ -23,10 +24,12 @@ export class CandidatesController {
constructor(private readonly candidatesService: CandidatesService) { } constructor(private readonly candidatesService: CandidatesService) { }
@Post() @Post()
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@ApiOperation({ summary: 'Create a new candidate' }) @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: 201, description: 'The candidate has been successfully created.', type: CandidateResponseDto })
@ApiResponse({ status: 400, description: 'Bad Request.' })
create(@Body() createCandidateDto: CreateCandidateDto, @User('id') userId: number) { create(@Body() createCandidateDto: CreateCandidateDto, @User('id') userId: number) {
return this.candidatesService.create(createCandidateDto, userId); return this.candidatesService.create(createCandidateDto, userId);
} }
...@@ -54,8 +57,8 @@ export class CandidatesController { ...@@ -54,8 +57,8 @@ export class CandidatesController {
} }
} }
}) })
findAll(@Query() query: QueryCandidateDto) { findAll(@Query() query: QueryCandidateDto, @User('id') userId: number) {
return this.candidatesService.findAll(query); return this.candidatesService.findAll(query, userId);
} }
@Get('site/:siteId') @Get('site/:siteId')
...@@ -65,20 +68,24 @@ export class CandidatesController { ...@@ -65,20 +68,24 @@ export class CandidatesController {
description: 'Return the candidates for the site.', description: 'Return the candidates for the site.',
type: [CandidateResponseDto] type: [CandidateResponseDto]
}) })
findBySiteId(@Param('siteId', ParseIntPipe) siteId: number) { findBySiteId(@Param('siteId', ParseIntPipe) siteId: number, @User('id') userId: number) {
return this.candidatesService.findBySiteId(siteId); return this.candidatesService.findBySiteId(siteId, userId);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get a candidate by id' }) @ApiOperation({ summary: 'Get a candidate by id' })
@ApiResponse({ status: 200, description: 'Return the candidate.', type: CandidateResponseDto }) @ApiResponse({ status: 200, description: 'Return the candidate.', type: CandidateResponseDto })
@ApiResponse({ status: 404, description: 'Candidate not found.' }) @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); return this.candidatesService.findOne(id);
} }
@Patch(':id') @Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER) @Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Update a candidate' }) @ApiOperation({ summary: 'Update a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully updated.', type: CandidateResponseDto }) @ApiResponse({ status: 200, description: 'The candidate has been successfully updated.', type: CandidateResponseDto })
@ApiResponse({ status: 404, description: 'Candidate not found.' }) @ApiResponse({ status: 404, description: 'Candidate not found.' })
...@@ -86,7 +93,13 @@ export class CandidatesController { ...@@ -86,7 +93,13 @@ export class CandidatesController {
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() updateCandidateDto: UpdateCandidateDto, @Body() updateCandidateDto: UpdateCandidateDto,
@User('id') userId: number, @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); return this.candidatesService.update(id, updateCandidateDto);
} }
...@@ -100,6 +113,7 @@ export class CandidatesController { ...@@ -100,6 +113,7 @@ export class CandidatesController {
} }
@Post(':id/sites') @Post(':id/sites')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER, Role.PARTNER)
@ApiOperation({ summary: 'Add multiple sites to a candidate' }) @ApiOperation({ summary: 'Add multiple sites to a candidate' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
...@@ -110,7 +124,13 @@ export class CandidatesController { ...@@ -110,7 +124,13 @@ export class CandidatesController {
addSitesToCandidate( addSitesToCandidate(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() addSitesDto: AddSitesToCandidateDto, @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); return this.candidatesService.addSitesToCandidate(id, addSitesDto);
} }
...@@ -133,16 +153,17 @@ export class CandidatesController { ...@@ -133,16 +153,17 @@ export class CandidatesController {
async uploadPhoto( async uploadPhoto(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Partner() partnerId: number | null,
@User('role') role: Role
) { ) {
if (!file) { if (!file) {
throw new BadRequestException('No file uploaded'); throw new BadRequestException('No file uploaded');
} }
console.log('Received file:', { // For PARTNER role, check if the candidate belongs to their partner
originalname: file.originalname, if (role === Role.PARTNER) {
mimetype: file.mimetype, await this.candidatesService.checkCandidatePartner(id, partnerId);
size: file.size }
});
return this.candidatesService.uploadPhoto(id, file, { return this.candidatesService.uploadPhoto(id, file, {
filename: file.originalname, filename: file.originalname,
...@@ -152,7 +173,15 @@ export class CandidatesController { ...@@ -152,7 +173,15 @@ export class CandidatesController {
} }
@Get(':id/photos') @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); return this.candidatesService.getCandidatePhotos(id);
} }
...@@ -160,7 +189,15 @@ export class CandidatesController { ...@@ -160,7 +189,15 @@ export class CandidatesController {
@ApiOperation({ summary: 'Delete a photo by its ID' }) @ApiOperation({ summary: 'Delete a photo by its ID' })
@ApiResponse({ status: 200, description: 'Photo deleted successfully' }) @ApiResponse({ status: 200, description: 'Photo deleted successfully' })
@ApiResponse({ status: 404, description: 'Photo not found' }) @ApiResponse({ status: 404, description: 'Photo not found' })
async deletePhoto(@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); return this.candidatesService.deletePhoto(photoId);
} }
} }
\ No newline at end of file
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateCandidateDto } from './dto/create-candidate.dto'; import { CreateCandidateDto } from './dto/create-candidate.dto';
import { UpdateCandidateDto } from './dto/update-candidate.dto'; import { UpdateCandidateDto } from './dto/update-candidate.dto';
...@@ -8,31 +8,126 @@ import { UploadPhotoDto } from './dto/upload-photo.dto'; ...@@ -8,31 +8,126 @@ import { UploadPhotoDto } from './dto/upload-photo.dto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import Jimp from 'jimp'; import Jimp from 'jimp';
import { Prisma } from '@prisma/client'; import { Prisma, Role } from '@prisma/client';
@Injectable() @Injectable()
export class CandidatesService { export class CandidatesService {
constructor(private prisma: PrismaService) { } constructor(private prisma: PrismaService) { }
/**
* Generates the next alphabetical code for a site
* Codes start from A and progress to Z, then AA, AB, ..., ZZ, AAA, etc.
*/
private async generateNextCandidateCode(siteId: number): Promise<string> {
// Find all candidates associated with this site
const siteCandidates = await this.prisma.candidateSite.findMany({
where: { siteId },
include: {
candidate: true
}
});
// If no candidates exist for this site, start with 'A'
if (siteCandidates.length === 0) {
return 'A';
}
// Get all existing codes
const existingCodes = siteCandidates.map(sc => sc.candidate.candidateCode);
// Find the highest code
// Sort alphabetically with longer strings coming after shorter ones
const sortedCodes = [...existingCodes].sort((a, b) => {
if (a.length !== b.length) return a.length - b.length;
return a.localeCompare(b);
});
const highestCode = sortedCodes[sortedCodes.length - 1];
return this.incrementAlphabeticCode(highestCode);
}
/**
* Increments an alphabetic code (A->B, Z->AA, AA->AB, etc.)
*/
private incrementAlphabeticCode(code: string): string {
// Convert to array of characters for easier manipulation
const chars = code.split('');
// Start from the last character and try to increment
let i = chars.length - 1;
while (i >= 0) {
// If current character is not 'Z', just increment it
if (chars[i] !== 'Z') {
chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1);
return chars.join('');
}
// Current character is 'Z', set it to 'A' and move to previous position
chars[i] = 'A';
i--;
}
// If we're here, we've carried over beyond the first character
// (e.g., incrementing 'ZZ' to 'AAA')
return 'A' + chars.join('');
}
async create(createCandidateDto: CreateCandidateDto, userId?: number) { async create(createCandidateDto: CreateCandidateDto, userId?: number) {
const { comment, siteIds, ...candidateData } = createCandidateDto; const { comment, siteIds, ...candidateData } = createCandidateDto;
// Get the user's partner if they have one (for PARTNER role)
let userPartnerId: number | null = null;
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { partnerId: true, role: true }
});
// If user is a PARTNER, assign the candidate to their partner
if (user?.role === Role.PARTNER && user.partnerId) {
userPartnerId = user.partnerId;
}
}
// Create the candidate with a transaction to ensure both operations succeed or fail together // Create the candidate with a transaction to ensure both operations succeed or fail together
return this.prisma.$transaction(async (prisma) => { return this.prisma.$transaction(async (prisma) => {
// If candidateCode is not provided, generate it for the first site
const finalCandidateCode = candidateData.candidateCode ||
(siteIds.length > 0 ? await this.generateNextCandidateCode(siteIds[0]) : 'A');
// Create candidate data with the basic properties
const data: any = {
candidateCode: finalCandidateCode,
latitude: candidateData.latitude,
longitude: candidateData.longitude,
type: candidateData.type,
address: candidateData.address,
currentStatus: candidateData.currentStatus,
onGoing: candidateData.onGoing,
sites: {
create: siteIds.map(siteId => ({
site: {
connect: { id: siteId }
}
}))
}
};
// Add relations for creator/updater
if (userId) {
data.createdBy = { connect: { id: userId } };
data.updatedBy = { connect: { id: userId } };
}
// Add partner relation if applicable
if (userPartnerId) {
data.partner = { connect: { id: userPartnerId } };
}
// Create the candidate // Create the candidate
const candidate = await prisma.candidate.create({ const candidate = await prisma.candidate.create({
data: { data,
...candidateData,
createdById: userId,
updatedById: userId,
sites: {
create: siteIds.map(siteId => ({
site: {
connect: { id: siteId }
}
}))
}
},
include: { include: {
sites: { sites: {
include: { include: {
...@@ -97,9 +192,25 @@ export class CandidatesService { ...@@ -97,9 +192,25 @@ export class CandidatesService {
}); });
} }
async findAll(query: QueryCandidateDto) { async findAll(query: QueryCandidateDto, userId?: number) {
const { candidateCode, type, currentStatus, onGoing, siteId, page = 1, limit = 10 } = query; const { candidateCode, type, currentStatus, onGoing, siteId, page = 1, limit = 10 } = query;
// Check if user is a PARTNER and get their partnerId
let partnerFilter = {};
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { partnerId: true, role: true }
});
// If user is a PARTNER, only show candidates from their partner
if (user?.role === Role.PARTNER && user.partnerId) {
partnerFilter = {
partnerId: user.partnerId
};
}
}
const where: Prisma.CandidateWhereInput = { const where: Prisma.CandidateWhereInput = {
...(candidateCode && { candidateCode: { contains: candidateCode, mode: Prisma.QueryMode.insensitive } }), ...(candidateCode && { candidateCode: { contains: candidateCode, mode: Prisma.QueryMode.insensitive } }),
...(type && { type }), ...(type && { type }),
...@@ -112,6 +223,7 @@ export class CandidatesService { ...@@ -112,6 +223,7 @@ export class CandidatesService {
} }
} }
}), }),
...partnerFilter, // Add partner filtering
}; };
const [total, data] = await Promise.all([ const [total, data] = await Promise.all([
...@@ -142,6 +254,19 @@ export class CandidatesService { ...@@ -142,6 +254,19 @@ export class CandidatesService {
createdAt: 'desc', createdAt: 'desc',
}, },
}, },
partner: {
select: {
id: true,
name: true
}
},
createdBy: {
select: {
id: true,
name: true,
email: true
}
}
}, },
}), }),
]); ]);
...@@ -263,14 +388,31 @@ export class CandidatesService { ...@@ -263,14 +388,31 @@ export class CandidatesService {
} }
} }
async findBySiteId(siteId: number) { async findBySiteId(siteId: number, userId?: number) {
// Check if user is a PARTNER and get their partnerId
let partnerFilter = {};
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { partnerId: true, role: true }
});
// If user is a PARTNER, only show candidates from their partner
if (user?.role === Role.PARTNER && user.partnerId) {
partnerFilter = {
partnerId: user.partnerId
};
}
}
return this.prisma.candidate.findMany({ return this.prisma.candidate.findMany({
where: { where: {
sites: { sites: {
some: { some: {
siteId: siteId siteId: siteId
} }
} },
...partnerFilter
}, },
include: { include: {
sites: { sites: {
...@@ -306,13 +448,29 @@ export class CandidatesService { ...@@ -306,13 +448,29 @@ export class CandidatesService {
throw new NotFoundException(`Candidate with ID ${id} not found`); throw new NotFoundException(`Candidate with ID ${id} not found`);
} }
// Create the candidate-site relationships // Get existing site relationships to avoid duplicates
const candidateSites = await this.prisma.candidateSite.createMany({ const existingSiteIds = await this.prisma.candidateSite.findMany({
data: siteIds.map(siteId => ({ where: { candidateId: id },
candidateId: id, select: { siteId: true }
siteId, }).then(relations => relations.map(rel => rel.siteId));
})),
skipDuplicates: true, // Skip if relationship already exists // Filter out sites that are already associated with this candidate
const newSiteIds = siteIds.filter(siteId => !existingSiteIds.includes(siteId));
// Create the candidate-site relationships with transaction
await this.prisma.$transaction(async (prisma) => {
for (const siteId of newSiteIds) {
// Generate a new alphabetical code for this site
const nextCode = await this.generateNextCandidateCode(siteId);
// Create the relationship
await prisma.candidateSite.create({
data: {
candidateId: id,
siteId,
}
});
}
}); });
// Return the updated candidate with all its sites // Return the updated candidate with all its sites
...@@ -466,4 +624,175 @@ export class CandidatesService { ...@@ -466,4 +624,175 @@ export class CandidatesService {
return { message: 'Photo deleted successfully' }; return { message: 'Photo deleted successfully' };
} }
// New method for finding a candidate with partner check
async findOneWithPartnerCheck(id: number, partnerId: number | null) {
const candidate = await this.prisma.candidate.findUnique({
where: { id },
include: {
sites: {
include: {
site: true
}
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
// Check if this candidate belongs to the user's partner
if (partnerId && candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`);
}
return candidate;
}
// New method for updating a candidate with partner check
async updateWithPartnerCheck(id: number, updateCandidateDto: UpdateCandidateDto, userId?: number, partnerId?: number | null) {
// Check if candidate exists and belongs to the partner
const candidate = await this.prisma.candidate.findUnique({
where: { id },
select: { partnerId: true }
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
// Enforce partner check for PARTNER role
if (partnerId && candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`);
}
try {
const { siteIds, ...candidateData } = updateCandidateDto;
return await this.prisma.candidate.update({
where: { id },
data: {
...candidateData,
...(userId && { updatedById: userId }), // Update the updatedById if userId is provided
...(siteIds && {
sites: {
deleteMany: {}, // Remove all existing site associations
create: siteIds.map(siteId => ({
site: {
connect: { id: siteId }
}
}))
}
})
},
include: {
sites: {
include: {
site: true
}
},
comments: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
} catch (error) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
}
// New method for adding sites to a candidate with partner check
async addSitesToCandidateWithPartnerCheck(id: number, addSitesDto: AddSitesToCandidateDto, partnerId: number | null) {
// Check if candidate exists and belongs to the partner
const candidate = await this.prisma.candidate.findUnique({
where: { id },
select: { partnerId: true }
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
// Enforce partner check for PARTNER role
if (partnerId && candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`);
}
return this.addSitesToCandidate(id, addSitesDto);
}
// New method to check if a candidate belongs to a partner
async checkCandidatePartner(id: number, partnerId: number | null) {
if (!partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
}
const candidate = await this.prisma.candidate.findUnique({
where: { id },
select: { partnerId: true }
});
if (!candidate) {
throw new NotFoundException(`Candidate with ID ${id} not found`);
}
if (candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to candidate with ID ${id} is not authorized`);
}
return true;
}
// New method to check if a photo belongs to a candidate that belongs to a partner
async checkPhotoPartner(photoId: number, partnerId: number | null) {
if (!partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
}
const photo = await this.prisma.photo.findUnique({
where: { id: photoId },
include: {
candidate: {
select: { partnerId: true }
}
}
});
if (!photo) {
throw new NotFoundException(`Photo with ID ${photoId} not found`);
}
if (!photo.candidate || photo.candidate.partnerId !== partnerId) {
throw new ForbiddenException(`Access to photo with ID ${photoId} is not authorized`);
}
return true;
}
} }
\ No newline at end of file
...@@ -22,7 +22,8 @@ export enum CandidateStatus { ...@@ -22,7 +22,8 @@ export enum CandidateStatus {
export class CreateCandidateDto { export class CreateCandidateDto {
@ApiProperty({ description: 'Candidate code' }) @ApiProperty({ description: 'Candidate code' })
@IsString() @IsString()
candidateCode: string; @IsOptional()
candidateCode?: string;
@ApiProperty({ description: 'Latitude coordinate' }) @ApiProperty({ description: 'Latitude coordinate' })
@IsNumber() @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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsNumber, IsOptional, Min, Max } from 'class-validator'; import { IsString, IsNotEmpty, IsNumber, IsOptional, Min, Max, IsBoolean, IsArray, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { CompanyName } from './company.dto';
export class CreateSiteDto { export class CreateSiteDto {
@ApiProperty({ @ApiProperty({
...@@ -39,4 +41,32 @@ export class CreateSiteDto { ...@@ -39,4 +41,32 @@ export class CreateSiteDto {
@Min(-180) @Min(-180)
@Max(180) @Max(180)
longitude: number; 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 { 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 { export enum OrderDirection {
ASC = 'asc', ASC = 'asc',
...@@ -7,6 +8,20 @@ export enum OrderDirection { ...@@ -7,6 +8,20 @@ export enum OrderDirection {
} }
export class FindSitesDto { 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' }) @ApiPropertyOptional({ description: 'Filter by site code' })
@IsOptional() @IsOptional()
@IsString() @IsString()
...@@ -17,6 +32,11 @@ export class FindSitesDto { ...@@ -17,6 +32,11 @@ export class FindSitesDto {
@IsString() @IsString()
siteName?: string; siteName?: string;
@ApiPropertyOptional({ description: 'Filter by site type' })
@IsOptional()
@IsString()
type?: string;
@ApiPropertyOptional({ description: 'Filter by site address' }) @ApiPropertyOptional({ description: 'Filter by site address' })
@IsOptional() @IsOptional()
@IsString() @IsString()
...@@ -37,6 +57,26 @@ export class FindSitesDto { ...@@ -37,6 +57,26 @@ export class FindSitesDto {
@IsString() @IsString()
country?: string; 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)' }) @ApiPropertyOptional({ description: 'Order by field (e.g., siteName, siteCode, address, city, state, country)' })
@IsOptional() @IsOptional()
@IsString() @IsString()
......
import { ApiProperty } from '@nestjs/swagger'; 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 { export class SiteResponseDto {
@ApiProperty({ description: 'Site ID' }) @ApiProperty({ description: 'Site ID' })
id: number; id: number;
@ApiProperty({ description: 'Site code' }) @ApiProperty({ description: 'Site code' })
code: string; siteCode: string;
@ApiProperty({ description: 'Site name' }) @ApiProperty({ description: 'Site name' })
name: string; siteName: string;
@ApiProperty({ description: 'Latitude coordinate' }) @ApiProperty({ description: 'Latitude coordinate' })
latitude: number; latitude: number;
...@@ -19,9 +31,52 @@ export class SiteResponseDto { ...@@ -19,9 +31,52 @@ export class SiteResponseDto {
@ApiProperty({ description: 'Address of the site' }) @ApiProperty({ description: 'Address of the site' })
address: string; 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' }) @ApiProperty({ description: 'Creation timestamp' })
createdAt: Date; createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' }) @ApiProperty({ description: 'Last update timestamp' })
updatedAt: Date; 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'; ...@@ -25,6 +25,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator'; import { User } from '../auth/decorators/user.decorator';
import { FindSitesDto } from './dto/find-sites.dto'; import { FindSitesDto } from './dto/find-sites.dto';
import { SiteResponseDto } from './dto/site-response.dto';
@ApiTags('sites') @ApiTags('sites')
@Controller('sites') @Controller('sites')
...@@ -39,6 +40,7 @@ export class SitesController { ...@@ -39,6 +40,7 @@ export class SitesController {
@ApiResponse({ @ApiResponse({
status: 201, status: 201,
description: 'The site has been successfully created.', description: 'The site has been successfully created.',
type: SiteResponseDto
}) })
@ApiResponse({ status: 400, description: 'Bad Request.' }) @ApiResponse({ status: 400, description: 'Bad Request.' })
@ApiResponse({ status: 409, description: 'Site code already exists.' }) @ApiResponse({ status: 409, description: 'Site code already exists.' })
...@@ -46,11 +48,45 @@ export class SitesController { ...@@ -46,11 +48,45 @@ export class SitesController {
return this.sitesService.create(createSiteDto, userId); return this.sitesService.create(createSiteDto, userId);
} }
@Get() @Get('map')
@ApiOperation({ summary: 'Get all sites with filtering and ordering options' }) @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({ @ApiResponse({
status: 200, status: 200,
description: 'Return all sites with applied filters and ordering.', 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) { findAll(@Query() findSitesDto: FindSitesDto) {
return this.sitesService.findAll(findSitesDto); return this.sitesService.findAll(findSitesDto);
...@@ -61,6 +97,7 @@ export class SitesController { ...@@ -61,6 +97,7 @@ export class SitesController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return the site.', description: 'Return the site.',
type: SiteResponseDto
}) })
@ApiResponse({ status: 404, description: 'Site not found.' }) @ApiResponse({ status: 404, description: 'Site not found.' })
findByCode(@Param('siteCode') siteCode: string) { findByCode(@Param('siteCode') siteCode: string) {
...@@ -72,6 +109,7 @@ export class SitesController { ...@@ -72,6 +109,7 @@ export class SitesController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return the site.', description: 'Return the site.',
type: SiteResponseDto
}) })
@ApiResponse({ status: 404, description: 'Site not found.' }) @ApiResponse({ status: 404, description: 'Site not found.' })
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('id', ParseIntPipe) id: number) {
...@@ -83,22 +121,7 @@ export class SitesController { ...@@ -83,22 +121,7 @@ export class SitesController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return the site with its candidates.', description: 'Return the site with its candidates.',
schema: { type: SiteResponseDto
properties: {
data: {
type: 'object',
properties: {
id: { type: 'number' },
code: { type: 'string' },
name: { type: 'string' },
candidates: {
type: 'array',
items: { $ref: '#/components/schemas/CandidateResponseDto' }
}
}
}
}
}
}) })
@ApiResponse({ status: 404, description: 'Site not found.' }) @ApiResponse({ status: 404, description: 'Site not found.' })
findOneWithCandidates(@Param('id', ParseIntPipe) id: number) { findOneWithCandidates(@Param('id', ParseIntPipe) id: number) {
...@@ -111,6 +134,7 @@ export class SitesController { ...@@ -111,6 +134,7 @@ export class SitesController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'The site has been successfully updated.', description: 'The site has been successfully updated.',
type: SiteResponseDto
}) })
@ApiResponse({ status: 404, description: 'Site not found.' }) @ApiResponse({ status: 404, description: 'Site not found.' })
@ApiResponse({ status: 409, description: 'Site code already exists.' }) @ApiResponse({ status: 409, description: 'Site code already exists.' })
...@@ -133,4 +157,21 @@ export class SitesController { ...@@ -133,4 +157,21 @@ export class SitesController {
remove(@Param('id', ParseIntPipe) id: number) { remove(@Param('id', ParseIntPipe) id: number) {
return this.sitesService.remove(id); 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 { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaClient, Prisma, CompanyName } from '@prisma/client';
import { CreateSiteDto } from './dto/create-site.dto'; import { CreateSiteDto } from './dto/create-site.dto';
import { UpdateSiteDto } from './dto/update-site.dto'; import { UpdateSiteDto } from './dto/update-site.dto';
import { FindSitesDto, OrderDirection } from './dto/find-sites.dto'; import { FindSitesDto, OrderDirection } from './dto/find-sites.dto';
import { Prisma } from '@prisma/client';
export enum CandidateStatusPriority {
SEARCH_AREA = 0,
REJECTED = 1,
NEGOTIATION_ONGOING = 2,
MNO_VALIDATION = 3,
CLOSING = 4,
APPROVED = 5,
}
@Injectable() @Injectable()
export class SitesService { export class SitesService {
constructor(private prisma: PrismaService) { } private prisma: PrismaClient;
constructor() {
this.prisma = new PrismaClient();
}
// Helper method to get the highest priority status from a list of candidates
private getHighestPriorityStatus(candidates) {
if (!candidates || candidates.length === 0) {
return null;
}
return candidates
.map(candidate => candidate.candidate?.currentStatus)
.filter(Boolean)
.sort((a, b) => {
const priorityA = CandidateStatusPriority[a] !== undefined ? Number(CandidateStatusPriority[a]) : -1;
const priorityB = CandidateStatusPriority[b] !== undefined ? Number(CandidateStatusPriority[b]) : -1;
return priorityB - priorityA;
})[0] || null;
}
async create(createSiteDto: CreateSiteDto, userId: number) { async create(createSiteDto: CreateSiteDto, userId: number) {
try { try {
return await this.prisma.site.create({ return await this.prisma.site.create({
data: { data: {
...createSiteDto, siteCode: createSiteDto.siteCode,
siteName: createSiteDto.siteName,
latitude: createSiteDto.latitude,
longitude: createSiteDto.longitude,
type: createSiteDto.type,
isDigi: createSiteDto.isDigi || false,
companies: createSiteDto.companies || [],
createdById: userId, createdById: userId,
updatedById: userId, updatedById: userId,
}, },
...@@ -44,17 +78,23 @@ export class SitesService { ...@@ -44,17 +78,23 @@ export class SitesService {
async findAll(findSitesDto: FindSitesDto) { async findAll(findSitesDto: FindSitesDto) {
const { const {
page = 1,
limit = 10,
siteCode, siteCode,
siteName, siteName,
address, address,
city, city,
state, state,
country, country,
orderBy = 'siteName', orderBy,
orderDirection = OrderDirection.ASC, orderDirection,
withCandidates,
type,
isDigi,
} = findSitesDto; } = findSitesDto;
// Build where clause for filters const skip = (page - 1) * limit;
const where: Prisma.SiteWhereInput = { const where: Prisma.SiteWhereInput = {
...(siteCode && { siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive } }), ...(siteCode && { siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive } }),
...(siteName && { siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive } }), ...(siteName && { siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive } }),
...@@ -62,60 +102,59 @@ export class SitesService { ...@@ -62,60 +102,59 @@ export class SitesService {
...(city && { city: { contains: city, mode: Prisma.QueryMode.insensitive } }), ...(city && { city: { contains: city, mode: Prisma.QueryMode.insensitive } }),
...(state && { state: { contains: state, mode: Prisma.QueryMode.insensitive } }), ...(state && { state: { contains: state, mode: Prisma.QueryMode.insensitive } }),
...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }), ...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }),
...(type && { type: { contains: type, mode: Prisma.QueryMode.insensitive } }),
...(isDigi !== undefined && { isDigi }),
}; };
// Get all filtered results if (withCandidates === true) {
const sites = await this.prisma.site.findMany({ where.candidates = {
where, some: {},
include: { };
createdBy: { }
select: {
id: true, const [sites, total] = await Promise.all([
name: true, this.prisma.site.findMany({
email: true, where,
}, skip,
}, take: limit,
updatedBy: { orderBy: orderBy ? { [orderBy]: orderDirection || 'asc' } : undefined,
select: { include: {
id: true, _count: {
name: true, select: {
email: true, candidates: true,
},
},
candidates: {
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
}, },
}, },
candidates: withCandidates ? {
include: {
candidate: true,
},
} : undefined,
}, },
_count: { }),
select: { this.prisma.site.count({ where }),
candidates: true, ]);
},
}, // Add highest priority status to sites with candidates
}, const sitesWithHighestStatus = sites.map(site => {
orderBy: { if (site.candidates && site.candidates.length > 0) {
[orderBy]: orderDirection, return {
}, ...site,
highestCandidateStatus: this.getHighestPriorityStatus(site.candidates),
candidates: undefined, // Remove candidates to avoid sending large data
};
}
return site;
}); });
return sites; return {
data: sitesWithHighestStatus,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
} }
async findOne(id: number) { async findOne(id: number) {
...@@ -170,6 +209,15 @@ export class SitesService { ...@@ -170,6 +209,15 @@ export class SitesService {
throw new NotFoundException(`Site with ID ${id} not found`); throw new NotFoundException(`Site with ID ${id} not found`);
} }
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus,
};
}
return site; return site;
} }
...@@ -221,6 +269,15 @@ export class SitesService { ...@@ -221,6 +269,15 @@ export class SitesService {
throw new NotFoundException(`Site with ID ${id} not found`); throw new NotFoundException(`Site with ID ${id} not found`);
} }
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus,
};
}
return site; return site;
} }
...@@ -229,7 +286,13 @@ export class SitesService { ...@@ -229,7 +286,13 @@ export class SitesService {
return await this.prisma.site.update({ return await this.prisma.site.update({
where: { id }, where: { id },
data: { data: {
...updateSiteDto, siteCode: updateSiteDto.siteCode,
siteName: updateSiteDto.siteName,
latitude: updateSiteDto.latitude,
longitude: updateSiteDto.longitude,
type: updateSiteDto.type,
isDigi: updateSiteDto.isDigi,
companies: updateSiteDto.companies !== undefined ? updateSiteDto.companies : undefined,
updatedById: userId, updatedById: userId,
}, },
include: { include: {
...@@ -275,7 +338,7 @@ export class SitesService { ...@@ -275,7 +338,7 @@ export class SitesService {
} }
async findByCode(siteCode: string) { async findByCode(siteCode: string) {
const site = await this.prisma.site.findUnique({ const site = await this.prisma.site.findFirst({
where: { siteCode }, where: { siteCode },
include: { include: {
createdBy: { createdBy: {
...@@ -292,6 +355,28 @@ export class SitesService { ...@@ -292,6 +355,28 @@ export class SitesService {
email: true, email: true,
}, },
}, },
candidates: {
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
},
_count: { _count: {
select: { select: {
candidates: true, candidates: true,
...@@ -304,6 +389,84 @@ export class SitesService { ...@@ -304,6 +389,84 @@ export class SitesService {
throw new NotFoundException(`Site with code ${siteCode} not found`); throw new NotFoundException(`Site with code ${siteCode} not found`);
} }
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus,
};
}
return site; return site;
} }
async findAllForMap(findSitesDto: FindSitesDto) {
const {
siteCode,
siteName,
address,
city,
state,
country,
orderBy,
orderDirection,
withCandidates,
type,
isDigi,
} = findSitesDto;
const where: Prisma.SiteWhereInput = {
...(siteCode && { siteCode: { contains: siteCode, mode: Prisma.QueryMode.insensitive } }),
...(siteName && { siteName: { contains: siteName, mode: Prisma.QueryMode.insensitive } }),
...(address && { address: { contains: address, mode: Prisma.QueryMode.insensitive } }),
...(city && { city: { contains: city, mode: Prisma.QueryMode.insensitive } }),
...(state && { state: { contains: state, mode: Prisma.QueryMode.insensitive } }),
...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }),
...(type && { type: { contains: type, mode: Prisma.QueryMode.insensitive } }),
...(isDigi !== undefined && { isDigi }),
};
if (withCandidates === true) {
where.candidates = {
some: {},
};
}
const sites = await this.prisma.site.findMany({
where,
orderBy: orderBy ? { [orderBy]: orderDirection || 'asc' } : undefined,
include: {
_count: {
select: {
candidates: true,
},
},
candidates: withCandidates ? {
include: {
candidate: true,
},
} : undefined,
},
});
// Add highest priority status to sites with candidates
const sitesWithHighestStatus = sites.map(site => {
if (site.candidates && site.candidates.length > 0) {
return {
...site,
highestCandidateStatus: this.getHighestPriorityStatus(site.candidates),
candidates: undefined, // Remove candidates to avoid sending large data
};
}
return site;
});
return sitesWithHighestStatus;
}
async findAllCompanies() {
// Return all values of the CompanyName enum
return Object.values(CompanyName);
}
} }
\ 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 { ...@@ -8,15 +8,18 @@ import {
Delete, Delete,
ParseIntPipe, ParseIntPipe,
UseGuards, UseGuards,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { FindUsersDto } from './dto/find-users.dto';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
...@@ -49,8 +52,20 @@ export class UsersController { ...@@ -49,8 +52,20 @@ export class UsersController {
status: 200, status: 200,
description: 'Return all users.', description: 'Return all users.',
}) })
findAll() { @ApiQuery({
return this.usersService.findAll(); 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') @Get('inactive')
......
...@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/commo ...@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/commo
import { PrismaService } from '../../common/prisma/prisma.service'; import { PrismaService } from '../../common/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { FindUsersDto } from './dto/find-users.dto';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
...@@ -30,8 +31,22 @@ export class UsersService { ...@@ -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({ return this.prisma.user.findMany({
where,
select: { select: {
id: true, id: true,
email: true, email: true,
...@@ -40,6 +55,7 @@ export class UsersService { ...@@ -40,6 +55,7 @@ export class UsersService {
isActive: true, isActive: true,
createdAt: true, createdAt: true,
updatedAt: 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