Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc89ed124f | ||
|
|
d4d9249933 | ||
|
|
6ea1f412a1 | ||
|
|
7f9beaac2b | ||
|
|
85aea30730 | ||
|
|
da9707f3fb | ||
|
|
59ee1fbe33 | ||
|
|
248f6b51ea | ||
|
|
5f4a4c6874 | ||
|
|
8fdfa6b7ba | ||
|
|
ade5df1c32 | ||
|
|
d2864961de | ||
|
|
91ea365cf9 | ||
|
|
58c01d4580 | ||
|
|
58d77fc67f | ||
|
|
c14521a41d | ||
|
|
83ad0cb8d5 | ||
|
|
469fa6bb28 | ||
|
|
94528841d9 | ||
|
|
a66fff68de | ||
|
|
40f30b4a5d | ||
|
|
bb3baeb6ac | ||
|
|
ff6295e1fc | ||
|
|
555327a891 | ||
|
|
29c6b70878 | ||
|
|
d7ab370bb5 | ||
|
|
1a0b4916d0 | ||
|
|
3b032ab7c4 | ||
|
|
d792a9a9ed | ||
|
|
c923285c7e | ||
|
|
ff0dd2e418 | ||
|
|
a5ed2cc666 | ||
|
|
bef320f32e |
@@ -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
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",
|
||||
"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"
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tickets" ADD COLUMN "latitude" DOUBLE PRECISION,
|
||||
ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
58
src/comment/comment.controller.ts
Normal file
58
src/comment/comment.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/comment/comment.module.ts
Normal file
13
src/comment/comment.module.ts
Normal 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 {}
|
||||
117
src/comment/comment.service.ts
Normal file
117
src/comment/comment.service.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
19
src/comment/dto/create-comment.dto.ts
Normal file
19
src/comment/dto/create-comment.dto.ts
Normal 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[];
|
||||
}
|
||||
36
src/common/multer/multer.config.ts
Normal file
36
src/common/multer/multer.config.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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 {}
|
||||
|
||||
19
src/ticket/dto/create-comment.dto.ts
Normal file
19
src/ticket/dto/create-comment.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user