Commit e3976b1e by Augusto

MVP

parent 1356dcd8
-- SQL Migration: Add active field to users table
-- This script adds the 'active' field to the users table and sets all existing users as active
-- Add the active column to the users table
ALTER TABLE users
ADD COLUMN active BOOLEAN NOT NULL DEFAULT TRUE;
-- Create index on the active field for better query performance
CREATE INDEX active_idx ON users(active);
-- Update all existing users to be active (this is already the default, but being explicit)
UPDATE users SET active = TRUE WHERE active IS NULL;
-- Optional: Verify the changes
SELECT
id,
firstName,
lastName,
email,
user_role,
active,
createdAt
FROM users
LIMIT 5;
-- SQL Migration: Create System User for Automatic Comments
-- This script creates a system user that will be used to author system-generated comments
-- that cannot be deleted.
-- Insert system user if it doesn't exist
INSERT IGNORE INTO users (
id,
firstName,
lastName,
email,
user_role,
password,
createdAt,
updatedAt
) VALUES (
'system_user_001', -- You can use any unique ID here
'System',
'User',
'system@unike.com',
'ADMIN',
'system_user_no_login', -- This user should never be able to login
NOW(),
NOW()
);
-- Optional: Verify the system user was created
SELECT
id,
firstName,
lastName,
email,
user_role,
createdAt
FROM users
WHERE email = 'system@unike.com';
ALTER TABLE `attachments` ADD `attachment_type` enum('GENERIC','PRODUCTION') DEFAULT 'GENERIC' NOT NULL;--> statement-breakpoint
ALTER TABLE `occurrences` ADD CONSTRAINT `occurrences_reference_unique` UNIQUE(`reference`);
\ No newline at end of file
ALTER TABLE `occurrences` MODIFY COLUMN `reference` varchar(500);
\ No newline at end of file
ALTER TABLE `occurrences` ADD `awardDate` timestamp;--> statement-breakpoint
ALTER TABLE `occurrences` ADD `oniReporter` varchar(255);
\ No newline at end of file
ALTER TABLE `comments` MODIFY COLUMN `status` enum('OPEN','ASSIGNED','IN_PROGRESS','RESOLVED','PAUSED','CLOSED','PARCIAL_RESOLVED','CANCELLED') NOT NULL DEFAULT 'OPEN';--> statement-breakpoint
ALTER TABLE `occurrence_logs` MODIFY COLUMN `status` enum('OPEN','ASSIGNED','IN_PROGRESS','RESOLVED','PAUSED','CLOSED','PARCIAL_RESOLVED','CANCELLED') NOT NULL DEFAULT 'OPEN';--> statement-breakpoint
ALTER TABLE `occurrences` MODIFY COLUMN `status` enum('OPEN','ASSIGNED','IN_PROGRESS','RESOLVED','PAUSED','CLOSED','PARCIAL_RESOLVED','CANCELLED') NOT NULL DEFAULT 'OPEN';--> statement-breakpoint
ALTER TABLE `users` ADD `active` boolean DEFAULT true NOT NULL;--> statement-breakpoint
CREATE INDEX `active_idx` ON `users` (`active`);
\ No newline at end of file
......@@ -50,6 +50,34 @@
"when": 1757687180760,
"tag": "0006_past_supernaut",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1758636750679,
"tag": "0007_careful_felicia_hardy",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1758636891644,
"tag": "0008_flat_nighthawk",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1758637348278,
"tag": "0009_gigantic_mac_gargan",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1758791693736,
"tag": "0010_strange_gateway",
"breakpoints": true
}
]
}
\ No newline at end of file
......@@ -21,6 +21,7 @@ export const userRoleEnum = mysqlEnum('user_role', [
]);
export const occurrenceStatusEnum = mysqlEnum('status', [
'OPEN',
'ASSIGNED',
'IN_PROGRESS',
'RESOLVED',
'PAUSED',
......@@ -51,6 +52,10 @@ export const logActionEnum = mysqlEnum('log_action', [
'ASSISTANT_UNASSIGNED',
'DELETED',
]);
export const attachmentTypeEnum = mysqlEnum('attachment_type', [
'GENERIC',
'PRODUCTION',
]);
// Users table
export const users = mysqlTable(
......@@ -62,11 +67,13 @@ export const users = mysqlTable(
email: varchar('email', { length: 255 }).notNull().unique(),
user_role: userRoleEnum.notNull().default('USER'),
password: varchar('password', { length: 255 }).notNull(),
active: boolean('active').notNull().default(true),
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt').notNull().defaultNow().onUpdateNow(),
},
(table) => ({
emailIdx: index('email_idx').on(table.email),
activeIdx: index('active_idx').on(table.active),
}),
);
......@@ -83,7 +90,9 @@ export const occurrences = mysqlTable(
location: varchar('location', { length: 255 }),
latitude: decimal('latitude', { precision: 10, scale: 8 }),
longitude: decimal('longitude', { precision: 11, scale: 8 }),
reference: text('reference'),
reference: varchar('reference', { length: 500 }).unique(),
awardDate: timestamp('awardDate'),
oniReporter: varchar('oniReporter', { length: 255 }),
reporterId: varchar('reporterId', { length: 25 }).notNull(),
assigneeId: varchar('assigneeId', { length: 25 }),
managerId: varchar('managerId', { length: 25 }),
......@@ -130,6 +139,7 @@ export const attachments = mysqlTable(
size: int('size').notNull(),
path: varchar('path', { length: 500 }).notNull(),
occurrenceId: varchar('occurrenceId', { length: 25 }).notNull(),
attachmentType: attachmentTypeEnum.notNull().default('GENERIC'),
createdAt: timestamp('createdAt').notNull().defaultNow(),
},
(table) => ({
......@@ -165,7 +175,7 @@ export const occurrenceLogs = mysqlTable(
oldValue: text('oldValue'),
newValue: text('newValue'),
performedBy: varchar('performedBy', { length: 25 }).notNull(),
status: occurrenceStatusEnum,
status: occurrenceStatusEnum.notNull().default('OPEN'),
createdAt: timestamp('createdAt').notNull().defaultNow(),
},
(table) => ({
......@@ -329,8 +339,10 @@ export type NewOccurrenceAssistant = typeof occurrenceAssistants.$inferInsert;
export type UserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export type OccurrenceStatus =
| 'OPEN'
| 'ASSIGNED'
| 'IN_PROGRESS'
| 'RESOLVED'
| 'PAUSED'
| 'CLOSED'
| 'PARCIAL_RESOLVED'
| 'CANCELLED';
......@@ -351,6 +363,7 @@ export type LogAction =
| 'ASSISTANT_ASSIGNED'
| 'ASSISTANT_UNASSIGNED'
| 'DELETED';
export type AttachmentType = 'GENERIC' | 'PRODUCTION';
// Enum constants for runtime usage
export const UserRoleEnum = {
......@@ -361,8 +374,10 @@ export const UserRoleEnum = {
export const OccurrenceStatusEnum = {
OPEN: 'OPEN' as const,
ASSIGNED: 'ASSIGNED' 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,
......@@ -392,3 +407,8 @@ export const LogActionEnum = {
ASSISTANT_UNASSIGNED: 'ASSISTANT_UNASSIGNED' as const,
DELETED: 'DELETED' as const,
} as const;
export const AttachmentTypeEnum = {
GENERIC: 'GENERIC' as const,
PRODUCTION: 'PRODUCTION' as const,
} as const;
......@@ -82,8 +82,13 @@ export class AttachmentController {
async uploadFile(
@UploadedFile() file: Express.Multer.File,
@Body() uploadAttachmentDto: UploadAttachmentDto,
@CurrentUser() user: UserResponseDto,
): Promise<AttachmentResponseDto> {
return this.attachmentService.uploadFile(file, uploadAttachmentDto);
return this.attachmentService.uploadFile(
file,
uploadAttachmentDto,
user.id,
);
}
@Post('upload-multiple')
......@@ -128,10 +133,12 @@ export class AttachmentController {
async uploadMultipleFiles(
@UploadedFiles() files: Express.Multer.File[],
@Body() uploadAttachmentDto: UploadAttachmentDto,
@CurrentUser() user: UserResponseDto,
): Promise<AttachmentResponseDto[]> {
return this.attachmentService.uploadMultipleFiles(
files,
uploadAttachmentDto,
user.id,
);
}
......@@ -142,6 +149,12 @@ export class AttachmentController {
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiQuery({
name: 'type',
required: false,
enum: ['GENERIC', 'PRODUCTION'],
description: 'Filter by attachment type',
})
@ApiResponse({
status: 200,
description: 'List of attachments for the occurrence',
......@@ -157,8 +170,12 @@ export class AttachmentController {
})
async findByOccurrence(
@Param('occurrenceId') occurrenceId: string,
@Query('type') attachmentType?: 'GENERIC' | 'PRODUCTION',
): Promise<AttachmentResponseDto[]> {
return this.attachmentService.findByOccurrence(occurrenceId);
return this.attachmentService.findByOccurrence(
occurrenceId,
attachmentType,
);
}
@Get('stats')
......@@ -169,6 +186,12 @@ export class AttachmentController {
type: String,
description: 'Filter stats by occurrence ID',
})
@ApiQuery({
name: 'type',
required: false,
enum: ['GENERIC', 'PRODUCTION'],
description: 'Filter by attachment type',
})
@ApiResponse({
status: 200,
description: 'Attachment statistics',
......@@ -185,6 +208,13 @@ export class AttachmentController {
'image/png': 7,
},
},
byAttachmentType: {
type: 'object',
example: {
GENERIC: 30,
PRODUCTION: 12,
},
},
},
},
})
......@@ -192,12 +222,16 @@ export class AttachmentController {
status: 401,
description: 'Unauthorized',
})
async getStats(@Query('occurrenceId') occurrenceId?: string): Promise<{
async getStats(
@Query('occurrenceId') occurrenceId?: string,
@Query('type') attachmentType?: 'GENERIC' | 'PRODUCTION',
): Promise<{
totalFiles: number;
totalSize: number;
byMimeType: Record<string, number>;
byAttachmentType: Record<string, number>;
}> {
return this.attachmentService.getStats(occurrenceId);
return this.attachmentService.getStats(occurrenceId, attachmentType);
}
@Get(':id')
......
......@@ -3,10 +3,12 @@ import { MulterModule } from '@nestjs/platform-express';
import { AttachmentService } from './attachment.service';
import { AttachmentController } from './attachment.controller';
import { DrizzleService } from '../../common/drizzle.service';
import { CommentModule } from '../comment/comment.module';
import { memoryStorage } from 'multer';
@Module({
imports: [
CommentModule,
MulterModule.register({
storage: memoryStorage(), // Store files in memory for processing
limits: {
......
......@@ -5,8 +5,9 @@ import {
InternalServerErrorException,
} from '@nestjs/common';
import { DrizzleService } from '../../common/drizzle.service';
import { CommentService } from '../comment/comment.service';
import { attachments, occurrences } from '../../drizzle/schema';
import { eq, desc, count, sum } from 'drizzle-orm';
import { eq, desc, count, sum, and } from 'drizzle-orm';
import { AttachmentResponseDto } from './dto/attachment-response.dto';
import { UploadAttachmentDto } from './dto/upload-attachment.dto';
import * as fs from 'fs';
......@@ -45,7 +46,10 @@ export class AttachmentService {
'application/x-7z-compressed',
];
constructor(private readonly drizzle: DrizzleService) {
constructor(
private readonly drizzle: DrizzleService,
private readonly commentService: CommentService,
) {
this.ensureUploadDirectory();
}
......@@ -59,6 +63,7 @@ export class AttachmentService {
async uploadFile(
file: Express.Multer.File,
uploadAttachmentDto: UploadAttachmentDto,
performedBy?: string,
): Promise<AttachmentResponseDto> {
// Validate file
this.validateFile(file);
......@@ -97,8 +102,28 @@ export class AttachmentService {
size: file.size,
path: filePath,
occurrenceId: uploadAttachmentDto.occurrenceId,
attachmentType: uploadAttachmentDto.attachmentType || 'GENERIC',
});
// Create system comment for attachment addition
try {
const attachmentTypeText =
uploadAttachmentDto.attachmentType === 'PRODUCTION'
? 'Produção'
: 'Genérico';
await this.commentService.createSystemComment(
uploadAttachmentDto.occurrenceId,
`Anexo adicionado (${attachmentTypeText}): ${file.originalname}`,
true,
performedBy,
);
} catch (commentError) {
console.error(
'Failed to create system comment for attachment:',
commentError,
);
}
// Get the created attachment
const [attachment] = await this.drizzle.db
.select()
......@@ -126,6 +151,7 @@ export class AttachmentService {
async uploadMultipleFiles(
files: Express.Multer.File[],
uploadAttachmentDto: UploadAttachmentDto,
performedBy?: string,
): Promise<AttachmentResponseDto[]> {
if (!files || files.length === 0) {
throw new BadRequestException('No files provided');
......@@ -173,6 +199,7 @@ export class AttachmentService {
size: file.size,
path: filePath,
occurrenceId: uploadAttachmentDto.occurrenceId,
attachmentType: uploadAttachmentDto.attachmentType || 'GENERIC',
});
// Get the created attachment
......@@ -185,6 +212,28 @@ export class AttachmentService {
uploadedAttachments.push(attachment);
}
// Create system comment for multiple attachments
try {
const attachmentTypeText =
uploadAttachmentDto.attachmentType === 'PRODUCTION'
? 'Produção'
: 'Genérico';
const fileNames = uploadedAttachments
.map((att) => att.originalName)
.join(', ');
await this.commentService.createSystemComment(
uploadAttachmentDto.occurrenceId,
`Anexos adicionados (${attachmentTypeText}): ${fileNames}`,
true,
performedBy,
);
} catch (commentError) {
console.error(
'Failed to create system comment for multiple attachments:',
commentError,
);
}
return uploadedAttachments;
} catch (error) {
// Clean up all uploaded files if any operation fails
......@@ -206,6 +255,7 @@ export class AttachmentService {
async findByOccurrence(
occurrenceId: string,
attachmentType?: 'GENERIC' | 'PRODUCTION',
): Promise<AttachmentResponseDto[]> {
// Validate that occurrence exists
const [occurrence] = await this.drizzle.db
......@@ -218,10 +268,18 @@ export class AttachmentService {
throw new NotFoundException('Occurrence not found');
}
// Build where conditions
const whereConditions: any[] = [eq(attachments.occurrenceId, occurrenceId)];
// Add type filter if provided
if (attachmentType) {
whereConditions.push(eq(attachments.attachmentType, attachmentType));
}
return this.drizzle.db
.select()
.from(attachments)
.where(eq(attachments.occurrenceId, occurrenceId))
.where(and(...whereConditions))
.orderBy(desc(attachments.createdAt));
}
......@@ -337,19 +395,32 @@ export class AttachmentService {
.slice(0, 100); // Limit length
}
async getStats(occurrenceId?: string): Promise<{
async getStats(
occurrenceId?: string,
attachmentType?: 'GENERIC' | 'PRODUCTION',
): Promise<{
totalFiles: number;
totalSize: number;
byMimeType: Record<string, number>;
byAttachmentType: Record<string, number>;
}> {
const whereClause = occurrenceId
? eq(attachments.occurrenceId, occurrenceId)
: undefined;
// Build where conditions
const whereConditions: any[] = [];
if (occurrenceId) {
whereConditions.push(eq(attachments.occurrenceId, occurrenceId));
}
if (attachmentType) {
whereConditions.push(eq(attachments.attachmentType, attachmentType));
}
const whereClause =
whereConditions.length > 0 ? and(...whereConditions) : undefined;
const attachmentList = await this.drizzle.db
.select({
size: attachments.size,
mimeType: attachments.mimeType,
attachmentType: attachments.attachmentType,
})
.from(attachments)
.where(whereClause);
......@@ -365,10 +436,19 @@ export class AttachmentService {
{} as Record<string, number>,
);
const byAttachmentType = attachmentList.reduce(
(acc, att) => {
acc[att.attachmentType] = (acc[att.attachmentType] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return {
totalFiles,
totalSize,
byMimeType,
byAttachmentType,
};
}
}
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AttachmentType, AttachmentTypeEnum } from '../../../types';
export class UploadAttachmentDto {
@ApiProperty({
......@@ -9,4 +10,14 @@ export class UploadAttachmentDto {
@IsString()
@IsNotEmpty()
occurrenceId: string;
@ApiPropertyOptional({
description: 'Type of attachment (generic or production)',
enum: AttachmentTypeEnum,
example: AttachmentTypeEnum.GENERIC,
default: AttachmentTypeEnum.GENERIC,
})
@IsOptional()
@IsEnum(AttachmentTypeEnum)
attachmentType?: AttachmentType = AttachmentTypeEnum.GENERIC;
}
......@@ -80,6 +80,12 @@ export class CommentController {
type: Number,
description: 'Number of items per page (default: 20)',
})
@ApiQuery({
name: 'order',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order by creation date (default: asc)',
})
@ApiResponse({
status: 200,
description: 'List of comments for the occurrence',
......@@ -98,12 +104,14 @@ export class CommentController {
@CurrentUser() user: UserResponseDto,
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('order') order?: 'asc' | 'desc',
): Promise<CommentResponseDto[]> {
return this.commentService.findByOccurrence(
occurrenceId,
user.user_role,
page ? Number(page) : undefined,
limit ? Number(limit) : undefined,
order || 'asc',
);
}
......@@ -307,6 +315,12 @@ export class CommentController {
type: Number,
description: 'Number of items per page (default: 20)',
})
@ApiQuery({
name: 'order',
required: false,
enum: ['asc', 'desc'],
description: 'Sort order by creation date (default: asc)',
})
@ApiResponse({
status: 200,
description: 'List of internal comments for the occurrence',
......@@ -328,6 +342,7 @@ export class CommentController {
@Param('occurrenceId') occurrenceId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('order') order?: 'asc' | 'desc',
): Promise<CommentResponseDto[]> {
// Since this endpoint is restricted to admins/moderators, we pass ADMIN role
// which will show all comments including internal ones, then we'll filter
......@@ -336,6 +351,7 @@ export class CommentController {
UserRoleEnum.ADMIN,
page ? Number(page) : undefined,
limit ? Number(limit) : undefined,
order || 'asc',
);
// Filter to only internal comments
......
......@@ -10,7 +10,7 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentResponseDto } from './dto/comment-response.dto';
import { UserRole, UserRoleEnum } from '../../types';
import { comments, users, occurrences } from '../../drizzle/schema';
import { eq, and, desc, count } from 'drizzle-orm';
import { eq, and, desc, asc, count } from 'drizzle-orm';
@Injectable()
export class CommentService {
......@@ -93,6 +93,7 @@ export class CommentService {
userRole: UserRole,
page: number = 1,
limit: number = 20,
order: 'asc' | 'desc' = 'asc',
): Promise<CommentResponseDto[]> {
// Validate that occurrence exists
const [occurrence] = await this.drizzle.db
......@@ -138,7 +139,9 @@ export class CommentService {
.from(comments)
.leftJoin(users, eq(comments.authorId, users.id))
.where(and(...whereConditions))
.orderBy(comments.createdAt)
.orderBy(
order === 'desc' ? desc(comments.createdAt) : asc(comments.createdAt),
)
.limit(limit)
.offset(offset);
......@@ -254,6 +257,11 @@ export class CommentService {
throw new ForbiddenException('Access denied to internal comment');
}
// System comments cannot be deleted (identified by system user email)
if (comment.author.email === 'system@unike.com') {
throw new ForbiddenException('System comments cannot be deleted');
}
// Check if user can delete the comment
// Users can only delete their own comments, admins and moderators can delete any comment
if (
......@@ -297,4 +305,91 @@ export class CommentService {
return result.count;
}
async createSystemComment(
occurrenceId: string,
content: string,
isInternal: boolean = false,
performedBy?: string,
): Promise<CommentResponseDto> {
// Validate that occurrence exists
const [occurrence] = await this.drizzle.db
.select()
.from(occurrences)
.where(eq(occurrences.id, occurrenceId))
.limit(1);
if (!occurrence) {
throw new BadRequestException('Occurrence not found');
}
// Get or create system user
let systemUser = await this.getOrCreateSystemUser();
// If performedBy is provided, get user information and append to content
let finalContent = content;
if (performedBy) {
try {
const [user] = await this.drizzle.db
.select({
firstName: users.firstName,
lastName: users.lastName,
})
.from(users)
.where(eq(users.id, performedBy))
.limit(1);
if (user) {
finalContent = `${content} Por ${user.firstName} ${user.lastName}`;
}
} catch (error) {
console.error(
'Failed to get user information for system comment:',
error,
);
// Continue with original content if user lookup fails
}
}
// Create system comment
const commentId = this.drizzle.generateId();
await this.drizzle.db.insert(comments).values({
id: commentId,
content: finalContent,
isInternal: isInternal,
occurrenceId: occurrenceId,
authorId: systemUser.id,
});
return this.getCommentWithRelations(commentId);
}
private async getOrCreateSystemUser(): Promise<{
id: string;
email: string;
}> {
// Try to find existing system user
const [existingSystemUser] = await this.drizzle.db
.select()
.from(users)
.where(eq(users.email, 'system@unike.com'))
.limit(1);
if (existingSystemUser) {
return existingSystemUser;
}
// Create system user if it doesn't exist
const systemUserId = this.drizzle.generateId();
await this.drizzle.db.insert(users).values({
id: systemUserId,
firstName: 'System',
lastName: 'User',
email: 'system@unike.com',
user_role: 'ADMIN',
password: 'system_user_no_login', // This user should never be able to login
});
return { id: systemUserId, email: 'system@unike.com' };
}
}
......@@ -7,6 +7,7 @@ import {
MaxLength,
IsNumber,
IsDecimal,
IsDateString,
} from 'class-validator';
import {
OccurrenceStatus,
......@@ -70,7 +71,8 @@ export class CreateOccurrenceDto {
longitude?: number;
@ApiPropertyOptional({
description: 'Reference information for the occurrence location',
description:
'Reference information for the occurrence location (must be unique)',
example: 'Near the main entrance, next to the parking lot',
})
@IsOptional()
......@@ -78,6 +80,22 @@ export class CreateOccurrenceDto {
reference?: string;
@ApiPropertyOptional({
description: 'Award date for the occurrence',
example: '2024-01-15T10:30:00.000Z',
})
@IsOptional()
@IsDateString()
awardDate?: string;
@ApiPropertyOptional({
description: 'ONI reporter name',
example: 'John Smith',
})
@IsOptional()
@IsString()
oniReporter?: string;
@ApiPropertyOptional({
description: 'Occurrence priority level',
enum: PriorityEnum,
example: PriorityEnum.HIGH,
......
......@@ -23,7 +23,7 @@ export class LogResponseDto {
@ApiProperty({
description: 'Human-readable description of what happened',
example: 'Status changed from OPEN to IN_PROGRESS',
example: 'Status changed from OPEN to ASSIGNED',
})
description: string;
......@@ -35,7 +35,7 @@ export class LogResponseDto {
@ApiPropertyOptional({
description: 'New value after the change (if applicable)',
example: 'IN_PROGRESS',
example: 'ASSIGNED',
})
newValue?: string | null;
......@@ -47,7 +47,7 @@ export class LogResponseDto {
@ApiPropertyOptional({
description: 'Status of the occurrence at the time of this log entry',
example: 'IN_PROGRESS',
example: 'ASSIGNED',
})
status?: string | null;
......
......@@ -67,11 +67,23 @@ export class OccurrenceResponseDto {
longitude?: number | null;
@ApiPropertyOptional({
description: 'Reference information for the occurrence location',
description: 'Reference information for the occurrence location (unique)',
example: 'Near the main entrance, next to the parking lot',
})
reference?: string | null;
@ApiPropertyOptional({
description: 'Award date for the occurrence',
example: '2024-01-15T10:30:00.000Z',
})
awardDate?: Date | null;
@ApiPropertyOptional({
description: 'ONI reporter name',
example: 'John Smith',
})
oniReporter?: string | null;
@ApiProperty({
description: 'ID of the user who reported the occurrence',
example: 'clxyz123abc456def',
......
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsString, ArrayMinSize } from 'class-validator';
export class UnassignMultipleAssistantsDto {
@ApiProperty({
description: 'Array of assistant IDs to unassign from the occurrence',
example: ['clxyz123abc456def', 'clxyz789def012ghi', 'clxyz456ghi789jkl'],
type: [String],
})
@IsArray()
@ArrayMinSize(1, { message: 'At least one assistant ID must be provided' })
@IsString({ each: true })
assistantIds: string[];
}
......@@ -12,7 +12,7 @@ export class UpdateOccurrenceDto extends PartialType(CreateOccurrenceDto) {
@ApiPropertyOptional({
description: 'Occurrence status',
enum: OccurrenceStatusEnum,
example: OccurrenceStatusEnum.IN_PROGRESS,
example: OccurrenceStatusEnum.ASSIGNED,
})
@IsOptional()
@IsEnum(OccurrenceStatusEnum)
......
......@@ -28,7 +28,7 @@ export class OccurrenceLogService {
oldValue: oldValue || null,
newValue: newValue || null,
performedBy,
status: status || null,
status: status || 'OPEN',
});
}
......@@ -110,6 +110,7 @@ export class OccurrenceLogService {
performedBy,
oldStatus,
newStatus,
newStatus,
);
}
......
......@@ -29,6 +29,7 @@ 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 { UnassignMultipleAssistantsDto } from './dto/unassign-multiple-assistants.dto';
import { LogResponseDto } from './dto/log-response.dto';
import {
OccurrenceStatus,
......@@ -64,6 +65,10 @@ export class OccurrenceController {
status: 400,
description: 'Invalid input data or user not found',
})
@ApiResponse({
status: 409,
description: 'An occurrence with this reference already exists',
})
async create(
@Body() createOccurrenceDto: CreateOccurrenceDto,
): Promise<OccurrenceResponseDto> {
......@@ -216,6 +221,7 @@ export class OccurrenceController {
type: 'object',
properties: {
OPEN: { type: 'number', example: 25 },
ASSIGNED: { type: 'number', example: 15 },
IN_PROGRESS: { type: 'number', example: 30 },
RESOLVED: { type: 'number', example: 20 },
CLOSED: { type: 'number', example: 15 },
......@@ -364,11 +370,16 @@ export class OccurrenceController {
status: 400,
description: 'Invalid input data or user not found',
})
@ApiResponse({
status: 409,
description: 'An occurrence with this reference already exists',
})
async update(
@Param('id') id: string,
@Body() updateOccurrenceDto: UpdateOccurrenceDto,
@CurrentUser() user: UserResponseDto,
): Promise<OccurrenceResponseDto> {
return this.occurrenceService.update(id, updateOccurrenceDto);
return this.occurrenceService.update(id, updateOccurrenceDto, user.id);
}
@Patch(':id/assign')
......@@ -616,4 +627,42 @@ export class OccurrenceController {
async remove(@Param('id') id: string): Promise<void> {
return this.occurrenceService.remove(id);
}
@Patch(':id/unassign-multiple-assistants')
@ApiOperation({
summary: 'Unassign multiple assistants from occurrence',
description:
'Remove multiple assistants from the occurrence in a single operation. Creates only one system comment instead of multiple individual comments.',
})
@ApiParam({
name: 'id',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiBody({ type: UnassignMultipleAssistantsDto })
@ApiResponse({
status: 200,
description: 'Multiple assistants unassigned successfully',
type: OccurrenceResponseDto,
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
@ApiResponse({
status: 400,
description:
'No valid assistants found or none are assigned to this occurrence',
})
async unassignMultipleAssistants(
@Param('id') id: string,
@Body() unassignMultipleAssistantsDto: UnassignMultipleAssistantsDto,
@CurrentUser() user: UserResponseDto,
): Promise<OccurrenceResponseDto> {
return this.occurrenceService.unassignMultipleAssistants(
id,
unassignMultipleAssistantsDto.assistantIds,
user.id,
);
}
}
......@@ -3,8 +3,10 @@ import { OccurrenceService } from './occurrence.service';
import { OccurrenceController } from './occurrence.controller';
import { OccurrenceLogService } from './occurrence-log.service';
import { DrizzleService } from '../../common/drizzle.service';
import { CommentModule } from '../comment/comment.module';
@Module({
imports: [CommentModule],
controllers: [OccurrenceController],
providers: [OccurrenceService, OccurrenceLogService, DrizzleService],
exports: [OccurrenceService, OccurrenceLogService], // Export services for use in other modules
......
......@@ -5,6 +5,8 @@ import {
IsNotEmpty,
IsString,
MinLength,
IsOptional,
IsBoolean,
} from 'class-validator';
import { UserRole, UserRoleEnum } from '../../../types';
......@@ -51,4 +53,13 @@ export class CreateUserDto {
})
@IsEnum(UserRoleEnum)
user_role?: UserRole = UserRoleEnum.USER;
@ApiProperty({
description: 'Whether the user is active or inactive',
example: true,
default: true,
})
@IsOptional()
@IsBoolean()
active?: boolean = true;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty } from 'class-validator';
export class ToggleUserStatusDto {
@ApiProperty({
description: 'Whether the user should be active or inactive',
example: true,
})
@IsNotEmpty()
@IsBoolean()
active: boolean;
}
......@@ -34,6 +34,12 @@ export class UserResponseDto {
user_role: UserRole;
@ApiProperty({
description: 'Whether the user is active or inactive',
example: true,
})
active: boolean;
@ApiProperty({
description: 'User creation timestamp',
example: '2024-01-01T12:00:00.000Z',
})
......
......@@ -23,6 +23,7 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { AdminChangePasswordDto } from './dto/admin-change-password.dto';
import { ToggleUserStatusDto } from './dto/toggle-user-status.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@ApiTags('users')
......@@ -242,4 +243,32 @@ export class UserController {
async remove(@Param('id') id: string): Promise<void> {
return this.userService.remove(id);
}
@Patch(':id/toggle-status')
@ApiOperation({ summary: 'Toggle user active status' })
@ApiParam({
name: 'id',
description: 'User unique identifier',
example: 'clxyz123abc456def',
})
@ApiBody({ type: ToggleUserStatusDto })
@ApiResponse({
status: 200,
description: 'User status updated successfully',
type: UserResponseDto,
})
@ApiResponse({
status: 404,
description: 'User not found',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async toggleUserStatus(
@Param('id') id: string,
@Body() toggleUserStatusDto: ToggleUserStatusDto,
): Promise<UserResponseDto> {
return this.userService.toggleUserStatus(id, toggleUserStatusDto);
}
}
......@@ -13,6 +13,7 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { AdminChangePasswordDto } from './dto/admin-change-password.dto';
import { ToggleUserStatusDto } from './dto/toggle-user-status.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
......@@ -43,6 +44,7 @@ export class UserService {
email: createUserDto.email,
user_role: createUserDto.user_role || 'USER',
password: hashedPassword,
active: createUserDto.active !== undefined ? createUserDto.active : true,
});
// Get the created user
......@@ -53,6 +55,7 @@ export class UserService {
lastName: users.lastName,
email: users.email,
user_role: users.user_role,
active: users.active,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
......@@ -71,6 +74,7 @@ export class UserService {
lastName: users.lastName,
email: users.email,
user_role: users.user_role,
active: users.active,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
......@@ -88,6 +92,7 @@ export class UserService {
lastName: users.lastName,
email: users.email,
user_role: users.user_role,
active: users.active,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
......@@ -110,6 +115,7 @@ export class UserService {
lastName: users.lastName,
email: users.email,
user_role: users.user_role,
active: users.active,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
......@@ -156,6 +162,7 @@ export class UserService {
lastName: users.lastName,
email: users.email,
user_role: users.user_role,
active: users.active,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
......@@ -244,4 +251,44 @@ export class UserService {
return { message: 'Password changed successfully by admin' };
}
async toggleUserStatus(
userId: string,
toggleUserStatusDto: ToggleUserStatusDto,
): Promise<UserResponseDto> {
// Check if user exists
const [existingUser] = await this.drizzle.db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!existingUser) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
// Update user active status
await this.drizzle.db
.update(users)
.set({ active: toggleUserStatusDto.active })
.where(eq(users.id, userId));
// Get the updated user
const [user] = await this.drizzle.db
.select({
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
user_role: users.user_role,
active: users.active,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user;
}
}
......@@ -2,6 +2,7 @@
export type UserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export type OccurrenceStatus =
| 'OPEN'
| 'ASSIGNED'
| 'IN_PROGRESS'
| 'RESOLVED'
| 'PAUSED'
......@@ -25,6 +26,7 @@ export type LogAction =
| 'ASSISTANT_ASSIGNED'
| 'ASSISTANT_UNASSIGNED'
| 'DELETED';
export type AttachmentType = 'GENERIC' | 'PRODUCTION';
// Enum constants for runtime usage
export const UserRoleEnum = {
......@@ -35,6 +37,7 @@ export const UserRoleEnum = {
export const OccurrenceStatusEnum = {
OPEN: 'OPEN' as const,
ASSIGNED: 'ASSIGNED' as const,
IN_PROGRESS: 'IN_PROGRESS' as const,
RESOLVED: 'RESOLVED' as const,
PAUSED: 'PAUSED' as const,
......@@ -67,3 +70,8 @@ export const LogActionEnum = {
ASSISTANT_UNASSIGNED: 'ASSISTANT_UNASSIGNED' as const,
DELETED: 'DELETED' as const,
} as const;
export const AttachmentTypeEnum = {
GENERIC: 'GENERIC' as const,
PRODUCTION: 'PRODUCTION' 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