Merge dev to main #1

Open
kibin wants to merge 33 commits from dev into main
8 changed files with 79 additions and 28 deletions
Showing only changes of commit 59ee1fbe33 - Show all commits

View File

@@ -24,7 +24,7 @@ export class CommentController {
constructor(private readonly commentService: CommentService) {} constructor(private readonly commentService: CommentService) {}
@Get() @Get()
@Roles('technician') @Roles('technician', 'collector')
async findAll( async findAll(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,
@Param('ticketId') ticketId: string, @Param('ticketId') ticketId: string,
@@ -33,7 +33,7 @@ export class CommentController {
} }
@Post() @Post()
@Roles('technician') @Roles('technician', 'collector')
@UseInterceptors(FileFieldsInterceptor([{ name: 'files', maxCount: 3 }], multerOptions)) @UseInterceptors(FileFieldsInterceptor([{ name: 'files', maxCount: 3 }], multerOptions))
async create( async create(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,

View File

@@ -2,12 +2,17 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import helmet from 'helmet'; import helmet from 'helmet';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { join } from 'path';
import * as express from 'express';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'], logger: ['error', 'warn', 'log'],
}); });
// Serve uploaded files before helmet so they're not blocked
app.use('/uploads', express.static(join(__dirname, '..', 'uploads')));
// Security headers // Security headers
app.use( app.use(
helmet({ helmet({

View File

@@ -42,8 +42,10 @@ export class PaymentController {
@Get('unremitted') @Get('unremitted')
@Roles('technician', 'collector') @Roles('technician', 'collector')
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('collectorId') collectorId?: string) { async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('all') all?: string) {
return this.paymentService.getUnremittedPayments(user.tenantId, collectorId || user.sub); 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') @Get('remittances')

View File

@@ -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({ const remittedIds = (await this.prisma.remittancePayment.findMany({
where: { remittance: { tenantId } }, where: { remittance: { tenantId } },
select: { paymentId: true }, select: { paymentId: true },
@@ -166,10 +166,15 @@ export class PaymentService {
.map((r) => r.paymentId); .map((r) => r.paymentId);
return this.prisma.payment.findMany({ 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: { include: {
client: { select: { firstName: true, lastName: true, accountNumber: true } }, client: { select: { firstName: true, lastName: true, accountNumber: true } },
invoice: { select: { id: true, number: true } }, invoice: { select: { id: true, number: true } },
collectedBy: { select: { id: true, firstName: true, lastName: true } },
}, },
orderBy: { createdAt: 'desc' }, 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 } } }, 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({ this.audit.log({
tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId, tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId,
details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) }, 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 }, where: { id: remittanceId },
data: { data: {
status: 'rejected', status: 'rejected',
@@ -271,5 +287,18 @@ export class PaymentService {
confirmedAt: new Date(), 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;
} }
} }

View File

@@ -12,7 +12,6 @@ import { AuthGuard } from '@nestjs/passport';
import { TicketService } from './ticket.service'; import { TicketService } from './ticket.service';
import { CreateTicketDto } from './dto/create-ticket.dto'; import { CreateTicketDto } from './dto/create-ticket.dto';
import { UpdateTicketDto } from './dto/update-ticket.dto'; import { UpdateTicketDto } from './dto/update-ticket.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard'; import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.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); 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);
}
} }

View File

@@ -4,6 +4,7 @@ import {
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
import { CreateTicketDto } from './dto/create-ticket.dto'; import { CreateTicketDto } from './dto/create-ticket.dto';
import { UpdateTicketDto } from './dto/update-ticket.dto'; import { UpdateTicketDto } from './dto/update-ticket.dto';
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
@@ -21,7 +22,10 @@ export class TicketService {
onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null = onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null =
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 }) { async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) {
const db = this.prisma.forTenant(tenantId); 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({ return this.prisma.ticket.update({
where: { id }, where: { id },
data: { data: {

View File

@@ -27,6 +27,12 @@ export class UserController {
return this.userService.findAll(user.tenantId); 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') @Get(':id')
async findById( async findById(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,

View File

@@ -51,6 +51,16 @@ export class UserService {
return users.map((u) => this.formatUser(u)); 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) { async findById(tenantId: string, userId: string) {
const db = this.prisma.forTenant(tenantId); const db = this.prisma.forTenant(tenantId);
const user = await db.user.findFirst({ const user = await db.user.findFirst({