Commit 41932462 by Augusto

logs

parent 4abd89bc
CREATE TABLE `occurrence_logs` (
`id` varchar(25) NOT NULL,
`occurrenceId` varchar(25) NOT NULL,
`log_action` enum('CREATED','UPDATED','ASSIGNED','DUAL_ASSIGNED','UNASSIGNED','STATUS_CHANGED','PRIORITY_CHANGED','COMMENT_ADDED','ATTACHMENT_ADDED','ATTACHMENT_REMOVED','CONCLUSION_ADDED','CONCLUSION_UPDATED','DELETED') NOT NULL,
`description` text NOT NULL,
`oldValue` text,
`newValue` text,
`performedBy` varchar(25) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT now(),
CONSTRAINT `occurrence_logs_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `log_occurrence_idx` ON `occurrence_logs` (`occurrenceId`);--> statement-breakpoint
CREATE INDEX `log_performed_by_idx` ON `occurrence_logs` (`performedBy`);--> statement-breakpoint
CREATE INDEX `log_action_idx` ON `occurrence_logs` (`log_action`);--> statement-breakpoint
CREATE INDEX `log_created_at_idx` ON `occurrence_logs` (`createdAt`);
\ No newline at end of file
......@@ -8,6 +8,13 @@
"when": 1757350101348,
"tag": "0000_add_manager_to_occurrences",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1757352023356,
"tag": "0001_perpetual_human_fly",
"breakpoints": true
}
]
}
}
\ No newline at end of file
......@@ -32,6 +32,21 @@ export const priorityEnum = mysqlEnum('priority', [
'HIGH',
'URGENT',
]);
export const logActionEnum = mysqlEnum('log_action', [
'CREATED',
'UPDATED',
'ASSIGNED',
'DUAL_ASSIGNED',
'UNASSIGNED',
'STATUS_CHANGED',
'PRIORITY_CHANGED',
'COMMENT_ADDED',
'ATTACHMENT_ADDED',
'ATTACHMENT_REMOVED',
'CONCLUSION_ADDED',
'CONCLUSION_UPDATED',
'DELETED',
]);
// Users table
export const users = mysqlTable(
......@@ -84,6 +99,7 @@ export const comments = mysqlTable(
id: varchar('id', { length: 25 }).primaryKey(),
content: text('content').notNull(),
isInternal: boolean('isInternal').notNull().default(false),
status: occurrenceStatusEnum('status'),
occurrenceId: varchar('occurrenceId', { length: 25 }).notNull(),
authorId: varchar('authorId', { length: 25 }).notNull(),
createdAt: timestamp('createdAt').notNull().defaultNow(),
......@@ -130,12 +146,34 @@ export const conclusions = mysqlTable(
}),
);
// Occurrence logs table
export const occurrenceLogs = mysqlTable(
'occurrence_logs',
{
id: varchar('id', { length: 25 }).primaryKey(),
occurrenceId: varchar('occurrenceId', { length: 25 }).notNull(),
action: logActionEnum.notNull(),
description: text('description').notNull(),
oldValue: text('oldValue'),
newValue: text('newValue'),
performedBy: varchar('performedBy', { length: 25 }).notNull(),
createdAt: timestamp('createdAt').notNull().defaultNow(),
},
(table) => ({
occurrenceIdx: index('log_occurrence_idx').on(table.occurrenceId),
performedByIdx: index('log_performed_by_idx').on(table.performedBy),
actionIdx: index('log_action_idx').on(table.action),
createdAtIdx: index('log_created_at_idx').on(table.createdAt),
}),
);
// Relations
export const usersRelations = relations(users, ({ many }) => ({
reportedOccurrences: many(occurrences, { relationName: 'reportedBy' }),
assignedOccurrences: many(occurrences, { relationName: 'assignedTo' }),
managedOccurrences: many(occurrences, { relationName: 'managedBy' }),
comments: many(comments),
logs: many(occurrenceLogs),
}));
export const occurrencesRelations = relations(occurrences, ({ one, many }) => ({
......@@ -157,6 +195,7 @@ export const occurrencesRelations = relations(occurrences, ({ one, many }) => ({
comments: many(comments),
attachments: many(attachments),
conclusion: one(conclusions),
logs: many(occurrenceLogs),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
......@@ -184,6 +223,17 @@ export const conclusionsRelations = relations(conclusions, ({ one }) => ({
}),
}));
export const occurrenceLogsRelations = relations(occurrenceLogs, ({ one }) => ({
occurrence: one(occurrences, {
fields: [occurrenceLogs.occurrenceId],
references: [occurrences.id],
}),
performedBy: one(users, {
fields: [occurrenceLogs.performedBy],
references: [users.id],
}),
}));
// Type exports for TypeScript
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
......@@ -195,6 +245,8 @@ export type Attachment = typeof attachments.$inferSelect;
export type NewAttachment = typeof attachments.$inferInsert;
export type Conclusion = typeof conclusions.$inferSelect;
export type NewConclusion = typeof conclusions.$inferInsert;
export type OccurrenceLog = typeof occurrenceLogs.$inferSelect;
export type NewOccurrenceLog = typeof occurrenceLogs.$inferInsert;
// Enum types
export type UserRole = 'USER' | 'MODERATOR' | 'ADMIN';
......@@ -206,6 +258,20 @@ export type OccurrenceStatus =
| 'PARCIAL_RESOLVED'
| 'CANCELLED';
export type Priority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
export type LogAction =
| 'CREATED'
| 'UPDATED'
| 'ASSIGNED'
| 'DUAL_ASSIGNED'
| 'UNASSIGNED'
| 'STATUS_CHANGED'
| 'PRIORITY_CHANGED'
| 'COMMENT_ADDED'
| 'ATTACHMENT_ADDED'
| 'ATTACHMENT_REMOVED'
| 'CONCLUSION_ADDED'
| 'CONCLUSION_UPDATED'
| 'DELETED';
// Enum constants for runtime usage
export const UserRoleEnum = {
......@@ -229,3 +295,19 @@ export const PriorityEnum = {
HIGH: 'HIGH' as const,
URGENT: 'URGENT' as const,
} as const;
export const LogActionEnum = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
ASSIGNED: 'ASSIGNED' as const,
DUAL_ASSIGNED: 'DUAL_ASSIGNED' as const,
UNASSIGNED: 'UNASSIGNED' as const,
STATUS_CHANGED: 'STATUS_CHANGED' as const,
PRIORITY_CHANGED: 'PRIORITY_CHANGED' as const,
COMMENT_ADDED: 'COMMENT_ADDED' as const,
ATTACHMENT_ADDED: 'ATTACHMENT_ADDED' as const,
ATTACHMENT_REMOVED: 'ATTACHMENT_REMOVED' as const,
CONCLUSION_ADDED: 'CONCLUSION_ADDED' as const,
CONCLUSION_UPDATED: 'CONCLUSION_UPDATED' as const,
DELETED: 'DELETED' as const,
} as const;
......@@ -46,6 +46,6 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
await app.listen(process.env.PORT ?? 3001);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { LogAction, LogActionEnum } from '../../../types';
export class LogResponseDto {
@ApiProperty({
description: 'Unique identifier for the log entry',
example: 'clxyz123abc456def',
})
id: string;
@ApiProperty({
description: 'ID of the occurrence this log entry belongs to',
example: 'clxyz123abc456def',
})
occurrenceId: string;
@ApiProperty({
description: 'Type of action that was performed',
enum: LogActionEnum,
example: LogActionEnum.STATUS_CHANGED,
})
action: LogAction;
@ApiProperty({
description: 'Human-readable description of what happened',
example: 'Status changed from OPEN to IN_PROGRESS',
})
description: string;
@ApiPropertyOptional({
description: 'Previous value before the change (if applicable)',
example: 'OPEN',
})
oldValue?: string | null;
@ApiPropertyOptional({
description: 'New value after the change (if applicable)',
example: 'IN_PROGRESS',
})
newValue?: string | null;
@ApiProperty({
description: 'ID of the user who performed the action',
example: 'clxyz789def012ghi',
})
performedBy: string;
@ApiProperty({
description: 'Date and time when the action was performed',
example: '2024-01-01T12:00:00.000Z',
})
createdAt: Date;
@ApiPropertyOptional({
description: 'User who performed the action',
type: 'object',
properties: {
id: { type: 'string', example: 'clxyz789def012ghi' },
firstName: { type: 'string', example: 'John' },
lastName: { type: 'string', example: 'Doe' },
email: { type: 'string', example: 'john.doe@example.com' },
},
})
performedByUser?: {
id: string;
firstName: string;
lastName: string;
email: string;
};
}
import { Injectable } from '@nestjs/common';
import { DrizzleService } from '../../common/drizzle.service';
import { LogResponseDto } from './dto/log-response.dto';
import { LogAction } from '../../types';
import { occurrenceLogs, users } from '../../drizzle/schema';
import { eq, desc } from 'drizzle-orm';
@Injectable()
export class OccurrenceLogService {
constructor(private readonly drizzle: DrizzleService) {}
async createLog(
occurrenceId: string,
action: LogAction,
description: string,
performedBy: string,
oldValue?: string,
newValue?: string,
): Promise<void> {
const logId = this.drizzle.generateId();
await this.drizzle.db.insert(occurrenceLogs).values({
id: logId,
occurrenceId,
action,
description,
oldValue: oldValue || null,
newValue: newValue || null,
performedBy,
});
}
async getTimeline(occurrenceId: string): Promise<LogResponseDto[]> {
const logs = await this.drizzle.db
.select({
id: occurrenceLogs.id,
occurrenceId: occurrenceLogs.occurrenceId,
action: occurrenceLogs.action,
description: occurrenceLogs.description,
oldValue: occurrenceLogs.oldValue,
newValue: occurrenceLogs.newValue,
performedBy: occurrenceLogs.performedBy,
createdAt: occurrenceLogs.createdAt,
performedByUser: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
},
})
.from(occurrenceLogs)
.leftJoin(users, eq(occurrenceLogs.performedBy, users.id))
.where(eq(occurrenceLogs.occurrenceId, occurrenceId))
.orderBy(desc(occurrenceLogs.createdAt));
return logs.map((log) => ({
id: log.id,
occurrenceId: log.occurrenceId,
action: log.action,
description: log.description,
oldValue: log.oldValue,
newValue: log.newValue,
performedBy: log.performedBy,
createdAt: log.createdAt,
performedByUser: log.performedByUser || undefined,
}));
}
// Helper methods for common logging scenarios
async logOccurrenceCreated(
occurrenceId: string,
performedBy: string,
title: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'CREATED',
`Occurrence "${title}" was created`,
performedBy,
);
}
async logOccurrenceUpdated(
occurrenceId: string,
performedBy: string,
changes: string[],
): Promise<void> {
await this.createLog(
occurrenceId,
'UPDATED',
`Occurrence updated: ${changes.join(', ')}`,
performedBy,
);
}
async logStatusChanged(
occurrenceId: string,
performedBy: string,
oldStatus: string,
newStatus: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'STATUS_CHANGED',
`Status changed from ${oldStatus} to ${newStatus}`,
performedBy,
oldStatus,
newStatus,
);
}
async logPriorityChanged(
occurrenceId: string,
performedBy: string,
oldPriority: string,
newPriority: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'PRIORITY_CHANGED',
`Priority changed from ${oldPriority} to ${newPriority}`,
performedBy,
oldPriority,
newPriority,
);
}
async logAssignment(
occurrenceId: string,
performedBy: string,
assigneeName: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'ASSIGNED',
`Occurrence assigned to ${assigneeName}`,
performedBy,
);
}
async logDualAssignment(
occurrenceId: string,
performedBy: string,
assigneeName: string,
managerName: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'DUAL_ASSIGNED',
`Occurrence assigned to ${assigneeName} with manager ${managerName}`,
performedBy,
);
}
async logUnassignment(
occurrenceId: string,
performedBy: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'UNASSIGNED',
'Occurrence was unassigned',
performedBy,
);
}
async logCommentAdded(
occurrenceId: string,
performedBy: string,
isInternal: boolean = false,
): Promise<void> {
const commentType = isInternal ? 'internal comment' : 'comment';
await this.createLog(
occurrenceId,
'COMMENT_ADDED',
`A ${commentType} was added`,
performedBy,
);
}
async logAttachmentAdded(
occurrenceId: string,
performedBy: string,
filename: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'ATTACHMENT_ADDED',
`Attachment "${filename}" was added`,
performedBy,
);
}
async logAttachmentRemoved(
occurrenceId: string,
performedBy: string,
filename: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'ATTACHMENT_REMOVED',
`Attachment "${filename}" was removed`,
performedBy,
);
}
async logConclusionAdded(
occurrenceId: string,
performedBy: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'CONCLUSION_ADDED',
'A conclusion was added',
performedBy,
);
}
async logConclusionUpdated(
occurrenceId: string,
performedBy: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'CONCLUSION_UPDATED',
'The conclusion was updated',
performedBy,
);
}
async logOccurrenceDeleted(
occurrenceId: string,
performedBy: string,
title: string,
): Promise<void> {
await this.createLog(
occurrenceId,
'DELETED',
`Occurrence "${title}" was deleted`,
performedBy,
);
}
}
......@@ -21,11 +21,13 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { OccurrenceService } from './occurrence.service';
import { OccurrenceLogService } from './occurrence-log.service';
import { CreateOccurrenceDto } from './dto/create-occurrence.dto';
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 { LogResponseDto } from './dto/log-response.dto';
import {
OccurrenceStatus,
Priority,
......@@ -36,12 +38,17 @@ import {
} from '../../types';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { UserResponseDto } from '../user/dto/user-response.dto';
@ApiTags('occurrences')
@ApiBearerAuth('JWT-auth')
@Controller('occurrences')
export class OccurrenceController {
constructor(private readonly occurrenceService: OccurrenceService) {}
constructor(
private readonly occurrenceService: OccurrenceService,
private readonly occurrenceLogService: OccurrenceLogService,
) {}
@Post()
@ApiOperation({ summary: 'Create a new occurrence' })
......@@ -308,6 +315,32 @@ export class OccurrenceController {
return this.occurrenceService.findOne(id);
}
@Get(':id/timeline')
@ApiOperation({
summary: 'Get occurrence timeline/logs',
description:
'Returns a chronological list of all actions and changes made to the occurrence',
})
@ApiParam({
name: 'id',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'Timeline of occurrence changes',
type: [LogResponseDto],
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
async getTimeline(@Param('id') id: string): Promise<LogResponseDto[]> {
// First check if occurrence exists
await this.occurrenceService.findOne(id);
return this.occurrenceLogService.getTimeline(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update occurrence by ID' })
@ApiParam({
......@@ -368,8 +401,13 @@ export class OccurrenceController {
async assignOccurrence(
@Param('id') id: string,
@Body() assignOccurrenceDto: AssignOccurrenceDto,
@CurrentUser() user: UserResponseDto,
): Promise<OccurrenceResponseDto> {
return this.occurrenceService.assignOccurrence(id, assignOccurrenceDto);
return this.occurrenceService.assignOccurrence(
id,
assignOccurrenceDto,
user.id,
);
}
@Patch(':id/dual-assign')
......@@ -407,10 +445,12 @@ export class OccurrenceController {
async dualAssignOccurrence(
@Param('id') id: string,
@Body() dualAssignOccurrenceDto: DualAssignOccurrenceDto,
@CurrentUser() user: UserResponseDto,
): Promise<OccurrenceResponseDto> {
return this.occurrenceService.dualAssignOccurrence(
id,
dualAssignOccurrenceDto,
user.id,
);
}
......@@ -438,8 +478,9 @@ export class OccurrenceController {
})
async unassignOccurrence(
@Param('id') id: string,
@CurrentUser() user: UserResponseDto,
): Promise<OccurrenceResponseDto> {
return this.occurrenceService.unassignOccurrence(id);
return this.occurrenceService.unassignOccurrence(id, user.id);
}
@Delete(':id')
......
import { Module } from '@nestjs/common';
import { OccurrenceService } from './occurrence.service';
import { OccurrenceController } from './occurrence.controller';
import { OccurrenceLogService } from './occurrence-log.service';
import { DrizzleService } from '../../common/drizzle.service';
@Module({
controllers: [OccurrenceController],
providers: [OccurrenceService, DrizzleService],
exports: [OccurrenceService], // Export service for use in other modules
providers: [OccurrenceService, OccurrenceLogService, DrizzleService],
exports: [OccurrenceService, OccurrenceLogService], // Export services for use in other modules
})
export class OccurrenceModule {}
......@@ -9,6 +9,7 @@ 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 { OccurrenceLogService } from './occurrence-log.service';
import {
OccurrenceStatus,
Priority,
......@@ -26,7 +27,10 @@ import { eq, and, or, like, desc, count, sql } from 'drizzle-orm';
@Injectable()
export class OccurrenceService {
constructor(private readonly drizzle: DrizzleService) {}
constructor(
private readonly drizzle: DrizzleService,
private readonly logService: OccurrenceLogService,
) {}
private async getOccurrenceWithRelations(
occurrenceId: string,
......@@ -162,6 +166,13 @@ export class OccurrenceService {
...createOccurrenceDto,
});
// Log the creation
await this.logService.logOccurrenceCreated(
occurrenceId,
createOccurrenceDto.reporterId,
createOccurrenceDto.title,
);
// Get the full occurrence with relations
const occurrence = await this.getOccurrenceWithRelations(occurrenceId);
......@@ -445,6 +456,7 @@ export class OccurrenceService {
async assignOccurrence(
occurrenceId: string,
assignOccurrenceDto: AssignOccurrenceDto,
performedBy: string,
): Promise<OccurrenceResponseDto> {
// Check if occurrence exists
await this.findOne(occurrenceId);
......@@ -481,11 +493,19 @@ export class OccurrenceService {
});
}
// Log the assignment
await this.logService.logAssignment(
occurrenceId,
performedBy,
`${assignee.firstName} ${assignee.lastName}`,
);
return this.getOccurrenceWithRelations(occurrenceId);
}
async unassignOccurrence(
occurrenceId: string,
performedBy: string,
): Promise<OccurrenceResponseDto> {
// Check if occurrence exists
await this.findOne(occurrenceId);
......@@ -499,12 +519,16 @@ export class OccurrenceService {
})
.where(eq(occurrences.id, occurrenceId));
// Log the unassignment
await this.logService.logUnassignment(occurrenceId, performedBy);
return this.getOccurrenceWithRelations(occurrenceId);
}
async dualAssignOccurrence(
occurrenceId: string,
dualAssignOccurrenceDto: DualAssignOccurrenceDto,
performedBy: string,
): Promise<OccurrenceResponseDto> {
// Check if occurrence exists
await this.findOne(occurrenceId);
......@@ -553,6 +577,14 @@ export class OccurrenceService {
});
}
// Log the dual assignment
await this.logService.logDualAssignment(
occurrenceId,
performedBy,
`${assignee.firstName} ${assignee.lastName}`,
`${manager.firstName} ${manager.lastName}`,
);
return this.getOccurrenceWithRelations(occurrenceId);
}
}
......@@ -8,6 +8,20 @@ export type OccurrenceStatus =
| 'PARCIAL_RESOLVED'
| 'CANCELLED';
export type Priority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
export type LogAction =
| 'CREATED'
| 'UPDATED'
| 'ASSIGNED'
| 'DUAL_ASSIGNED'
| 'UNASSIGNED'
| 'STATUS_CHANGED'
| 'PRIORITY_CHANGED'
| 'COMMENT_ADDED'
| 'ATTACHMENT_ADDED'
| 'ATTACHMENT_REMOVED'
| 'CONCLUSION_ADDED'
| 'CONCLUSION_UPDATED'
| 'DELETED';
// Enum constants for runtime usage
export const UserRoleEnum = {
......@@ -31,3 +45,19 @@ export const PriorityEnum = {
HIGH: 'HIGH' as const,
URGENT: 'URGENT' as const,
} as const;
export const LogActionEnum = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
ASSIGNED: 'ASSIGNED' as const,
DUAL_ASSIGNED: 'DUAL_ASSIGNED' as const,
UNASSIGNED: 'UNASSIGNED' as const,
STATUS_CHANGED: 'STATUS_CHANGED' as const,
PRIORITY_CHANGED: 'PRIORITY_CHANGED' as const,
COMMENT_ADDED: 'COMMENT_ADDED' as const,
ATTACHMENT_ADDED: 'ATTACHMENT_ADDED' as const,
ATTACHMENT_REMOVED: 'ATTACHMENT_REMOVED' as const,
CONCLUSION_ADDED: 'CONCLUSION_ADDED' as const,
CONCLUSION_UPDATED: 'CONCLUSION_UPDATED' 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