Commit 1ae1b266 by Augusto

partner logic

parent 049b121e
# Deployment Guide - Inspection Generation System
## 🚀 Quick Deployment Steps
### 1. Database Migration
#### For Local Development:
```bash
# Connect to your local database
psql -d unike_inspection_local
# Run the migration
\i migration.sql
```
#### For Production:
```bash
# Connect to production database (replace with your connection details)
psql -h your-production-host -d unike_inspection_prod -U your-username
# Run the migration
\i migration.sql
```
### 2. Verify Migration
```sql
-- Check if the deadline column was added successfully
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'Inspection' AND column_name = 'deadline';
```
Expected result:
```
column_name | data_type | is_nullable
-------------+-----------+-------------
deadline | timestamp | YES
```
### 3. Deploy Application Code
```bash
# Build the application
npm run build
# Start the application
npm run start:prod
```
### 4. Test New Endpoints
```bash
# Test partner sites endpoint
curl -X GET "http://localhost:3000/partners/1/sites" \
-H "Authorization: Bearer your-jwt-token"
# Test inspection generation
curl -X POST "http://localhost:3000/inspection/generate" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-jwt-token" \
-d '{
"siteId": 1,
"deadline": "2025-12-31T23:59:59.000Z",
"comment": "Test inspection"
}'
```
## 🔄 Rollback Instructions
If you need to rollback the changes:
### 1. Rollback Database
```bash
# Connect to your database
psql -d your_database
# Run the rollback script
\i rollback.sql
```
### 2. Deploy Previous Version
```bash
# Deploy the previous version of your application
git checkout previous-version-tag
npm run build
npm run start:prod
```
## 📋 Pre-Deployment Checklist
- [ ] Backup your database
- [ ] Test migration on staging environment
- [ ] Verify all new endpoints work correctly
- [ ] Check that existing functionality is not broken
- [ ] Update API documentation
- [ ] Notify frontend team of new endpoints
## 🧪 Testing Checklist
- [ ] Test partner-site association endpoints
- [ ] Test inspection generation with and without partnerId
- [ ] Test automatic scheduling functionality
- [ ] Verify existing inspections still work
- [ ] Test error handling for invalid inputs
- [ ] Check authentication and authorization
## 📊 Monitoring
After deployment, monitor:
- Application logs for any errors
- Database performance
- API response times
- Error rates for new endpoints
## 🆘 Troubleshooting
### Common Issues:
1. **Migration fails**: Check database permissions and connection
2. **New endpoints return 404**: Verify the application was restarted
3. **Authentication errors**: Check JWT token validity
4. **Database constraint errors**: Verify partner and site IDs exist
### Logs to Check:
- Application logs: `logs/app.log`
- Database logs: Check your PostgreSQL logs
- Nginx/Apache logs: If using a reverse proxy
## 📞 Support
If you encounter issues during deployment:
1. Check the troubleshooting section above
2. Review application logs
3. Contact the development team
4. Create an issue in the project repository
# Inspection Generation & Partner Management System
## Overview
This update introduces a comprehensive inspection generation system with partner-site association management. The system allows you to generate inspections for specific sites with deadlines and automatically manages the inspection scheduling cycle.
## 🆕 New Features
### 1. Partner-Site Association Management
- Associate partners with specific sites for inspection responsibility
- Switch partners for sites when needed
- View all sites associated with a partner
### 2. Inspection Generation System
- Generate inspections for specific sites with custom deadlines
- Associate or switch partners during inspection generation
- Automatic creation of default "NA" responses for all questions
### 3. Enhanced Automatic Scheduling
- 3-month advance scheduling before deadlines
- 1-year inspection cycle management
- Continuous automatic inspection generation
## 📋 API Endpoints
### Partner-Site Management
#### GET `/partners/:id/sites`
Get all sites associated with a specific partner.
**Access**: ADMIN, MANAGER, PARTNER roles
**Example**:
```bash
GET /partners/1/sites
```
#### POST `/partners/:partnerId/sites/:siteId`
Associate a site with a partner.
**Access**: ADMIN, SUPERADMIN roles only
**Example**:
```bash
POST /partners/1/sites/5
```
#### DELETE `/partners/:partnerId/sites/:siteId`
Remove a site from a partner.
**Access**: ADMIN, SUPERADMIN roles only
**Example**:
```bash
DELETE /partners/1/sites/5
```
#### PATCH `/partners/sites/:siteId/switch-to/:newPartnerId`
Switch a site from its current partner to a different partner.
**Access**: ADMIN, SUPERADMIN roles only
**Example**:
```bash
PATCH /partners/sites/5/switch-to/2
```
### Inspection Generation
#### POST `/inspection/generate`
Generate a new inspection with deadline for a specific site.
**Access**: ADMIN, MANAGER, OPERATOR, SUPERADMIN roles
**Request Body**:
```json
{
"siteId": 5, // Required: Site ID
"partnerId": 2, // Optional: Partner ID
"deadline": "2025-12-31T23:59:59.000Z", // Required: Deadline date
"comment": "Annual inspection" // Optional: Comment
}
```
**Features**:
- If `partnerId` is provided: Associates/switches the site to that partner
- If no `partnerId`: Uses the partner already associated with the site
- Creates default "NA" responses for all inspection questions
- Sets inspection status to "PENDING"
## 🔄 Workflow Examples
### Scenario 1: Associate Partner with Site and Generate Inspection
```bash
# 1. Associate partner with site
POST /partners/1/sites/5
# 2. Generate inspection for that site
POST /inspection/generate
{
"siteId": 5,
"deadline": "2025-12-31T23:59:59.000Z",
"comment": "Annual inspection for Site 5"
}
```
### Scenario 2: Switch Partner and Generate Inspection
```bash
# 1. Switch site to different partner
PATCH /partners/sites/5/switch-to/2
# 2. Generate inspection (will use the new partner)
POST /inspection/generate
{
"siteId": 5,
"deadline": "2025-12-31T23:59:59.000Z"
}
```
### Scenario 3: Generate Inspection and Associate Partner in One Step
```bash
# Generate inspection and associate with specific partner
POST /inspection/generate
{
"siteId": 5,
"partnerId": 2,
"deadline": "2025-12-31T23:59:59.000Z",
"comment": "Annual inspection with Partner 2"
}
```
-- Cleanup Script: Remove hardcoded auto-generated comments
-- Date: 2025-01-27
-- Description: Removes hardcoded "Auto-generated" and "Generated inspection" comments from the database
-- Show current hardcoded comments before cleanup
SELECT
id,
comment,
"createdAt"
FROM "Inspection"
WHERE comment LIKE '%Auto-generated%'
OR comment LIKE '%Generated inspection%'
OR comment LIKE '%pending completion%'
ORDER BY "createdAt" DESC;
-- Count how many records will be affected
SELECT COUNT(*) as affected_inspections
FROM "Inspection"
WHERE comment LIKE '%Auto-generated%'
OR comment LIKE '%Generated inspection%'
OR comment LIKE '%pending completion%';
-- Clean up inspection comments
UPDATE "Inspection"
SET comment = NULL
WHERE comment LIKE '%Auto-generated%'
OR comment LIKE '%Generated inspection%'
OR comment LIKE '%pending completion%';
-- Clean up inspection response comments
UPDATE "InspectionResponse"
SET comment = NULL
WHERE comment LIKE '%Auto-generated%'
OR comment LIKE '%to be completed%';
-- Show results after cleanup
SELECT
'Inspections cleaned' as table_name,
COUNT(*) as remaining_records
FROM "Inspection"
WHERE comment LIKE '%Auto-generated%'
OR comment LIKE '%Generated inspection%'
OR comment LIKE '%pending completion%'
UNION ALL
SELECT
'InspectionResponse cleaned' as table_name,
COUNT(*) as remaining_records
FROM "InspectionResponse"
WHERE comment LIKE '%Auto-generated%'
OR comment LIKE '%to be completed%';
-- Verify cleanup was successful
SELECT
'Cleanup verification' as status,
CASE
WHEN COUNT(*) = 0 THEN 'SUCCESS: No hardcoded comments found'
ELSE 'WARNING: ' || COUNT(*) || ' hardcoded comments still exist'
END as result
FROM (
SELECT id FROM "Inspection"
WHERE comment LIKE '%Auto-generated%'
OR comment LIKE '%Generated inspection%'
OR comment LIKE '%pending completion%'
UNION ALL
SELECT id FROM "InspectionResponse"
WHERE comment LIKE '%Auto-generated%'
OR comment LIKE '%to be completed%'
) as remaining_comments;
CREATE TYPE "public"."FinalCommentStatus" AS ENUM('PENDING', 'VALIDATED', 'REJECTED');--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "role" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'VIEWER'::text;--> statement-breakpoint
DROP TYPE "public"."Role";--> statement-breakpoint
CREATE TYPE "public"."Role" AS ENUM('SUPERADMIN', 'ADMIN', 'MANAGER', 'PARTNER', 'OPERATOR', 'VIEWER');--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'VIEWER'::"public"."Role";--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "role" SET DATA TYPE "public"."Role" USING "role"::"public"."Role";--> statement-breakpoint
ALTER TABLE "Inspection" ADD COLUMN "finalCommentStatus" "FinalCommentStatus" DEFAULT 'PENDING';--> statement-breakpoint
CREATE UNIQUE INDEX "InspectionResponse_inspectionId_questionId_key" ON "InspectionResponse" USING btree ("inspectionId","questionId");--> statement-breakpoint
CREATE INDEX "Inspection_finalCommentStatus_idx" ON "Inspection" USING btree ("finalCommentStatus");
\ No newline at end of file
ALTER TABLE "Inspection" ADD COLUMN "deadline" timestamp;
\ No newline at end of file
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1757952962587,
"tag": "0000_overrated_jack_flag",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1758110934061,
"tag": "0001_parched_dust",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1758185080204,
"tag": "0002_furry_genesis",
"breakpoints": true
}
]
}
\ No newline at end of file
-- Migration: Add deadline field to Inspection table
-- Date: 2025-01-27
-- Description: Adds deadline field to support inspection generation with custom deadlines
-- Add deadline column to Inspection table
ALTER TABLE "Inspection" ADD COLUMN "deadline" timestamp;
-- Add comment to the new column for documentation
COMMENT ON COLUMN "Inspection"."deadline" IS 'Deadline date for when the inspection should be completed';
-- Optional: Create an index on the deadline column for better query performance
-- (Uncomment if you expect to query by deadline frequently)
-- CREATE INDEX "Inspection_deadline_idx" ON "Inspection" ("deadline");
-- Verify the migration
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'Inspection' AND column_name = 'deadline';
-- Rollback Migration: Remove deadline field from Inspection table
-- Date: 2025-01-27
-- Description: Removes deadline field from Inspection table (rollback for migration.sql)
-- Remove the index if it was created (uncomment if you created the index)
-- DROP INDEX IF EXISTS "Inspection_deadline_idx";
-- Remove deadline column from Inspection table
ALTER TABLE "Inspection" DROP COLUMN IF EXISTS "deadline";
-- Verify the rollback
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'Inspection' AND column_name = 'deadline';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { AppService } from './app.service';
......@@ -17,6 +17,7 @@ import { InspectionModule } from './modules/inspection/inspection.module';
import { QuestionsModule } from './modules/questions/questions.module';
import { TelecommunicationStationIdentificationModule } from './modules/telecommunication-station-identification/telecommunication-station-identification.module';
import { InspectionPhotosModule } from './modules/inspection-photos/inspection-photos.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
@Module({
imports: [
......@@ -66,6 +67,10 @@ import { InspectionPhotosModule } from './modules/inspection-photos/inspection-p
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,
},
],
})
export class AppModule {}
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any)?.message || exception.message;
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(
`Unhandled error: ${exception.message}`,
exception.stack,
`${request.method} ${request.url}`,
);
}
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message,
};
this.logger.error(
`HTTP ${status} Error: ${message}`,
JSON.stringify(errorResponse),
);
response.status(status).json(errorResponse);
}
}
......@@ -15,12 +15,12 @@ import { relations } from 'drizzle-orm';
// Enums - matching Prisma enum names exactly
export const roleEnum = pgEnum('Role', [
'ADMIN',
'MANAGER',
'OPERATOR',
'VIEWER',
'SUPERADMIN',
'PARTNER',
'SUPERADMIN', // Main company - full access
'ADMIN', // Main company - admin access
'MANAGER', // Main company - management access
'PARTNER', // Partner owner/manager - can validate final comments
'OPERATOR', // Partner normal user - can create final comments
'VIEWER', // Keep for backward compatibility
]);
export const companyNameEnum = pgEnum('CompanyName', [
......@@ -46,6 +46,12 @@ export const inspectionStatusEnum = pgEnum('InspectionStatus', [
'APPROVED',
]);
export const finalCommentStatusEnum = pgEnum('FinalCommentStatus', [
'PENDING', // Operator created, waiting for partner validation
'VALIDATED', // Partner validated, visible to admin/superadmin/manager
'REJECTED', // Partner rejected, needs revision
]);
// Tables
export const users = pgTable(
'User',
......@@ -178,8 +184,11 @@ export const inspections = pgTable(
{
id: serial('id').primaryKey(),
date: timestamp('date').notNull(),
deadline: timestamp('deadline'),
comment: text('comment'),
finalComment: text('finalComment'),
finalCommentStatus:
finalCommentStatusEnum('finalCommentStatus').default('PENDING'),
siteId: integer('siteId')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
......@@ -194,6 +203,9 @@ export const inspections = pgTable(
createdByIdx: index('Inspection_createdById_idx').on(table.createdById),
updatedByIdx: index('Inspection_updatedById_idx').on(table.updatedById),
statusIdx: index('Inspection_status_idx').on(table.status),
finalCommentStatusIdx: index('Inspection_finalCommentStatus_idx').on(
table.finalCommentStatus,
),
}),
);
......@@ -219,6 +231,10 @@ export const inspectionResponses = pgTable(
inspectionIdx: index('InspectionResponse_inspectionId_idx').on(
table.inspectionId,
),
// Unique constraint to prevent duplicate responses for the same question in the same inspection
uniqueInspectionQuestion: uniqueIndex(
'InspectionResponse_inspectionId_questionId_key',
).on(table.inspectionId, table.questionId),
}),
);
......
......@@ -24,11 +24,16 @@ async function bootstrap() {
);
// Serve static files
app.use('/uploads', express.static('/home/api-cellnex/public_html/uploads'));
// In development, serve from local directory
if (process.env.NODE_ENV === 'development') {
app.use('/uploads', express.static(join(__dirname, '..', 'uploads')));
}
const uploadBasePath =
process.env.UPLOAD_BASE_PATH ||
(process.env.NODE_ENV === 'production'
? '/home/api-verticalflow/public_html/uploads'
: join(__dirname, '..', 'uploads'));
app.use('/uploads', express.static(uploadBasePath));
// Serve fallback uploads from /tmp if needed
app.use('/tmp-uploads', express.static('/tmp/uploads'));
// Swagger configuration
const config = new DocumentBuilder()
......@@ -38,7 +43,6 @@ async function bootstrap() {
.addTag('auth', 'Authentication endpoints')
.addTag('users', 'User management endpoints')
.addTag('sites', 'Site management endpoints')
.addTag('candidates', 'Candidate management endpoints')
.addTag('inspection', 'Site inspection management endpoints')
.addBearerAuth(
{
......@@ -64,7 +68,7 @@ async function bootstrap() {
SwaggerModule.setup('docs', app, document, swaggerOptions);
const port = process.env.PORT ?? 3002;
const port = process.env.PORT ?? 3000;
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(
......
......@@ -87,10 +87,12 @@ export class AuthService {
}),
]);
const now = new Date();
await this.databaseService.db.insert(refreshTokens).values({
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
createdAt: now,
});
return {
......@@ -176,10 +178,12 @@ export class AuthService {
]);
// Store new refresh token
const now = new Date();
await this.databaseService.db.insert(refreshTokens).values({
token: newRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
createdAt: now,
});
return {
......
import { SetMetadata } from '@nestjs/common';
import { Role } from '@prisma/client';
import { roleEnum } from '../../../database/schema';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
export const Roles = (...roles: (typeof roleEnum.enumValues)[number][]) =>
SetMetadata(ROLES_KEY, roles);
......@@ -4,7 +4,7 @@ import {
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { roleEnum } from '../../../database/schema';
@Injectable()
export class PartnerAuthGuard implements CanActivate {
......@@ -15,8 +15,8 @@ export class PartnerAuthGuard implements CanActivate {
? 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) {
// If it's a PARTNER or OPERATOR user, make sure they can only access their own partner data
if (user.role === 'PARTNER' || user.role === 'OPERATOR') {
// Check if the user has a partnerId and if it matches the requested partnerId
if (!user.partnerId) {
throw new ForbiddenException(
......
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@prisma/client';
import { roleEnum } from '../../../database/schema';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
......@@ -8,10 +8,9 @@ export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
const requiredRoles = this.reflector.getAllAndOverride<
(typeof roleEnum.enumValues)[number][]
>(ROLES_KEY, [context.getHandler(), context.getClass()]);
if (!requiredRoles) {
return true;
......
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { IsNumber, IsOptional, IsString, IsPositive } from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateInspectionPhotoDto {
@ApiProperty({
......@@ -7,7 +8,15 @@ export class CreateInspectionPhotoDto {
example: 1,
type: Number,
})
@Transform(({ value }) => {
const parsed = parseInt(value);
if (isNaN(parsed)) {
throw new Error('inspectionId must be a valid number');
}
return parsed;
})
@IsNumber()
@IsPositive()
inspectionId: number;
@ApiPropertyOptional({
......
......@@ -16,7 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { roleEnum } from '../../database/schema';
import { InspectionPhotosService } from './inspection-photos.service';
import {
CreateInspectionPhotoDto,
......@@ -46,7 +46,7 @@ export class InspectionPhotosController {
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@Roles('ADMIN', 'MANAGER', 'OPERATOR', 'PARTNER', 'SUPERADMIN')
@UseInterceptors(FileInterceptor('photo', multerConfig))
@ApiOperation({
summary: 'Upload a new inspection photo',
......@@ -94,10 +94,15 @@ export class InspectionPhotosController {
@Body() createInspectionPhotoDto: CreateInspectionPhotoDto,
@UploadedFile() file: Express.Multer.File,
) {
return this.inspectionPhotosService.createInspectionPhoto(
createInspectionPhotoDto,
file,
);
try {
return await this.inspectionPhotosService.createInspectionPhoto(
createInspectionPhotoDto,
file,
);
} catch (error) {
console.error('Error uploading inspection photo:', error);
throw error;
}
}
@Get()
......@@ -178,7 +183,7 @@ export class InspectionPhotosController {
@Put(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@Roles('ADMIN', 'MANAGER', 'OPERATOR', 'PARTNER', 'SUPERADMIN')
@ApiOperation({
summary: 'Update an inspection photo',
description:
......@@ -218,7 +223,7 @@ export class InspectionPhotosController {
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@Roles('ADMIN', 'MANAGER', 'OPERATOR', 'PARTNER', 'SUPERADMIN')
@ApiOperation({
summary: 'Delete an inspection photo',
description:
......
......@@ -20,6 +20,11 @@ export class InspectionPhotosService {
dto: CreateInspectionPhotoDto,
file: Express.Multer.File,
): Promise<InspectionPhotoResponseDto> {
// Validate file parameter
if (!file) {
throw new Error('No file provided. Please upload a photo file.');
}
// Check if inspection exists
const inspectionList = await this.databaseService.db
.select()
......@@ -37,6 +42,7 @@ export class InspectionPhotosService {
const filePaths = await saveInspectionPhotos([file], dto.inspectionId);
// Create photo record in database
const now = new Date();
const [photo] = await this.databaseService.db
.insert(inspectionPhotos)
.values({
......@@ -46,6 +52,8 @@ export class InspectionPhotosService {
url: filePaths[0],
description: dto.description,
inspectionId: dto.inspectionId,
createdAt: now,
updatedAt: now,
})
.returning();
......
import {
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsString,
......@@ -24,7 +25,7 @@ export class CreateInspectionResponseDto {
example: InspectionResponseOption.YES,
enumName: 'InspectionResponseOption',
})
@IsString()
@IsEnum(InspectionResponseOption)
response: InspectionResponseOption;
@ApiPropertyOptional({
......
import { IsDateString, IsInt, IsOptional, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { InspectionStatus } from '@prisma/client';
import { inspectionStatusEnum } from '../../../database/schema';
export class FindInspectionDto {
@ApiPropertyOptional({
......@@ -36,11 +36,11 @@ export class FindInspectionDto {
@ApiPropertyOptional({
description: 'Filter inspection records by status',
enum: InspectionStatus,
example: InspectionStatus.PENDING,
enum: inspectionStatusEnum.enumValues,
example: 'PENDING',
enumName: 'InspectionStatus',
})
@IsEnum(InspectionStatus)
@IsEnum(inspectionStatusEnum.enumValues)
@IsOptional()
status?: InspectionStatus;
status?: (typeof inspectionStatusEnum.enumValues)[number];
}
import { IsDateString, IsInt, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class GenerateInspectionDto {
@ApiProperty({
description: 'Deadline date for the inspection to be completed',
example: '2025-12-31T23:59:59.000Z',
type: String,
})
@IsDateString()
deadline: string;
@ApiPropertyOptional({
description:
'ID of the partner to associate the inspection with (if not provided, will use the partner already associated with the site)',
example: 1,
type: Number,
})
@Type(() => Number)
@IsInt()
@IsOptional()
partnerId?: number;
@ApiProperty({
description: 'ID of the site to generate inspection for',
example: 1,
type: Number,
})
@Type(() => Number)
@IsInt()
siteId: number;
@ApiPropertyOptional({
description: 'Optional comment for the generated inspection',
example: 'Annual inspection scheduled for this site',
type: String,
})
@IsString()
@IsOptional()
comment?: string;
}
export * from './create-inspection.dto';
export * from './find-inspection.dto';
export * from './generate-inspection.dto';
export * from './inspection-response.dto';
export * from './inspection-response-option.enum';
export * from './start-inspection.dto';
......
......@@ -2,7 +2,10 @@ import { InspectionResponseOption } from './inspection-response-option.enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
// Import the status enum from Prisma
import { InspectionStatus } from '@prisma/client';
import {
inspectionStatusEnum,
finalCommentStatusEnum,
} from '../../../database/schema';
export class InspectionQuestionDto {
@ApiProperty({
......@@ -103,6 +106,13 @@ export class InspectionDto {
date: Date;
@ApiPropertyOptional({
description: 'Deadline date for the inspection to be completed',
example: '2025-12-31T23:59:59.000Z',
type: Date,
})
deadline?: Date;
@ApiPropertyOptional({
description: 'Optional general comment about the inspection',
example: 'Annual preventive inspection completed with minor issues noted',
type: String,
......@@ -118,6 +128,14 @@ export class InspectionDto {
})
finalComment?: string;
@ApiPropertyOptional({
description: 'Status of the final comment validation',
enum: finalCommentStatusEnum.enumValues,
example: 'PENDING',
enumName: 'FinalCommentStatus',
})
finalCommentStatus?: (typeof finalCommentStatusEnum.enumValues)[number];
@ApiProperty({
description: 'ID of the site where inspection was performed',
example: 1,
......@@ -127,11 +145,11 @@ export class InspectionDto {
@ApiProperty({
description: 'Current status of the inspection',
enum: InspectionStatus,
example: InspectionStatus.PENDING,
enum: inspectionStatusEnum.enumValues,
example: 'PENDING',
enumName: 'InspectionStatus',
})
status: InspectionStatus;
status: (typeof inspectionStatusEnum.enumValues)[number];
@ApiProperty({
description: 'Date and time when the record was created',
......
import { IsEnum } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { InspectionStatus } from '@prisma/client';
import { inspectionStatusEnum } from '../../../database/schema';
export class UpdateInspectionStatusDto {
@ApiProperty({
description: 'New status for the inspection',
enum: InspectionStatus,
example: InspectionStatus.IN_PROGRESS,
enum: inspectionStatusEnum.enumValues,
example: 'IN_PROGRESS',
enumName: 'InspectionStatus',
})
@IsEnum(InspectionStatus)
status: InspectionStatus;
@IsEnum(inspectionStatusEnum.enumValues)
status: (typeof inspectionStatusEnum.enumValues)[number];
}
......@@ -31,6 +31,7 @@ import {
CreateInspectionResponseDto,
} from './dto/create-inspection.dto';
import { FindInspectionDto } from './dto/find-inspection.dto';
import { GenerateInspectionDto } from './dto/generate-inspection.dto';
import { UpdateInspectionStatusDto } from './dto/update-inspection-status.dto';
import { StartInspectionDto } from './dto/start-inspection.dto';
import { UpdateFinalCommentDto } from './dto/update-final-comment.dto';
......@@ -135,6 +136,40 @@ export class InspectionController {
);
}
@Post('generate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN)
@ApiOperation({
summary: 'Generate a new inspection with deadline for a specific site',
description:
'Generates a new inspection with a specified deadline date for a specific site. If a partnerId is provided, it will associate/switch the site to that partner. If no partnerId is provided, it will use the partner already associated with the site. The system will automatically schedule the next inspection 1 year after the deadline, 3 months in advance.',
})
@ApiResponse({
status: 201,
description: 'The inspection has been successfully generated.',
type: InspectionDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Site or partner not found.' })
@ApiBody({
description: 'Inspection generation data',
type: GenerateInspectionDto,
})
async generateInspection(
@Body() generateInspectionDto: GenerateInspectionDto,
@Req() req,
) {
return this.inspectionService.generateInspection(
generateInspectionDto,
req.user.id,
);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiOperation({
......@@ -276,17 +311,17 @@ export class InspectionController {
return this.inspectionService.getResponsesByInspectionId(id);
}
@Post(':id/responses')
@Patch(':id/responses')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Add responses to an existing inspection record',
summary: 'Update responses for an existing inspection record',
description:
'Adds or updates responses for a specific inspection record. Can be used to complete a partially filled inspection record.',
'Updates or creates responses for a specific inspection record. If a response already exists for a question, it will be updated. If not, a new response will be created.',
})
@ApiResponse({
status: 201,
description: 'The responses have been successfully added.',
status: 200,
description: 'The responses have been successfully updated.',
type: [InspectionResponseDto],
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
......@@ -306,15 +341,15 @@ export class InspectionController {
example: 1,
})
@ApiBody({
description: 'Array of inspection responses to add',
description: 'Array of inspection responses to update or create',
type: [CreateInspectionResponseDto],
})
async addResponsesToInspection(
async updateResponsesToInspection(
@Param('id', ParseIntPipe) id: number,
@Body() responses: CreateInspectionResponseDto[],
@Req() req,
) {
return this.inspectionService.addResponsesToInspection(
return this.inspectionService.updateResponsesToInspection(
id,
responses,
req.user.id,
......@@ -581,6 +616,64 @@ export class InspectionController {
id,
updateFinalCommentDto.finalComment,
req.user.id,
req.user.role,
);
}
@Patch(':id/final-comment/validate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.PARTNER)
@ApiOperation({
summary: 'Validate or reject a final comment',
description:
'Allows partner owners/managers to validate or reject final comments created by operators.',
})
@ApiResponse({
status: 200,
description: 'The final comment validation status has been updated.',
type: InspectionDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Only PARTNER role can validate final comments.',
})
@ApiResponse({ status: 404, description: 'Inspection not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the inspection to validate',
example: 1,
})
@ApiBody({
description: 'Validation data',
schema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['validate', 'reject'],
description: 'Action to perform on the final comment',
},
comment: {
type: 'string',
description: 'Optional comment for rejection reason',
},
},
required: ['action'],
},
})
async validateFinalComment(
@Param('id', ParseIntPipe) id: number,
@Body() validationDto: { action: 'validate' | 'reject'; comment?: string },
@Req() req,
) {
return this.inspectionService.validateFinalComment(
id,
validationDto.action,
req.user.id,
validationDto.comment,
);
}
}
......@@ -161,8 +161,8 @@ export class InspectionGateway
try {
// Update the response in the database
const updatedResponses =
await this.inspectionService.addResponsesToInspection(
const updatedInspection =
await this.inspectionService.updateResponsesToInspection(
inspectionId,
[response],
userId,
......@@ -171,7 +171,7 @@ export class InspectionGateway
// Broadcast the update to all users in the room
this.server.to(`inspection_${inspectionId}`).emit('responseUpdated', {
inspectionId,
response: updatedResponses[0],
inspection: updatedInspection,
updatedBy: client.id,
timestamp: new Date(),
});
......
......@@ -19,33 +19,91 @@ export async function saveInspectionPhotos(
return [];
}
const uploadDir =
process.env.NODE_ENV === 'production'
? `/home/api-cellnex/public_html/uploads/inspection/${inspectionId}`
: path.join(
process.cwd(),
'uploads',
'inspection',
inspectionId.toString(),
);
// Validate inspectionId
if (!inspectionId || inspectionId <= 0) {
throw new Error('Invalid inspection ID provided');
}
// Use environment variable for upload path, with fallback to default
const baseUploadPath =
process.env.UPLOAD_BASE_PATH ||
(process.env.NODE_ENV === 'production'
? '/home/api-verticalflow/public_html/uploads'
: path.join(process.cwd(), 'uploads'));
const uploadDir = path.join(
baseUploadPath,
'inspection',
inspectionId.toString(),
);
// Create directory if it doesn't exist
let finalUploadDir = uploadDir;
try {
// First, try to create the base uploads directory if it doesn't exist
await mkdir(baseUploadPath, { recursive: true });
// Then create the inspection subdirectory
await mkdir(uploadDir, { recursive: true });
} catch (error) {
console.error(`Error creating directory ${uploadDir}:`, error);
throw new Error(`Failed to create upload directory: ${error.message}`);
// If permission denied, try using a fallback directory
if (error.code === 'EACCES') {
console.warn(
`Permission denied for ${uploadDir}, trying fallback directory`,
);
// Try using /tmp as fallback
const fallbackDir = path.join(
'/tmp',
'uploads',
'inspection',
inspectionId.toString(),
);
try {
await mkdir(fallbackDir, { recursive: true });
finalUploadDir = fallbackDir;
console.log(`Using fallback directory: ${finalUploadDir}`);
} catch (fallbackError) {
console.error(
`Fallback directory creation also failed:`,
fallbackError,
);
throw new Error(
`Permission denied: Unable to create upload directory. Please check directory permissions for ${uploadDir}. Fallback also failed: ${fallbackError.message}`,
);
}
} else if (error.code === 'ENOENT') {
throw new Error(
`Parent directory does not exist: Unable to create upload directory at ${uploadDir}`,
);
} else {
throw new Error(`Failed to create upload directory: ${error.message}`);
}
}
// Save files
const savedPaths: string[] = [];
const savePromises = files.map(async (file) => {
const filename = file.originalname;
const filePath = path.join(uploadDir, filename);
if (!file || !file.buffer) {
throw new Error('Invalid file provided - missing buffer');
}
const filename = file.originalname || `photo_${Date.now()}.jpg`;
const filePath = path.join(finalUploadDir, filename);
try {
await writeFile(filePath, file.buffer);
savedPaths.push(`/uploads/inspection/${inspectionId}/${filename}`);
// Generate the URL path based on the actual directory used
if (finalUploadDir.startsWith('/tmp')) {
// For fallback directory, use a different URL pattern
savedPaths.push(`/tmp-uploads/inspection/${inspectionId}/${filename}`);
} else {
savedPaths.push(`/uploads/inspection/${inspectionId}/${filename}`);
}
} catch (error) {
console.error(`Error saving file ${filename}:`, error);
throw new Error(`Failed to save file ${filename}: ${error.message}`);
......
......@@ -167,4 +167,118 @@ export class PartnersController {
getPartnerCandidates(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.getPartnerCandidates(id);
}
@Patch(':partnerId/users/:userId/promote')
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Promote a user to partner manager (PARTNER role)' })
@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 promoted to partner manager.',
})
@ApiResponse({ status: 404, description: 'Partner or user not found.' })
promoteUserToPartnerManager(
@Param('partnerId', ParseIntPipe) partnerId: number,
@Param('userId', ParseIntPipe) userId: number,
) {
return this.partnersService.promoteUserToPartnerManager(partnerId, userId);
}
@Patch(':partnerId/users/:userId/demote')
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Demote a user to operator (OPERATOR role)' })
@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 demoted to operator.',
})
@ApiResponse({ status: 404, description: 'Partner or user not found.' })
demoteUserToOperator(
@Param('partnerId', ParseIntPipe) partnerId: number,
@Param('userId', ParseIntPipe) userId: number,
) {
return this.partnersService.demoteUserToOperator(partnerId, userId);
}
@Get(':id/sites')
@Roles(Role.ADMIN, Role.SUPERADMIN, Role.MANAGER, Role.PARTNER)
@UseGuards(PartnerAuthGuard)
@ApiOperation({ summary: 'Get all sites associated with a partner' })
@ApiParam({ name: 'id', description: 'Partner ID', type: 'number' })
@ApiResponse({
status: 200,
description: 'Return all sites associated with the partner.',
})
@ApiResponse({ status: 404, description: 'Partner not found.' })
getPartnerSites(@Param('id', ParseIntPipe) id: number) {
return this.partnersService.getPartnerSites(id);
}
@Post(':partnerId/sites/:siteId')
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Associate a site with a partner' })
@ApiParam({ name: 'partnerId', description: 'Partner ID', type: 'number' })
@ApiParam({ name: 'siteId', description: 'Site ID', type: 'number' })
@ApiResponse({
status: 201,
description: 'The site has been successfully associated with the partner.',
})
@ApiResponse({ status: 404, description: 'Partner or site not found.' })
@ApiResponse({
status: 409,
description: 'Site is already associated with this partner.',
})
addSiteToPartner(
@Param('partnerId', ParseIntPipe) partnerId: number,
@Param('siteId', ParseIntPipe) siteId: number,
) {
return this.partnersService.addSiteToPartner(partnerId, siteId);
}
@Delete(':partnerId/sites/:siteId')
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Remove a site from a partner' })
@ApiParam({ name: 'partnerId', description: 'Partner ID', type: 'number' })
@ApiParam({ name: 'siteId', description: 'Site ID', type: 'number' })
@ApiResponse({
status: 200,
description: 'The site has been successfully removed from the partner.',
})
@ApiResponse({
status: 404,
description: 'Partner, site, or association not found.',
})
removeSiteFromPartner(
@Param('partnerId', ParseIntPipe) partnerId: number,
@Param('siteId', ParseIntPipe) siteId: number,
) {
return this.partnersService.removeSiteFromPartner(partnerId, siteId);
}
@Patch('sites/:siteId/switch-to/:newPartnerId')
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({ summary: 'Switch a site to a different partner' })
@ApiParam({ name: 'siteId', description: 'Site ID', type: 'number' })
@ApiParam({
name: 'newPartnerId',
description: 'New Partner ID',
type: 'number',
})
@ApiResponse({
status: 200,
description: 'The site has been successfully switched to the new partner.',
})
@ApiResponse({ status: 404, description: 'Site or new partner not found.' })
@ApiResponse({
status: 409,
description: 'Site is already associated with the new partner.',
})
switchPartnerForSite(
@Param('siteId', ParseIntPipe) siteId: number,
@Param('newPartnerId', ParseIntPipe) newPartnerId: number,
) {
return this.partnersService.switchPartnerForSite(siteId, newPartnerId);
}
}
......@@ -13,9 +13,14 @@ export class PartnersService {
constructor(private databaseService: DatabaseService) {}
async create(createPartnerDto: CreatePartnerDto) {
const now = new Date();
const [partner] = await this.databaseService.db
.insert(partners)
.values(createPartnerDto)
.values({
...createPartnerDto,
createdAt: now,
updatedAt: now,
})
.returning();
return partner;
......@@ -143,7 +148,7 @@ export class PartnersService {
.update(users)
.set({
partnerId,
role: 'PARTNER', // Set role to PARTNER automatically
role: 'OPERATOR', // Set role to OPERATOR by default (normal partner user)
})
.where(eq(users.id, userId))
.returning();
......@@ -249,11 +254,14 @@ export class PartnersService {
);
}
const now = new Date();
const [partnerSite] = await this.databaseService.db
.insert(partnerSites)
.values({
partnerId,
siteId,
createdAt: now,
updatedAt: now,
})
.returning();
......@@ -304,8 +312,130 @@ export class PartnersService {
return { message: 'Site removed from partner successfully' };
}
async promoteUserToPartnerManager(partnerId: number, userId: number) {
// Check if both partner and user exist
const partner = await this.findOne(partnerId);
const userList = await this.databaseService.db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (userList.length === 0) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
const user = userList[0];
if (user.partnerId !== partnerId) {
throw new NotFoundException(
`User with ID ${userId} is not associated with Partner ID ${partnerId}`,
);
}
const [updatedUser] = await this.databaseService.db
.update(users)
.set({
role: 'PARTNER', // Promote to PARTNER role (owner/manager)
})
.where(eq(users.id, userId))
.returning();
return updatedUser;
}
async demoteUserToOperator(partnerId: number, userId: number) {
// Check if both partner and user exist
const partner = await this.findOne(partnerId);
const userList = await this.databaseService.db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (userList.length === 0) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
const user = userList[0];
if (user.partnerId !== partnerId) {
throw new NotFoundException(
`User with ID ${userId} is not associated with Partner ID ${partnerId}`,
);
}
const [updatedUser] = await this.databaseService.db
.update(users)
.set({
role: 'OPERATOR', // Demote to OPERATOR role (normal user)
})
.where(eq(users.id, userId))
.returning();
return updatedUser;
}
// Legacy method name for backward compatibility
async getPartnerCandidates(partnerId: number) {
return this.getPartnerSites(partnerId);
}
async switchPartnerForSite(siteId: number, newPartnerId: number) {
// Check if the new partner exists
await this.findOne(newPartnerId);
// Check if the site exists
const siteList = await this.databaseService.db
.select()
.from(sites)
.where(eq(sites.id, siteId))
.limit(1);
if (siteList.length === 0) {
throw new NotFoundException(`Site with ID ${siteId} not found`);
}
// Check if the site is already associated with the new partner
const existingAssociation = await this.databaseService.db
.select()
.from(partnerSites)
.where(
and(
eq(partnerSites.partnerId, newPartnerId),
eq(partnerSites.siteId, siteId),
),
)
.limit(1);
if (existingAssociation.length > 0) {
throw new ConflictException(
`Site with ID ${siteId} is already associated with Partner ID ${newPartnerId}`,
);
}
// Remove any existing partner association for this site
await this.databaseService.db
.delete(partnerSites)
.where(eq(partnerSites.siteId, siteId));
// Add the new partner association
const now = new Date();
const [newPartnerSite] = await this.databaseService.db
.insert(partnerSites)
.values({
partnerId: newPartnerId,
siteId,
createdAt: now,
updatedAt: now,
})
.returning();
return {
message: `Site ${siteId} has been successfully switched to Partner ${newPartnerId}`,
partnerSite: newPartnerSite,
};
}
}
......@@ -34,11 +34,14 @@ export class QuestionsService {
);
}
const now = new Date();
const [question] = await this.databaseService.db
.insert(inspectionQuestions)
.values({
question: dto.question,
orderIndex: dto.orderIndex,
createdAt: now,
updatedAt: now,
})
.returning();
......
......@@ -8,7 +8,7 @@ import {
IsBoolean,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { InspectionStatus } from '@prisma/client';
import { inspectionStatusEnum } from '../../../database/schema';
export enum OrderDirection {
ASC = 'asc',
......@@ -82,10 +82,10 @@ export class FindSitesDto {
@ApiProperty({
required: false,
enum: InspectionStatus,
enum: inspectionStatusEnum.enumValues,
description: 'Filter sites by their latest inspection status',
})
@IsOptional()
@IsEnum(InspectionStatus)
inspectionStatus?: InspectionStatus;
@IsEnum(inspectionStatusEnum.enumValues)
inspectionStatus?: (typeof inspectionStatusEnum.enumValues)[number];
}
......@@ -100,6 +100,7 @@ export class SitesService {
async create(createSiteDto: CreateSiteDto, userId: number) {
try {
// First create the site
const now = new Date();
const [newSite] = await this.databaseService.db
.insert(sites)
.values({
......@@ -109,6 +110,8 @@ export class SitesService {
longitude: createSiteDto.longitude,
createdById: userId,
updatedById: userId,
createdAt: now,
updatedAt: now,
})
.returning();
......@@ -131,34 +134,69 @@ export class SitesService {
}
async findAll(findSitesDto: FindSitesDto, partnerId?: number | null) {
// Simplified version - just return basic sites
const page = findSitesDto?.page || 1;
const limit = findSitesDto?.limit || 10;
const offset = (page - 1) * limit;
// Build where conditions
const conditions: SQL[] = [];
// Build where conditions for sites
const siteConditions: SQL[] = [];
if (findSitesDto?.siteCode) {
conditions.push(ilike(sites.siteCode, `%${findSitesDto.siteCode}%`));
siteConditions.push(ilike(sites.siteCode, `%${findSitesDto.siteCode}%`));
}
if (findSitesDto?.siteName) {
conditions.push(ilike(sites.siteName, `%${findSitesDto.siteName}%`));
siteConditions.push(ilike(sites.siteName, `%${findSitesDto.siteName}%`));
}
const whereCondition =
conditions.length > 0 ? and(...conditions) : undefined;
const siteWhereCondition =
siteConditions.length > 0 ? and(...siteConditions) : undefined;
// Get total count
const [{ totalCount }] = await this.databaseService.db
.select({ totalCount: count() })
.from(sites)
.where(whereCondition);
.where(siteWhereCondition);
// Build inspection status filter condition
const inspectionConditions: SQL[] = [];
if (findSitesDto?.inspectionStatus) {
inspectionConditions.push(
eq(inspections.status, findSitesDto.inspectionStatus as any),
);
}
// Get sites with latest inspection data
const sitesList = await this.databaseService.db
.select()
.select({
id: sites.id,
siteCode: sites.siteCode,
siteName: sites.siteName,
latitude: sites.latitude,
longitude: sites.longitude,
createdAt: sites.createdAt,
updatedAt: sites.updatedAt,
createdById: sites.createdById,
updatedById: sites.updatedById,
latestInspectionStatus: inspections.status,
latestInspectionDate: inspections.date,
})
.from(sites)
.where(whereCondition)
.leftJoin(
inspections,
and(
eq(inspections.siteId, sites.id),
// Get the latest inspection for each site
sql`${inspections.id} = (
SELECT MAX(id)
FROM "Inspection"
WHERE "siteId" = ${sites.id}
)`,
// Apply inspection status filter if provided
...inspectionConditions,
),
)
.where(siteWhereCondition)
.limit(limit)
.offset(offset);
......@@ -166,8 +204,10 @@ export class SitesService {
data: sitesList.map((site) => ({
...site,
highestCandidateStatus: null,
latestInspectionStatus: null,
nextInspectionDueDate: null,
latestInspectionStatus: site.latestInspectionStatus,
nextInspectionDueDate: this.calculateNextInspectionDueDate(
site.latestInspectionDate,
),
_count: { candidates: 0 },
})),
meta: {
......@@ -181,8 +221,32 @@ export class SitesService {
async findOne(id: number) {
const sitesList = await this.databaseService.db
.select()
.select({
id: sites.id,
siteCode: sites.siteCode,
siteName: sites.siteName,
latitude: sites.latitude,
longitude: sites.longitude,
createdAt: sites.createdAt,
updatedAt: sites.updatedAt,
createdById: sites.createdById,
updatedById: sites.updatedById,
latestInspectionStatus: inspections.status,
latestInspectionDate: inspections.date,
})
.from(sites)
.leftJoin(
inspections,
and(
eq(inspections.siteId, sites.id),
// Get the latest inspection for this site
sql`${inspections.id} = (
SELECT MAX(id)
FROM "Inspection"
WHERE "siteId" = ${sites.id}
)`,
),
)
.where(eq(sites.id, id))
.limit(1);
......@@ -190,11 +254,15 @@ export class SitesService {
throw new NotFoundException(`Site with ID ${id} not found`);
}
const site = sitesList[0];
return {
...sitesList[0],
...site,
highestCandidateStatus: null,
latestInspectionStatus: null,
nextInspectionDueDate: null,
latestInspectionStatus: site.latestInspectionStatus,
nextInspectionDueDate: this.calculateNextInspectionDueDate(
site.latestInspectionDate,
),
_count: { candidates: 0 },
};
}
......@@ -204,12 +272,10 @@ export class SitesService {
throw new ForbiddenException('Partner access required');
}
// For now, same as findOne - TODO: Add partner filtering
return this.findOne(id);
}
async findOneWithCandidates(id: number, partnerId?: number | null) {
// For now, same as findOne - TODO: Add candidates
const site = await this.findOne(id);
return {
...site,
......@@ -278,8 +344,32 @@ export class SitesService {
async findByCode(siteCode: string) {
const siteList = await this.databaseService.db
.select()
.select({
id: sites.id,
siteCode: sites.siteCode,
siteName: sites.siteName,
latitude: sites.latitude,
longitude: sites.longitude,
createdAt: sites.createdAt,
updatedAt: sites.updatedAt,
createdById: sites.createdById,
updatedById: sites.updatedById,
latestInspectionStatus: inspections.status,
latestInspectionDate: inspections.date,
})
.from(sites)
.leftJoin(
inspections,
and(
eq(inspections.siteId, sites.id),
// Get the latest inspection for this site
sql`${inspections.id} = (
SELECT MAX(id)
FROM "Inspection"
WHERE "siteId" = ${sites.id}
)`,
),
)
.where(eq(sites.siteCode, siteCode))
.limit(1);
......@@ -289,13 +379,13 @@ export class SitesService {
const site = siteList[0];
// For now, return a simplified version
// TODO: Add candidate and inspection relations when needed
return {
...site,
highestCandidateStatus: null,
latestInspectionStatus: null,
nextInspectionDueDate: null,
latestInspectionStatus: site.latestInspectionStatus,
nextInspectionDueDate: this.calculateNextInspectionDueDate(
site.latestInspectionDate,
),
_count: { candidates: 0 },
};
}
......@@ -322,13 +412,31 @@ export class SitesService {
siteName: sites.siteName,
latitude: sites.latitude,
longitude: sites.longitude,
latestInspectionStatus: inspections.status,
latestInspectionDate: inspections.date,
})
.from(sites)
.leftJoin(
inspections,
and(
eq(inspections.siteId, sites.id),
// Get the latest inspection for each site
sql`${inspections.id} = (
SELECT MAX(id)
FROM "Inspection"
WHERE "siteId" = ${sites.id}
)`,
),
)
.where(whereCondition);
return sitesList.map((site) => ({
...site,
highestCandidateStatus: null,
latestInspectionStatus: site.latestInspectionStatus,
nextInspectionDueDate: this.calculateNextInspectionDueDate(
site.latestInspectionDate,
),
}));
}
}
......@@ -65,6 +65,7 @@ export class TelecommunicationStationIdentificationService {
);
}
const now = new Date();
const [identification] = await this.databaseService.db
.insert(telecommunicationStationIdentifications)
.values({
......@@ -73,6 +74,8 @@ export class TelecommunicationStationIdentificationService {
serialNumber: dto.serialNumber,
isFirstCertification: dto.isFirstCertification,
modelReference: dto.modelReference,
createdAt: now,
updatedAt: now,
})
.returning();
......
......@@ -6,7 +6,7 @@ import {
IsString,
MinLength,
} from 'class-validator';
import { Role } from '@prisma/client';
import { roleEnum } from '../../../database/schema';
export class CreateUserDto {
@ApiProperty({
......@@ -37,10 +37,10 @@ export class CreateUserDto {
@ApiProperty({
description: 'The role of the user',
enum: Role,
default: Role.VIEWER,
example: Role.VIEWER,
enum: roleEnum.enumValues,
default: 'VIEWER',
example: 'VIEWER',
})
@IsEnum(Role)
role: Role = Role.VIEWER;
@IsEnum(roleEnum.enumValues)
role: (typeof roleEnum.enumValues)[number] = 'VIEWER';
}
......@@ -29,12 +29,15 @@ export class UsersService {
async create(createUserDto: CreateUserDto) {
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
const now = new Date();
const [newUser] = await this.databaseService.db
.insert(users)
.values({
...createUserDto,
password: hashedPassword,
isActive: false, // New users are inactive by default
createdAt: now,
updatedAt: now,
})
.returning({
id: users.id,
......
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