Compare commits
6 Commits
59ee1fbe33
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc89ed124f | ||
|
|
d4d9249933 | ||
|
|
6ea1f412a1 | ||
|
|
7f9beaac2b | ||
|
|
85aea30730 | ||
|
|
da9707f3fb |
11
alter-tenant-id.sh
Normal file
11
alter-tenant-id.sh
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
cd packages/db
|
||||||
|
node -e "
|
||||||
|
const { Client } = require('pg');
|
||||||
|
const url = process.env.DATABASE_URL.replace(/%21/g, '!');
|
||||||
|
const c = new Client(url);
|
||||||
|
c.connect().then(() => c.query('ALTER TABLE users ALTER COLUMN tenantId DROP NOT NULL'))
|
||||||
|
.then(() => { console.log('tenantId nullable OK'); return c.end(); })
|
||||||
|
.catch(e => { console.log('Error:', e.message.substring(0,100)); return c.end(); });
|
||||||
|
"
|
||||||
|
npx tsx prisma/seed.ts
|
||||||
7602
package-lock.json
generated
Normal file
7602
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "fiberops-api",
|
"name": "fiberops-api",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { AccountingModule } from './accounting/accounting.module';
|
|||||||
import { PayrollModule } from './payroll/payroll.module';
|
import { PayrollModule } from './payroll/payroll.module';
|
||||||
import { RoleModule } from './role/role.module';
|
import { RoleModule } from './role/role.module';
|
||||||
import { CommentModule } from './comment/comment.module';
|
import { CommentModule } from './comment/comment.module';
|
||||||
|
import { ImportModule } from './import/import.module';
|
||||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||||
@@ -71,6 +72,7 @@ import { AccessGuard } from './common/guards/access.guard';
|
|||||||
PayrollModule,
|
PayrollModule,
|
||||||
RoleModule,
|
RoleModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
|
ImportModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class ClientController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles('manager')
|
@Roles('manager', 'technician', 'collector')
|
||||||
async update(
|
async update(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||||
import { CommentService } from './comment.service';
|
import { CommentService } from './comment.service';
|
||||||
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';
|
||||||
@@ -38,14 +38,20 @@ export class CommentController {
|
|||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Param('ticketId') ticketId: string,
|
@Param('ticketId') ticketId: string,
|
||||||
@Body() dto: CreateCommentDto,
|
@Body() body: any,
|
||||||
@UploadedFiles() files?: { files?: Express.Multer.File[] },
|
@UploadedFiles() files?: { files?: Express.Multer.File[] },
|
||||||
) {
|
) {
|
||||||
|
let content = body?.content;
|
||||||
|
if (Array.isArray(content)) content = content[0];
|
||||||
|
if (typeof content !== 'string') content = String(content ?? '');
|
||||||
|
if (!content || content.length > 50000) {
|
||||||
|
throw new BadRequestException('Content must be between 1 and 50000 characters');
|
||||||
|
}
|
||||||
return this.commentService.create(
|
return this.commentService.create(
|
||||||
user.tenantId,
|
user.tenantId,
|
||||||
ticketId,
|
ticketId,
|
||||||
user.sub,
|
user.sub,
|
||||||
dto.content,
|
content,
|
||||||
files?.files,
|
files?.files,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/comment/dto/create-comment.dto.bak
Normal file
7
src/comment/dto/create-comment.dto.bak
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import { IsString, MinLength } from 'class-validator';
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsArray,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateCommentDto {
|
export class CreateCommentDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
content!: string;
|
@MaxLength(50000)
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
files?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname, resolve } from 'path';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
const uploadDir = resolve('./uploads');
|
||||||
|
if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
export const multerOptions: MulterOptions = {
|
export const multerOptions: MulterOptions = {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: (_req: Request, _file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
|
destination: (_req: Request, _file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
|
||||||
cb(null, './uploads');
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
@@ -22,7 +26,6 @@ export const multerOptions: MulterOptions = {
|
|||||||
'image/png',
|
'image/png',
|
||||||
'image/gif',
|
'image/gif',
|
||||||
'image/webp',
|
'image/webp',
|
||||||
'application/pdf',
|
|
||||||
];
|
];
|
||||||
if (allowed.includes(file.mimetype)) {
|
if (allowed.includes(file.mimetype)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
|
|||||||
@@ -14,28 +14,24 @@ export class DashboardController {
|
|||||||
@Get('kpis')
|
@Get('kpis')
|
||||||
@Roles('manager')
|
@Roles('manager')
|
||||||
async getKpis(@CurrentUser() user: CurrentUserPayload) {
|
async getKpis(@CurrentUser() user: CurrentUserPayload) {
|
||||||
const data = await this.dashboardService.getKpis(user.tenantId);
|
return this.dashboardService.getKpis(user.tenantId);
|
||||||
return { data };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('revenue-chart')
|
@Get('revenue-chart')
|
||||||
@Roles('manager')
|
@Roles('manager')
|
||||||
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
|
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
|
||||||
const data = await this.dashboardService.getRevenueChart(user.tenantId);
|
return this.dashboardService.getRevenueChart(user.tenantId);
|
||||||
return { data };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('activity')
|
@Get('activity')
|
||||||
@Roles('manager')
|
@Roles('manager')
|
||||||
async getActivity(@CurrentUser() user: CurrentUserPayload) {
|
async getActivity(@CurrentUser() user: CurrentUserPayload) {
|
||||||
const data = await this.dashboardService.getRecentActivity(user.tenantId);
|
return this.dashboardService.getRecentActivity(user.tenantId);
|
||||||
return { data };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('financial-summary')
|
@Get('financial-summary')
|
||||||
@Roles('manager')
|
@Roles('manager')
|
||||||
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
|
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
|
||||||
const data = await this.dashboardService.getFinancialSummary(user.tenantId);
|
return this.dashboardService.getFinancialSummary(user.tenantId);
|
||||||
return { data };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/import/import.controller.ts
Normal file
91
src/import/import.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||||
|
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@Controller('import')
|
||||||
|
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||||
|
export class ImportController {
|
||||||
|
constructor(private readonly importService: ImportService) {}
|
||||||
|
|
||||||
|
@Get('template/:type')
|
||||||
|
@Roles('tenant_admin')
|
||||||
|
async downloadTemplate(
|
||||||
|
@Param('type') type: string,
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { buffer, filename, contentType } =
|
||||||
|
await this.importService.generateTemplate(user.tenantId, type);
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('clients')
|
||||||
|
@Roles('tenant_admin')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const ext = file.originalname.split('.').pop()?.toLowerCase();
|
||||||
|
if (['csv', 'xlsx', 'xls'].includes(ext || '')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only CSV and Excel files are allowed'), false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async importClients(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.importService.importClients(
|
||||||
|
user.tenantId,
|
||||||
|
user.sub,
|
||||||
|
file.buffer,
|
||||||
|
file.originalname,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('invoices')
|
||||||
|
@Roles('tenant_admin')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const ext = file.originalname.split('.').pop()?.toLowerCase();
|
||||||
|
if (['csv', 'xlsx', 'xls'].includes(ext || '')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only CSV and Excel files are allowed'), false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async importInvoices(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.importService.importInvoices(
|
||||||
|
user.tenantId,
|
||||||
|
file.buffer,
|
||||||
|
file.originalname,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/import/import.module.ts
Normal file
11
src/import/import.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ImportController } from './import.controller';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [ImportController],
|
||||||
|
providers: [ImportService],
|
||||||
|
})
|
||||||
|
export class ImportModule {}
|
||||||
427
src/import/import.service.ts
Normal file
427
src/import/import.service.ts
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
interface RowResult {
|
||||||
|
row: number;
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
total: number;
|
||||||
|
imported: number;
|
||||||
|
errors: number;
|
||||||
|
details: RowResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Template generation */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async generateTemplate(
|
||||||
|
tenantId: string,
|
||||||
|
type: string,
|
||||||
|
): Promise<{ buffer: Buffer; filename: string; contentType: string }> {
|
||||||
|
if (type === 'clients') {
|
||||||
|
return this.generateClientsTemplate(tenantId);
|
||||||
|
}
|
||||||
|
if (type === 'invoices') {
|
||||||
|
return this.generateInvoicesTemplate(tenantId);
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unknown template type "${type}". Use "clients" or "invoices".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateClientsTemplate(tenantId: string) {
|
||||||
|
const [plans, areas] = await Promise.all([
|
||||||
|
this.prisma.plan.findMany({
|
||||||
|
where: { tenantId, isActive: true, deletedAt: null },
|
||||||
|
select: { name: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
}),
|
||||||
|
this.prisma.area.findMany({
|
||||||
|
where: { tenantId, deletedAt: null },
|
||||||
|
select: { name: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'firstName*',
|
||||||
|
'lastName*',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'address*',
|
||||||
|
'accountNumber',
|
||||||
|
'planName*',
|
||||||
|
'subscriptionType*',
|
||||||
|
'areaName',
|
||||||
|
];
|
||||||
|
|
||||||
|
const exampleRow = [
|
||||||
|
'Juan',
|
||||||
|
'Dela Cruz',
|
||||||
|
'juan@example.com',
|
||||||
|
'+639171234567',
|
||||||
|
'123 Main Street, Barangay 1',
|
||||||
|
'C-000001',
|
||||||
|
plans[0]?.name || 'Plan 1',
|
||||||
|
'postpaid',
|
||||||
|
areas[0]?.name || '',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
|
||||||
|
|
||||||
|
// Add data-validation notes in a second sheet
|
||||||
|
const notesData = [
|
||||||
|
['Field', 'Required', 'Description', 'Valid Values'],
|
||||||
|
['firstName', 'Yes', 'Client first name', ''],
|
||||||
|
['lastName', 'Yes', 'Client last name', ''],
|
||||||
|
['email', 'No', 'Email address', ''],
|
||||||
|
['phone', 'No', 'Phone number', ''],
|
||||||
|
['address', 'Yes', 'Full address', ''],
|
||||||
|
['accountNumber', 'No', 'Existing account number (auto-generated if empty)', ''],
|
||||||
|
['planName', 'Yes', 'Must match an existing plan name', plans.map((p) => p.name).join(', ') || '(no plans created yet)'],
|
||||||
|
['subscriptionType', 'Yes', 'Subscription type', 'prepaid, postpaid'],
|
||||||
|
['areaName', 'No', 'Must match an existing area name', areas.map((a) => a.name).join(', ') || '(no areas created yet)'],
|
||||||
|
];
|
||||||
|
const notesWs = XLSX.utils.aoa_to_sheet(notesData);
|
||||||
|
|
||||||
|
// Set column widths
|
||||||
|
ws['!cols'] = headers.map(() => ({ wch: 25 }));
|
||||||
|
notesWs['!cols'] = [
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 10 },
|
||||||
|
{ wch: 45 },
|
||||||
|
{ wch: 50 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Clients');
|
||||||
|
XLSX.utils.book_append_sheet(wb, notesWs, 'Field Guide');
|
||||||
|
|
||||||
|
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
filename: 'fiberops_clients_template.xlsx',
|
||||||
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateInvoicesTemplate(tenantId: string) {
|
||||||
|
const headers = [
|
||||||
|
'accountNumber*',
|
||||||
|
'invoiceNumber',
|
||||||
|
'amount*',
|
||||||
|
'balance*',
|
||||||
|
'dueDate*',
|
||||||
|
'status*',
|
||||||
|
];
|
||||||
|
|
||||||
|
const exampleRow = [
|
||||||
|
'C-000001',
|
||||||
|
'',
|
||||||
|
'1500.00',
|
||||||
|
'1500.00',
|
||||||
|
'2026-06-01',
|
||||||
|
'unpaid',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
|
||||||
|
|
||||||
|
const notesData = [
|
||||||
|
['Field', 'Required', 'Description', 'Valid Values'],
|
||||||
|
['accountNumber', 'Yes', 'Must match an existing client account number', ''],
|
||||||
|
['invoiceNumber', 'No', 'Invoice number (auto-generated if empty)', ''],
|
||||||
|
['amount', 'Yes', 'Invoice total amount', ''],
|
||||||
|
['balance', 'Yes', 'Outstanding balance', ''],
|
||||||
|
['dueDate', 'Yes', 'Due date in YYYY-MM-DD format', ''],
|
||||||
|
['status', 'Yes', 'Invoice status', 'unpaid, partial'],
|
||||||
|
];
|
||||||
|
const notesWs = XLSX.utils.aoa_to_sheet(notesData);
|
||||||
|
|
||||||
|
ws['!cols'] = headers.map(() => ({ wch: 25 }));
|
||||||
|
notesWs['!cols'] = [
|
||||||
|
{ wch: 20 },
|
||||||
|
{ wch: 10 },
|
||||||
|
{ wch: 50 },
|
||||||
|
{ wch: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Invoices');
|
||||||
|
XLSX.utils.book_append_sheet(wb, notesWs, 'Field Guide');
|
||||||
|
|
||||||
|
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
filename: 'fiberops_invoices_template.xlsx',
|
||||||
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Client + Subscription import */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async importClients(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
const rows = this.parseFile(fileBuffer, filename);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new BadRequestException('File is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefetch plans and areas for lookup
|
||||||
|
const [plans, areas] = await Promise.all([
|
||||||
|
this.prisma.plan.findMany({
|
||||||
|
where: { tenantId, isActive: true, deletedAt: null },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
|
this.prisma.area.findMany({
|
||||||
|
where: { tenantId, deletedAt: null },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const planMap = new Map(plans.map((p) => [p.name.toLowerCase(), p.id]));
|
||||||
|
const areaMap = new Map(areas.map((a) => [a.name.toLowerCase(), a.id]));
|
||||||
|
|
||||||
|
const details: RowResult[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const rowNumber = i + 2; // Excel is 1-indexed, +1 for header
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstName = this.req(row, 'firstName', rowNumber);
|
||||||
|
const lastName = this.req(row, 'lastName', rowNumber);
|
||||||
|
const address = this.req(row, 'address', rowNumber);
|
||||||
|
const planName = this.req(row, 'planName', rowNumber);
|
||||||
|
const subscriptionType = this.req(row, 'subscriptionType', rowNumber);
|
||||||
|
|
||||||
|
if (!['prepaid', 'postpaid'].includes(subscriptionType.toLowerCase())) {
|
||||||
|
throw new Error(`Invalid subscriptionType "${subscriptionType}". Must be "prepaid" or "postpaid".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const planId = planMap.get(planName.toLowerCase());
|
||||||
|
if (!planId) {
|
||||||
|
throw new Error(`Plan "${planName}" not found. Available plans: ${plans.map((p) => p.name).join(', ') || '(none)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaName = this.opt(row, 'areaName');
|
||||||
|
const areaId = areaName ? areaMap.get(areaName.toLowerCase()) : undefined;
|
||||||
|
if (areaName && !areaId) {
|
||||||
|
throw new Error(`Area "${areaName}" not found. Available areas: ${areas.map((a) => a.name).join(', ') || '(none)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountNumber = this.opt(row, 'accountNumber') || undefined;
|
||||||
|
|
||||||
|
// Check account number uniqueness if provided
|
||||||
|
if (accountNumber) {
|
||||||
|
const existing = await this.prisma.client.findFirst({
|
||||||
|
where: { tenantId, accountNumber },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Account number "${accountNumber}" already exists for client ${existing.firstName} ${existing.lastName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate account number if not provided
|
||||||
|
const finalAccountNumber =
|
||||||
|
accountNumber ||
|
||||||
|
(await this.generateAccountNumber(tenantId));
|
||||||
|
|
||||||
|
const client = await this.prisma.client.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
accountNumber: finalAccountNumber,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: this.opt(row, 'email'),
|
||||||
|
phone: this.opt(row, 'phone'),
|
||||||
|
address,
|
||||||
|
areaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
await this.prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
clientId: client.id,
|
||||||
|
planId,
|
||||||
|
type: subscriptionType.toLowerCase(),
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
details.push({ row: rowNumber, status: 'ok', message: `Created ${firstName} ${lastName} (${finalAccountNumber})` });
|
||||||
|
} catch (err: any) {
|
||||||
|
details.push({ row: rowNumber, status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = details.filter((d) => d.status === 'ok').length;
|
||||||
|
const errors = details.filter((d) => d.status === 'error').length;
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
if (imported > 0) {
|
||||||
|
await this.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
action: 'IMPORT_CLIENTS',
|
||||||
|
entity: 'client',
|
||||||
|
entityId: 'bulk',
|
||||||
|
details: { imported, errors, total: rows.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total: rows.length, imported, errors, details };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Invoice import */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async importInvoices(
|
||||||
|
tenantId: string,
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
const rows = this.parseFile(fileBuffer, filename);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new BadRequestException('File is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const details: RowResult[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const rowNumber = i + 2;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountNumber = this.req(row, 'accountNumber', rowNumber);
|
||||||
|
const amountStr = this.req(row, 'amount', rowNumber);
|
||||||
|
const balanceStr = this.req(row, 'balance', rowNumber);
|
||||||
|
const dueDateStr = this.req(row, 'dueDate', rowNumber);
|
||||||
|
const status = this.req(row, 'status', rowNumber);
|
||||||
|
|
||||||
|
// Find client by account number
|
||||||
|
const client = await this.prisma.client.findFirst({
|
||||||
|
where: { tenantId, accountNumber },
|
||||||
|
});
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Client with account number "${accountNumber}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = parseFloat(amountStr);
|
||||||
|
const balance = parseFloat(balanceStr);
|
||||||
|
if (isNaN(amount) || amount <= 0) {
|
||||||
|
throw new Error(`Invalid amount "${amountStr}"`);
|
||||||
|
}
|
||||||
|
if (isNaN(balance) || balance < 0) {
|
||||||
|
throw new Error(`Invalid balance "${balanceStr}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueDate = new Date(dueDateStr);
|
||||||
|
if (isNaN(dueDate.getTime())) {
|
||||||
|
throw new Error(`Invalid dueDate "${dueDateStr}". Use YYYY-MM-DD format.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['unpaid', 'partial'].includes(status.toLowerCase())) {
|
||||||
|
throw new Error(`Invalid status "${status}". Must be "unpaid" or "partial".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate invoice number
|
||||||
|
const invoiceNumber =
|
||||||
|
this.opt(row, 'invoiceNumber') ||
|
||||||
|
(await this.generateInvoiceNumber(tenantId));
|
||||||
|
|
||||||
|
await this.prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
clientId: client.id,
|
||||||
|
number: invoiceNumber,
|
||||||
|
amount,
|
||||||
|
balance,
|
||||||
|
status: status.toLowerCase(),
|
||||||
|
dueDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
details.push({
|
||||||
|
row: rowNumber,
|
||||||
|
status: 'ok',
|
||||||
|
message: `Invoice ${invoiceNumber} for ${client.firstName} ${client.lastName} (${accountNumber})`,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
details.push({ row: rowNumber, status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = details.filter((d) => d.status === 'ok').length;
|
||||||
|
const errors = details.filter((d) => d.status === 'error').length;
|
||||||
|
|
||||||
|
return { total: rows.length, imported, errors, details };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
private parseFile(buffer: Buffer, filename: string): Record<string, string>[] {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
|
const wb = XLSX.read(buffer, { type: 'buffer' });
|
||||||
|
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const rows: Record<string, string>[] = XLSX.utils.sheet_to_json(ws, {
|
||||||
|
defval: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize headers: strip asterisks and whitespace
|
||||||
|
return rows.map((row) => {
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
normalized[key.replace(/\*/g, '').trim()] = String(value).trim();
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private req(row: Record<string, string>, field: string, rowNumber: number): string {
|
||||||
|
const value = row[field];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required field "${field}"`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private opt(row: Record<string, string>, field: string): string | undefined {
|
||||||
|
const value = row[field];
|
||||||
|
return value || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateAccountNumber(tenantId: string): Promise<string> {
|
||||||
|
const count = await this.prisma.client.count({ where: { tenantId } });
|
||||||
|
return `C-${String(count + 1).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateInvoiceNumber(tenantId: string): Promise<string> {
|
||||||
|
const count = await this.prisma.invoice.count({ where: { tenantId } });
|
||||||
|
return `INV-${String(count + 1).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,10 +41,9 @@ export class PaymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('unremitted')
|
@Get('unremitted')
|
||||||
@Roles('technician', 'collector')
|
@Roles('technician', 'collector', 'manager')
|
||||||
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('all') all?: string) {
|
async getUnremitted(@CurrentUser() user: CurrentUserPayload) {
|
||||||
const isManager = user.roles?.some((r: string) => ['manager', 'tenant_admin', 'super_admin'].includes(r));
|
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);
|
return this.paymentService.getUnremittedPayments(user.tenantId, isManager ? null : user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,4 +79,10 @@ export class PaymentController {
|
|||||||
) {
|
) {
|
||||||
return this.paymentService.rejectRemittance(user.tenantId, id, user.sub);
|
return this.paymentService.rejectRemittance(user.tenantId, id, user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('unremitted-breakdown')
|
||||||
|
@Roles('manager')
|
||||||
|
async getUnremittedBreakdown(@CurrentUser() user: CurrentUserPayload) {
|
||||||
|
return this.paymentService.getUnremittedBreakdown(user.tenantId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export class PaymentService {
|
|||||||
// Attach full payment details to each remittance's join records
|
// Attach full payment details to each remittance's join records
|
||||||
return remittances.map((r) => ({
|
return remittances.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
|
createdAt: r.submittedAt.toISOString(),
|
||||||
payments: r.payments.map((rp) => ({
|
payments: r.payments.map((rp) => ({
|
||||||
...rp,
|
...rp,
|
||||||
payment: paymentMap.get(rp.paymentId) ?? null,
|
payment: paymentMap.get(rp.paymentId) ?? null,
|
||||||
@@ -301,4 +302,36 @@ export class PaymentService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUnremittedBreakdown(tenantId: string) {
|
||||||
|
const remittedIds = (await this.prisma.remittancePayment.findMany({
|
||||||
|
where: { remittance: { tenantId } },
|
||||||
|
select: { paymentId: true },
|
||||||
|
})).map((r) => r.paymentId);
|
||||||
|
|
||||||
|
const grouped = await this.prisma.payment.groupBy({
|
||||||
|
by: ['collectedById'],
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
|
||||||
|
collectedById: { not: null },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectorIds = grouped.map((g) => g.collectedById!).filter(Boolean);
|
||||||
|
const collectors = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: collectorIds } },
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
});
|
||||||
|
const nameMap = new Map(collectors.map((c) => [c.id, `${c.firstName} ${c.lastName}`]));
|
||||||
|
|
||||||
|
return grouped.map((g) => ({
|
||||||
|
collectorId: g.collectedById,
|
||||||
|
collectorName: nameMap.get(g.collectedById!) ?? 'Unknown',
|
||||||
|
totalAmount: Number(g._sum.amount ?? 0),
|
||||||
|
count: g._count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/scheduler/payment.scheduler.ts
Normal file
86
src/scheduler/payment.scheduler.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { NotificationService } from '../notification/notification.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PaymentScheduler {
|
||||||
|
private readonly logger = new Logger(PaymentScheduler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron('0 17 * * *')
|
||||||
|
async sendUnremittedReminders() {
|
||||||
|
this.logger.log('Sending unremitted payment reminders...');
|
||||||
|
|
||||||
|
const tenants = await this.prisma.tenant.findMany({ where: { isActive: true } });
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
try {
|
||||||
|
await this._remindForTenant(tenant.id);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send reminders for tenant ${tenant.slug}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('Unremitted payment reminders complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _remindForTenant(tenantId: string) {
|
||||||
|
// Find remitted payment IDs
|
||||||
|
const remittedIds = (await this.prisma.remittancePayment.findMany({
|
||||||
|
where: { remittance: { tenantId } },
|
||||||
|
select: { paymentId: true },
|
||||||
|
})).map((r) => r.paymentId);
|
||||||
|
|
||||||
|
// Find unremitted payments grouped by collector
|
||||||
|
const unremitted = await this.prisma.payment.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
|
||||||
|
collectedById: { not: null },
|
||||||
|
},
|
||||||
|
select: { collectedById: true, amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unremitted.length === 0) return;
|
||||||
|
|
||||||
|
// Group by collector
|
||||||
|
const byCollector = new Map<string, number>();
|
||||||
|
for (const p of unremitted) {
|
||||||
|
const id = p.collectedById!;
|
||||||
|
byCollector.set(id, (byCollector.get(id) ?? 0) + Number(p.amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which collectors already got a reminder today
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const alreadyReminded = await this.prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
channel: 'unremitted_reminder',
|
||||||
|
createdAt: { gte: todayStart },
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
const remindedSet = new Set(alreadyReminded.map((n) => n.userId).filter(Boolean));
|
||||||
|
|
||||||
|
for (const [collectorId, total] of byCollector) {
|
||||||
|
if (remindedSet.has(collectorId)) continue;
|
||||||
|
|
||||||
|
this.notificationService.create(tenantId, {
|
||||||
|
userId: collectorId,
|
||||||
|
type: 'in_app',
|
||||||
|
channel: 'unremitted_reminder',
|
||||||
|
title: 'End-of-Day Reminder',
|
||||||
|
message: `You have ₱${total.toLocaleString()} in unremitted payments. Please submit your remittance.`,
|
||||||
|
}).catch((err) =>
|
||||||
|
this.logger.error(`Failed to notify ${collectorId}: ${err.message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { InvoiceScheduler } from './invoice.scheduler';
|
import { InvoiceScheduler } from './invoice.scheduler';
|
||||||
|
import { PaymentScheduler } from './payment.scheduler';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot(), BillingModule],
|
imports: [ScheduleModule.forRoot(), BillingModule, NotificationModule],
|
||||||
providers: [InvoiceScheduler],
|
providers: [InvoiceScheduler, PaymentScheduler],
|
||||||
})
|
})
|
||||||
export class SchedulerModule {}
|
export class SchedulerModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user