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) {}
@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,

View File

@@ -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({

View File

@@ -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')

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({
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;
}
}

View File

@@ -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);
}
}

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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({