Merge dev to main #1

Open
kibin wants to merge 33 commits from dev into main
37 changed files with 9927 additions and 845 deletions

View File

@@ -34,4 +34,4 @@ COPY --from=builder /app/packages/db/src ./packages/db/src
ENV NODE_ENV=production
EXPOSE 3001
ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "cd packages/db && npx prisma migrate deploy && cd /app && node dist/main"]
CMD ["sh", "-c", "cd packages/db && npx prisma migrate resolve --applied 20260504100000_add_user_must_change_password 2>/dev/null; npx prisma migrate resolve --applied 20260506070000_add_client_coordinates 2>/dev/null; npx prisma migrate deploy && if [ \"$RUN_SEED\" = \"true\" ]; then echo 'Seeding database...' && npx tsx prisma/seed.ts; fi && cd /app && node dist/main"]

11
alter-tenant-id.sh Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
{
"name": "fiberops-api",
"private": true,
"workspaces": ["packages/*"],
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
@@ -32,6 +34,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"xlsx": "^0.18.5",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -40,6 +43,7 @@
"@nestjs/testing": "^11.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/passport-jwt": "^4.0.1",
"typescript": "^5.7.0",
"vitest": "^3.1.0"

View File

@@ -0,0 +1,12 @@
-- AlterTable: make tenantId nullable for super_admin users
ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL;
-- Fix FK to allow NULL (super_admin has no tenant)
ALTER TABLE "users" DROP CONSTRAINT "users_tenantId_fkey";
ALTER TABLE "users" ADD CONSTRAINT "users_tenantId_fkey"
FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Drop unique index that requires tenantId (email must be unique globally for super_admin)
DROP INDEX IF EXISTS "users_tenantId_email_key";
CREATE UNIQUE INDEX "users_tenantId_email_key" ON "users"("tenantId", "email") WHERE "tenantId" IS NOT NULL;
CREATE UNIQUE INDEX "users_email_key" ON "users"("email") WHERE "tenantId" IS NULL;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "tickets" ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;

View File

@@ -0,0 +1,38 @@
-- CreateTable
CREATE TABLE "ticket_comments" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ticket_comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_attachments" (
"id" TEXT NOT NULL,
"commentId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileType" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ticket_attachments_pkey" PRIMARY KEY ("id")
);
-- AlterTable: add ticketId to notifications
ALTER TABLE "notifications" ADD COLUMN "ticketId" TEXT;
-- CreateIndex
CREATE INDEX "ticket_comments_ticketId_idx" ON "ticket_comments"("ticketId");
CREATE INDEX "ticket_comments_tenantId_idx" ON "ticket_comments"("tenantId");
-- AddForeignKey
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ticket_attachments" ADD CONSTRAINT "ticket_attachments_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "ticket_comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "clients" ADD COLUMN "latitude" DOUBLE PRECISION;
ALTER TABLE "clients" ADD COLUMN "longitude" DOUBLE PRECISION;

File diff suppressed because it is too large Load Diff

View File

@@ -113,7 +113,7 @@ async function main() {
const userDefs = [
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'technician' },
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'collector' },
{ email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' },
{ email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' },
{ email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' },
@@ -162,6 +162,7 @@ async function main() {
tenant_admin: 'tenant_admin',
manager: 'manager',
technician: 'technician',
collector: 'collector', // collector user gets collector tenant role
viewer: 'collector', // viewer user gets collector role for demo
};
@@ -224,7 +225,7 @@ async function main() {
]);
console.log(`Plans: ${plans.length}`);
// ─── Clients (20 clients across various areas/plans) ──
// ─── Clients (45 clients across various areas/plans) ──
// Area center coordinates (Lipa City, Batangas area)
const areaCoords: [number, number][] = [
[14.0785, 121.1760], // Barangay 1 - Centro
@@ -250,6 +251,38 @@ async function main() {
{ first: 'Ricardo', last: 'Flores', phone: '09291234567', email: null, address: '55 Katipunan Rd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
{ first: 'Teresa', last: 'Navarro', phone: '09301234567', email: 'teresa@email.com', address: '66 Makabayan St, Riverside', area: 3, plan: 1, type: 'prepaid', latOff: -0.003, lngOff: 0.001 },
{ first: 'Fernando', last: 'Castillo', phone: '09311234567', email: null, address: '77 Silang Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
// New signups — pending installation (no lat/lng, pending status, open tickets)
{ first: 'Rafael', last: 'Dimaculangan', phone: '09321234567', email: null, address: '88 Burgos St, Centro', area: 0, plan: 1, type: 'postpaid', latOff: 0.0015, lngOff: -0.001 },
{ first: 'Lorna', last: 'Perez', phone: '09331234567', email: 'lorna@email.com', address: '99 Villareal St, Poblacion', area: 1, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.002 },
{ first: 'Danilo', last: 'Rivera', phone: '09341234567', email: null, address: '101 Kapitan St, San Isidro', area: 2, plan: 0, type: 'prepaid', latOff: 0.002, lngOff: 0.001 },
{ first: 'Grace', last: 'Sison', phone: '09351234567', email: 'grace@email.com', address: '202 Magdalena St, Riverside', area: 3, plan: 3, type: 'postpaid', latOff: -0.001, lngOff: -0.002 },
{ first: 'Allan', last: 'Vergara', phone: '09361234567', email: null, address: '303 Gomez St, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: 0.0015 },
// Additional clients (indices 20-29)
{ first: 'Angelo', last: 'Manalo', phone: '09371234567', email: 'angelo@email.com', address: '15 Rizal Ave, Centro', area: 0, plan: 3, type: 'postpaid', latOff: 0.0025, lngOff: -0.0015 },
{ first: 'Bella', last: 'Cruz', phone: '09381234567', email: 'bella@email.com', address: '26 Mabini Ext, Poblacion', area: 1, plan: 1, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
{ first: 'Claudio', last: 'Diaz', phone: '09391234567', email: null, address: '37 Bonifacio Rd, San Isidro', area: 2, plan: 4, type: 'postpaid', latOff: 0.001, lngOff: 0.003 },
{ first: 'Diana', last: 'Espiritu', phone: '09401234567', email: 'diana@email.com', address: '48 Luna Ext, Riverside', area: 3, plan: 0, type: 'prepaid', latOff: -0.002, lngOff: -0.001 },
{ first: 'Eduardo', last: 'Fernandez', phone: '09411234567', email: null, address: '59 Del Pilar St, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.003, lngOff: 0.001 },
{ first: 'Flora', last: 'Gonzales', phone: '09421234567', email: 'flora@email.com', address: '60 Quezon Blvd, Centro', area: 0, plan: 1, type: 'postpaid', latOff: -0.001, lngOff: -0.002 },
{ first: 'Gilbert', last: 'Hernandez', phone: '09431234567', email: null, address: '71 Magsaysay St, Poblacion', area: 1, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: 0.001 },
{ first: 'Helen', last: 'Ibañez', phone: '09441234567', email: 'helen@email.com', address: '82 Roxas Blvd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: -0.003, lngOff: -0.001 },
{ first: 'Ivan', last: 'Jimenez', phone: '09451234567', email: null, address: '93 Laurel Ave, Riverside', area: 3, plan: 4, type: 'postpaid', latOff: 0.0015, lngOff: 0.002 },
{ first: 'Julia', last: 'Kho', phone: '09461234567', email: 'julia@email.com', address: '104 Osmena St, Hilltop', area: 4, plan: 1, type: 'prepaid', latOff: -0.002, lngOff: 0.0015 },
{ first: 'Kenneth', last: 'Lopez', phone: '09471234567', email: null, address: '115 Aguinaldo Blvd, Centro', area: 0, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.003 },
{ first: 'Linda', last: 'Madrid', phone: '09481234567', email: 'linda@email.com', address: '126 Andres St, Poblacion', area: 1, plan: 0, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
{ first: 'Mario', last: 'Ng', phone: '09491234567', email: null, address: '137 Katipunan Rd, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: -0.002 },
{ first: 'Nancy', last: 'Ong', phone: '09501234567', email: 'nancy@email.com', address: '148 Makabayan Blvd, Riverside', area: 3, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
// New signups — pending installation (indices 30-44)
{ first: 'Oscar', last: 'Pineda', phone: '09511234567', email: 'oscar@email.com', address: '159 Rizal Ext, Centro', area: 0, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
{ first: 'Patricia', last: 'Quintos', phone: '09521234567', email: 'patricia@email.com', address: '170 Mabini Rd, Poblacion', area: 1, plan: 1, type: 'postpaid', latOff: -0.002, lngOff: 0.001 },
{ first: 'Quentin', last: 'Reyes Jr', phone: '09531234567', email: null, address: '181 Bonifacio Ext, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: 0.002 },
{ first: 'Rita', last: 'Santillan', phone: '09541234567', email: 'rita@email.com', address: '192 Luna St, Riverside', area: 3, plan: 0, type: 'prepaid', latOff: -0.001, lngOff: -0.001 },
{ first: 'Samuel', last: 'Torres', phone: '09551234567', email: null, address: '203 Del Pilar Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.0015, lngOff: 0.001 },
{ first: 'Tina', last: 'Uy', phone: '09561234567', email: 'tina@email.com', address: '214 Quezon Rd, Centro', area: 0, plan: 4, type: 'postpaid', latOff: -0.002, lngOff: -0.0015 },
{ first: 'Ulysses', last: 'Velasco', phone: '09571234567', email: null, address: '225 Magsaysay Ext, Poblacion', area: 1, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.002 },
{ first: 'Vivian', last: 'Walsh', phone: '09581234567', email: 'vivian@email.com', address: '236 Roxas Ave, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
{ first: 'Walter', last: 'Xavier', phone: '09591234567', email: null, address: '247 Laurel Blvd, Riverside', area: 3, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
{ first: 'Yolanda', last: 'Yap', phone: '09601234567', email: 'yolanda@email.com', address: '258 Osmena Rd, Hilltop', area: 4, plan: 1, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
];
const clients: any[] = [];
@@ -260,6 +293,7 @@ async function main() {
const c = clientDefs[i];
const accountNumber = `C-${String(i + 1).padStart(6, '0')}`;
const plan = plans[c.plan];
const isNewSignup = i >= 30; // 15 new signups pending installation
const client = await prisma.client.create({
data: {
@@ -271,120 +305,230 @@ async function main() {
email: c.email,
address: c.address,
areaId: areas[c.area].id,
latitude: areaCoords[c.area][0] + c.latOff,
longitude: areaCoords[c.area][1] + c.lngOff,
status: isNewSignup ? 'pending' : 'active',
latitude: isNewSignup ? null : (areaCoords[c.area][0] + c.latOff),
longitude: isNewSignup ? null : (areaCoords[c.area][1] + c.lngOff),
},
});
clients.push(client);
// Create subscription
const installedAt = new Date(now);
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
if (isNewSignup) {
// ── New signup: pending subscription + open installation ticket ──
const activatedAt = new Date(installedAt);
activatedAt.setDate(activatedAt.getDate() + 2);
await prisma.subscription.create({
data: {
tenantId: tenant.id,
clientId: client.id,
planId: plan.id,
type: c.type,
status: 'pending',
},
});
await prisma.subscription.create({
data: {
tenantId: tenant.id,
clientId: client.id,
planId: plan.id,
type: c.type,
status: 'active',
installedAt,
activatedAt,
startDate: activatedAt,
},
});
// Open installation ticket (alternating assigned/unassigned)
const assignedTech = i % 2 === 0 ? users.technician.id : null;
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: assignedTech,
type: 'installation',
title: `Installation for ${c.first} ${c.last}`,
description: `New installation at ${c.address}`,
status: assignedTech ? 'in_progress' : 'open',
priority: 'high',
},
});
// Create resolved installation + activation tickets
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: users.technician.id,
type: 'installation',
title: `Installation for ${c.first} ${c.last}`,
description: `Installation at ${c.address}`,
status: 'resolved',
priority: 'high',
resolvedAt: new Date(installedAt.getTime() + 86400000),
},
});
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: users.technician.id,
type: 'activation',
title: `Activation for ${c.first} ${c.last}`,
status: 'resolved',
priority: 'high',
resolvedAt: activatedAt,
},
});
// Create 2 invoices per client
for (let m = 0; m < 2; m++) {
// Overdue invoice for new signup (installation fee / first billing)
invoiceCount++;
const periodStart = new Date(activatedAt);
periodStart.setMonth(periodStart.getMonth() + m);
const periodEnd = new Date(periodStart);
periodEnd.setDate(periodEnd.getDate() + 30);
const dueDate = new Date(periodStart);
dueDate.setDate(dueDate.getDate() + 15);
const isPaid = m === 0 || Math.random() > 0.4; // First invoice always paid, second 60% chance
const balance = isPaid ? 0 : Number(plan.price);
const invoice = await prisma.invoice.create({
const overdueDate = new Date(now);
overdueDate.setDate(overdueDate.getDate() - 7);
await prisma.invoice.create({
data: {
tenantId: tenant.id,
clientId: client.id,
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount: plan.price,
balance,
status: isPaid ? 'paid' : (dueDate < now ? 'overdue' : 'sent'),
dueDate,
paidAt: isPaid ? new Date(dueDate.getTime() - 86400000 * 3) : null,
periodStart,
periodEnd,
amount: plan ? Number(plan.price) : 999,
balance: plan ? Number(plan.price) : 999,
status: 'overdue',
dueDate: overdueDate,
periodStart: new Date(now.getTime() - 30 * 86400000),
periodEnd: new Date(now.getTime()),
},
});
} else {
// ── Existing client: active subscription, resolved tickets, invoices ──
// Create subscription
const installedAt = new Date(now);
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
const activatedAt = new Date(installedAt);
activatedAt.setDate(activatedAt.getDate() + 2);
await prisma.subscription.create({
data: {
tenantId: tenant.id,
clientId: client.id,
planId: plan.id,
type: c.type,
status: 'active',
installedAt,
activatedAt,
startDate: activatedAt,
},
});
// Create payment for paid invoices
if (isPaid) {
const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
const method = methods[Math.floor(Math.random() * methods.length)];
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
// Create resolved installation + activation tickets
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: users.technician.id,
type: 'installation',
title: `Installation for ${c.first} ${c.last}`,
description: `Installation at ${c.address}`,
status: 'resolved',
priority: 'high',
resolvedAt: new Date(installedAt.getTime() + 86400000),
},
});
await prisma.payment.create({
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: users.technician.id,
type: 'activation',
title: `Activation for ${c.first} ${c.last}`,
status: 'resolved',
priority: 'high',
resolvedAt: activatedAt,
},
});
// Create invoices per client (3-4 per client with varied statuses)
for (let m = 0; m < 4; m++) {
invoiceCount++;
const periodStart = new Date(activatedAt);
periodStart.setMonth(periodStart.getMonth() + m);
const periodEnd = new Date(periodStart);
periodEnd.setDate(periodEnd.getDate() + 30);
const dueDate = new Date(periodStart);
dueDate.setDate(dueDate.getDate() + 15);
// Determine invoice status based on month
let status: string;
let balance: number;
let paidAt: Date | null = null;
const amount = Number(plan.price);
if (m === 0) {
// Month 1: always paid
status = 'paid';
balance = 0;
paidAt = new Date(dueDate.getTime() - 86400000 * 3);
} else if (m === 1) {
// Month 2: overdue (unpaid, past due)
status = 'overdue';
balance = amount;
} else if (m === 2) {
// Month 3: 50% paid → partial
status = 'partial';
balance = Math.round(amount / 2);
} else {
// Month 4: upcoming (due in near future)
const futureDue = new Date(now);
futureDue.setDate(futureDue.getDate() + 3);
status = 'sent';
balance = amount;
dueDate.setTime(futureDue.getTime());
}
const invoice = await prisma.invoice.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: users.technician.id,
amount: plan.price,
method,
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
createdAt: paidDate,
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount,
balance,
status,
dueDate,
paidAt,
periodStart,
periodEnd,
},
});
// Create payment(s) for paid/partial invoices
const collector = i % 2 === 0 ? usersByEmail['collector@demo-isp.com'] : usersByEmail['tech@demo-isp.com'];
if (status === 'paid') {
const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
const method = methods[Math.floor(Math.random() * methods.length)];
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
await prisma.payment.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: collector.id,
amount,
method,
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
createdAt: paidDate,
},
});
} else if (status === 'partial') {
const methods = ['gcash', 'cash'];
const method = methods[Math.floor(Math.random() * methods.length)];
const paidDate = new Date(dueDate.getTime() - 86400000 * 2);
await prisma.payment.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: collector.id,
amount: Math.round(amount / 2),
method,
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
createdAt: paidDate,
},
});
}
}
}
}
console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`);
// ─── Open support tickets ──────────────────────────────
// ─── Support tickets ───────────────────────────────────
const supportTickets = [
{ clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high' },
{ clientIdx: 5, title: 'Slow speed during peak hours', desc: 'Speed drops to 5 Mbps from 8-10 PM', priority: 'normal' },
{ clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent' },
{ clientIdx: 11, title: 'Request for plan upgrade', desc: 'Would like to upgrade from Basic to Standard', priority: 'low' },
{ clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high' },
{ clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high', status: 'open', type: 'support', assignee: null },
{ clientIdx: 5, title: 'Slow speed during peak hours', desc: 'Speed drops to 5 Mbps from 8-10 PM', priority: 'normal', status: 'open', type: 'support', assignee: null },
{ clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent', status: 'in_progress', type: 'support', assignee: 'tech' },
{ clientIdx: 11, title: 'Request for plan upgrade', desc: 'Would like to upgrade from Basic to Standard', priority: 'low', status: 'open', type: 'support', assignee: null },
{ clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high', status: 'in_progress', type: 'support', assignee: 'tech2' },
{ clientIdx: 20, title: 'Fiber cable damaged by construction', desc: 'Backhoe hit the fiber line on Rizal Ave', priority: 'urgent', status: 'in_progress', type: 'maintenance', assignee: 'tech' },
{ clientIdx: 22, title: 'Billing discrepancy - double charged', desc: 'Customer was charged twice for March billing', priority: 'high', status: 'open', type: 'support', assignee: null },
{ clientIdx: 25, title: 'New access point installation request', desc: 'Needs additional AP for 2nd floor', priority: 'normal', status: 'open', type: 'installation', assignee: null },
{ clientIdx: 18, title: 'Connection slow after rain', desc: 'Speed degrades significantly during/after rainfall', priority: 'normal', status: 'in_progress', type: 'maintenance', assignee: 'tech2' },
{ clientIdx: 28, title: 'Account suspension appeal', desc: 'Customer requests reconnection, willing to pay balance', priority: 'high', status: 'open', type: 'support', assignee: null },
{ clientIdx: 23, title: 'Router firmware update needed', desc: 'Current firmware causing intermittent WiFi drops', priority: 'normal', status: 'open', type: 'maintenance', assignee: null },
{ clientIdx: 15, title: 'Relocation request - new address', desc: 'Moving to Barangay 4, wants service transferred', priority: 'low', status: 'open', type: 'support', assignee: null },
{ clientIdx: 26, title: 'High latency for gaming', desc: 'Ping above 100ms during evenings', priority: 'normal', status: 'in_progress', type: 'support', assignee: 'tech' },
{ clientIdx: 19, title: 'ONT replacement needed', desc: 'ONT showing red fault light intermittently', priority: 'high', status: 'open', type: 'maintenance', assignee: null },
{ clientIdx: 21, title: 'Monthly service credit request', desc: 'Requesting credit for 2-day outage last month', priority: 'low', status: 'open', type: 'support', assignee: null },
{ clientIdx: 3, title: 'Second floor extension installation', desc: 'Client wants additional fiber drop to 2nd floor office', priority: 'normal', status: 'open', type: 'installation', assignee: null },
{ clientIdx: 9, title: 'Fiber relocation due to renovation', desc: 'House renovation requires moving fiber entry point', priority: 'normal', status: 'in_progress', type: 'installation', assignee: 'tech2' },
{ clientIdx: 14, title: 'ONT upgrade to GPON', desc: 'Current ONT outdated, needs GPON-compatible replacement', priority: 'low', status: 'open', type: 'installation', assignee: null },
{ clientIdx: 7, title: 'New branch office fiber install', desc: 'Client opened sari-sari store next door, wants 2nd connection', priority: 'high', status: 'open', type: 'installation', assignee: null },
{ clientIdx: 24, title: 'Intermittent packet loss', desc: 'Ping shows 5-10% packet loss during daytime', priority: 'high', status: 'in_progress', type: 'maintenance', assignee: 'tech' },
{ clientIdx: 12, title: 'Cable exposed across driveway', desc: 'Fiber cable hanging low across client driveway, safety hazard', priority: 'urgent', status: 'open', type: 'maintenance', assignee: null },
];
for (const t of supportTickets) {
@@ -393,11 +537,12 @@ async function main() {
tenantId: tenant.id,
clientId: clients[t.clientIdx].id,
createdById: users.tenant_admin.id,
type: 'support',
type: t.type,
title: t.title,
description: t.desc,
priority: t.priority,
status: 'open',
status: t.status,
assigneeId: t.assignee ? users[t.assignee]?.id ?? users.technician.id : null,
},
});
}
@@ -573,14 +718,149 @@ async function main() {
});
console.log('Fund transfers: 2');
// ─── Remittances ───────────────────────────────────────
await prisma.remittance.create({
data: { tenantId: tenant.id, collectorId: users.technician.id, confirmedById: users.tenant_admin.id, totalAmount: 8995, status: 'confirmed', confirmedAt: new Date() },
// ─── Remittances (properly linked to payments) ──────────
// Get all payments that were for paid invoices (these are candidates for remittances)
const allPayments = await prisma.payment.findMany({
where: { tenantId: tenant.id },
orderBy: { createdAt: 'asc' },
});
await prisma.remittance.create({
data: { tenantId: tenant.id, collectorId: users.technician.id, totalAmount: 5497, status: 'pending' },
});
console.log('Remittances: 2');
// Split payments: first 60% → remitted (confirmed), next 20% → remitted (pending), last 20% → unremitted
const confirmedEnd = Math.floor(allPayments.length * 0.6);
const pendingEnd = Math.floor(allPayments.length * 0.8);
const confirmedPayments = allPayments.slice(0, confirmedEnd);
const pendingPayments = allPayments.slice(confirmedEnd, pendingEnd);
// remaining payments (pendingEnd onward) stay unremitted
// Create confirmed remittance
if (confirmedPayments.length > 0) {
const confirmedTotal = confirmedPayments.reduce((s, p) => s + Number(p.amount), 0);
const confirmedRemittance = await prisma.remittance.create({
data: {
tenantId: tenant.id,
collectorId: users.technician.id,
confirmedById: users.tenant_admin.id,
totalAmount: confirmedTotal,
status: 'confirmed',
submittedAt: new Date(Date.now() - 86400000 * 7),
confirmedAt: new Date(Date.now() - 86400000 * 5),
payments: {
create: confirmedPayments.map((p) => ({ paymentId: p.id })),
},
},
});
console.log(`Remittance (confirmed): ₱${confirmedTotal} (${confirmedPayments.length} payments)`);
}
// Create pending remittance
if (pendingPayments.length > 0) {
const pendingTotal = pendingPayments.reduce((s, p) => s + Number(p.amount), 0);
const pendingRemittance = await prisma.remittance.create({
data: {
tenantId: tenant.id,
collectorId: users.technician.id,
totalAmount: pendingTotal,
status: 'pending',
submittedAt: new Date(Date.now() - 86400000 * 2),
payments: {
create: pendingPayments.map((p) => ({ paymentId: p.id })),
},
},
});
console.log(`Remittance (pending): ₱${pendingTotal} (${pendingPayments.length} payments)`);
}
const unremittedCount = allPayments.length - pendingEnd;
console.log(`Unremitted payments: ${unremittedCount} (from invoice loop)`);
// ─── Additional unremitted payments (recent, for testing) ──
const recentPaymentDefs = [
{ clientIdx: 0, amount: 699, method: 'gcash' as const, ref: 'GCASH-44221', daysAgo: 0 },
{ clientIdx: 2, amount: 999, method: 'cash' as const, ref: null, daysAgo: 0 },
{ clientIdx: 5, amount: 1499, method: 'maya' as const, ref: 'MAYA-88312', daysAgo: 1 },
{ clientIdx: 7, amount: 999, method: 'bank_transfer' as const, ref: 'BDO-10293', daysAgo: 1 },
{ clientIdx: 9, amount: 2499, method: 'gcash' as const, ref: 'GCASH-44228', daysAgo: 2 },
{ clientIdx: 10, amount: 699, method: 'cash' as const, ref: null, daysAgo: 2 },
{ clientIdx: 14, amount: 1499, method: 'gcash' as const, ref: 'GCASH-44235', daysAgo: 3 },
{ clientIdx: 3, amount: 999, method: 'maya' as const, ref: 'MAYA-88319', daysAgo: 4 },
];
// Find or create overdue invoices for these clients to attach payments to
for (const rp of recentPaymentDefs) {
const client = clients[rp.clientIdx];
const paidDate = new Date();
paidDate.setDate(paidDate.getDate() - rp.daysAgo);
// Find an existing overdue or partial invoice for this client
let invoice = await prisma.invoice.findFirst({
where: { tenantId: tenant.id, clientId: client.id, status: { in: ['overdue', 'partial'] } },
});
// If no overdue invoice, create one
if (!invoice) {
invoiceCount++;
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() - 5);
invoice = await prisma.invoice.create({
data: {
tenantId: tenant.id,
clientId: client.id,
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount: rp.amount,
balance: rp.amount,
status: 'overdue',
dueDate,
periodStart: new Date(dueDate.getTime() - 30 * 86400000),
periodEnd: dueDate,
},
});
}
await prisma.payment.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: users.collector.id,
amount: rp.amount,
method: rp.method,
referenceNo: rp.ref,
createdAt: paidDate,
},
});
}
console.log(`Recent unremitted payments: ${recentPaymentDefs.length}`);
// ─── Notifications (unread, for testing) ──────────────────
const notifDefs = [
{ userId: users.collector.id, type: 'in_app', channel: 'payment_confirmation', title: 'Payment Recorded', message: 'Payment of ₱699 for Juan Dela Cruz has been recorded.', daysAgo: 0 },
{ userId: users.collector.id, type: 'in_app', channel: 'billing_reminder', title: 'Overdue Reminder', message: '3 invoices are overdue in Barangay 1 - Centro.', daysAgo: 1 },
{ userId: users.collector.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Assigned', message: 'DNS resolution issues ticket has been assigned to you.', daysAgo: 1 },
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'New Ticket', message: 'Fiber cable repair - Poblacion ticket needs attention.', daysAgo: 0 },
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Resolved', message: 'Cannot connect after reboot ticket has been resolved.', daysAgo: 0 },
{ userId: users.manager.id, type: 'in_app', channel: 'billing_reminder', title: 'Weekly Summary', message: '12 payments collected this week totaling ₱14,988.', daysAgo: 2 },
{ userId: users.manager.id, type: 'in_app', channel: 'ticket_update', title: 'Urgent Maintenance', message: 'Node outage - Riverside sector affecting 8 subscribers.', daysAgo: 0 },
{ userId: users.tenant_admin.id, type: 'in_app', channel: 'payment_confirmation', title: 'Remittance Confirmed', message: 'Remittance of ₱5,000 has been confirmed by Admin.', daysAgo: 3 },
];
for (const n of notifDefs) {
const sentAt = new Date();
sentAt.setDate(sentAt.getDate() - n.daysAgo);
await prisma.notification.create({
data: {
tenantId: tenant.id,
userId: n.userId,
type: n.type,
channel: n.channel,
title: n.title,
message: n.message,
isRead: false,
sentAt,
},
});
}
console.log(`Notifications: ${notifDefs.length} (unread)`);
console.log('\n✅ Seed completed successfully!');
console.log(`\n📊 Summary:`);
@@ -594,8 +874,12 @@ async function main() {
console.log(` Expenses: ${expDefs.length} (approved + pending)`);
console.log(` Assets: ${assetDefs.length}`);
console.log(` Company Accounts: ${accts.length}`);
console.log(`\n🔑 Login: admin@demo-isp.com / admin123!`);
console.log(`🌐 Portal: C-000001 / 09171234567`);
console.log(`\n🔑 Logins (all passwords: admin123!):`);
console.log(` admin@demo-isp.com (tenant_admin) - full access`);
console.log(` manager@demo-isp.com (manager) - operational management`);
console.log(` collector@demo-isp.com (collector) - payment collection`);
console.log(` tech@demo-isp.com (technician) - field operations`);
console.log(` tech2@demo-isp.com (technician) - field operations`);
}
main()

View File

@@ -3,6 +3,7 @@ export const Role = {
TENANT_ADMIN: 'tenant_admin',
MANAGER: 'manager',
TECHNICIAN: 'technician',
COLLECTOR: 'collector',
VIEWER: 'viewer',
} as const;
@@ -20,6 +21,7 @@ const ROLE_LEVEL: Record<string, number> = {
[Role.TENANT_ADMIN]: 80,
[Role.MANAGER]: 60,
[Role.TECHNICIAN]: 40,
[Role.COLLECTOR]: 30,
[Role.VIEWER]: 20,
};

View File

@@ -28,6 +28,8 @@ import { SchedulerModule } from './scheduler/scheduler.module';
import { AccountingModule } from './accounting/accounting.module';
import { PayrollModule } from './payroll/payroll.module';
import { RoleModule } from './role/role.module';
import { CommentModule } from './comment/comment.module';
import { ImportModule } from './import/import.module';
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { PermissionsGuard } from './common/guards/permissions.guard';
@@ -69,6 +71,8 @@ import { AccessGuard } from './common/guards/access.guard';
AccountingModule,
PayrollModule,
RoleModule,
CommentModule,
ImportModule,
],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },

View File

@@ -23,7 +23,7 @@ export class ClientController {
constructor(private readonly clientService: ClientService) {}
@Get()
@Roles('technician')
@Roles('technician', 'collector')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('areaId') areaId?: string,
@@ -36,7 +36,7 @@ export class ClientController {
}
@Get(':id')
@Roles('technician')
@Roles('technician', 'collector')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
@@ -54,7 +54,7 @@ export class ClientController {
}
@Patch(':id')
@Roles('manager')
@Roles('manager', 'technician', 'collector')
async update(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,

View File

@@ -0,0 +1,58 @@
import {
Controller,
Get,
Post,
Param,
Body,
UseGuards,
UseInterceptors,
UploadedFiles,
BadRequestException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { CommentService } from './comment.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';
import { multerOptions } from '../common/multer/multer.config';
@Controller('tickets/:ticketId/comments')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@Get()
@Roles('technician', 'collector')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Param('ticketId') ticketId: string,
) {
return this.commentService.findAll(user.tenantId, ticketId);
}
@Post()
@Roles('technician', 'collector')
@UseInterceptors(FileFieldsInterceptor([{ name: 'files', maxCount: 3 }], multerOptions))
async create(
@CurrentUser() user: CurrentUserPayload,
@Param('ticketId') ticketId: string,
@Body() body: any,
@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(
user.tenantId,
ticketId,
user.sub,
content,
files?.files,
);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CommentController } from './comment.controller';
import { CommentService } from './comment.service';
import { PrismaModule } from '../prisma/prisma.module';
import { NotificationModule } from '../notification/notification.module';
@Module({
imports: [PrismaModule, NotificationModule],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
})
export class CommentModule {}

View File

@@ -0,0 +1,117 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
@Injectable()
export class CommentService {
constructor(
private readonly prisma: PrismaService,
private readonly notificationService: NotificationService,
) {}
async findAll(tenantId: string, ticketId: string) {
const db = this.prisma.forTenant(tenantId);
const ticket = await db.ticket.findFirst({ where: { id: ticketId } });
if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticketComment.findMany({
where: { tenantId, ticketId },
include: {
author: { select: { id: true, firstName: true, lastName: true } },
attachments: true,
},
orderBy: { createdAt: 'asc' },
});
}
async create(
tenantId: string,
ticketId: string,
userId: string,
content: string,
files?: Express.Multer.File[],
) {
const db = this.prisma.forTenant(tenantId);
const ticket = await db.ticket.findFirst({ where: { id: ticketId } });
if (!ticket) throw new NotFoundException('Ticket not found');
const comment = await this.prisma.ticketComment.create({
data: {
tenantId,
ticketId,
userId,
content,
attachments: files?.length
? {
create: files.map((f) => ({
fileName: f.originalname,
filePath: f.filename,
fileType: f.mimetype,
fileSize: f.size,
})),
}
: undefined,
},
include: {
author: { select: { id: true, firstName: true, lastName: true } },
attachments: true,
},
});
// Parse @mentions and notify mentioned users
const mentions = this.parseMentions(content);
if (mentions.length > 0) {
const db2 = this.prisma.forTenant(tenantId);
const users = await db2.user.findMany({
where: { tenantId, isActive: true },
select: { id: true, firstName: true, lastName: true },
});
const commenterName = `${comment.author.firstName} ${comment.author.lastName}`;
for (const mention of mentions) {
const mentionedUser = users.find(
(u) =>
`${u.firstName} ${u.lastName}`.toLowerCase() === mention.toLowerCase() ||
u.firstName.toLowerCase() === mention.toLowerCase(),
);
if (mentionedUser && mentionedUser.id !== userId) {
await this.notificationService.create(tenantId, {
userId: mentionedUser.id,
type: 'in_app',
channel: 'mention',
title: 'You were mentioned',
message: `${commenterName} mentioned you in "${ticket.title}"`,
ticketId,
});
}
}
}
// Notify ticket creator (if not the commenter)
if (ticket.createdById && ticket.createdById !== userId) {
const commenterName = `${comment.author.firstName} ${comment.author.lastName}`;
await this.notificationService.create(tenantId, {
userId: ticket.createdById,
type: 'in_app',
channel: 'comment_added',
title: 'New comment on your ticket',
message: `${commenterName} commented on "${ticket.title}"`,
ticketId,
});
}
return comment;
}
/** Extract @FirstName or @FirstNameLastName from content */
private parseMentions(content: string): string[] {
const regex = /@(\w+(?:\s+\w+)?)/g;
const matches: string[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
matches.push(match[1]);
}
return [...new Set(matches)];
}
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@MinLength(1)
content!: string;
}

View File

@@ -0,0 +1,19 @@
import {
IsString,
IsOptional,
IsArray,
MinLength,
MaxLength,
} from 'class-validator';
export class CreateCommentDto {
@IsString()
@MinLength(1)
@MaxLength(50000)
content: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
files?: string[];
}

View File

@@ -0,0 +1,36 @@
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { diskStorage } from 'multer';
import { extname, resolve } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { Request } from 'express';
const uploadDir = resolve('./uploads');
if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true });
export const multerOptions: MulterOptions = {
storage: diskStorage({
destination: (_req: Request, _file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
cb(null, uploadDir);
},
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, uniqueSuffix + extname(file.originalname));
},
}),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB per file
},
fileFilter: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, acceptFile: boolean) => void) => {
const allowed = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type ${file.mimetype} not allowed`), false);
}
},
};

View 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,
);
}
}

View 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 {}

View 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')}`;
}
}

View File

@@ -20,17 +20,18 @@ export class InvoiceController {
constructor(private readonly invoiceService: InvoiceService) {}
@Get()
@Roles('technician')
@Roles('technician', 'collector')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('clientId') clientId?: string,
@Query('status') status?: string,
@Query('sort') sort?: string,
) {
return this.invoiceService.findAll(user.tenantId, { clientId, status });
return this.invoiceService.findAll(user.tenantId, { clientId, status, sort });
}
@Get(':id')
@Roles('technician')
@Roles('technician', 'collector')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,

View File

@@ -10,23 +10,33 @@ import { paginationArgs, paginatedResult } from '../common/dto/pagination.dto';
export class InvoiceService {
constructor(private readonly prisma: PrismaService) {}
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; page?: number; limit?: number }) {
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; sort?: string; page?: number; limit?: number }) {
const { skip, take, page, limit } = paginationArgs({ page: filters?.page, limit: filters?.limit });
const db = this.prisma.forTenant(tenantId);
const statusFilter = filters?.status
? (filters.status.includes(',') ? { in: filters.status.split(',') } : filters.status)
: undefined;
const where = {
...(filters?.clientId && { clientId: filters.clientId }),
...(filters?.status && { status: filters.status }),
...(statusFilter && { status: statusFilter }),
};
const orderBy = filters?.sort === 'dueDate:asc'
? { dueDate: 'asc' as const }
: { createdAt: 'desc' as const };
const [items, total] = await Promise.all([
db.invoice.findMany({
where,
skip,
take,
include: {
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true, phone: true, latitude: true, longitude: true } },
_count: { select: { payments: true } },
},
orderBy: { createdAt: 'desc' },
orderBy,
}),
db.invoice.count({ where }),
]);
@@ -83,7 +93,7 @@ export class InvoiceService {
periodEnd: dueDate,
},
include: {
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true, phone: true, latitude: true, longitude: true } },
},
});
}

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

@@ -40,6 +40,7 @@ export class NotificationService {
channel: string;
title: string;
message: string;
ticketId?: string;
}) {
return this.prisma.notification.create({
data: {
@@ -50,6 +51,7 @@ export class NotificationService {
channel: data.channel,
title: data.title,
message: data.message,
ticketId: data.ticketId,
sentAt: new Date(),
},
});

View File

@@ -23,7 +23,7 @@ export class PaymentController {
constructor(private readonly paymentService: PaymentService) {}
@Get()
@Roles('technician')
@Roles('technician', 'collector')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('clientId') clientId?: string,
@@ -32,7 +32,7 @@ export class PaymentController {
}
@Post()
@Roles('technician')
@Roles('technician', 'collector')
async record(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: RecordPaymentDto,
@@ -41,19 +41,20 @@ export class PaymentController {
}
@Get('unremitted')
@Roles('technician')
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('collectorId') collectorId?: string) {
return this.paymentService.getUnremittedPayments(user.tenantId, collectorId || user.sub);
@Roles('technician', 'collector', 'manager')
async getUnremitted(@CurrentUser() user: CurrentUserPayload) {
const isManager = user.roles?.some((r: string) => ['manager', 'tenant_admin', 'super_admin'].includes(r));
return this.paymentService.getUnremittedPayments(user.tenantId, isManager ? null : user.sub);
}
@Get('remittances')
@Roles('technician')
@Roles('technician', 'collector')
async findRemittances(@CurrentUser() user: CurrentUserPayload) {
return this.paymentService.findRemittances(user.tenantId);
}
@Post('remittances')
@Roles('technician')
@Roles('technician', 'collector')
async submitRemittance(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateRemittanceDto,
@@ -78,4 +79,10 @@ export class PaymentController {
) {
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);
}
}

View File

@@ -124,7 +124,7 @@ export class PaymentService {
// ─── Remittance (custodial clearing) ─────────────────────────
async findRemittances(tenantId: string) {
return this.prisma.remittance.findMany({
const remittances = await this.prisma.remittance.findMany({
where: { tenantId },
include: {
collector: { select: { id: true, firstName: true, lastName: true } },
@@ -133,17 +133,49 @@ export class PaymentService {
},
orderBy: { submittedAt: 'desc' },
});
// Collect all payment IDs across remittances
const paymentIds = remittances.flatMap((r) => r.payments.map((p) => p.paymentId));
if (paymentIds.length === 0) return remittances;
// Fetch full payment details in one query
const payments = await this.prisma.payment.findMany({
where: { id: { in: paymentIds } },
include: {
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
invoice: { select: { id: true, number: true } },
},
});
const paymentMap = new Map(payments.map((p) => [p.id, p]));
// Attach full payment details to each remittance's join records
return remittances.map((r) => ({
...r,
createdAt: r.submittedAt.toISOString(),
payments: r.payments.map((rp) => ({
...rp,
payment: paymentMap.get(rp.paymentId) ?? null,
})),
}));
}
async getUnremittedPayments(tenantId: string, collectorId: string) {
const remittedIds = (await this.prisma.remittancePayment.findMany({ select: { paymentId: true } }))
async getUnremittedPayments(tenantId: string, collectorId: string | null) {
const remittedIds = (await this.prisma.remittancePayment.findMany({
where: { remittance: { tenantId } },
select: { paymentId: true },
}))
.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: { number: true } },
invoice: { select: { id: true, number: true } },
collectedBy: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
});
@@ -188,6 +220,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) },
@@ -237,7 +280,7 @@ export class PaymentService {
}
}
return this.prisma.remittance.update({
const result = await this.prisma.remittance.update({
where: { id: remittanceId },
data: {
status: 'rejected',
@@ -245,5 +288,50 @@ 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;
}
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,
}));
}
}

View 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}`),
);
}
}
}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { InvoiceScheduler } from './invoice.scheduler';
import { PaymentScheduler } from './payment.scheduler';
import { BillingModule } from '../billing/billing.module';
import { NotificationModule } from '../notification/notification.module';
@Module({
imports: [ScheduleModule.forRoot(), BillingModule],
providers: [InvoiceScheduler],
imports: [ScheduleModule.forRoot(), BillingModule, NotificationModule],
providers: [InvoiceScheduler, PaymentScheduler],
})
export class SchedulerModule {}

View File

@@ -0,0 +1,19 @@
import {
IsString,
IsOptional,
IsArray,
MinLength,
MaxLength,
} from 'class-validator';
export class CreateCommentDto {
@IsString()
@MinLength(1)
@MaxLength(5000)
content: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
files?: string[];
}

View File

@@ -1,4 +1,5 @@
import { IsString, IsOptional, IsIn, MinLength, IsUUID } from 'class-validator';
import { IsString, IsOptional, IsIn, MinLength, IsUUID, IsNumber } from 'class-validator';
import { Transform } from 'class-transformer';
export class UpdateTicketDto {
@IsOptional()
@@ -23,4 +24,14 @@ export class UpdateTicketDto {
@IsString()
@IsIn(['open', 'in_progress', 'resolved', 'cancelled'])
status?: string;
@IsOptional()
@Transform(({ value }) => value !== undefined && value !== null ? Number(value) : undefined)
@IsNumber()
latitude?: number;
@IsOptional()
@Transform(({ value }) => value !== undefined && value !== null ? Number(value) : undefined)
@IsNumber()
longitude?: number;
}

View File

@@ -23,7 +23,7 @@ export class TicketController {
constructor(private readonly ticketService: TicketService) {}
@Get()
@Roles('technician')
@Roles('technician', 'collector')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('clientId') clientId?: string,
@@ -34,7 +34,7 @@ export class TicketController {
}
@Get(':id')
@Roles('technician')
@Roles('technician', 'collector')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
@@ -43,7 +43,7 @@ export class TicketController {
}
@Post()
@Roles('manager')
@Roles('technician')
async create(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateTicketDto,
@@ -52,7 +52,7 @@ export class TicketController {
}
@Patch(':id')
@Roles('technician')
@Roles('technician', 'collector')
async update(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
@@ -62,7 +62,7 @@ export class TicketController {
}
@Patch(':id/resolve')
@Roles('technician')
@Roles('technician', 'collector')
async resolve(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,

View File

@@ -4,9 +4,10 @@ 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 { NotificationService } from '../notification/notification.service';
import { CreateCommentDto } from './dto/create-comment.dto';
export interface TicketResolvedEvent {
ticketId: string;
@@ -107,6 +108,29 @@ export class TicketService {
throw new NotFoundException('Ticket not found');
}
// When saving location, also propagate to the client record
if (dto.latitude !== undefined && dto.longitude !== undefined && existing.clientId) {
await this.prisma.client.update({
where: { id: existing.clientId },
data: { latitude: dto.latitude, longitude: dto.longitude },
});
}
// 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: {
@@ -115,22 +139,13 @@ export class TicketService {
...(dto.description !== undefined && { description: dto.description }),
...(dto.priority && { priority: dto.priority }),
...(dto.status && { status: dto.status }),
...(dto.latitude !== undefined && { latitude: dto.latitude }),
...(dto.longitude !== undefined && { longitude: dto.longitude }),
},
}).then(async (ticket) => {
// Notify on status change
if (dto.status) {
this.notificationService.create(tenantId, {
type: 'in_app',
channel: 'ticket_update',
title: 'Ticket Updated',
message: `Ticket "${existing.title}" status changed to ${dto.status.replace(/_/g, ' ')}`,
}).catch(() => {});
}
return ticket;
});
}
async resolve(tenantId: string, id: string, resolvedById: string, coords?: { latitude?: number; longitude?: number }) {
async resolve(tenantId: string, id: string, resolvedById: string, body?: { latitude?: number; longitude?: number }) {
const db = this.prisma.forTenant(tenantId);
const ticket = await db.ticket.findFirst({ where: { id } });
@@ -152,17 +167,11 @@ export class TicketService {
status: 'resolved',
resolvedAt: new Date(),
assigneeId: resolvedById,
...(body?.latitude !== undefined && { latitude: body.latitude }),
...(body?.longitude !== undefined && { longitude: body.longitude }),
},
});
// Update client coordinates if this is an installation ticket with coords
if (coords?.latitude !== undefined && coords?.longitude !== undefined && ticket.clientId && ticket.type === 'installation') {
await this.prisma.client.update({
where: { id: ticket.clientId },
data: { latitude: coords.latitude, longitude: coords.longitude },
});
}
// Fire event for workflow automation
if (this.onTicketResolved && ticket.clientId) {
await this.onTicketResolved({
@@ -173,14 +182,52 @@ export class TicketService {
});
}
// Notify on ticket resolution
this.notificationService.create(tenantId, {
type: 'in_app',
channel: 'ticket_update',
title: 'Ticket Resolved',
message: `Ticket "${ticket.title}" has been resolved`,
}).catch(() => {});
return resolved;
}
// Comments
async getComments(tenantId: string, ticketId: string) {
const db = this.prisma.forTenant(tenantId);
const comments = await db.ticketComment.findMany({
where: { ticketId },
include: {
author: {
select: { id: true, firstName: true, lastName: true },
},
},
orderBy: { createdAt: 'asc' },
});
return comments.map((c: any) => ({
...c,
createdByName: c.author
? `${c.author.firstName} ${c.author.lastName}`
: 'Unknown',
}));
}
async addComment(
tenantId: string,
ticketId: string,
userId: string,
dto: CreateCommentDto,
) {
const db = this.prisma.forTenant(tenantId);
const ticket = await db.ticket.findFirst({
where: { id: ticketId },
});
if (!ticket) {
throw new NotFoundException('Ticket not found');
}
return db.ticketComment.create({
data: {
tenantId,
ticketId,
userId,
content: dto.content,
},
});
}
}

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