Merge dev to main #1
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user