Commit a5a1dd45 by Augusto

assistant's + logs update

parent 336e7041
ALTER TABLE `occurrences` MODIFY COLUMN `status` enum('OPEN','IN_PROGRESS','RESOLVED','PAUSED','CLOSED','PARCIAL_RESOLVED','CANCELLED') NOT NULL DEFAULT 'OPEN';--> statement-breakpoint
ALTER TABLE `comments` ADD `status` enum('OPEN','IN_PROGRESS','RESOLVED','PAUSED','CLOSED','PARCIAL_RESOLVED','CANCELLED') DEFAULT 'OPEN' NOT NULL;--> statement-breakpoint
ALTER TABLE `occurrences` ADD `latitude` decimal(10,8);--> statement-breakpoint
ALTER TABLE `occurrences` ADD `longitude` decimal(11,8);--> statement-breakpoint
ALTER TABLE `occurrences` ADD `reference` text;--> statement-breakpoint
CREATE INDEX `location_idx` ON `occurrences` (`latitude`,`longitude`);
\ No newline at end of file
CREATE TABLE `assistants` (
`id` varchar(25) NOT NULL,
`email` varchar(255) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `assistants_id` PRIMARY KEY(`id`),
CONSTRAINT `assistants_email_unique` UNIQUE(`email`)
);
--> statement-breakpoint
CREATE INDEX `assistant_email_idx` ON `assistants` (`email`);
\ No newline at end of file
ALTER TABLE `occurrences` ADD `assistantId` varchar(25);--> statement-breakpoint
CREATE INDEX `assistant_idx` ON `occurrences` (`assistantId`);
\ No newline at end of file
CREATE TABLE `occurrence_assistants` (
`id` varchar(25) NOT NULL,
`occurrenceId` varchar(25) NOT NULL,
`assistantId` varchar(25) NOT NULL,
`assignedBy` varchar(25) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `occurrence_assistants_id` PRIMARY KEY(`id`),
CONSTRAINT `unique_occurrence_assistant` UNIQUE(`occurrenceId`,`assistantId`)
);
--> statement-breakpoint
DROP INDEX `assistant_idx` ON `occurrences`;--> statement-breakpoint
CREATE INDEX `occurrence_assistant_occurrence_idx` ON `occurrence_assistants` (`occurrenceId`);--> statement-breakpoint
CREATE INDEX `occurrence_assistant_assistant_idx` ON `occurrence_assistants` (`assistantId`);--> statement-breakpoint
CREATE INDEX `occurrence_assistant_assigned_by_idx` ON `occurrence_assistants` (`assignedBy`);--> statement-breakpoint
ALTER TABLE `occurrences` DROP COLUMN `assistantId`;
\ No newline at end of file
ALTER TABLE `occurrence_logs` MODIFY COLUMN `log_action` enum('CREATED','UPDATED','ASSIGNED','DUAL_ASSIGNED','UNASSIGNED','STATUS_CHANGED','PRIORITY_CHANGED','COMMENT_ADDED','ATTACHMENT_ADDED','ATTACHMENT_REMOVED','CONCLUSION_ADDED','CONCLUSION_UPDATED','ASSISTANT_ASSIGNED','ASSISTANT_UNASSIGNED','DELETED') NOT NULL;--> statement-breakpoint
ALTER TABLE `occurrence_logs` ADD `status` enum('OPEN','IN_PROGRESS','RESOLVED','PAUSED','CLOSED','PARCIAL_RESOLVED','CANCELLED') DEFAULT 'OPEN' NOT NULL;
\ No newline at end of file
......@@ -15,6 +15,41 @@
"when": 1757352023356,
"tag": "0001_perpetual_human_fly",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1757493222915,
"tag": "0002_flawless_mother_askani",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1757608837695,
"tag": "0003_black_stephen_strange",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1757610845102,
"tag": "0004_military_speed",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1757686691705,
"tag": "0005_flowery_deadpool",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1757687180760,
"tag": "0006_past_supernaut",
"breakpoints": true
}
]
}
\ No newline at end of file
......@@ -10,6 +10,7 @@ import { OccurrenceModule } from './modules/occurrence/occurrence.module';
import { CommentModule } from './modules/comment/comment.module';
import { AttachmentModule } from './modules/attachment/attachment.module';
import { ConclusionModule } from './modules/conclusion/conclusion.module';
import { AssistantModule } from './modules/assistant/assistant.module';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@Module({
......@@ -24,6 +25,7 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
CommentModule,
AttachmentModule,
ConclusionModule,
AssistantModule,
],
controllers: [AppController],
providers: [
......
......@@ -9,6 +9,7 @@ import {
primaryKey,
unique,
index,
decimal,
} from 'drizzle-orm/mysql-core';
import { relations } from 'drizzle-orm';
......@@ -46,6 +47,8 @@ export const logActionEnum = mysqlEnum('log_action', [
'ATTACHMENT_REMOVED',
'CONCLUSION_ADDED',
'CONCLUSION_UPDATED',
'ASSISTANT_ASSIGNED',
'ASSISTANT_UNASSIGNED',
'DELETED',
]);
......@@ -78,6 +81,9 @@ export const occurrences = mysqlTable(
priority: priorityEnum.notNull().default('MEDIUM'),
category: varchar('category', { length: 255 }).notNull(),
location: varchar('location', { length: 255 }),
latitude: decimal('latitude', { precision: 10, scale: 8 }),
longitude: decimal('longitude', { precision: 11, scale: 8 }),
reference: text('reference'),
reporterId: varchar('reporterId', { length: 25 }).notNull(),
assigneeId: varchar('assigneeId', { length: 25 }),
managerId: varchar('managerId', { length: 25 }),
......@@ -90,6 +96,7 @@ export const occurrences = mysqlTable(
assigneeIdx: index('assignee_idx').on(table.assigneeId),
managerIdx: index('manager_idx').on(table.managerId),
statusIdx: index('status_idx').on(table.status),
locationIdx: index('location_idx').on(table.latitude, table.longitude),
}),
);
......@@ -158,6 +165,7 @@ export const occurrenceLogs = mysqlTable(
oldValue: text('oldValue'),
newValue: text('newValue'),
performedBy: varchar('performedBy', { length: 25 }).notNull(),
status: occurrenceStatusEnum,
createdAt: timestamp('createdAt').notNull().defaultNow(),
},
(table) => ({
......@@ -168,6 +176,47 @@ export const occurrenceLogs = mysqlTable(
}),
);
// Assistants table
export const assistants = mysqlTable(
'assistants',
{
id: varchar('id', { length: 25 }).primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow().onUpdateNow(),
},
(table) => ({
emailIdx: index('assistant_email_idx').on(table.email),
}),
);
// Occurrence Assistants junction table (many-to-many)
export const occurrenceAssistants = mysqlTable(
'occurrence_assistants',
{
id: varchar('id', { length: 25 }).primaryKey(),
occurrenceId: varchar('occurrenceId', { length: 25 }).notNull(),
assistantId: varchar('assistantId', { length: 25 }).notNull(),
assignedBy: varchar('assignedBy', { length: 25 }).notNull(),
createdAt: timestamp('createdAt').notNull().defaultNow(),
},
(table) => ({
occurrenceIdx: index('occurrence_assistant_occurrence_idx').on(
table.occurrenceId,
),
assistantIdx: index('occurrence_assistant_assistant_idx').on(
table.assistantId,
),
assignedByIdx: index('occurrence_assistant_assigned_by_idx').on(
table.assignedBy,
),
uniqueOccurrenceAssistant: unique('unique_occurrence_assistant').on(
table.occurrenceId,
table.assistantId,
),
}),
);
// Relations
export const usersRelations = relations(users, ({ many }) => ({
reportedOccurrences: many(occurrences, { relationName: 'reportedBy' }),
......@@ -193,6 +242,7 @@ export const occurrencesRelations = relations(occurrences, ({ one, many }) => ({
references: [users.id],
relationName: 'managedBy',
}),
occurrenceAssistants: many(occurrenceAssistants),
comments: many(comments),
attachments: many(attachments),
conclusion: one(conclusions),
......@@ -235,6 +285,28 @@ export const occurrenceLogsRelations = relations(occurrenceLogs, ({ one }) => ({
}),
}));
export const assistantsRelations = relations(assistants, ({ many }) => ({
occurrenceAssistants: many(occurrenceAssistants),
}));
export const occurrenceAssistantsRelations = relations(
occurrenceAssistants,
({ one }) => ({
occurrence: one(occurrences, {
fields: [occurrenceAssistants.occurrenceId],
references: [occurrences.id],
}),
assistant: one(assistants, {
fields: [occurrenceAssistants.assistantId],
references: [assistants.id],
}),
assignedByUser: one(users, {
fields: [occurrenceAssistants.assignedBy],
references: [users.id],
}),
}),
);
// Type exports for TypeScript
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
......@@ -248,6 +320,10 @@ export type Conclusion = typeof conclusions.$inferSelect;
export type NewConclusion = typeof conclusions.$inferInsert;
export type OccurrenceLog = typeof occurrenceLogs.$inferSelect;
export type NewOccurrenceLog = typeof occurrenceLogs.$inferInsert;
export type Assistant = typeof assistants.$inferSelect;
export type NewAssistant = typeof assistants.$inferInsert;
export type OccurrenceAssistant = typeof occurrenceAssistants.$inferSelect;
export type NewOccurrenceAssistant = typeof occurrenceAssistants.$inferInsert;
// Enum types
export type UserRole = 'USER' | 'MODERATOR' | 'ADMIN';
......@@ -272,6 +348,8 @@ export type LogAction =
| 'ATTACHMENT_REMOVED'
| 'CONCLUSION_ADDED'
| 'CONCLUSION_UPDATED'
| 'ASSISTANT_ASSIGNED'
| 'ASSISTANT_UNASSIGNED'
| 'DELETED';
// Enum constants for runtime usage
......@@ -310,5 +388,7 @@ export const LogActionEnum = {
ATTACHMENT_REMOVED: 'ATTACHMENT_REMOVED' as const,
CONCLUSION_ADDED: 'CONCLUSION_ADDED' as const,
CONCLUSION_UPDATED: 'CONCLUSION_UPDATED' as const,
ASSISTANT_ASSIGNED: 'ASSISTANT_ASSIGNED' as const,
ASSISTANT_UNASSIGNED: 'ASSISTANT_UNASSIGNED' as const,
DELETED: 'DELETED' as const,
} as const;
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBody,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AssistantService } from './assistant.service';
import { CreateAssistantDto } from './dto/create-assistant.dto';
import { UpdateAssistantDto } from './dto/update-assistant.dto';
import { AssistantResponseDto } from './dto/assistant-response.dto';
@ApiTags('assistants')
@ApiBearerAuth('JWT-auth')
@Controller('assistants')
export class AssistantController {
constructor(private readonly assistantService: AssistantService) {}
@Post()
@ApiOperation({ summary: 'Create a new assistant' })
@ApiBody({ type: CreateAssistantDto })
@ApiResponse({
status: 201,
description: 'Assistant created successfully',
type: AssistantResponseDto,
})
@ApiResponse({
status: 409,
description: 'Assistant with this email already exists',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async create(
@Body() createAssistantDto: CreateAssistantDto,
): Promise<AssistantResponseDto> {
return this.assistantService.create(createAssistantDto);
}
@Get()
@ApiOperation({ summary: 'Get all assistants' })
@ApiResponse({
status: 200,
description: 'List of all assistants',
type: [AssistantResponseDto],
})
async findAll(): Promise<AssistantResponseDto[]> {
return this.assistantService.findAll();
}
@Get('count')
@ApiOperation({ summary: 'Get total number of assistants' })
@ApiResponse({
status: 200,
description: 'Total number of assistants',
schema: {
type: 'object',
properties: {
count: {
type: 'number',
example: 5,
},
},
},
})
async getCount(): Promise<{ count: number }> {
const count = await this.assistantService.count();
return { count };
}
@Get(':id')
@ApiOperation({ summary: 'Get assistant by ID' })
@ApiParam({
name: 'id',
description: 'Assistant unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'Assistant found',
type: AssistantResponseDto,
})
@ApiResponse({
status: 404,
description: 'Assistant not found',
})
async findOne(@Param('id') id: string): Promise<AssistantResponseDto> {
return this.assistantService.findOne(id);
}
@Get('email/:email')
@ApiOperation({ summary: 'Get assistant by email' })
@ApiParam({
name: 'email',
description: 'Assistant email address',
example: 'assistant@example.com',
})
@ApiResponse({
status: 200,
description: 'Assistant found',
type: AssistantResponseDto,
})
@ApiResponse({
status: 404,
description: 'Assistant not found',
})
async findByEmail(
@Param('email') email: string,
): Promise<AssistantResponseDto | null> {
return this.assistantService.findByEmail(email);
}
@Patch(':id')
@ApiOperation({ summary: 'Update assistant by ID' })
@ApiParam({
name: 'id',
description: 'Assistant unique identifier',
example: 'clxyz123abc456def',
})
@ApiBody({ type: UpdateAssistantDto })
@ApiResponse({
status: 200,
description: 'Assistant updated successfully',
type: AssistantResponseDto,
})
@ApiResponse({
status: 404,
description: 'Assistant not found',
})
@ApiResponse({
status: 409,
description: 'Assistant with this email already exists',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async update(
@Param('id') id: string,
@Body() updateAssistantDto: UpdateAssistantDto,
): Promise<AssistantResponseDto> {
return this.assistantService.update(id, updateAssistantDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete assistant by ID' })
@ApiParam({
name: 'id',
description: 'Assistant unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 204,
description: 'Assistant deleted successfully',
})
@ApiResponse({
status: 404,
description: 'Assistant not found',
})
async remove(@Param('id') id: string): Promise<void> {
return this.assistantService.remove(id);
}
}
import { Module } from '@nestjs/common';
import { AssistantController } from './assistant.controller';
import { AssistantService } from './assistant.service';
import { DrizzleService } from '../../common/drizzle.service';
@Module({
controllers: [AssistantController],
providers: [AssistantService, DrizzleService],
exports: [AssistantService],
})
export class AssistantModule {}
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { DrizzleService } from '../../common/drizzle.service';
import { assistants } from '../../drizzle/schema';
import { eq, and, ne } from 'drizzle-orm';
import { CreateAssistantDto } from './dto/create-assistant.dto';
import { UpdateAssistantDto } from './dto/update-assistant.dto';
import { AssistantResponseDto } from './dto/assistant-response.dto';
@Injectable()
export class AssistantService {
constructor(private readonly drizzle: DrizzleService) {}
async create(
createAssistantDto: CreateAssistantDto,
): Promise<AssistantResponseDto> {
// Check if assistant with email already exists
const existingAssistant = await this.drizzle.db
.select()
.from(assistants)
.where(eq(assistants.email, createAssistantDto.email))
.limit(1);
if (existingAssistant.length > 0) {
throw new ConflictException('Assistant with this email already exists');
}
// Create assistant
const assistantId = this.drizzle.generateId();
await this.drizzle.db.insert(assistants).values({
id: assistantId,
email: createAssistantDto.email,
});
// Get the created assistant
const [assistant] = await this.drizzle.db
.select({
id: assistants.id,
email: assistants.email,
createdAt: assistants.createdAt,
updatedAt: assistants.updatedAt,
})
.from(assistants)
.where(eq(assistants.id, assistantId))
.limit(1);
return assistant;
}
async findAll(): Promise<AssistantResponseDto[]> {
const assistantsList = await this.drizzle.db
.select({
id: assistants.id,
email: assistants.email,
createdAt: assistants.createdAt,
updatedAt: assistants.updatedAt,
})
.from(assistants)
.orderBy(assistants.createdAt);
return assistantsList;
}
async findOne(id: string): Promise<AssistantResponseDto> {
const [assistant] = await this.drizzle.db
.select({
id: assistants.id,
email: assistants.email,
createdAt: assistants.createdAt,
updatedAt: assistants.updatedAt,
})
.from(assistants)
.where(eq(assistants.id, id))
.limit(1);
if (!assistant) {
throw new NotFoundException(`Assistant with ID ${id} not found`);
}
return assistant;
}
async findByEmail(email: string): Promise<AssistantResponseDto | null> {
const [assistant] = await this.drizzle.db
.select({
id: assistants.id,
email: assistants.email,
createdAt: assistants.createdAt,
updatedAt: assistants.updatedAt,
})
.from(assistants)
.where(eq(assistants.email, email))
.limit(1);
return assistant || null;
}
async update(
id: string,
updateAssistantDto: UpdateAssistantDto,
): Promise<AssistantResponseDto> {
// Check if assistant exists
await this.findOne(id);
// If email is being updated, check for conflicts
if (updateAssistantDto.email) {
const existingAssistant = await this.drizzle.db
.select()
.from(assistants)
.where(
and(
eq(assistants.email, updateAssistantDto.email),
ne(assistants.id, id),
),
)
.limit(1);
if (existingAssistant.length > 0) {
throw new ConflictException('Assistant with this email already exists');
}
}
await this.drizzle.db
.update(assistants)
.set(updateAssistantDto)
.where(eq(assistants.id, id));
// Get the updated assistant
const [assistant] = await this.drizzle.db
.select({
id: assistants.id,
email: assistants.email,
createdAt: assistants.createdAt,
updatedAt: assistants.updatedAt,
})
.from(assistants)
.where(eq(assistants.id, id))
.limit(1);
return assistant;
}
async remove(id: string): Promise<void> {
// Check if assistant exists
await this.findOne(id);
await this.drizzle.db.delete(assistants).where(eq(assistants.id, id));
}
async count(): Promise<number> {
const result = await this.drizzle.db
.select({ count: assistants.id })
.from(assistants);
return result.length;
}
}
import { ApiProperty } from '@nestjs/swagger';
export class AssistantResponseDto {
@ApiProperty({
description: 'Assistant unique identifier',
example: 'clxyz123abc456def',
})
id: string;
@ApiProperty({
description: 'Assistant email address',
example: 'assistant@example.com',
})
email: string;
@ApiProperty({
description: 'Assistant creation timestamp',
example: '2024-01-15T10:30:00.000Z',
})
createdAt: Date;
@ApiProperty({
description: 'Assistant last update timestamp',
example: '2024-01-15T10:30:00.000Z',
})
updatedAt: Date;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty } from 'class-validator';
export class CreateAssistantDto {
@ApiProperty({
description: 'Assistant email address',
example: 'assistant@example.com',
})
@IsEmail({}, { message: 'Please provide a valid email address' })
@IsNotEmpty({ message: 'Email is required' })
email: string;
}
import { PartialType } from '@nestjs/swagger';
import { CreateAssistantDto } from './create-assistant.dto';
export class UpdateAssistantDto extends PartialType(CreateAssistantDto) {}
export * from './assistant.controller';
export * from './assistant.service';
export * from './assistant.module';
export * from './dto/assistant-response.dto';
export * from './dto/create-assistant.dto';
export * from './dto/update-assistant.dto';
......@@ -24,6 +24,7 @@ export class CommentService {
id: comments.id,
content: comments.content,
isInternal: comments.isInternal,
status: comments.status,
occurrenceId: comments.occurrenceId,
authorId: comments.authorId,
createdAt: comments.createdAt,
......@@ -79,6 +80,7 @@ export class CommentService {
id: commentId,
content: createCommentDto.content,
isInternal: createCommentDto.isInternal || false,
status: createCommentDto.status,
occurrenceId: createCommentDto.occurrenceId,
authorId: authorId,
});
......@@ -121,6 +123,7 @@ export class CommentService {
id: comments.id,
content: comments.content,
isInternal: comments.isInternal,
status: comments.status,
occurrenceId: comments.occurrenceId,
authorId: comments.authorId,
createdAt: comments.createdAt,
......@@ -184,23 +187,53 @@ export class CommentService {
throw new ForbiddenException('You can only edit your own comments');
}
// Only admins and moderators can change the isInternal flag
// Only admins and moderators can change the isInternal flag and status
const updateData: {
content?: string;
isInternal?: boolean;
} = { content: updateCommentDto.content };
status?: string;
} = {};
// Only include content if it's provided
if (updateCommentDto.content !== undefined) {
updateData.content = updateCommentDto.content;
}
if (updateCommentDto.isInternal !== undefined) {
if (
userRole !== UserRoleEnum.ADMIN &&
userRole !== UserRoleEnum.MODERATOR
) {
throw new ForbiddenException(
'Only admins and moderators can change internal status',
);
// Only check permissions if the value is actually being changed
if (updateCommentDto.isInternal !== comment.isInternal) {
if (
userRole !== UserRoleEnum.ADMIN &&
userRole !== UserRoleEnum.MODERATOR
) {
throw new ForbiddenException(
'Only admins and moderators can change internal status',
);
}
}
updateData.isInternal = updateCommentDto.isInternal;
}
if (updateCommentDto.status !== undefined) {
// Only check permissions if the value is actually being changed
if (updateCommentDto.status !== comment.status) {
if (
userRole !== UserRoleEnum.ADMIN &&
userRole !== UserRoleEnum.MODERATOR
) {
throw new ForbiddenException(
'Only admins and moderators can change status',
);
}
}
updateData.status = updateCommentDto.status;
}
// Check if there's anything to update
if (Object.keys(updateData).length === 0) {
throw new BadRequestException('No fields provided for update');
}
await this.drizzle.db
.update(comments)
.set(updateData)
......
......@@ -83,6 +83,7 @@ export class CommentResponseDto {
'OPEN',
'IN_PROGRESS',
'RESOLVED',
'PAUSED',
'CLOSED',
'PARCIAL_RESOLVED',
'CANCELLED',
......@@ -93,6 +94,7 @@ export class CommentResponseDto {
| 'OPEN'
| 'IN_PROGRESS'
| 'RESOLVED'
| 'PAUSED'
| 'CLOSED'
| 'PARCIAL_RESOLVED'
| 'CANCELLED';
......
......@@ -41,6 +41,7 @@ export class CreateCommentDto {
'OPEN',
'IN_PROGRESS',
'RESOLVED',
'PAUSED',
'CLOSED',
'PARCIAL_RESOLVED',
'CANCELLED',
......@@ -52,6 +53,7 @@ export class CreateCommentDto {
'OPEN',
'IN_PROGRESS',
'RESOLVED',
'PAUSED',
'CLOSED',
'PARCIAL_RESOLVED',
'CANCELLED',
......@@ -60,6 +62,7 @@ export class CreateCommentDto {
| 'OPEN'
| 'IN_PROGRESS'
| 'RESOLVED'
| 'PAUSED'
| 'CLOSED'
| 'PARCIAL_RESOLVED'
| 'CANCELLED';
......
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class AssignAssistantDto {
@ApiProperty({
description: 'ID of the assistant to assign to the occurrence',
example: 'clxyz123abc456def',
})
@IsNotEmpty({ message: 'Assistant ID is required' })
@IsString()
assistantId: string;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsString, ArrayMinSize } from 'class-validator';
export class AssignMultipleAssistantsDto {
@ApiProperty({
description: 'Array of assistant IDs to assign to the occurrence',
example: ['clxyz123abc456def', 'clxyz789def012ghi'],
type: [String],
})
@IsArray({ message: 'assistantIds must be an array' })
@ArrayMinSize(1, { message: 'At least one assistant ID is required' })
@IsString({ each: true, message: 'Each assistant ID must be a string' })
@IsNotEmpty({ each: true, message: 'Assistant IDs cannot be empty' })
assistantIds: string[];
}
......@@ -5,6 +5,8 @@ import {
IsOptional,
IsString,
MaxLength,
IsNumber,
IsDecimal,
} from 'class-validator';
import {
OccurrenceStatus,
......@@ -50,6 +52,32 @@ export class CreateOccurrenceDto {
location?: string;
@ApiPropertyOptional({
description: 'GPS latitude coordinate',
example: -23.5505,
type: 'number',
})
@IsOptional()
@IsNumber()
latitude?: number;
@ApiPropertyOptional({
description: 'GPS longitude coordinate',
example: -46.6333,
type: 'number',
})
@IsOptional()
@IsNumber()
longitude?: number;
@ApiPropertyOptional({
description: 'Reference information for the occurrence location',
example: 'Near the main entrance, next to the parking lot',
})
@IsOptional()
@IsString()
reference?: string;
@ApiPropertyOptional({
description: 'Occurrence priority level',
enum: PriorityEnum,
example: PriorityEnum.HIGH,
......
......@@ -45,6 +45,12 @@ export class LogResponseDto {
})
performedBy: string;
@ApiPropertyOptional({
description: 'Status of the occurrence at the time of this log entry',
example: 'IN_PROGRESS',
})
status?: string | null;
@ApiProperty({
description: 'Date and time when the action was performed',
example: '2024-01-01T12:00:00.000Z',
......
......@@ -52,6 +52,26 @@ export class OccurrenceResponseDto {
})
location?: string | null;
@ApiPropertyOptional({
description: 'GPS latitude coordinate',
example: -23.5505,
type: 'number',
})
latitude?: number | null;
@ApiPropertyOptional({
description: 'GPS longitude coordinate',
example: -46.6333,
type: 'number',
})
longitude?: number | null;
@ApiPropertyOptional({
description: 'Reference information for the occurrence location',
example: 'Near the main entrance, next to the parking lot',
})
reference?: string | null;
@ApiProperty({
description: 'ID of the user who reported the occurrence',
example: 'clxyz123abc456def',
......@@ -140,6 +160,39 @@ export class OccurrenceResponseDto {
} | null;
@ApiPropertyOptional({
description: 'Assistants assigned to the occurrence',
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'clxyz123abc456def' },
email: { type: 'string', example: 'assistant@example.com' },
assignedAt: { type: 'string', example: '2024-01-01T12:00:00.000Z' },
assignedBy: {
type: 'object',
properties: {
id: { type: 'string', example: 'clxyz123abc456def' },
firstName: { type: 'string', example: 'John' },
lastName: { type: 'string', example: 'Doe' },
email: { type: 'string', example: 'john.doe@example.com' },
},
},
},
},
})
assistants?: Array<{
id: string;
email: string;
assignedAt: Date;
assignedBy: {
id: string;
firstName: string;
lastName: string;
email: string;
};
}>;
@ApiPropertyOptional({
description: 'Number of comments on this occurrence',
example: 5,
})
......
......@@ -16,6 +16,7 @@ export class OccurrenceLogService {
performedBy: string,
oldValue?: string,
newValue?: string,
status?: string,
): Promise<void> {
const logId = this.drizzle.generateId();
......@@ -27,6 +28,7 @@ export class OccurrenceLogService {
oldValue: oldValue || null,
newValue: newValue || null,
performedBy,
status: status || null,
});
}
......@@ -40,6 +42,7 @@ export class OccurrenceLogService {
oldValue: occurrenceLogs.oldValue,
newValue: occurrenceLogs.newValue,
performedBy: occurrenceLogs.performedBy,
status: occurrenceLogs.status,
createdAt: occurrenceLogs.createdAt,
performedByUser: {
id: users.id,
......@@ -61,6 +64,7 @@ export class OccurrenceLogService {
oldValue: log.oldValue,
newValue: log.newValue,
performedBy: log.performedBy,
status: log.status,
createdAt: log.createdAt,
performedByUser: log.performedByUser || undefined,
}));
......@@ -240,4 +244,38 @@ export class OccurrenceLogService {
performedBy,
);
}
async logAssistantAssigned(
occurrenceId: string,
performedBy: string,
assistantName: string,
status?: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'ASSISTANT_ASSIGNED',
`Assistant ${assistantName} was assigned to the occurrence`,
performedBy,
undefined,
undefined,
status,
);
}
async logAssistantUnassigned(
occurrenceId: string,
performedBy: string,
assistantName: string,
status?: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'ASSISTANT_UNASSIGNED',
`Assistant ${assistantName} was unassigned from the occurrence`,
performedBy,
undefined,
undefined,
status,
);
}
}
......@@ -27,6 +27,8 @@ import { UpdateOccurrenceDto } from './dto/update-occurrence.dto';
import { OccurrenceResponseDto } from './dto/occurrence-response.dto';
import { AssignOccurrenceDto } from './dto/assign-occurrence.dto';
import { DualAssignOccurrenceDto } from './dto/dual-assign-occurrence.dto';
import { AssignAssistantDto } from './dto/assign-assistant.dto';
import { AssignMultipleAssistantsDto } from './dto/assign-multiple-assistants.dto';
import { LogResponseDto } from './dto/log-response.dto';
import {
OccurrenceStatus,
......@@ -484,6 +486,117 @@ export class OccurrenceController {
return this.occurrenceService.unassignOccurrence(id, user.id);
}
@Patch(':id/assign-assistant')
@ApiOperation({
summary: 'Assign assistant to occurrence',
description:
'Assign an assistant to help with the occurrence. Available to all authenticated users.',
})
@ApiParam({
name: 'id',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiBody({ type: AssignAssistantDto })
@ApiResponse({
status: 200,
description: 'Assistant assigned successfully',
type: OccurrenceResponseDto,
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
@ApiResponse({
status: 400,
description: 'Assistant not found',
})
async assignAssistant(
@Param('id') id: string,
@Body() assignAssistantDto: AssignAssistantDto,
@CurrentUser() user: UserResponseDto,
): Promise<OccurrenceResponseDto> {
return this.occurrenceService.assignAssistant(
id,
assignAssistantDto,
user.id,
);
}
@Patch(':id/assign-multiple-assistants')
@ApiOperation({
summary: 'Assign multiple assistants to occurrence',
description:
'Assign multiple assistants to help with the occurrence in a single request. Available to all authenticated users.',
})
@ApiParam({
name: 'id',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiBody({ type: AssignMultipleAssistantsDto })
@ApiResponse({
status: 200,
description: 'Assistants assigned successfully',
type: OccurrenceResponseDto,
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
@ApiResponse({
status: 400,
description: 'One or more assistants not found or already assigned',
})
async assignMultipleAssistants(
@Param('id') id: string,
@Body() assignMultipleAssistantsDto: AssignMultipleAssistantsDto,
@CurrentUser() user: UserResponseDto,
): Promise<OccurrenceResponseDto> {
return this.occurrenceService.assignMultipleAssistants(
id,
assignMultipleAssistantsDto,
user.id,
);
}
@Patch(':id/unassign-assistant/:assistantId')
@ApiOperation({
summary: 'Unassign assistant from occurrence',
description:
'Remove a specific assistant from the occurrence. Available to all authenticated users.',
})
@ApiParam({
name: 'id',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiParam({
name: 'assistantId',
description: 'Assistant unique identifier',
example: 'clxyz789def012ghi',
})
@ApiResponse({
status: 200,
description: 'Assistant unassigned successfully',
type: OccurrenceResponseDto,
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
@ApiResponse({
status: 400,
description: 'Assistant is not assigned to this occurrence',
})
async unassignAssistant(
@Param('id') id: string,
@Param('assistantId') assistantId: string,
@CurrentUser() user: UserResponseDto,
): Promise<OccurrenceResponseDto> {
return this.occurrenceService.unassignAssistant(id, assistantId, user.id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete occurrence by ID' })
......
......@@ -22,6 +22,8 @@ import {
comments,
attachments,
conclusions,
assistants,
occurrenceAssistants,
} from '../../drizzle/schema';
import { eq, and, or, like, desc, count, sql } from 'drizzle-orm';
......@@ -45,6 +47,9 @@ export class OccurrenceService {
priority: occurrences.priority,
category: occurrences.category,
location: occurrences.location,
latitude: occurrences.latitude,
longitude: occurrences.longitude,
reference: occurrences.reference,
reporterId: occurrences.reporterId,
assigneeId: occurrences.assigneeId,
managerId: occurrences.managerId,
......@@ -101,6 +106,24 @@ export class OccurrenceService {
managedBy = manager;
}
// Get assistants if any exist
const assistantsList = await this.drizzle.db
.select({
id: assistants.id,
email: assistants.email,
assignedAt: occurrenceAssistants.createdAt,
assignedBy: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
},
})
.from(occurrenceAssistants)
.leftJoin(assistants, eq(occurrenceAssistants.assistantId, assistants.id))
.leftJoin(users, eq(occurrenceAssistants.assignedBy, users.id))
.where(eq(occurrenceAssistants.occurrenceId, occurrenceId));
// Get conclusion if exists
const [conclusion] = await this.drizzle.db
.select()
......@@ -124,6 +147,7 @@ export class OccurrenceService {
reportedBy: occurrence.reportedBy || undefined,
assignedTo,
managedBy,
assistants: assistantsList.length > 0 ? assistantsList : undefined,
conclusion: conclusion || undefined,
_count: {
comments: commentCount.count,
......@@ -224,6 +248,9 @@ export class OccurrenceService {
priority: occurrences.priority,
category: occurrences.category,
location: occurrences.location,
latitude: occurrences.latitude,
longitude: occurrences.longitude,
reference: occurrences.reference,
reporterId: occurrences.reporterId,
assigneeId: occurrences.assigneeId,
managerId: occurrences.managerId,
......@@ -286,11 +313,12 @@ export class OccurrenceService {
updateData.closedAt = new Date();
}
// If status is being changed back to OPEN or IN_PROGRESS, clear closedAt
// If status is being changed back to OPEN, IN_PROGRESS, or PAUSED, clear closedAt
if (
updateOccurrenceDto.status &&
(updateOccurrenceDto.status === OccurrenceStatusEnum.OPEN ||
updateOccurrenceDto.status === OccurrenceStatusEnum.IN_PROGRESS)
updateOccurrenceDto.status === OccurrenceStatusEnum.IN_PROGRESS ||
updateOccurrenceDto.status === OccurrenceStatusEnum.PAUSED)
) {
updateData.closedAt = null;
}
......@@ -427,6 +455,9 @@ export class OccurrenceService {
priority: occurrences.priority,
category: occurrences.category,
location: occurrences.location,
latitude: occurrences.latitude,
longitude: occurrences.longitude,
reference: occurrences.reference,
reporterId: occurrences.reporterId,
assigneeId: occurrences.assigneeId,
managerId: occurrences.managerId,
......@@ -634,4 +665,214 @@ export class OccurrenceService {
return this.getOccurrenceWithRelations(occurrenceId);
}
async assignAssistant(
occurrenceId: string,
assignAssistantDto: { assistantId: string },
performedBy: string,
): Promise<OccurrenceResponseDto> {
// Check if occurrence exists
await this.findOne(occurrenceId);
// Validate that assistant exists
const [assistant] = await this.drizzle.db
.select()
.from(assistants)
.where(eq(assistants.id, assignAssistantDto.assistantId))
.limit(1);
if (!assistant) {
throw new BadRequestException('Assistant not found');
}
// Check if assistant is already assigned to this occurrence
const [existingAssignment] = await this.drizzle.db
.select()
.from(occurrenceAssistants)
.where(
and(
eq(occurrenceAssistants.occurrenceId, occurrenceId),
eq(occurrenceAssistants.assistantId, assignAssistantDto.assistantId),
),
)
.limit(1);
if (existingAssignment) {
throw new BadRequestException(
'Assistant is already assigned to this occurrence',
);
}
// Create new assignment
const assignmentId = this.drizzle.generateId();
await this.drizzle.db.insert(occurrenceAssistants).values({
id: assignmentId,
occurrenceId: occurrenceId,
assistantId: assignAssistantDto.assistantId,
assignedBy: performedBy,
});
// Get current occurrence status for logging
const [currentOccurrence] = await this.drizzle.db
.select({ status: occurrences.status })
.from(occurrences)
.where(eq(occurrences.id, occurrenceId))
.limit(1);
// Log the assistant assignment
await this.logService.logAssistantAssigned(
occurrenceId,
performedBy,
assistant.email,
currentOccurrence?.status,
);
return this.getOccurrenceWithRelations(occurrenceId);
}
async unassignAssistant(
occurrenceId: string,
assistantId: string,
performedBy: string,
): Promise<OccurrenceResponseDto> {
// Check if occurrence exists
await this.findOne(occurrenceId);
// Check if assignment exists
const [assignment] = await this.drizzle.db
.select()
.from(occurrenceAssistants)
.where(
and(
eq(occurrenceAssistants.occurrenceId, occurrenceId),
eq(occurrenceAssistants.assistantId, assistantId),
),
)
.limit(1);
if (!assignment) {
throw new BadRequestException(
'Assistant is not assigned to this occurrence',
);
}
// Get assistant info for logging
const [assistant] = await this.drizzle.db
.select()
.from(assistants)
.where(eq(assistants.id, assistantId))
.limit(1);
// Remove assignment
await this.drizzle.db
.delete(occurrenceAssistants)
.where(
and(
eq(occurrenceAssistants.occurrenceId, occurrenceId),
eq(occurrenceAssistants.assistantId, assistantId),
),
);
// Get current occurrence status for logging
const [currentOccurrence] = await this.drizzle.db
.select({ status: occurrences.status })
.from(occurrences)
.where(eq(occurrences.id, occurrenceId))
.limit(1);
// Log the assistant unassignment
await this.logService.logAssistantUnassigned(
occurrenceId,
performedBy,
assistant?.email || 'Unknown Assistant',
currentOccurrence?.status,
);
return this.getOccurrenceWithRelations(occurrenceId);
}
async assignMultipleAssistants(
occurrenceId: string,
assignMultipleAssistantsDto: { assistantIds: string[] },
performedBy: string,
): Promise<OccurrenceResponseDto> {
// Check if occurrence exists
await this.findOne(occurrenceId);
// Validate that all assistants exist
const foundAssistants = await this.drizzle.db
.select()
.from(assistants)
.where(
or(
...assignMultipleAssistantsDto.assistantIds.map((id) =>
eq(assistants.id, id),
),
),
);
if (
foundAssistants.length !== assignMultipleAssistantsDto.assistantIds.length
) {
const foundIds = foundAssistants.map((a) => a.id);
const missingIds = assignMultipleAssistantsDto.assistantIds.filter(
(id) => !foundIds.includes(id),
);
throw new BadRequestException(
`Assistants not found: ${missingIds.join(', ')}`,
);
}
// Check for existing assignments
const existingAssignments = await this.drizzle.db
.select()
.from(occurrenceAssistants)
.where(
and(
eq(occurrenceAssistants.occurrenceId, occurrenceId),
or(
...assignMultipleAssistantsDto.assistantIds.map((id) =>
eq(occurrenceAssistants.assistantId, id),
),
),
),
);
if (existingAssignments.length > 0) {
const alreadyAssignedIds = existingAssignments.map((a) => a.assistantId);
throw new BadRequestException(
`Assistants already assigned: ${alreadyAssignedIds.join(', ')}`,
);
}
// Create all assignments
const assignments = assignMultipleAssistantsDto.assistantIds.map(
(assistantId) => ({
id: this.drizzle.generateId(),
occurrenceId: occurrenceId,
assistantId: assistantId,
assignedBy: performedBy,
}),
);
await this.drizzle.db.insert(occurrenceAssistants).values(assignments);
// Get current occurrence status for logging
const [currentOccurrence] = await this.drizzle.db
.select({ status: occurrences.status })
.from(occurrences)
.where(eq(occurrences.id, occurrenceId))
.limit(1);
// Log the bulk assignment
const assistantEmails = foundAssistants.map((a) => a.email).join(', ');
await this.logService.logAssistantAssigned(
occurrenceId,
performedBy,
`Multiple Assistants: ${assistantEmails}`,
currentOccurrence?.status,
);
return this.getOccurrenceWithRelations(occurrenceId);
}
}
......@@ -4,6 +4,7 @@ export type OccurrenceStatus =
| 'OPEN'
| 'IN_PROGRESS'
| 'RESOLVED'
| 'PAUSED'
| 'CLOSED'
| 'PARCIAL_RESOLVED'
| 'CANCELLED';
......@@ -21,6 +22,8 @@ export type LogAction =
| 'ATTACHMENT_REMOVED'
| 'CONCLUSION_ADDED'
| 'CONCLUSION_UPDATED'
| 'ASSISTANT_ASSIGNED'
| 'ASSISTANT_UNASSIGNED'
| 'DELETED';
// Enum constants for runtime usage
......@@ -34,6 +37,7 @@ export const OccurrenceStatusEnum = {
OPEN: 'OPEN' as const,
IN_PROGRESS: 'IN_PROGRESS' as const,
RESOLVED: 'RESOLVED' as const,
PAUSED: 'PAUSED' as const,
CLOSED: 'CLOSED' as const,
PARCIAL_RESOLVED: 'PARCIAL_RESOLVED' as const,
CANCELLED: 'CANCELLED' as const,
......@@ -59,5 +63,7 @@ export const LogActionEnum = {
ATTACHMENT_REMOVED: 'ATTACHMENT_REMOVED' as const,
CONCLUSION_ADDED: 'CONCLUSION_ADDED' as const,
CONCLUSION_UPDATED: 'CONCLUSION_UPDATED' as const,
ASSISTANT_ASSIGNED: 'ASSISTANT_ASSIGNED' as const,
ASSISTANT_UNASSIGNED: 'ASSISTANT_UNASSIGNED' as const,
DELETED: 'DELETED' as const,
} as const;
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