diff --git a/src/comment/comment.controller.ts b/src/comment/comment.controller.ts index 7ac6e35..0d0bd3f 100644 --- a/src/comment/comment.controller.ts +++ b/src/comment/comment.controller.ts @@ -24,7 +24,7 @@ export class CommentController { constructor(private readonly commentService: CommentService) {} @Get() - @Roles('technician') + @Roles('technician', 'collector') async findAll( @CurrentUser() user: CurrentUserPayload, @Param('ticketId') ticketId: string, @@ -33,7 +33,7 @@ export class CommentController { } @Post() - @Roles('technician') + @Roles('technician', 'collector') @UseInterceptors(FileFieldsInterceptor([{ name: 'files', maxCount: 3 }], multerOptions)) async create( @CurrentUser() user: CurrentUserPayload, diff --git a/src/main.ts b/src/main.ts index 464bd24..a97bbda 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,12 +2,17 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import helmet from 'helmet'; import { AppModule } from './app.module'; +import { join } from 'path'; +import * as express from 'express'; async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'], }); + // Serve uploaded files before helmet so they're not blocked + app.use('/uploads', express.static(join(__dirname, '..', 'uploads'))); + // Security headers app.use( helmet({ diff --git a/src/payment/payment.controller.ts b/src/payment/payment.controller.ts index 7af0332..5c32abe 100644 --- a/src/payment/payment.controller.ts +++ b/src/payment/payment.controller.ts @@ -42,8 +42,10 @@ export class PaymentController { @Get('unremitted') @Roles('technician', 'collector') - async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('collectorId') collectorId?: string) { - return this.paymentService.getUnremittedPayments(user.tenantId, collectorId || user.sub); + async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('all') all?: string) { + const isManager = user.roles?.some((r: string) => ['manager', 'tenant_admin', 'super_admin'].includes(r)); + // Managers see all unremitted; collectors see only their own + return this.paymentService.getUnremittedPayments(user.tenantId, isManager ? null : user.sub); } @Get('remittances') diff --git a/src/payment/payment.service.ts b/src/payment/payment.service.ts index 54dcfe1..5ebc358 100644 --- a/src/payment/payment.service.ts +++ b/src/payment/payment.service.ts @@ -158,7 +158,7 @@ export class PaymentService { })); } - async getUnremittedPayments(tenantId: string, collectorId: string) { + async getUnremittedPayments(tenantId: string, collectorId: string | null) { const remittedIds = (await this.prisma.remittancePayment.findMany({ where: { remittance: { tenantId } }, select: { paymentId: true }, @@ -166,10 +166,15 @@ export class PaymentService { .map((r) => r.paymentId); return this.prisma.payment.findMany({ - where: { tenantId, collectedById: collectorId, id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] } }, + where: { + tenantId, + ...(collectorId ? { collectedById: collectorId } : {}), + id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] }, + }, include: { client: { select: { firstName: true, lastName: true, accountNumber: true } }, invoice: { select: { id: true, number: true } }, + collectedBy: { select: { id: true, firstName: true, lastName: true } }, }, orderBy: { createdAt: 'desc' }, }); @@ -214,6 +219,17 @@ export class PaymentService { include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } }, }); + // Notify collector that their remittance was approved + if (remittance.collectorId) { + this.notificationService.create(tenantId, { + userId: remittance.collectorId, + type: 'in_app', + channel: 'remittance_approved', + title: 'Remittance Approved', + message: `Your remittance of ₱${Number(remittance.totalAmount).toLocaleString()} has been approved`, + }).catch(() => {}); + } + this.audit.log({ tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId, details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) }, @@ -263,7 +279,7 @@ export class PaymentService { } } - return this.prisma.remittance.update({ + const result = await this.prisma.remittance.update({ where: { id: remittanceId }, data: { status: 'rejected', @@ -271,5 +287,18 @@ export class PaymentService { confirmedAt: new Date(), }, }); + + // Notify collector that their remittance was rejected + if (remittance.collectorId) { + this.notificationService.create(tenantId, { + userId: remittance.collectorId, + type: 'in_app', + channel: 'remittance_rejected', + title: 'Remittance Rejected', + message: `Your remittance of ₱${Number(remittance.totalAmount).toLocaleString()} has been rejected`, + }).catch(() => {}); + } + + return result; } } diff --git a/src/ticket/ticket.controller.ts b/src/ticket/ticket.controller.ts index 792d169..db6098a 100644 --- a/src/ticket/ticket.controller.ts +++ b/src/ticket/ticket.controller.ts @@ -12,7 +12,6 @@ import { AuthGuard } from '@nestjs/passport'; import { TicketService } from './ticket.service'; import { CreateTicketDto } from './dto/create-ticket.dto'; import { UpdateTicketDto } from './dto/update-ticket.dto'; -import { CreateCommentDto } from './dto/create-comment.dto'; import { Roles } from '../common/decorators/roles.decorator'; import { RolesGuard } from '../common/guards/roles.guard'; import { TenantGuard } from '../common/guards/tenant.guard'; @@ -71,23 +70,4 @@ export class TicketController { ) { return this.ticketService.resolve(user.tenantId, id, user.sub, body); } - - @Get(':id/comments') - @Roles('technician', 'collector') - async getComments( - @CurrentUser() user: CurrentUserPayload, - @Param('id') id: string, - ) { - return this.ticketService.getComments(user.tenantId, id); - } - - @Post(':id/comments') - @Roles('technician', 'collector') - async addComment( - @CurrentUser() user: CurrentUserPayload, - @Param('id') id: string, - @Body() dto: CreateCommentDto, - ) { - return this.ticketService.addComment(user.tenantId, id, user.sub, dto); - } } diff --git a/src/ticket/ticket.service.ts b/src/ticket/ticket.service.ts index d4c7f5a..8de5af2 100644 --- a/src/ticket/ticket.service.ts +++ b/src/ticket/ticket.service.ts @@ -4,6 +4,7 @@ import { BadRequestException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { NotificationService } from '../notification/notification.service'; import { CreateTicketDto } from './dto/create-ticket.dto'; import { UpdateTicketDto } from './dto/update-ticket.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; @@ -21,7 +22,10 @@ export class TicketService { onTicketResolved: ((event: TicketResolvedEvent) => Promise) | null = null; - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly notificationService: NotificationService, + ) {} async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) { const db = this.prisma.forTenant(tenantId); @@ -112,6 +116,21 @@ export class TicketService { }); } + // Notify newly assigned user + if (dto.assigneeId && dto.assigneeId !== existing.assigneeId) { + const assignee = await this.prisma.user.findUnique({ where: { id: dto.assigneeId } }); + if (assignee) { + this.notificationService.create(tenantId, { + userId: dto.assigneeId, + type: 'in_app', + channel: 'ticket_assigned', + title: 'Ticket assigned to you', + message: `"${existing.title}" has been assigned to you`, + ticketId: id, + }).catch(() => {}); + } + } + return this.prisma.ticket.update({ where: { id }, data: { diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 9acaab2..a8c89cd 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -27,6 +27,12 @@ export class UserController { return this.userService.findAll(user.tenantId); } + @Get('mention-list') + @Roles('tenant_admin', 'technician', 'collector') + async getMentionList(@CurrentUser() user: CurrentUserPayload) { + return this.userService.findForMention(user.tenantId); + } + @Get(':id') async findById( @CurrentUser() user: CurrentUserPayload, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 0484ac0..b19f4a3 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -51,6 +51,16 @@ export class UserService { return users.map((u) => this.formatUser(u)); } + async findForMention(tenantId: string) { + const db = this.prisma.forTenant(tenantId); + const users = await db.user.findMany({ + where: { isActive: true }, + select: { id: true, firstName: true, lastName: true }, + orderBy: { firstName: 'asc' }, + }); + return users; + } + async findById(tenantId: string, userId: string) { const db = this.prisma.forTenant(tenantId); const user = await db.user.findFirst({