Commit 2d6285a3 by Augusto

small features for user, inspection and inspection-photos

parent ffe7ce06
ALTER TABLE "Inspection" ADD COLUMN "partnerId" integer NOT NULL;--> statement-breakpoint
ALTER TABLE "Inspection" ADD CONSTRAINT "Inspection_partnerId_Partner_id_fk" FOREIGN KEY ("partnerId") REFERENCES "public"."Partner"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "Inspection_partnerId_idx" ON "Inspection" USING btree ("partnerId");
\ No newline at end of file
ALTER TABLE "InspectionPhoto" ADD COLUMN "isAnomaly" boolean DEFAULT false NOT NULL;
\ No newline at end of file
ALTER TABLE "User" ADD COLUMN "formationValidation" timestamp;
\ No newline at end of file
ALTER TABLE "Inspection" ADD COLUMN "materialList" json;
\ No newline at end of file
......@@ -64,6 +64,34 @@
"when": 1759738883201,
"tag": "0008_adorable_quentin_quire",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1759825456628,
"tag": "0009_quiet_krista_starr",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1759914221257,
"tag": "0010_curly_doomsday",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1759914481385,
"tag": "0011_solid_hammerhead",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1759914965328,
"tag": "0012_nervous_havok",
"breakpoints": true
}
]
}
\ No newline at end of file
import { relations } from "drizzle-orm/relations";
import { user, refreshToken, site, partner, userSite, inspectionQuestion, inspectionResponse, inspection, inspectionPhoto, telecommunicationStationIdentification } from "./schema";
export const refreshTokenRelations = relations(refreshToken, ({one}) => ({
user: one(user, {
fields: [refreshToken.userId],
references: [user.id]
}),
}));
export const userRelations = relations(user, ({one, many}) => ({
refreshTokens: many(refreshToken),
sites_createdById: many(site, {
relationName: "site_createdById_user_id"
}),
sites_updatedById: many(site, {
relationName: "site_updatedById_user_id"
}),
partner: one(partner, {
fields: [user.partnerId],
references: [partner.id]
}),
userSites: many(userSite),
inspections_createdById: many(inspection, {
relationName: "inspection_createdById_user_id"
}),
inspections_updatedById: many(inspection, {
relationName: "inspection_updatedById_user_id"
}),
}));
export const siteRelations = relations(site, ({one, many}) => ({
user_createdById: one(user, {
fields: [site.createdById],
references: [user.id],
relationName: "site_createdById_user_id"
}),
user_updatedById: one(user, {
fields: [site.updatedById],
references: [user.id],
relationName: "site_updatedById_user_id"
}),
userSites: many(userSite),
telecommunicationStationIdentifications: many(telecommunicationStationIdentification),
inspections: many(inspection),
}));
export const partnerRelations = relations(partner, ({many}) => ({
users: many(user),
}));
export const userSiteRelations = relations(userSite, ({one}) => ({
user: one(user, {
fields: [userSite.userId],
references: [user.id]
}),
site: one(site, {
fields: [userSite.siteId],
references: [site.id]
}),
}));
export const inspectionResponseRelations = relations(inspectionResponse, ({one}) => ({
inspectionQuestion: one(inspectionQuestion, {
fields: [inspectionResponse.questionId],
references: [inspectionQuestion.id]
}),
inspection: one(inspection, {
fields: [inspectionResponse.inspectionId],
references: [inspection.id]
}),
}));
export const inspectionQuestionRelations = relations(inspectionQuestion, ({many}) => ({
inspectionResponses: many(inspectionResponse),
}));
export const inspectionRelations = relations(inspection, ({one, many}) => ({
inspectionResponses: many(inspectionResponse),
inspectionPhotos: many(inspectionPhoto),
site: one(site, {
fields: [inspection.siteId],
references: [site.id]
}),
user_createdById: one(user, {
fields: [inspection.createdById],
references: [user.id],
relationName: "inspection_createdById_user_id"
}),
user_updatedById: one(user, {
fields: [inspection.updatedById],
references: [user.id],
relationName: "inspection_updatedById_user_id"
}),
}));
export const inspectionPhotoRelations = relations(inspectionPhoto, ({one}) => ({
inspection: one(inspection, {
fields: [inspectionPhoto.inspectionId],
references: [inspection.id]
}),
}));
export const telecommunicationStationIdentificationRelations = relations(telecommunicationStationIdentification, ({one}) => ({
site: one(site, {
fields: [telecommunicationStationIdentification.siteId],
references: [site.id]
}),
}));
\ No newline at end of file
-- Migration to add partnerId to inspections table
-- This migration handles existing data by populating partnerId from current partner-site associations
-- Step 1: Add the column as nullable first
ALTER TABLE "Inspection" ADD COLUMN "partnerId" integer;
-- Step 2: Populate existing inspections with partnerId from current partner-site associations
UPDATE "Inspection"
SET "partnerId" = (
SELECT ps."partnerId"
FROM "PartnerSite" ps
WHERE ps."siteId" = "Inspection"."siteId"
LIMIT 1
)
WHERE "partnerId" IS NULL;
-- Step 3: For any inspections that still don't have a partnerId (sites without partners),
-- we need to either delete them or assign a default partner
-- Let's check if there are any such inspections first
-- If there are inspections without partners, we'll need to handle them
-- Step 4: Check for any remaining NULL values and handle them
-- Option 1: Delete inspections without partners (if that's acceptable)
-- DELETE FROM "Inspection" WHERE "partnerId" IS NULL;
-- Option 2: Assign a default partner (you'll need to create one if it doesn't exist)
-- First, let's create a default partner if it doesn't exist
INSERT INTO "Partner" ("name", "description", "isActive", "createdAt", "updatedAt")
SELECT 'Default Partner', 'Default partner for historical inspections', true, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM "Partner" WHERE "name" = 'Default Partner');
-- Then assign this default partner to any remaining inspections without partners
UPDATE "Inspection"
SET "partnerId" = (SELECT id FROM "Partner" WHERE "name" = 'Default Partner' LIMIT 1)
WHERE "partnerId" IS NULL;
-- Step 5: Now we can safely make the column NOT NULL
ALTER TABLE "Inspection" ALTER COLUMN "partnerId" SET NOT NULL;
-- Step 6: Add foreign key constraint
ALTER TABLE "Inspection" ADD CONSTRAINT "Inspection_partnerId_Partner_id_fk"
FOREIGN KEY ("partnerId") REFERENCES "public"."Partner"("id") ON DELETE cascade ON UPDATE no action;
-- Step 7: Create index
CREATE INDEX "Inspection_partnerId_idx" ON "Inspection" USING btree ("partnerId");
......@@ -10,6 +10,7 @@ import {
pgEnum,
index,
uniqueIndex,
json,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
......@@ -56,6 +57,7 @@ export const users = pgTable(
password: varchar('password', { length: 255 }).notNull(),
role: roleEnum('role').notNull().default('VIEWER'),
signature: varchar('signature', { length: 500 }),
formationValidation: timestamp('formationValidation'),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
resetToken: varchar('resetToken', { length: 255 }),
......@@ -185,9 +187,13 @@ export const inspections = pgTable(
finalComment: text('finalComment'),
finalCommentStatus:
inspectionStatusEnum('finalCommentStatus').default('PENDING'),
materialList: json('materialList'),
siteId: integer('siteId')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
partnerId: integer('partnerId')
.notNull()
.references(() => partners.id, { onDelete: 'cascade' }),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
createdById: integer('createdById').references(() => users.id),
......@@ -198,6 +204,7 @@ export const inspections = pgTable(
},
(table) => ({
siteIdx: index('Inspection_siteId_idx').on(table.siteId),
partnerIdx: index('Inspection_partnerId_idx').on(table.partnerId),
createdByIdx: index('Inspection_createdById_idx').on(table.createdById),
updatedByIdx: index('Inspection_updatedById_idx').on(table.updatedById),
submittedByIdx: index('Inspection_submittedById_idx').on(
......@@ -252,6 +259,7 @@ export const inspectionPhotos = pgTable(
order: integer('order').notNull().default(0),
isPrincipal: boolean('isPrincipal').notNull().default(false),
isResume: boolean('isResume').notNull().default(false),
isAnomaly: boolean('isAnomaly').notNull().default(false),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
inspectionId: integer('inspectionId')
......@@ -317,6 +325,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({
export const partnersRelations = relations(partners, ({ many }) => ({
users: many(users),
sites: many(partnerSites),
inspections: many(inspections),
}));
export const refreshTokensRelations = relations(refreshTokens, ({ one }) => ({
......@@ -379,6 +388,10 @@ export const inspectionsRelations = relations(inspections, ({ one, many }) => ({
fields: [inspections.siteId],
references: [sites.id],
}),
partner: one(partners, {
fields: [inspections.partnerId],
references: [partners.id],
}),
createdBy: one(users, {
fields: [inspections.createdById],
references: [users.id],
......
......@@ -64,4 +64,13 @@ export class CreateInspectionPhotoDto {
@IsOptional()
@IsBoolean()
isResume?: boolean;
@ApiPropertyOptional({
description: 'Whether this photo represents an anomaly in the inspection',
example: false,
type: Boolean,
})
@IsOptional()
@IsBoolean()
isAnomaly?: boolean;
}
......@@ -68,6 +68,13 @@ export class InspectionPhotoResponseDto {
isResume: boolean;
@ApiProperty({
description: 'Whether this photo represents an anomaly in the inspection',
example: false,
type: Boolean,
})
isAnomaly: boolean;
@ApiProperty({
description: 'ID of the inspection this photo belongs to',
example: 1,
type: Number,
......
......@@ -47,4 +47,13 @@ export class UpdateInspectionPhotoDto {
@IsOptional()
@IsBoolean()
isResume?: boolean;
@ApiPropertyOptional({
description: 'Whether this photo represents an anomaly in the inspection',
example: true,
type: Boolean,
})
@IsOptional()
@IsBoolean()
isAnomaly?: boolean;
}
......@@ -82,6 +82,12 @@ export class InspectionPhotosController {
example: 'Front view of the site entrance',
description: 'Optional description for the photo',
},
isAnomaly: {
type: 'boolean',
example: false,
description:
'Whether this photo represents an anomaly in the inspection',
},
photo: {
type: 'string',
format: 'binary',
......
......@@ -79,6 +79,7 @@ export class InspectionPhotosService {
order: dto.order || 0,
isPrincipal: dto.isPrincipal || false,
isResume: dto.isResume || false,
isAnomaly: dto.isAnomaly || false,
inspectionId: dto.inspectionId,
createdAt: now,
updatedAt: now,
......@@ -241,6 +242,7 @@ export class InspectionPhotosService {
order: photo.order,
isPrincipal: photo.isPrincipal,
isResume: photo.isResume,
isAnomaly: photo.isAnomaly,
inspectionId: photo.inspectionId,
createdAt: photo.createdAt,
updatedAt: photo.updatedAt,
......
......@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
ValidateNested,
IsObject,
} from 'class-validator';
import { Type } from 'class-transformer';
import { InspectionResponseOption } from './inspection-response-option.enum';
......@@ -78,6 +79,36 @@ export class CreateInspectionDto {
@IsOptional()
finalComment?: string;
@ApiPropertyOptional({
description:
'Material list data as JSON object - can contain various material information',
example: {
materials: [
{
id: 'MAT001',
name: 'Cable RG-6',
quantity: 50,
unit: 'meters',
condition: 'good',
},
{
id: 'MAT002',
name: 'Connector BNC',
quantity: 20,
unit: 'pieces',
condition: 'excellent',
},
],
totalItems: 2,
lastUpdated: '2025-01-15T10:30:00Z',
},
type: 'object',
additionalProperties: true,
})
@IsObject()
@IsOptional()
materialList?: any;
@ApiProperty({
description: 'Responses to inspection questions',
type: [CreateInspectionResponseDto],
......
......@@ -6,3 +6,4 @@ export * from './inspection-response-option.enum';
export * from './start-inspection.dto';
export * from './update-final-comment.dto';
export * from './update-inspection-status.dto';
export * from './update-material-list.dto';
......@@ -239,4 +239,51 @@ export class InspectionDto {
isArray: true,
})
photos: InspectionPhotoDto[];
@ApiPropertyOptional({
description:
'Material list data as JSON object - can contain various material information',
example: {
materials: [
{
id: 'MAT001',
name: 'Cable RG-6',
quantity: 50,
unit: 'meters',
condition: 'good',
},
{
id: 'MAT002',
name: 'Connector BNC',
quantity: 20,
unit: 'pieces',
condition: 'excellent',
},
],
totalItems: 2,
lastUpdated: '2025-01-15T10:30:00Z',
},
type: 'object',
additionalProperties: true,
})
materialList?: any;
@ApiPropertyOptional({
description: 'Partner information at the time of inspection',
type: 'object',
properties: {
id: { type: 'number', example: 1 },
name: { type: 'string', example: 'Partner Company' },
description: { type: 'string', example: 'Partner description' },
logo: { type: 'string', example: '/uploads/logos/partner_logo.png' },
isActive: { type: 'boolean', example: true },
},
})
partner?: {
id: number;
name: string;
description?: string;
logo?: string;
isActive: boolean;
};
}
import { IsObject, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateMaterialListDto {
@ApiPropertyOptional({
description:
'Material list data as JSON object - can contain various material information',
example: {
materials: [
{
id: 'MAT001',
name: 'Cable RG-6',
quantity: 50,
unit: 'meters',
condition: 'good',
},
{
id: 'MAT002',
name: 'Connector BNC',
quantity: 20,
unit: 'pieces',
condition: 'excellent',
},
],
totalItems: 2,
lastUpdated: '2025-01-15T10:30:00Z',
},
type: 'object',
additionalProperties: true,
})
@IsObject()
@IsOptional()
materialList?: any;
}
......@@ -36,6 +36,7 @@ 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';
import { UpdateMaterialListDto } from './dto/update-material-list.dto';
import { multerConfig } from '../../common/multer/multer.config';
import {
ApiTags,
......@@ -632,6 +633,83 @@ export class InspectionController {
);
}
@Patch(':id/material-list')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Update the material list of an inspection',
description:
'Updates the material list of an inspection with JSON data containing various material information.',
})
@ApiResponse({
status: 200,
description: 'The inspection material list has been successfully updated.',
schema: {
type: 'object',
properties: {
id: { type: 'number', example: 1 },
date: { type: 'string', format: 'date-time' },
deadline: { type: 'string', format: 'date-time', nullable: true },
comment: { type: 'string', nullable: true },
finalComment: { type: 'string', nullable: true },
finalCommentStatus: {
type: 'string',
enum: [
'PENDING',
'IN_PROGRESS',
'COMPLETED',
'CANCELLED',
'APPROVING',
'REJECTED',
'APPROVED',
],
},
materialList: { type: 'object', nullable: true },
siteId: { type: 'number' },
status: {
type: 'string',
enum: [
'PENDING',
'IN_PROGRESS',
'COMPLETED',
'CANCELLED',
'APPROVING',
'REJECTED',
'APPROVED',
],
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
submittedBy: { type: 'object', nullable: true },
approvedBy: { type: 'object', nullable: true },
partner: { type: 'object', nullable: true },
responses: { type: 'array', items: { type: 'object' } },
photos: { type: 'array', items: { type: 'object' } },
},
},
})
@ApiParam({
name: 'id',
description: 'The ID of the inspection to update',
type: 'number',
example: 1,
})
@ApiBody({
type: UpdateMaterialListDto,
description: 'Material list data to update',
})
async updateMaterialList(
@Param('id', ParseIntPipe) id: number,
@Body() updateMaterialListDto: UpdateMaterialListDto,
@Req() req,
) {
return this.inspectionService.updateMaterialList(
id,
updateMaterialListDto,
req.user.id,
);
}
@Patch(':id/final-comment/validate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.PARTNER)
......
......@@ -41,6 +41,7 @@ import {
InspectionPhotoDto,
} from './dto/inspection-response.dto';
import { InspectionResponseOption } from './dto/inspection-response-option.enum';
import { UpdateMaterialListDto } from './dto/update-material-list.dto';
import { saveInspectionPhotos } from './inspection.utils';
@Injectable()
......@@ -55,17 +56,28 @@ export class InspectionService {
userId: number,
files?: Express.Multer.File[],
): Promise<InspectionDto> {
// Check if site exists
const siteList = await this.databaseService.db
.select()
// Check if site exists and get its associated partner
const siteWithPartner = await this.databaseService.db
.select({
siteId: sites.id,
partnerId: partnerSites.partnerId,
})
.from(sites)
.leftJoin(partnerSites, eq(partnerSites.siteId, sites.id))
.where(eq(sites.id, dto.siteId))
.limit(1);
if (siteList.length === 0) {
if (siteWithPartner.length === 0) {
throw new NotFoundException(`Site with ID ${dto.siteId} not found`);
}
const site = siteWithPartner[0];
if (!site.partnerId) {
throw new NotFoundException(
`No partner associated with site ${dto.siteId}`,
);
}
// Create inspection record
const now = new Date();
const [inspection] = await this.databaseService.db
......@@ -74,7 +86,9 @@ export class InspectionService {
date: new Date(dto.date),
comment: dto.comment,
finalComment: dto.finalComment,
materialList: dto.materialList,
siteId: dto.siteId,
partnerId: site.partnerId, // Store the partner ID at the time of inspection
createdById: userId,
createdAt: now,
updatedAt: now,
......@@ -199,6 +213,7 @@ export class InspectionService {
deadline: new Date(dto.deadline),
comment: dto.comment || null,
siteId: dto.siteId,
partnerId: partnerId, // Store the partner ID at the time of inspection
createdById: userId,
createdAt: now,
updatedAt: now,
......@@ -324,8 +339,30 @@ export class InspectionService {
async findInspectionById(id: number): Promise<InspectionDto> {
const inspectionList = await this.databaseService.db
.select()
.select({
id: inspections.id,
date: inspections.date,
deadline: inspections.deadline,
comment: inspections.comment,
finalComment: inspections.finalComment,
finalCommentStatus: inspections.finalCommentStatus,
materialList: inspections.materialList,
siteId: inspections.siteId,
partnerId: inspections.partnerId,
status: inspections.status,
createdAt: inspections.createdAt,
updatedAt: inspections.updatedAt,
createdById: inspections.createdById,
updatedById: inspections.updatedById,
submittedById: inspections.submittedById,
approvedById: inspections.approvedById,
partnerName: partners.name,
partnerDescription: partners.description,
partnerLogo: partners.logo,
partnerIsActive: partners.isActive,
})
.from(inspections)
.leftJoin(partners, eq(partners.id, inspections.partnerId))
.where(eq(inspections.id, id))
.limit(1);
......@@ -471,6 +508,18 @@ export class InspectionService {
const nextDeadline = new Date(deadlineDate);
nextDeadline.setFullYear(nextDeadline.getFullYear() + 1);
// Get the partnerId for this site
const partnerSiteList = await this.databaseService.db
.select({ partnerId: partnerSites.partnerId })
.from(partnerSites)
.where(eq(partnerSites.siteId, inspectionData.inspection.siteId))
.limit(1);
if (partnerSiteList.length === 0) {
// Skip this site if no partner is associated
continue;
}
// Create new inspection with next deadline
const [newInspection] = await this.databaseService.db
.insert(inspections)
......@@ -479,6 +528,7 @@ export class InspectionService {
deadline: nextDeadline,
comment: null,
siteId: inspectionData.inspection.siteId,
partnerId: partnerSiteList[0].partnerId,
createdById: userId,
createdAt: now,
updatedAt: now,
......@@ -824,6 +874,40 @@ export class InspectionService {
return this.mapToInspectionDto(updatedInspection);
}
async updateMaterialList(
id: number,
dto: UpdateMaterialListDto,
userId: number,
): Promise<InspectionDto> {
// 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`);
}
// Update the materialList
const [updatedInspection] = await this.databaseService.db
.update(inspections)
.set({
materialList: dto.materialList,
updatedAt: new Date(),
updatedById: userId,
})
.where(eq(inspections.id, id))
.returning();
if (!updatedInspection) {
throw new NotFoundException(`Inspection with ID ${id} not found`);
}
return this.mapToInspectionDto(updatedInspection);
}
async validateFinalComment(
id: number,
action: 'validate' | 'reject',
......@@ -977,12 +1061,22 @@ export class InspectionService {
comment: inspection.comment,
finalComment: inspection.finalComment,
finalCommentStatus: inspection.finalCommentStatus,
materialList: inspection.materialList,
siteId: inspection.siteId,
status: inspection.status,
createdAt: inspection.createdAt,
updatedAt: inspection.updatedAt,
submittedBy: submittedBy || null,
approvedBy: approvedBy || null,
partner: inspection.partnerId
? {
id: inspection.partnerId,
name: inspection.partnerName,
description: inspection.partnerDescription,
logo: inspection.partnerLogo,
isActive: inspection.partnerIsActive,
}
: undefined,
responses: responses.map((response) => ({
id: response.id,
response: response.response,
......
......@@ -88,4 +88,15 @@ export class FindSitesDto {
@IsOptional()
@IsEnum(inspectionStatusEnum.enumValues)
inspectionStatus?: (typeof inspectionStatusEnum.enumValues)[number];
@ApiProperty({
required: false,
type: Number,
description: 'Filter sites by partner ID',
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
partnerId?: number;
}
......@@ -89,7 +89,7 @@ export class SitesController {
@ApiOperation({
summary: 'Get all sites for list view (with pagination)',
description:
'Returns paginated sites with applied filters and ordering. You can filter by siteCode, siteName, address, city, state, country, and inspectionStatus (PENDING, IN_PROGRESS, COMPLETED, CANCELLED, APPROVING, REJECTED, APPROVED).',
'Returns paginated sites with applied filters and ordering. You can filter by siteCode, siteName, address, city, state, country, partnerId, and inspectionStatus (PENDING, IN_PROGRESS, COMPLETED, CANCELLED, APPROVING, REJECTED, APPROVED).',
})
@ApiResponse({
status: 200,
......@@ -116,7 +116,9 @@ export class SitesController {
},
})
async findAll(@Query() findSitesDto: FindSitesDto, @Request() req) {
const partnerId =
// For PARTNER and OPERATOR roles, use their partnerId as default
// But allow query parameter to override it
const userPartnerId =
req.user.role === Role.PARTNER || req.user.role === Role.OPERATOR
? req.user.partnerId
: null;
......@@ -124,7 +126,7 @@ export class SitesController {
// Debug logging to help identify the issue
if (
(req.user.role === Role.PARTNER || req.user.role === Role.OPERATOR) &&
!partnerId
!userPartnerId
) {
console.log(
'⚠️ User with role',
......@@ -134,7 +136,7 @@ export class SitesController {
);
}
return this.sitesService.findAll(findSitesDto, partnerId);
return this.sitesService.findAll(findSitesDto, userPartnerId);
}
@Get('code/:siteCode')
......
......@@ -205,14 +205,22 @@ export class SitesService {
const siteWhereCondition =
siteConditions.length > 0 ? and(...siteConditions) : undefined;
// Determine which partnerId to use: query parameter takes precedence over user's partnerId
const effectivePartnerId = findSitesDto?.partnerId || partnerId;
// Get total count with partner filtering if needed
let countQuery;
if (partnerId) {
if (effectivePartnerId) {
countQuery = this.databaseService.db
.select({ totalCount: count() })
.from(sites)
.leftJoin(partnerSites, eq(partnerSites.siteId, sites.id))
.where(and(siteWhereCondition, eq(partnerSites.partnerId, partnerId)));
.where(
and(
siteWhereCondition,
eq(partnerSites.partnerId, effectivePartnerId),
),
);
} else {
countQuery = this.databaseService.db
.select({ totalCount: count() })
......@@ -260,8 +268,11 @@ export class SitesService {
.leftJoin(partners, eq(partners.id, partnerSites.partnerId))
.where(
and(
partnerId
? and(siteWhereCondition, eq(partnerSites.partnerId, partnerId))
effectivePartnerId
? and(
siteWhereCondition,
eq(partnerSites.partnerId, effectivePartnerId),
)
: siteWhereCondition,
// Apply inspection status filter in WHERE clause to exclude null values
findSitesDto?.inspectionStatus
......
......@@ -6,6 +6,7 @@ import {
IsString,
MinLength,
IsOptional,
IsDate,
} from 'class-validator';
import { roleEnum } from '../../../database/schema';
......@@ -53,4 +54,14 @@ export class CreateUserDto {
@IsOptional()
@IsString()
signature?: string;
@ApiProperty({
description:
"Formation validation date - when the user's formation validation expires",
example: '2024-12-31T23:59:59.000Z',
required: false,
})
@IsOptional()
@IsDate()
formationValidation?: Date;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength, IsDate } from 'class-validator';
export class UpdateProfileDto {
@ApiProperty({
description: 'The name of the user',
example: 'John Doe',
required: false,
})
@IsOptional()
@IsString()
name?: string;
@ApiProperty({
description: 'The email of the user',
example: 'john.doe@example.com',
required: false,
})
@IsOptional()
@IsString()
email?: string;
@ApiProperty({
description: 'The password of the user',
example: 'newpassword123',
minLength: 8,
required: false,
})
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
@ApiProperty({
description: 'URL to the user signature image',
example: '/uploads/signatures/user123_signature.png',
required: false,
})
@IsOptional()
@IsString()
signature?: string;
@ApiProperty({
description:
"Formation validation date - when the user's formation validation expires",
example: '2024-12-31T23:59:59.000Z',
required: false,
})
@IsOptional()
@IsDate()
formationValidation?: Date;
}
......@@ -25,4 +25,14 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
@IsOptional()
@IsString()
signature?: string;
@ApiProperty({
description:
"Formation validation date - when the user's formation validation expires",
example: '2024-12-31T23:59:59.000Z',
required: false,
})
@IsOptional()
@IsDate()
formationValidation?: Date;
}
......@@ -16,6 +16,7 @@ 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';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { FindUsersDto, UserOrderBy } from './dto/find-users.dto';
import {
ApiTags,
......@@ -62,6 +63,16 @@ export class UsersController {
return this.usersService.create(createUserDto);
}
@Get('me')
@ApiOperation({ summary: 'Get current user info' })
@ApiResponse({
status: 200,
description: 'Return current user information.',
})
getCurrentUser(@User() user: any) {
return user;
}
@Get()
@Roles(
Role.ADMIN,
......@@ -148,18 +159,65 @@ export class UsersController {
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@ApiOperation({ summary: 'Update a user' })
@Roles(
Role.SUPERADMIN,
Role.ADMIN,
Role.MANAGER,
Role.PARTNER,
Role.OPERATOR,
Role.VIEWER,
)
@ApiOperation({ summary: 'Update a user (Admin only or own profile)' })
@ApiResponse({
status: 200,
description: 'The user has been successfully updated.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
@ApiResponse({
status: 403,
description:
'Forbidden - Can only update own profile or admin access required.',
})
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
@User('id') currentUserId: number,
@User('role') currentUserRole: string,
) {
return this.usersService.update(
id,
updateUserDto,
currentUserId,
currentUserRole,
);
}
@Patch('profile')
@Roles(
Role.SUPERADMIN,
Role.ADMIN,
Role.MANAGER,
Role.PARTNER,
Role.OPERATOR,
Role.VIEWER,
)
@ApiOperation({ summary: 'Update own profile' })
@ApiResponse({
status: 200,
description: 'The profile has been successfully updated.',
})
@ApiResponse({ status: 404, description: 'User not found.' })
updateProfile(
@User('id') userId: number,
@Body() updateProfileDto: UpdateProfileDto,
@User('role') currentUserRole: string,
) {
return this.usersService.update(id, updateUserDto);
return this.usersService.update(
userId,
updateProfileDto,
userId,
currentUserRole,
);
}
@Delete(':id')
......@@ -262,6 +320,8 @@ export class UsersController {
async uploadUserSignature(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
@User('id') currentUserId: number,
@User('role') currentUserRole: string,
) {
try {
if (!file) {
......@@ -272,7 +332,12 @@ export class UsersController {
const signatureUrl = await saveUserSignature(file, id);
// Update the user with the signature URL
await this.usersService.update(id, { signature: signatureUrl });
await this.usersService.update(
id,
{ signature: signatureUrl },
currentUserId,
currentUserRole,
);
return {
message: 'Signature uploaded successfully',
......
......@@ -45,6 +45,7 @@ export class UsersService {
name: users.name,
role: users.role,
isActive: users.isActive,
formationValidation: users.formationValidation,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
});
......@@ -144,6 +145,7 @@ export class UsersService {
role: users.role,
signature: users.signature,
isActive: users.isActive,
formationValidation: users.formationValidation,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
......@@ -175,6 +177,7 @@ export class UsersService {
role: users.role,
signature: users.signature,
isActive: users.isActive,
formationValidation: users.formationValidation,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
......@@ -190,7 +193,23 @@ export class UsersService {
return userList[0];
}
async update(id: number, updateUserDto: UpdateUserDto) {
async update(
id: number,
updateUserDto: UpdateUserDto,
currentUserId?: number,
currentUserRole?: string,
) {
// Check authorization: users can only update their own profile unless they are admin/superadmin
if (currentUserId && currentUserRole) {
const isAdmin =
currentUserRole === 'SUPERADMIN' || currentUserRole === 'ADMIN';
const isOwnProfile = currentUserId === id;
if (!isAdmin && !isOwnProfile) {
throw new ForbiddenException('You can only update your own profile');
}
}
const data: any = { ...updateUserDto };
if (updateUserDto.password) {
......@@ -208,6 +227,7 @@ export class UsersService {
name: users.name,
role: users.role,
isActive: users.isActive,
formationValidation: users.formationValidation,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
......@@ -284,6 +304,7 @@ export class UsersService {
name: users.name,
role: users.role,
isActive: users.isActive,
formationValidation: users.formationValidation,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
......@@ -318,6 +339,7 @@ export class UsersService {
name: users.name,
role: users.role,
isActive: users.isActive,
formationValidation: users.formationValidation,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
......@@ -341,6 +363,7 @@ export class UsersService {
name: users.name,
role: users.role,
isActive: users.isActive,
formationValidation: users.formationValidation,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
partnerId: users.partnerId,
......
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