Commit ede124b8 by Augusto

Users Signature, Partner Logo, InspectionPhotos isPrincipal, IsResume, arrange in order

parent 933b118d
ALTER TABLE "Inspection" ALTER COLUMN "finalCommentStatus" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "Inspection" ALTER COLUMN "finalCommentStatus" SET DATA TYPE "public"."InspectionStatus" USING "finalCommentStatus"::text::"public"."InspectionStatus";--> statement-breakpoint
ALTER TABLE "Inspection" ALTER COLUMN "finalCommentStatus" SET DEFAULT 'PENDING';--> statement-breakpoint
DROP TYPE "public"."FinalCommentStatus";
\ No newline at end of file
ALTER TABLE "User" ALTER COLUMN "isActive" SET DEFAULT true;
\ No newline at end of file
ALTER TABLE "InspectionPhoto" ADD COLUMN "order" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "Inspection" ADD COLUMN "submittedById" integer;--> statement-breakpoint
ALTER TABLE "Inspection" ADD COLUMN "approvedById" integer;--> statement-breakpoint
ALTER TABLE "Inspection" ADD CONSTRAINT "Inspection_submittedById_User_id_fk" FOREIGN KEY ("submittedById") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "Inspection" ADD CONSTRAINT "Inspection_approvedById_User_id_fk" FOREIGN KEY ("approvedById") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "Inspection_submittedById_idx" ON "Inspection" USING btree ("submittedById");--> statement-breakpoint
CREATE INDEX "Inspection_approvedById_idx" ON "Inspection" USING btree ("approvedById");
\ No newline at end of file
ALTER TABLE "InspectionPhoto" ADD COLUMN "isPrincipal" boolean DEFAULT false NOT NULL;
\ No newline at end of file
ALTER TABLE "InspectionPhoto" ADD COLUMN "isResume" boolean DEFAULT false NOT NULL;
\ No newline at end of file
ALTER TABLE "Partner" ADD COLUMN "logo" varchar(500);--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "signature" varchar(500);
\ No newline at end of file
......@@ -22,6 +22,48 @@
"when": 1758185080204,
"tag": "0002_furry_genesis",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1758642221769,
"tag": "0003_stiff_songbird",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1759245696335,
"tag": "0004_curvy_the_order",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1759738370590,
"tag": "0005_pink_fantastic_four",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1759738458247,
"tag": "0006_large_puppet_master",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1759738687335,
"tag": "0007_flimsy_malcolm_colcord",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1759738883201,
"tag": "0008_adorable_quentin_quire",
"breakpoints": true
}
]
}
\ No newline at end of file
-- Migration script to add submittedById and approvedById fields to Inspection table
-- This script adds the fields to track who submitted inspections to APPROVING status
-- and who approved the inspections
-- Add the new columns to the Inspection table
ALTER TABLE "Inspection"
ADD COLUMN "submittedById" INTEGER REFERENCES "User"("id"),
ADD COLUMN "approvedById" INTEGER REFERENCES "User"("id");
-- Create indexes for the new columns for better query performance
CREATE INDEX "Inspection_submittedById_idx" ON "Inspection"("submittedById");
CREATE INDEX "Inspection_approvedById_idx" ON "Inspection"("approvedById");
-- Optional: Add comments to document the purpose of these fields
COMMENT ON COLUMN "Inspection"."submittedById" IS 'User who submitted the inspection to APPROVING status';
COMMENT ON COLUMN "Inspection"."approvedById" IS 'User who approved the inspection (status APPROVED)';
-- Verify the changes
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'Inspection'
AND column_name IN ('submittedById', 'approvedById');
-- Check indexes were created
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'Inspection'
AND indexname IN ('Inspection_submittedById_idx', 'Inspection_approvedById_idx');
-- Migration script to update finalCommentStatus from FinalCommentStatus to InspectionStatus
-- This script safely converts existing data
-- Step 1: Drop the default constraint
ALTER TABLE "Inspection" ALTER COLUMN "finalCommentStatus" DROP DEFAULT;
-- Step 2: Change the column type from FinalCommentStatus to InspectionStatus
-- This will convert: VALIDATED -> APPROVED, PENDING -> PENDING, REJECTED -> REJECTED
ALTER TABLE "Inspection" ALTER COLUMN "finalCommentStatus" SET DATA TYPE "public"."InspectionStatus"
USING CASE
WHEN "finalCommentStatus"::text = 'VALIDATED' THEN 'APPROVED'::"public"."InspectionStatus"
WHEN "finalCommentStatus"::text = 'PENDING' THEN 'PENDING'::"public"."InspectionStatus"
WHEN "finalCommentStatus"::text = 'REJECTED' THEN 'REJECTED'::"public"."InspectionStatus"
ELSE 'PENDING'::"public"."InspectionStatus"
END;
-- Step 3: Set the new default
ALTER TABLE "Inspection" ALTER COLUMN "finalCommentStatus" SET DEFAULT 'PENDING';
-- Step 4: Drop the old enum type
DROP TYPE "public"."FinalCommentStatus";
-- Migration to add constraint for maximum 6 resume photos per inspection
-- This ensures data integrity at the database level
-- First, let's create a function to check the constraint
CREATE OR REPLACE FUNCTION check_max_resume_photos()
RETURNS TRIGGER AS $$
BEGIN
-- Only check when isResume is being set to true
IF NEW.isResume = true THEN
-- Count existing resume photos for this inspection
IF (
SELECT COUNT(*)
FROM "InspectionPhoto"
WHERE "inspectionId" = NEW."inspectionId"
AND "isResume" = true
AND "id" != COALESCE(NEW."id", 0)
) >= 6 THEN
RAISE EXCEPTION 'Maximum of 6 resume photos allowed per inspection';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for INSERT operations
DROP TRIGGER IF EXISTS check_resume_photos_insert ON "InspectionPhoto";
CREATE TRIGGER check_resume_photos_insert
BEFORE INSERT ON "InspectionPhoto"
FOR EACH ROW
EXECUTE FUNCTION check_max_resume_photos();
-- Create trigger for UPDATE operations
DROP TRIGGER IF EXISTS check_resume_photos_update ON "InspectionPhoto";
CREATE TRIGGER check_resume_photos_update
BEFORE UPDATE ON "InspectionPhoto"
FOR EACH ROW
EXECUTE FUNCTION check_max_resume_photos();
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Res } from '@nestjs/common';
import { AppService } from './app.service';
import { Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
@Controller()
export class AppController {
......@@ -9,4 +12,39 @@ export class AppController {
getHello(): string {
return this.appService.getHello();
}
@Get('debug/files')
async debugFiles(@Res() res: Response) {
const uploadBasePath =
process.env.UPLOAD_BASE_PATH ||
(process.env.NODE_ENV === 'production'
? '/home/api-verticalflow/public_html/uploads'
: path.join(process.cwd(), 'uploads'));
const signaturesDir = path.join(uploadBasePath, 'signatures');
const logosDir = path.join(uploadBasePath, 'logos');
const inspectionDir = path.join(uploadBasePath, 'inspection');
const debugInfo = {
uploadBasePath,
signaturesDir: {
exists: fs.existsSync(signaturesDir),
files: fs.existsSync(signaturesDir)
? fs.readdirSync(signaturesDir)
: [],
},
logosDir: {
exists: fs.existsSync(logosDir),
files: fs.existsSync(logosDir) ? fs.readdirSync(logosDir) : [],
},
inspectionDir: {
exists: fs.existsSync(inspectionDir),
files: fs.existsSync(inspectionDir)
? fs.readdirSync(inspectionDir)
: [],
},
};
res.json(debugInfo);
}
}
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
const mkdir = promisify(fs.mkdir);
const writeFile = promisify(fs.writeFile);
/**
* Saves uploaded signature image to the file system
* @param file Uploaded file
* @param userId The ID of the user
* @returns Saved file path
*/
export async function saveUserSignature(
file: Express.Multer.File,
userId: number,
): Promise<string> {
if (!file || !file.buffer) {
throw new Error('Invalid file provided - missing buffer');
}
// Validate userId
if (!userId || userId <= 0) {
throw new Error('Invalid user 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, 'signatures');
// 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, mode: 0o755 });
// Then create the signatures directory
await mkdir(uploadDir, { recursive: true, mode: 0o755 });
console.log(`Created signatures directory: ${uploadDir}`);
} catch (error) {
console.error('Error creating upload directory:', error);
// Fallback to temp directory if main upload directory fails
finalUploadDir = path.join('/tmp', 'uploads', 'signatures');
try {
await mkdir(finalUploadDir, { recursive: true, mode: 0o755 });
console.log(`Created fallback signatures directory: ${finalUploadDir}`);
} catch (fallbackError) {
console.error('Error creating fallback directory:', fallbackError);
throw new Error('Failed to create upload directory');
}
}
// Generate unique filename
const timestamp = Date.now();
const extension = path.extname(file.originalname || '');
const filename = `user_${userId}_signature_${timestamp}${extension}`;
const filePath = path.join(finalUploadDir, filename);
try {
await writeFile(filePath, file.buffer);
console.log(`Signature file saved to: ${filePath}`);
// Generate the URL path based on the actual directory used
let urlPath;
if (finalUploadDir.startsWith('/tmp')) {
// For fallback directory, use a different URL pattern
urlPath = `/tmp-uploads/signatures/${filename}`;
} else {
urlPath = `/uploads/signatures/${filename}`;
}
console.log(`Signature file URL: ${urlPath}`);
return urlPath;
} catch (error) {
console.error(`Error saving signature file ${filename}:`, error);
throw new Error(
`Failed to save signature file ${filename}: ${error.message}`,
);
}
}
/**
* Saves uploaded logo image to the file system
* @param file Uploaded file
* @param partnerId The ID of the partner
* @returns Saved file path
*/
export async function savePartnerLogo(
file: Express.Multer.File,
partnerId: number,
): Promise<string> {
if (!file || !file.buffer) {
throw new Error('Invalid file provided - missing buffer');
}
// Validate partnerId
if (!partnerId || partnerId <= 0) {
throw new Error('Invalid partner 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, 'logos');
// 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, mode: 0o755 });
// Then create the logos directory
await mkdir(uploadDir, { recursive: true, mode: 0o755 });
console.log(`Created logos directory: ${uploadDir}`);
} catch (error) {
console.error('Error creating upload directory:', error);
// Fallback to temp directory if main upload directory fails
finalUploadDir = path.join('/tmp', 'uploads', 'logos');
try {
await mkdir(finalUploadDir, { recursive: true, mode: 0o755 });
console.log(`Created fallback logos directory: ${finalUploadDir}`);
} catch (fallbackError) {
console.error('Error creating fallback directory:', fallbackError);
throw new Error('Failed to create upload directory');
}
}
// Generate unique filename
const timestamp = Date.now();
const extension = path.extname(file.originalname || '');
const filename = `partner_${partnerId}_logo_${timestamp}${extension}`;
const filePath = path.join(finalUploadDir, filename);
try {
await writeFile(filePath, file.buffer);
console.log(`Logo file saved to: ${filePath}`);
// Generate the URL path based on the actual directory used
let urlPath;
if (finalUploadDir.startsWith('/tmp')) {
// For fallback directory, use a different URL pattern
urlPath = `/tmp-uploads/logos/${filename}`;
} else {
urlPath = `/uploads/logos/${filename}`;
}
console.log(`Logo file URL: ${urlPath}`);
return urlPath;
} catch (error) {
console.error(`Error saving logo file ${filename}:`, error);
throw new Error(`Failed to save logo file ${filename}: ${error.message}`);
}
}
......@@ -46,12 +46,6 @@ 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',
......@@ -61,11 +55,12 @@ export const users = pgTable(
name: varchar('name', { length: 255 }).notNull(),
password: varchar('password', { length: 255 }).notNull(),
role: roleEnum('role').notNull().default('VIEWER'),
signature: varchar('signature', { length: 500 }),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
resetToken: varchar('resetToken', { length: 255 }),
resetTokenExpiry: timestamp('resetTokenExpiry'),
isActive: boolean('isActive').notNull().default(false),
isActive: boolean('isActive').notNull().default(true),
partnerId: integer('partnerId').references(() => partners.id),
},
(table) => ({
......@@ -98,6 +93,7 @@ export const partners = pgTable(
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull().unique(),
description: text('description'),
logo: varchar('logo', { length: 500 }),
isActive: boolean('isActive').notNull().default(true),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
......@@ -188,7 +184,7 @@ export const inspections = pgTable(
comment: text('comment'),
finalComment: text('finalComment'),
finalCommentStatus:
finalCommentStatusEnum('finalCommentStatus').default('PENDING'),
inspectionStatusEnum('finalCommentStatus').default('PENDING'),
siteId: integer('siteId')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
......@@ -196,12 +192,18 @@ export const inspections = pgTable(
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
createdById: integer('createdById').references(() => users.id),
updatedById: integer('updatedById').references(() => users.id),
submittedById: integer('submittedById').references(() => users.id),
approvedById: integer('approvedById').references(() => users.id),
status: inspectionStatusEnum('status').notNull().default('PENDING'),
},
(table) => ({
siteIdx: index('Inspection_siteId_idx').on(table.siteId),
createdByIdx: index('Inspection_createdById_idx').on(table.createdById),
updatedByIdx: index('Inspection_updatedById_idx').on(table.updatedById),
submittedByIdx: index('Inspection_submittedById_idx').on(
table.submittedById,
),
approvedByIdx: index('Inspection_approvedById_idx').on(table.approvedById),
statusIdx: index('Inspection_status_idx').on(table.status),
finalCommentStatusIdx: index('Inspection_finalCommentStatus_idx').on(
table.finalCommentStatus,
......@@ -247,6 +249,9 @@ export const inspectionPhotos = pgTable(
mimeType: varchar('mimeType', { length: 100 }).notNull(),
size: integer('size').notNull(),
description: text('description'),
order: integer('order').notNull().default(0),
isPrincipal: boolean('isPrincipal').notNull().default(false),
isResume: boolean('isResume').notNull().default(false),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
inspectionId: integer('inspectionId')
......@@ -384,6 +389,16 @@ export const inspectionsRelations = relations(inspections, ({ one, many }) => ({
references: [users.id],
relationName: 'inspectionUpdater',
}),
submittedBy: one(users, {
fields: [inspections.submittedById],
references: [users.id],
relationName: 'inspectionSubmitter',
}),
approvedBy: one(users, {
fields: [inspections.approvedById],
references: [users.id],
relationName: 'inspectionApprover',
}),
responses: many(inspectionResponses),
photos: many(inspectionPhotos),
}));
......
......@@ -30,10 +30,44 @@ async function bootstrap() {
? '/home/api-verticalflow/public_html/uploads'
: join(__dirname, '..', 'uploads'));
app.use('/uploads', express.static(uploadBasePath));
// Serve static files with explicit options
app.use(
'/uploads',
express.static(uploadBasePath, {
dotfiles: 'ignore',
etag: true,
lastModified: true,
maxAge: '1d',
setHeaders: (res, path) => {
// Set proper headers for images
if (
path.endsWith('.png') ||
path.endsWith('.jpg') ||
path.endsWith('.jpeg') ||
path.endsWith('.gif')
) {
res.setHeader('Content-Type', 'image/png');
}
},
}),
);
// Debug: Log static file serving configuration
console.log(`Static file serving configured for: ${uploadBasePath}`);
console.log(
`Files should be accessible at: /uploads/signatures/ and /uploads/logos/`,
);
// Serve fallback uploads from /tmp if needed
app.use('/tmp-uploads', express.static('/tmp/uploads'));
app.use(
'/tmp-uploads',
express.static('/tmp/uploads', {
dotfiles: 'ignore',
etag: true,
lastModified: true,
maxAge: '1d',
}),
);
// Swagger configuration
const config = new DocumentBuilder()
......
......@@ -73,8 +73,8 @@ export class AuthService {
sub: user.id,
email: user.email,
role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === 'PARTNER' &&
// Include partnerId in the payload if user is a PARTNER or OPERATOR and has a partnerId
...((user.role === 'PARTNER' || user.role === 'OPERATOR') &&
userDetail.partnerId && { partnerId: userDetail.partnerId }),
};
......@@ -163,8 +163,8 @@ export class AuthService {
sub: user.id,
email: user.email,
role: user.role,
// Include partnerId in the payload if user is a PARTNER and has a partnerId
...(user.role === 'PARTNER' &&
// Include partnerId in the payload if user is a PARTNER or OPERATOR and has a partnerId
...((user.role === 'PARTNER' || user.role === 'OPERATOR') &&
userDetail.partnerId && { partnerId: userDetail.partnerId }),
};
......
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, IsPositive } from 'class-validator';
import {
IsNumber,
IsOptional,
IsString,
IsPositive,
IsBoolean,
} from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateInspectionPhotoDto {
......@@ -27,4 +33,35 @@ export class CreateInspectionPhotoDto {
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({
description:
'Order/position of the photo within the inspection (for sorting)',
example: 1,
type: Number,
})
@IsOptional()
@IsNumber()
@IsPositive()
order?: number;
@ApiPropertyOptional({
description:
'Whether this photo is the principal/cover photo for the inspection',
example: false,
type: Boolean,
})
@IsOptional()
@IsBoolean()
isPrincipal?: boolean;
@ApiPropertyOptional({
description:
'Whether this photo is one of the 6 resume photos for the inspection',
example: false,
type: Boolean,
})
@IsOptional()
@IsBoolean()
isResume?: boolean;
}
......@@ -44,6 +44,30 @@ export class InspectionPhotoResponseDto {
description?: string;
@ApiProperty({
description:
'Order/position of the photo within the inspection (for sorting)',
example: 1,
type: Number,
})
order: number;
@ApiProperty({
description:
'Whether this photo is the principal/cover photo for the inspection',
example: false,
type: Boolean,
})
isPrincipal: boolean;
@ApiProperty({
description:
'Whether this photo is one of the 6 resume photos for the inspection',
example: false,
type: Boolean,
})
isResume: boolean;
@ApiProperty({
description: 'ID of the inspection this photo belongs to',
example: 1,
type: Number,
......
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import {
IsOptional,
IsString,
IsNumber,
IsPositive,
IsBoolean,
} from 'class-validator';
export class UpdateInspectionPhotoDto {
@ApiPropertyOptional({
......@@ -10,4 +16,35 @@ export class UpdateInspectionPhotoDto {
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({
description:
'Order/position of the photo within the inspection (for sorting)',
example: 2,
type: Number,
})
@IsOptional()
@IsNumber()
@IsPositive()
order?: number;
@ApiPropertyOptional({
description:
'Whether this photo is the principal/cover photo for the inspection',
example: true,
type: Boolean,
})
@IsOptional()
@IsBoolean()
isPrincipal?: boolean;
@ApiPropertyOptional({
description:
'Whether this photo is one of the 6 resume photos for the inspection',
example: true,
type: Boolean,
})
@IsOptional()
@IsBoolean()
isResume?: boolean;
}
......@@ -41,6 +41,31 @@ export class InspectionPhotosService {
// Save file to disk
const filePaths = await saveInspectionPhotos([file], dto.inspectionId);
// If this photo is being set as principal, unset any existing principal photo
if (dto.isPrincipal) {
await this.databaseService.db
.update(inspectionPhotos)
.set({ isPrincipal: false })
.where(eq(inspectionPhotos.inspectionId, dto.inspectionId));
}
// If this photo is being set as resume, check if we already have 6 resume photos
if (dto.isResume) {
const resumeCount = await this.databaseService.db
.select({ count: count() })
.from(inspectionPhotos)
.where(
and(
eq(inspectionPhotos.inspectionId, dto.inspectionId),
eq(inspectionPhotos.isResume, true),
),
);
if (resumeCount[0]?.count >= 6) {
throw new Error('Maximum of 6 resume photos allowed per inspection');
}
}
// Create photo record in database
const now = new Date();
const [photo] = await this.databaseService.db
......@@ -51,6 +76,9 @@ export class InspectionPhotosService {
size: file.size,
url: filePaths[0],
description: dto.description,
order: dto.order || 0,
isPrincipal: dto.isPrincipal || false,
isResume: dto.isResume || false,
inspectionId: dto.inspectionId,
createdAt: now,
updatedAt: now,
......@@ -77,7 +105,7 @@ export class InspectionPhotosService {
.select()
.from(inspectionPhotos)
.where(whereCondition)
.orderBy(desc(inspectionPhotos.createdAt));
.orderBy(asc(inspectionPhotos.order), desc(inspectionPhotos.createdAt));
return photos.map(this.mapToDto);
}
......@@ -102,16 +130,60 @@ export class InspectionPhotosService {
id: number,
dto: UpdateInspectionPhotoDto,
): Promise<InspectionPhotoResponseDto> {
// First get the inspectionId of the photo being updated
const photoList = await this.databaseService.db
.select({ inspectionId: inspectionPhotos.inspectionId })
.from(inspectionPhotos)
.where(eq(inspectionPhotos.id, id))
.limit(1);
if (photoList.length === 0) {
throw new NotFoundException(`Inspection photo with ID ${id} not found`);
}
const inspectionId = photoList[0].inspectionId;
// If this photo is being set as principal, unset any existing principal photo
if (dto.isPrincipal) {
await this.databaseService.db
.update(inspectionPhotos)
.set({ isPrincipal: false })
.where(eq(inspectionPhotos.inspectionId, inspectionId));
}
// If this photo is being set as resume, check if we already have 6 resume photos
if (dto.isResume) {
// First check if the current photo is already a resume photo
const currentPhoto = await this.databaseService.db
.select({ isResume: inspectionPhotos.isResume })
.from(inspectionPhotos)
.where(eq(inspectionPhotos.id, id))
.limit(1);
// Only count other resume photos if the current photo is not already a resume photo
if (!currentPhoto[0]?.isResume) {
const resumeCount = await this.databaseService.db
.select({ count: count() })
.from(inspectionPhotos)
.where(
and(
eq(inspectionPhotos.inspectionId, inspectionId),
eq(inspectionPhotos.isResume, true),
),
);
if (resumeCount[0]?.count >= 6) {
throw new Error('Maximum of 6 resume photos allowed per inspection');
}
}
}
const [updatedPhoto] = await this.databaseService.db
.update(inspectionPhotos)
.set(dto)
.where(eq(inspectionPhotos.id, id))
.returning();
if (!updatedPhoto) {
throw new NotFoundException(`Inspection photo with ID ${id} not found`);
}
return this.mapToDto(updatedPhoto);
}
......@@ -122,7 +194,7 @@ export class InspectionPhotosService {
.select()
.from(inspectionPhotos)
.where(eq(inspectionPhotos.inspectionId, inspectionId))
.orderBy(desc(inspectionPhotos.createdAt));
.orderBy(asc(inspectionPhotos.order), desc(inspectionPhotos.createdAt));
return photos.map(this.mapToDto);
}
......@@ -166,6 +238,9 @@ export class InspectionPhotosService {
size: photo.size,
url: photo.url,
description: photo.description,
order: photo.order,
isPrincipal: photo.isPrincipal,
isResume: photo.isResume,
inspectionId: photo.inspectionId,
createdAt: photo.createdAt,
updatedAt: photo.updatedAt,
......
......@@ -2,10 +2,7 @@ import { InspectionResponseOption } from './inspection-response-option.enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
// Import the status enum from Prisma
import {
inspectionStatusEnum,
finalCommentStatusEnum,
} from '../../../database/schema';
import { inspectionStatusEnum } from '../../../database/schema';
export class InspectionQuestionDto {
@ApiProperty({
......@@ -130,11 +127,11 @@ export class InspectionDto {
@ApiPropertyOptional({
description: 'Status of the final comment validation',
enum: finalCommentStatusEnum.enumValues,
enum: inspectionStatusEnum.enumValues,
example: 'PENDING',
enumName: 'FinalCommentStatus',
enumName: 'InspectionStatus',
})
finalCommentStatus?: (typeof finalCommentStatusEnum.enumValues)[number];
finalCommentStatus?: (typeof inspectionStatusEnum.enumValues)[number];
@ApiProperty({
description: 'ID of the site where inspection was performed',
......@@ -165,6 +162,36 @@ export class InspectionDto {
})
updatedAt: Date;
@ApiPropertyOptional({
description: 'User who submitted the inspection to APPROVING status',
type: 'object',
properties: {
id: { type: 'number', example: 1 },
name: { type: 'string', example: 'John Doe' },
email: { type: 'string', example: 'john.doe@example.com' },
},
})
submittedBy?: {
id: number;
name: string;
email: string;
};
@ApiPropertyOptional({
description: 'User who approved the inspection',
type: 'object',
properties: {
id: { type: 'number', example: 2 },
name: { type: 'string', example: 'Jane Smith' },
email: { type: 'string', example: 'jane.smith@example.com' },
},
})
approvedBy?: {
id: number;
name: string;
email: string;
};
@ApiProperty({
description: 'Responses to inspection questions',
type: [InspectionResponseDto],
......
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
......@@ -687,4 +688,75 @@ export class InspectionController {
validationDto.comment,
);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@ApiOperation({
summary: 'Delete a specific inspection',
description:
'Deletes an inspection record and all its related data (responses and photos). Only ADMIN and SUPERADMIN roles can delete inspections.',
})
@ApiParam({
name: 'id',
type: 'number',
description: 'Inspection ID to delete',
})
@ApiResponse({
status: 200,
description: 'The inspection has been successfully deleted.',
schema: {
type: 'object',
properties: {
message: {
type: 'string',
example: 'Inspection with ID 1 has been deleted',
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Inspection not found.' })
async deleteInspection(@Param('id', ParseIntPipe) id: number) {
return this.inspectionService.deleteInspection(id);
}
@Delete()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.SUPERADMIN)
@ApiOperation({
summary: 'Delete all inspections',
description:
'Deletes all inspection records and their related data (responses and photos). Only SUPERADMIN role can perform this action.',
})
@ApiResponse({
status: 200,
description: 'All inspections have been successfully deleted.',
schema: {
type: 'object',
properties: {
message: {
type: 'string',
example: 'All 25 inspections have been deleted',
},
deletedCount: {
type: 'number',
example: 25,
description: 'Number of inspections that were deleted',
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Only SUPERADMIN can delete all inspections.',
})
async deleteAllInspections() {
return this.inspectionService.deleteAllInspections();
}
}
......@@ -262,8 +262,59 @@ export class InspectionService {
.where(whereCondition)
.orderBy(desc(inspections.createdAt));
// Fetch user information for submittedBy and approvedBy
const inspectionIds = inspectionList.map((i) => i.id);
const userInfo = new Map();
if (inspectionIds.length > 0) {
// Get submittedBy users
const submittedByUsers = await this.databaseService.db
.select({
inspectionId: inspections.id,
userId: users.id,
userName: users.name,
userEmail: users.email,
})
.from(inspections)
.leftJoin(users, eq(users.id, inspections.submittedById))
.where(inArray(inspections.id, inspectionIds));
// Get approvedBy users
const approvedByUsers = await this.databaseService.db
.select({
inspectionId: inspections.id,
userId: users.id,
userName: users.name,
userEmail: users.email,
})
.from(inspections)
.leftJoin(users, eq(users.id, inspections.approvedById))
.where(inArray(inspections.id, inspectionIds));
// Build user info map
submittedByUsers.forEach((user) => {
if (user.userId) {
userInfo.set(`${user.inspectionId}_submitted`, {
id: user.userId,
name: user.userName,
email: user.userEmail,
});
}
});
approvedByUsers.forEach((user) => {
if (user.userId) {
userInfo.set(`${user.inspectionId}_approved`, {
id: user.userId,
name: user.userName,
email: user.userEmail,
});
}
});
}
return inspectionList.map((inspection) =>
this.mapToInspectionDto(inspection),
this.mapToInspectionDto(inspection, [], [], userInfo),
);
}
......@@ -305,7 +356,50 @@ export class InspectionService {
.from(inspectionPhotos)
.where(eq(inspectionPhotos.inspectionId, id));
return this.mapToInspectionDto(inspection, responses, photos);
// Fetch user information for submittedBy and approvedBy
const userInfo = new Map();
// Get submittedBy user
const submittedByUser = await this.databaseService.db
.select({
userId: users.id,
userName: users.name,
userEmail: users.email,
})
.from(inspections)
.leftJoin(users, eq(users.id, inspections.submittedById))
.where(eq(inspections.id, id))
.limit(1);
// Get approvedBy user
const approvedByUser = await this.databaseService.db
.select({
userId: users.id,
userName: users.name,
userEmail: users.email,
})
.from(inspections)
.leftJoin(users, eq(users.id, inspections.approvedById))
.where(eq(inspections.id, id))
.limit(1);
if (submittedByUser[0]?.userId) {
userInfo.set(`${id}_submitted`, {
id: submittedByUser[0].userId,
name: submittedByUser[0].userName,
email: submittedByUser[0].userEmail,
});
}
if (approvedByUser[0]?.userId) {
userInfo.set(`${id}_approved`, {
id: approvedByUser[0].userId,
name: approvedByUser[0].userName,
email: approvedByUser[0].userEmail,
});
}
return this.mapToInspectionDto(inspection, responses, photos, userInfo);
}
async getInspectionQuestions() {
......@@ -607,12 +701,24 @@ export class InspectionService {
// For now, implementing basic CRUD operations to get the module working
async updateInspectionStatus(id: number, status: string, userId: number) {
const updateData: any = {
status: status as any,
updatedById: userId,
};
// Set submittedById when status changes to APPROVING
if (status === 'APPROVING') {
updateData.submittedById = userId;
}
// Set approvedById when status changes to APPROVED
if (status === 'APPROVED') {
updateData.approvedById = userId;
}
const [updatedInspection] = await this.databaseService.db
.update(inspections)
.set({
status: status as any,
updatedById: userId,
})
.set(updateData)
.where(eq(inspections.id, id))
.returning();
......@@ -690,10 +796,10 @@ export class InspectionService {
// Set finalCommentStatus based on user role
if (userRole === 'OPERATOR') {
// Operators can create/update final comments, but they need validation
updateData.finalCommentStatus = 'PENDING';
updateData.finalCommentStatus = 'APPROVING';
} else if (userRole === 'PARTNER') {
// Partners can validate final comments
updateData.finalCommentStatus = 'VALIDATED';
updateData.finalCommentStatus = 'APPROVED';
}
// Other roles (ADMIN, SUPERADMIN, MANAGER) can see validated comments but don't change status
......@@ -772,14 +878,14 @@ export class InspectionService {
);
}
// Check if the final comment is in PENDING status
if (inspection.finalCommentStatus !== 'PENDING') {
// Check if the final comment is in APPROVING status
if (inspection.finalCommentStatus !== 'APPROVING') {
throw new ForbiddenException(
'Final comment is not in PENDING status for validation',
'Final comment is not in APPROVING status for validation',
);
}
const newStatus = action === 'validate' ? 'VALIDATED' : 'REJECTED';
const newStatus = action === 'validate' ? 'APPROVED' : 'REJECTED';
const [updatedInspection] = await this.databaseService.db
.update(inspections)
......@@ -851,7 +957,11 @@ export class InspectionService {
inspection: any,
responses: any[] = [],
photos: any[] = [],
userInfo?: Map<string, any>,
): InspectionDto {
const submittedBy = userInfo?.get(`${inspection.id}_submitted`);
const approvedBy = userInfo?.get(`${inspection.id}_approved`);
return {
id: inspection.id,
date: inspection.date,
......@@ -863,6 +973,8 @@ export class InspectionService {
status: inspection.status,
createdAt: inspection.createdAt,
updatedAt: inspection.updatedAt,
submittedBy: submittedBy || null,
approvedBy: approvedBy || null,
responses: responses.map((response) => ({
id: response.id,
response: response.response,
......@@ -877,4 +989,59 @@ export class InspectionService {
})),
};
}
async deleteInspection(id: number): Promise<{ message: string }> {
// Check if inspection exists
const existingInspection = await this.databaseService.db
.select()
.from(inspections)
.where(eq(inspections.id, id))
.limit(1);
if (existingInspection.length === 0) {
throw new NotFoundException(`Inspection with ID ${id} not found`);
}
// Delete related records first (inspection responses and photos)
await this.databaseService.db
.delete(inspectionResponses)
.where(eq(inspectionResponses.inspectionId, id));
await this.databaseService.db
.delete(inspectionPhotos)
.where(eq(inspectionPhotos.inspectionId, id));
// Delete the inspection
await this.databaseService.db
.delete(inspections)
.where(eq(inspections.id, id));
return { message: `Inspection with ID ${id} has been deleted` };
}
async deleteAllInspections(): Promise<{
message: string;
deletedCount: number;
}> {
// Get count before deletion
const [countResult] = await this.databaseService.db
.select({ count: count() })
.from(inspections);
const deletedCount = Number(countResult.count);
// Delete all inspection responses
await this.databaseService.db.delete(inspectionResponses);
// Delete all inspection photos
await this.databaseService.db.delete(inspectionPhotos);
// Delete all inspections
await this.databaseService.db.delete(inspections);
return {
message: `All ${deletedCount} inspections have been deleted`,
deletedCount,
};
}
}
......@@ -28,4 +28,13 @@ export class CreatePartnerDto {
@IsOptional()
@IsBoolean()
isActive?: boolean = true;
@ApiProperty({
description: 'URL to the partner logo image',
example: '/uploads/logos/partner123_logo.png',
required: false,
})
@IsOptional()
@IsString()
logo?: string;
}
......@@ -40,6 +40,13 @@ export class PartnerResponseDto {
isActive: boolean;
@ApiProperty({
description: 'URL to the partner logo image',
example: '/uploads/logos/partner123_logo.png',
required: false,
})
logo?: string;
@ApiProperty({
description: 'Partner creation timestamp',
example: '2023-05-13T15:25:41.358Z',
})
......
......@@ -23,4 +23,11 @@ export class UpdatePartnerDto extends PartialType(CreatePartnerDto) {
required: false,
})
isActive?: boolean;
@ApiProperty({
description: 'URL to the partner logo image',
example: '/uploads/logos/partner123_logo.png',
required: false,
})
logo?: string;
}
......@@ -8,13 +8,18 @@ import {
Delete,
ParseIntPipe,
UseGuards,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { PartnersService } from './partners.service';
import { CreatePartnerDto, UpdatePartnerDto, PartnerResponseDto } from './dto';
......@@ -24,6 +29,8 @@ 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 { multerConfig } from '../../common/multer/multer.config';
import { savePartnerLogo } from '../../common/utils/file-upload.utils';
// Role enum values as constants
const Role = {
ADMIN: 'ADMIN',
......@@ -281,4 +288,69 @@ export class PartnersController {
) {
return this.partnersService.switchPartnerForSite(siteId, newPartnerId);
}
@Post(':id/logo')
@Roles(Role.ADMIN, Role.SUPERADMIN)
@UseInterceptors(FileInterceptor('logo', multerConfig))
@ApiOperation({
summary: 'Upload partner logo',
description:
'Uploads a logo image for a partner. Only users with ADMIN or SUPERADMIN roles can upload logos.',
})
@ApiResponse({
status: 200,
description: 'The logo has been successfully uploaded.',
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Partner not found.' })
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Logo upload data',
schema: {
type: 'object',
required: ['logo'],
properties: {
logo: {
type: 'string',
format: 'binary',
description: 'Logo image file (max 5MB, images only)',
},
},
},
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the partner',
example: 1,
})
async uploadPartnerLogo(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
) {
try {
if (!file) {
throw new Error('No file provided. Please upload a logo file.');
}
// Save the logo file
const logoUrl = await savePartnerLogo(file, id);
// Update the partner with the logo URL
await this.partnersService.update(id, { logo: logoUrl });
return {
message: 'Logo uploaded successfully',
logoUrl,
};
} catch (error) {
console.error('Error uploading partner logo:', error);
throw error;
}
}
}
......@@ -161,7 +161,7 @@ export class PartnersService {
.update(users)
.set({
partnerId,
role: 'OPERATOR', // Set role to OPERATOR by default (normal partner user)
// Don't change the user's role - keep their existing role
})
.where(eq(users.id, userId))
.returning();
......
......@@ -47,4 +47,13 @@ export class CreateSiteDto {
@Min(-180)
@Max(180)
longitude: number;
@ApiPropertyOptional({
description: 'Partner ID to associate with this site',
example: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
partnerId?: number;
}
......@@ -11,6 +11,20 @@ export class UserResponseDto {
email: string;
}
export class PartnerResponseDto {
@ApiProperty({ description: 'Partner ID' })
id: number;
@ApiProperty({ description: 'Partner name' })
name: string;
@ApiProperty({ description: 'Partner description', required: false })
description?: string;
@ApiProperty({ description: 'Partner active status' })
isActive: boolean;
}
export class SiteResponseDto {
@ApiProperty({ description: 'Site ID' })
id: number;
......@@ -58,6 +72,14 @@ export class SiteResponseDto {
updatedBy: UserResponseDto;
@ApiProperty({
description: 'Partner associated with this site',
type: PartnerResponseDto,
required: false,
nullable: true,
})
partner?: PartnerResponseDto | null;
@ApiProperty({
description: 'Number of candidates associated with this site',
})
_count?: {
......
......@@ -85,7 +85,7 @@ export class SitesController {
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({
summary: 'Get all sites for list view (with pagination)',
description:
......@@ -117,7 +117,23 @@ export class SitesController {
})
async findAll(@Query() findSitesDto: FindSitesDto, @Request() req) {
const partnerId =
req.user.role === Role.PARTNER ? req.user.partnerId : null;
req.user.role === Role.PARTNER || req.user.role === Role.OPERATOR
? req.user.partnerId
: null;
// Debug logging to help identify the issue
if (
(req.user.role === Role.PARTNER || req.user.role === Role.OPERATOR) &&
!partnerId
) {
console.log(
'⚠️ User with role',
req.user.role,
'does not have partnerId set:',
req.user,
);
}
return this.sitesService.findAll(findSitesDto, partnerId);
}
......@@ -161,9 +177,19 @@ export class SitesController {
@Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null,
@User('role') role: string,
@Request() req,
) {
// For PARTNER role, we restrict access to only see sites associated with their partnerId
if (role === Role.PARTNER) {
// For PARTNER and OPERATOR roles, we restrict access to only see sites associated with their partnerId
if (role === Role.PARTNER || role === Role.OPERATOR) {
// Debug logging to help identify the issue
if (!partnerId) {
console.log(
'⚠️ User with role',
role,
'does not have partnerId set:',
req.user,
);
}
return this.sitesService.findOneFilteredByPartner(id, partnerId);
}
return this.sitesService.findOne(id);
......@@ -197,8 +223,8 @@ export class SitesController {
@User('role') role: string,
@Query('partnerId') filterPartnerId?: number,
) {
// For PARTNER role, we restrict access to only see candidates created with their partnerId
if (role === Role.PARTNER) {
// For PARTNER and OPERATOR roles, we restrict access to only see candidates created with their partnerId
if (role === Role.PARTNER || role === Role.OPERATOR) {
return this.sitesService.findOneWithCandidates(id, partnerId);
}
// If a specific partnerId is provided in the query, use that for filtering
......@@ -223,6 +249,26 @@ export class SitesController {
return this.sitesService.update(id, updateSiteDto, userId);
}
@Patch(':id/partner')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
@ApiOperation({ summary: 'Update site partner association' })
@ApiResponse({
status: 200,
description: 'The site partner association has been successfully updated.',
})
@ApiResponse({ status: 404, description: 'Site or partner not found.' })
@ApiResponse({
status: 409,
description: 'Site already associated with this partner.',
})
updateSitePartner(
@Param('id', ParseIntPipe) id: number,
@Body('partnerId') partnerId: number | null,
@User('id') userId: number,
) {
return this.sitesService.updateSitePartner(id, partnerId, userId);
}
@Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Delete a site' })
......
......@@ -48,7 +48,7 @@ export class TelecommunicationStationIdentificationController {
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@Roles(Role.ADMIN, Role.SUPERADMIN, Role.OPERATOR, Role.PARTNER)
@ApiOperation({
summary: 'Create a new telecommunication station identification',
})
......@@ -234,7 +234,7 @@ export class TelecommunicationStationIdentificationController {
@Put(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPERADMIN)
@Roles(Role.ADMIN, Role.SUPERADMIN, Role.OPERATOR, Role.PARTNER)
@ApiOperation({
summary: 'Update a telecommunication station identification',
})
......
......@@ -5,6 +5,7 @@ import {
IsNotEmpty,
IsString,
MinLength,
IsOptional,
} from 'class-validator';
import { roleEnum } from '../../../database/schema';
......@@ -43,4 +44,13 @@ export class CreateUserDto {
})
@IsEnum(roleEnum.enumValues)
role: (typeof roleEnum.enumValues)[number] = 'VIEWER';
@ApiProperty({
description: 'URL to the user signature image',
example: '/uploads/signatures/user123_signature.png',
required: false,
})
@IsOptional()
@IsString()
signature?: string;
}
......@@ -16,4 +16,13 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
@IsOptional()
@IsDate()
resetTokenExpiry?: Date | null;
@ApiProperty({
description: 'URL to the user signature image',
example: '/uploads/signatures/user123_signature.png',
required: false,
})
@IsOptional()
@IsString()
signature?: string;
}
......@@ -9,7 +9,10 @@ import {
ParseIntPipe,
UseGuards,
Query,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
......@@ -20,6 +23,9 @@ import {
ApiResponse,
ApiBearerAuth,
ApiQuery,
ApiConsumes,
ApiBody,
ApiParam,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator';
......@@ -34,6 +40,8 @@ const Role = {
PARTNER: 'PARTNER',
} as const;
import { User } from '../auth/decorators/user.decorator';
import { multerConfig } from '../../common/multer/multer.config';
import { saveUserSignature } from '../../common/utils/file-upload.utils';
@ApiTags('users')
@Controller('users')
......@@ -203,4 +211,76 @@ export class UsersController {
) {
return this.usersService.deactivateUser(id, role);
}
@Post(':id/signature')
@Roles(
Role.SUPERADMIN,
Role.ADMIN,
Role.MANAGER,
Role.PARTNER,
Role.OPERATOR,
Role.VIEWER,
)
@UseInterceptors(FileInterceptor('signature', multerConfig))
@ApiOperation({
summary: 'Upload user signature',
description:
'Uploads a signature image for a user. Only users with SUPERADMIN, ADMIN, or MANAGER roles can upload signatures.',
})
@ApiResponse({
status: 200,
description: 'The signature has been successfully uploaded.',
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Signature upload data',
schema: {
type: 'object',
required: ['signature'],
properties: {
signature: {
type: 'string',
format: 'binary',
description: 'Signature image file (max 5MB, images only)',
},
},
},
})
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the user',
example: 1,
})
async uploadUserSignature(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
) {
try {
if (!file) {
throw new Error('No file provided. Please upload a signature file.');
}
// Save the signature file
const signatureUrl = await saveUserSignature(file, id);
// Update the user with the signature URL
await this.usersService.update(id, { signature: signatureUrl });
return {
message: 'Signature uploaded successfully',
signatureUrl,
};
} catch (error) {
console.error('Error uploading user signature:', error);
throw error;
}
}
}
......@@ -142,6 +142,7 @@ export class UsersService {
email: users.email,
name: users.name,
role: users.role,
signature: users.signature,
isActive: users.isActive,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
......@@ -172,9 +173,11 @@ export class UsersService {
email: users.email,
name: users.name,
role: users.role,
signature: users.signature,
isActive: users.isActive,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
})
.from(users)
.where(eq(users.id, id))
......@@ -207,6 +210,7 @@ export class UsersService {
isActive: users.isActive,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
});
if (updatedUsers.length === 0) {
......@@ -282,6 +286,7 @@ export class UsersService {
isActive: users.isActive,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
});
if (updatedUsers.length === 0) {
......@@ -315,6 +320,7 @@ export class UsersService {
isActive: users.isActive,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
});
if (updatedUsers.length === 0) {
......@@ -337,6 +343,7 @@ export class UsersService {
isActive: users.isActive,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
})
.from(users)
.where(eq(users.isActive, false));
......
-- Test script to verify the resume photo constraint
-- This script tests various scenarios to ensure the constraint works correctly
-- Test 1: Insert 6 resume photos (should succeed)
INSERT INTO "InspectionPhoto" ("url", "filename", "mimeType", "size", "inspectionId", "isResume", "order", "isPrincipal", "createdAt", "updatedAt")
VALUES
('/test1.jpg', 'test1.jpg', 'image/jpeg', 1000, 1, true, 1, false, NOW(), NOW()),
('/test2.jpg', 'test2.jpg', 'image/jpeg', 1000, 1, true, 2, false, NOW(), NOW()),
('/test3.jpg', 'test3.jpg', 'image/jpeg', 1000, 1, true, 3, false, NOW(), NOW()),
('/test4.jpg', 'test4.jpg', 'image/jpeg', 1000, 1, true, 4, false, NOW(), NOW()),
('/test5.jpg', 'test5.jpg', 'image/jpeg', 1000, 1, true, 5, false, NOW(), NOW()),
('/test6.jpg', 'test6.jpg', 'image/jpeg', 1000, 1, true, 6, false, NOW(), NOW());
-- Test 2: Try to insert a 7th resume photo (should fail)
INSERT INTO "InspectionPhoto" ("url", "filename", "mimeType", "size", "inspectionId", "isResume", "order", "isPrincipal", "createdAt", "updatedAt")
VALUES ('/test7.jpg', 'test7.jpg', 'image/jpeg', 1000, 1, true, 7, false, NOW(), NOW());
-- Test 3: Try to update a non-resume photo to resume (should fail if already 6 resume photos)
UPDATE "InspectionPhoto" SET "isResume" = true WHERE "filename" = 'non-resume.jpg' AND "inspectionId" = 1;
-- Test 4: Update an existing resume photo (should succeed)
UPDATE "InspectionPhoto" SET "order" = 10 WHERE "filename" = 'test1.jpg' AND "inspectionId" = 1;
-- Test 5: Set a resume photo to non-resume (should succeed)
UPDATE "InspectionPhoto" SET "isResume" = false WHERE "filename" = 'test1.jpg' AND "inspectionId" = 1;
-- Test 6: Try to set a non-resume photo to resume (should succeed now that we have 5 resume photos)
UPDATE "InspectionPhoto" SET "isResume" = true WHERE "filename" = 'non-resume.jpg' AND "inspectionId" = 1;
-- Clean up test data
DELETE FROM "InspectionPhoto" WHERE "inspectionId" = 1;
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