feat: add notification triggers, comment roles, mention list, and static file serving
- Notify users on ticket assignment and remittance confirm/reject - Add collector role to comment endpoints - Add /users/mention-list endpoint for @mention support - Serve uploaded files via express.static
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>) | 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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user