Compare commits

14 Commits

Author SHA1 Message Date
kevin-asprec
b771bb124c chore: trigger Coolify redeploy for lat/lng + invoice client data 2026-05-04 14:09:39 +08:00
kevin-asprec
f1cfe85dfe feat: add latitude/longitude to ticket update endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 13:22:34 +08:00
kevin-asprec
dcfd93c545 fix: include phone, latitude, longitude in invoice client data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 12:36:49 +08:00
kevin-asprec
c8c6e0ebc3 fix: include full payment details in remittance history
findRemittances used `payments: true` which returned raw RemittancePayment
join records without actual payment data. Now fetches full Payment objects
via manual join and maps them to the remittance records so the mobile app
can display client names, amounts, and invoice numbers.
2026-05-03 11:00:03 +08:00
kevin-asprec
e94644548e chore: add schema fix script for deployment 2026-04-15 11:36:45 +08:00
kevin-asprec
de930e470b chore: force cache invalidation with RUN before COPY 2026-04-15 11:19:02 +08:00
kevin-asprec
5b168f5682 fix: make seed resilient to tenantId NOT NULL constraint 2026-04-15 11:08:56 +08:00
kevin-asprec
ab4eeb7ce6 feat: embed seed in CMD, remove post_deployment_command dependency 2026-04-15 11:06:18 +08:00
kevin-asprec
67081dbe76 chore: bust docker cache for migration deployment 2026-04-15 10:51:15 +08:00
kevin-asprec
7ee821d46e feat: add migration to make user tenantId nullable for platform-level superadmin 2026-04-15 10:39:10 +08:00
kevin-asprec
78d403dcff fix: assign tenantId to superadmin in seed (column is NOT NULL) 2026-04-15 10:26:20 +08:00
kevin-asprec
7c90f118ed revert: remove forced seed from CMD, use post_deployment_command instead 2026-04-15 10:14:20 +08:00
kevin-asprec
1eb61000c3 feat: force seed for initial UAT deploy 2026-04-15 09:58:47 +08:00
kevin-asprec
82cac36a48 feat: add RUN_SEED support to Dockerfile CMD 2026-04-15 09:47:51 +08:00
30 changed files with 850 additions and 1610 deletions

View File

@@ -1,4 +1,5 @@
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
# cache-bust-v3
WORKDIR /app WORKDIR /app
ENV NODE_ENV=development ENV NODE_ENV=development
@@ -8,6 +9,7 @@ COPY packages/shared/package.json ./packages/shared/
COPY packages/db/package.json ./packages/db/ COPY packages/db/package.json ./packages/db/
RUN npm install RUN npm install
RUN echo "bust-20260415-3" > /tmp/.cachebust && rm /tmp/.cachebust
COPY packages/shared/ ./packages/shared/ COPY packages/shared/ ./packages/shared/
COPY packages/db/ ./packages/db/ COPY packages/db/ ./packages/db/
COPY nest-cli.json ./ COPY nest-cli.json ./
@@ -30,8 +32,9 @@ RUN node -e "const p=require('./packages/shared/package.json');p.main='./dist/in
COPY --from=builder /app/packages/db/prisma ./packages/db/prisma COPY --from=builder /app/packages/db/prisma ./packages/db/prisma
COPY --from=builder /app/packages/db/package.json ./packages/db/ COPY --from=builder /app/packages/db/package.json ./packages/db/
COPY --from=builder /app/packages/db/src ./packages/db/src COPY --from=builder /app/packages/db/src ./packages/db/src
COPY --from=builder /app/packages/db/prisma/seed.ts ./packages/db/prisma/seed.ts
ENV NODE_ENV=production ENV NODE_ENV=production
EXPOSE 3001 EXPOSE 3001
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
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"] CMD ["sh", "-c", "cd packages/db && npx prisma migrate deploy && echo 'Seeding database...' && npx tsx prisma/seed.ts && cd /app && node dist/main"]

5
fix-schema.js Normal file
View File

@@ -0,0 +1,5 @@
const { PrismaClient } = require("@prisma/client");
const p = new PrismaClient();
p.$executeRawUnsafe("ALTER TABLE users ALTER COLUMN \"tenantId\" DROP NOT NULL")
.then(() => { console.log("tenantId nullable OK"); return p.$disconnect(); })
.catch(e => { console.log("Error:", e.message.substring(0, 100)); return p.$disconnect(); });

View File

@@ -40,7 +40,6 @@
"@nestjs/testing": "^11.0.0", "@nestjs/testing": "^11.0.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^3.1.0" "vitest": "^3.1.0"

View File

@@ -1,12 +1,12 @@
-- AlterTable: make tenantId nullable for super_admin users -- AlterColumn: make tenantId nullable for platform-level users (superadmin)
ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL; ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL;
-- Fix FK to allow NULL (super_admin has no tenant) -- Drop existing FK and recreate with ON DELETE SET NULL
ALTER TABLE "users" DROP CONSTRAINT "users_tenantId_fkey"; ALTER TABLE "users" DROP CONSTRAINT "users_tenantId_fkey";
ALTER TABLE "users" ADD 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; 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) -- Recreate unique index to allow multiple NULL tenantId values
DROP INDEX IF EXISTS "users_tenantId_email_key"; 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_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; CREATE UNIQUE INDEX "users_email_key" ON "users"("email") WHERE "tenantId" IS NULL;

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// ─── Multi-Tenant Foundation ──────────────────────────────── // ─── Multi-Tenant Foundation ────────────────────────────────────────
model Tenant { model Tenant {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -29,13 +29,12 @@
payments Payment[] payments Payment[]
tickets Ticket[] tickets Ticket[]
notifications Notification[] notifications Notification[]
ticketComments TicketComment[]
@@index([deletedAt]) @@index([deletedAt])
@@map("tenants") @@map("tenants")
} }
// ─── Auth & Users ─────────────────────────────────────────── // ─── Auth & Users ───────────────────────────────────────────────────
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -55,7 +54,6 @@
tenantRoles UserTenantRole[] tenantRoles UserTenantRole[]
assignedTickets Ticket[] @relation("TicketAssignee") assignedTickets Ticket[] @relation("TicketAssignee")
createdTickets Ticket[] @relation("TicketCreator") createdTickets Ticket[] @relation("TicketCreator")
authoredComments TicketComment[] @relation("CommentAuthor")
payments Payment[] payments Payment[]
remittances Remittance[] @relation("RemittanceCollector") remittances Remittance[] @relation("RemittanceCollector")
confirmedRemittances Remittance[] @relation("RemittanceConfirmer") confirmedRemittances Remittance[] @relation("RemittanceConfirmer")
@@ -69,7 +67,7 @@
model UserRole { model UserRole {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
role String role String // super_admin (platform-level only, legacy compat)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -77,7 +75,7 @@
@@map("user_roles") @@map("user_roles")
} }
// ─── Tenant-Scoped Role Management ───────────────────────── // ─── Tenant-Scoped Role Management ─────────────────────────────────
model TenantRole { model TenantRole {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -104,7 +102,7 @@
model RolePermission { model RolePermission {
id String @id @default(uuid()) id String @id @default(uuid())
tenantRoleId String tenantRoleId String
module String module String // clients, subscriptions, invoices, payments, tickets, employees, expenses, assets, payroll, accounts, accounting, reports, settings, users, areas, plans, dashboard, fund_transfers
canView Boolean @default(false) canView Boolean @default(false)
canCreate Boolean @default(false) canCreate Boolean @default(false)
canUpdate Boolean @default(false) canUpdate Boolean @default(false)
@@ -133,8 +131,6 @@
@@map("user_tenant_roles") @@map("user_tenant_roles")
} }
// ─── Refresh Token ───────────────────────────────────
model RefreshToken { model RefreshToken {
id String @id @default(uuid()) id String @id @default(uuid())
token String @unique token String @unique
@@ -147,7 +143,7 @@
@@map("refresh_tokens") @@map("refresh_tokens")
} }
// ─── Area & Zone Management ───────────────────────────────── // ─── Area & Zone Management ─────────────────────────────────────────
model Area { model Area {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -168,7 +164,7 @@
@@map("areas") @@map("areas")
} }
// ─── Plan / Package Management ───────────────────────── // ─── Plan / Package Management ──────────────────────────────────────
model Plan { model Plan {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -193,7 +189,7 @@
@@map("plans") @@map("plans")
} }
// ─── Client Profiling ───────────────────────────────── // ─── Client Profiling ───────────────────────────────────────────────
model Client { model Client {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -204,9 +200,9 @@
email String? email String?
phone String? phone String?
address String address String
areaId String?
latitude Float? latitude Float?
longitude Float? longitude Float?
areaId String?
status String @default("active") // active, inactive, suspended status String @default("active") // active, inactive, suspended
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -226,7 +222,7 @@
@@map("clients") @@map("clients")
} }
// ─── Subscription Management ───────────────────────── // ─── Subscription Management ────────────────────────────────────────
model Subscription { model Subscription {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -254,7 +250,7 @@
@@map("subscriptions") @@map("subscriptions")
} }
// ─── Billing & Invoicing ─────────────────────────────────── // ─── Billing & Invoicing ────────────────────────────────────────────
model Invoice { model Invoice {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -284,7 +280,7 @@
@@map("invoices") @@map("invoices")
} }
// ─── Payment & Collection ───────────────────────────────── // ─── Payment & Collection ───────────────────────────────────────────
model Payment { model Payment {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -311,8 +307,6 @@
@@map("payments") @@map("payments")
} }
// ─── Remittance ─────────────────────────────────────────
model Remittance { model Remittance {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
@@ -346,7 +340,7 @@
@@map("remittance_payments") @@map("remittance_payments")
} }
// ─── Tickets (Work Orders) ───────────────────────────────── // ─── Tickets (Work Orders) ──────────────────────────────────────────
model Ticket { model Ticket {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -359,8 +353,6 @@
title String title String
description String? description String?
priority String @default("normal") // low, normal, high, urgent priority String @default("normal") // low, normal, high, urgent
latitude Float?
longitude Float?
resolvedAt DateTime? resolvedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -370,7 +362,6 @@
client Client? @relation(fields: [clientId], references: [id]) client Client? @relation(fields: [clientId], references: [id])
createdBy User @relation("TicketCreator", fields: [createdById], references: [id]) createdBy User @relation("TicketCreator", fields: [createdById], references: [id])
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id]) assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
comments TicketComment[]
@@index([tenantId]) @@index([tenantId])
@@index([tenantId, type, status]) @@index([tenantId, type, status])
@@ -380,7 +371,7 @@
@@map("tickets") @@map("tickets")
} }
// ─── Notifications ───────────────────────────────────────── // ─── Notifications ──────────────────────────────────────────────────
model Notification { model Notification {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -393,7 +384,6 @@
message String message String
isRead Boolean @default(false) isRead Boolean @default(false)
sentAt DateTime? sentAt DateTime?
ticketId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@ -403,43 +393,7 @@
@@map("notifications") @@map("notifications")
} }
// ─── Ticket Comments ───────────────────────────────── // ─── Audit Log (append-only, no soft delete) ────────────────────────
model TicketComment {
id String @id @default(uuid())
tenantId String
ticketId String
userId String
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation("CommentAuthor", fields: [userId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
attachments TicketAttachment[]
@@index([ticketId])
@@map("ticket_comments")
}
// ─── Ticket Attachments ─────────────────────────────────
model TicketAttachment {
id String @id @default(uuid())
commentId String
fileName String
filePath String
fileType String
fileSize Int
createdAt DateTime @default(now())
comment TicketComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@map("ticket_attachments")
}
// ─── Audit Log (append-only, no soft delete) ─────────────────
model AuditLog { model AuditLog {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -458,7 +412,7 @@
@@map("audit_logs") @@map("audit_logs")
} }
// ─── Employee Management ───────────────────────── // ─── Employee Management ────────────────────────────────────────────
model Employee { model Employee {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -490,7 +444,7 @@
@@map("employees") @@map("employees")
} }
// ─── Payroll ───────────────────────────────────────────── // ─── Payroll ────────────────────────────────────────────────────────
model PayrollRun { model PayrollRun {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -524,15 +478,16 @@
status String @default("pending") // pending, paid status String @default("pending") // pending, paid
notes String? notes String?
payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id]) payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id], onDelete: Cascade)
employee Employee @relation(fields: [employeeId], references: [id]) employee Employee @relation(fields: [employeeId], references: [id])
@@unique([payrollRunId, employeeId]) @@unique([payrollRunId, employeeId])
@@index([payrollRunId]) @@index([payrollRunId])
@@index([employeeId])
@@map("payslips") @@map("payslips")
} }
// ─── Recurring Expenses ───────────────────────── // ─── Recurring Expenses ─────────────────────────────────────────────
model RecurringExpense { model RecurringExpense {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -553,7 +508,7 @@
@@map("recurring_expenses") @@map("recurring_expenses")
} }
// ─── Expense Management ───────────────────────── // ─── Expense Management ─────────────────────────────────────────────
model Expense { model Expense {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -579,7 +534,7 @@
@@map("expenses") @@map("expenses")
} }
// ─── Company Accounts & Fund Transfers ───────────────── // ─── Company Accounts & Fund Transfers ──────────────────────────────
model CompanyAccount { model CompanyAccount {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -600,6 +555,7 @@
@@unique([tenantId, name]) @@unique([tenantId, name])
@@index([tenantId]) @@index([tenantId])
@@index([deletedAt])
@@map("company_accounts") @@map("company_accounts")
} }
@@ -620,7 +576,7 @@
@@map("fund_transfers") @@map("fund_transfers")
} }
// ─── Asset Management ───────────────────────────────── // ─── Asset Management ───────────────────────────────────────────────
model Asset { model Asset {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -647,7 +603,7 @@
@@map("assets") @@map("assets")
} }
// ─── Billing Settings ───────────────────────── // ─── Billing Settings ───────────────────────────────────────────────
model BillingSetting { model BillingSetting {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -663,7 +619,7 @@
@@map("billing_settings") @@map("billing_settings")
} }
// ─── Chart of Accounts ───────────────────────── // ─── Chart of Accounts ──────────────────────────────────────────────
model ChartOfAccount { model ChartOfAccount {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -690,7 +646,7 @@
@@map("chart_of_accounts") @@map("chart_of_accounts")
} }
// ─── Journal Entries (append-only, no soft delete) ───────── // ─── Journal Entries (append-only, no soft delete) ──────────────────
model JournalEntry { model JournalEntry {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -717,7 +673,7 @@
debit Decimal @default(0) @db.Decimal(14, 2) debit Decimal @default(0) @db.Decimal(14, 2)
credit Decimal @default(0) @db.Decimal(14, 2) credit Decimal @default(0) @db.Decimal(14, 2)
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id]) journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade)
account ChartOfAccount @relation(fields: [accountId], references: [id]) account ChartOfAccount @relation(fields: [accountId], references: [id])
@@index([journalEntryId]) @@index([journalEntryId])

View File

@@ -57,6 +57,14 @@ async function hashPassword(password: string): Promise<string> {
async function main() { async function main() {
console.log('Seeding database...'); console.log('Seeding database...');
// Ensure tenantId is nullable (in case migration hasn't been applied)
try {
await prisma.$executeRawUnsafe(`ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL`);
console.log('Made tenantId nullable');
} catch (e: any) {
console.log('tenantId already nullable or error:', e.message?.substring(0, 80));
}
// Clean existing data (order matters for FK constraints) // Clean existing data (order matters for FK constraints)
await prisma.journalLine.deleteMany(); await prisma.journalLine.deleteMany();
await prisma.journalEntry.deleteMany(); await prisma.journalEntry.deleteMany();
@@ -101,9 +109,22 @@ async function main() {
console.log(`Tenant: ${tenant.name}`); console.log(`Tenant: ${tenant.name}`);
// ─── Super Admin (platform-level, no tenant) ─────────── // ─── Super Admin (platform-level, no tenant) ───────────
const superAdmin = await prisma.user.create({ // If tenantId column is NOT NULL (migration not applied), use tenant.id as fallback
let superAdmin;
try {
superAdmin = await prisma.user.create({
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' }, data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
}); });
} catch (e: any) {
if (e?.code === 'P2002' || e?.message?.includes('Null constraint')) {
console.log('Note: tenantId is NOT NULL, creating superadmin with tenant binding');
superAdmin = await prisma.user.create({
data: { tenantId: tenant.id, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
});
} else {
throw e;
}
}
await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } }); await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } });
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`); console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
@@ -113,7 +134,7 @@ async function main() {
const userDefs = [ const userDefs = [
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' }, { email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' }, { email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'collector' }, { email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'technician' },
{ email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' }, { email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' },
{ email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' }, { email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' },
{ email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' }, { email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' },
@@ -162,7 +183,6 @@ async function main() {
tenant_admin: 'tenant_admin', tenant_admin: 'tenant_admin',
manager: 'manager', manager: 'manager',
technician: 'technician', technician: 'technician',
collector: 'collector', // collector user gets collector tenant role
viewer: 'collector', // viewer user gets collector role for demo viewer: 'collector', // viewer user gets collector role for demo
}; };
@@ -225,7 +245,7 @@ async function main() {
]); ]);
console.log(`Plans: ${plans.length}`); console.log(`Plans: ${plans.length}`);
// ─── Clients (45 clients across various areas/plans) ── // ─── Clients (20 clients across various areas/plans) ──
// Area center coordinates (Lipa City, Batangas area) // Area center coordinates (Lipa City, Batangas area)
const areaCoords: [number, number][] = [ const areaCoords: [number, number][] = [
[14.0785, 121.1760], // Barangay 1 - Centro [14.0785, 121.1760], // Barangay 1 - Centro
@@ -251,38 +271,6 @@ 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: '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: '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 }, { 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[] = []; const clients: any[] = [];
@@ -293,7 +281,6 @@ async function main() {
const c = clientDefs[i]; const c = clientDefs[i];
const accountNumber = `C-${String(i + 1).padStart(6, '0')}`; const accountNumber = `C-${String(i + 1).padStart(6, '0')}`;
const plan = plans[c.plan]; const plan = plans[c.plan];
const isNewSignup = i >= 30; // 15 new signups pending installation
const client = await prisma.client.create({ const client = await prisma.client.create({
data: { data: {
@@ -305,62 +292,12 @@ async function main() {
email: c.email, email: c.email,
address: c.address, address: c.address,
areaId: areas[c.area].id, areaId: areas[c.area].id,
status: isNewSignup ? 'pending' : 'active', latitude: areaCoords[c.area][0] + c.latOff,
latitude: isNewSignup ? null : (areaCoords[c.area][0] + c.latOff), longitude: areaCoords[c.area][1] + c.lngOff,
longitude: isNewSignup ? null : (areaCoords[c.area][1] + c.lngOff),
}, },
}); });
clients.push(client); clients.push(client);
if (isNewSignup) {
// ── New signup: pending subscription + open installation ticket ──
await prisma.subscription.create({
data: {
tenantId: tenant.id,
clientId: client.id,
planId: plan.id,
type: c.type,
status: 'pending',
},
});
// 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',
},
});
// Overdue invoice for new signup (installation fee / first billing)
invoiceCount++;
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 ? 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 // Create subscription
const installedAt = new Date(now); const installedAt = new Date(now);
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
@@ -411,8 +348,8 @@ async function main() {
}, },
}); });
// Create invoices per client (3-4 per client with varied statuses) // Create 2 invoices per client
for (let m = 0; m < 4; m++) { for (let m = 0; m < 2; m++) {
invoiceCount++; invoiceCount++;
const periodStart = new Date(activatedAt); const periodStart = new Date(activatedAt);
periodStart.setMonth(periodStart.getMonth() + m); periodStart.setMonth(periodStart.getMonth() + m);
@@ -421,52 +358,26 @@ async function main() {
const dueDate = new Date(periodStart); const dueDate = new Date(periodStart);
dueDate.setDate(dueDate.getDate() + 15); dueDate.setDate(dueDate.getDate() + 15);
// Determine invoice status based on month const isPaid = m === 0 || Math.random() > 0.4; // First invoice always paid, second 60% chance
let status: string; const balance = isPaid ? 0 : Number(plan.price);
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({ const invoice = await prisma.invoice.create({
data: { data: {
tenantId: tenant.id, tenantId: tenant.id,
clientId: client.id, clientId: client.id,
number: `INV-${String(invoiceCount).padStart(6, '0')}`, number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount, amount: plan.price,
balance, balance,
status, status: isPaid ? 'paid' : (dueDate < now ? 'overdue' : 'sent'),
dueDate, dueDate,
paidAt, paidAt: isPaid ? new Date(dueDate.getTime() - 86400000 * 3) : null,
periodStart, periodStart,
periodEnd, periodEnd,
}, },
}); });
// Create payment(s) for paid/partial invoices // Create payment for paid invoices
const collector = i % 2 === 0 ? usersByEmail['collector@demo-isp.com'] : usersByEmail['tech@demo-isp.com']; if (isPaid) {
if (status === 'paid') {
const methods = ['gcash', 'maya', 'cash', 'bank_transfer']; const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
const method = methods[Math.floor(Math.random() * methods.length)]; const method = methods[Math.floor(Math.random() * methods.length)];
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5)); const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
@@ -476,59 +387,25 @@ async function main() {
tenantId: tenant.id, tenantId: tenant.id,
clientId: client.id, clientId: client.id,
invoiceId: invoice.id, invoiceId: invoice.id,
collectedById: collector.id, collectedById: users.technician.id,
amount, amount: plan.price,
method, method,
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null, referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
createdAt: paidDate, 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)`); console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`);
// ─── Support tickets ─────────────────────────────────── // ─── Open support tickets ──────────────────────────────
const supportTickets = [ const supportTickets = [
{ clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high', status: 'open', type: 'support', assignee: null }, { 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', 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' },
{ clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent', status: 'in_progress', type: 'support', assignee: 'tech' }, { 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', status: 'open', type: 'support', assignee: null }, { 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', status: 'in_progress', type: 'support', assignee: 'tech2' }, { clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high' },
{ 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) { for (const t of supportTickets) {
@@ -537,12 +414,11 @@ async function main() {
tenantId: tenant.id, tenantId: tenant.id,
clientId: clients[t.clientIdx].id, clientId: clients[t.clientIdx].id,
createdById: users.tenant_admin.id, createdById: users.tenant_admin.id,
type: t.type, type: 'support',
title: t.title, title: t.title,
description: t.desc, description: t.desc,
priority: t.priority, priority: t.priority,
status: t.status, status: 'open',
assigneeId: t.assignee ? users[t.assignee]?.id ?? users.technician.id : null,
}, },
}); });
} }
@@ -718,149 +594,14 @@ async function main() {
}); });
console.log('Fund transfers: 2'); console.log('Fund transfers: 2');
// ─── Remittances (properly linked to payments) ────────── // ─── Remittances ───────────────────────────────────────
// Get all payments that were for paid invoices (these are candidates for remittances) await prisma.remittance.create({
const allPayments = await prisma.payment.findMany({ data: { tenantId: tenant.id, collectorId: users.technician.id, confirmedById: users.tenant_admin.id, totalAmount: 8995, status: 'confirmed', confirmedAt: new Date() },
where: { tenantId: tenant.id },
orderBy: { createdAt: 'asc' },
}); });
await prisma.remittance.create({
// Split payments: first 60% → remitted (confirmed), next 20% → remitted (pending), last 20% → unremitted data: { tenantId: tenant.id, collectorId: users.technician.id, totalAmount: 5497, status: 'pending' },
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)`); console.log('Remittances: 2');
}
// 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✅ Seed completed successfully!');
console.log(`\n📊 Summary:`); console.log(`\n📊 Summary:`);
@@ -874,12 +615,8 @@ async function main() {
console.log(` Expenses: ${expDefs.length} (approved + pending)`); console.log(` Expenses: ${expDefs.length} (approved + pending)`);
console.log(` Assets: ${assetDefs.length}`); console.log(` Assets: ${assetDefs.length}`);
console.log(` Company Accounts: ${accts.length}`); console.log(` Company Accounts: ${accts.length}`);
console.log(`\n🔑 Logins (all passwords: admin123!):`); console.log(`\n🔑 Login: admin@demo-isp.com / admin123!`);
console.log(` admin@demo-isp.com (tenant_admin) - full access`); console.log(`🌐 Portal: C-000001 / 09171234567`);
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() main()

View File

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

View File

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

View File

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

View File

@@ -1,58 +0,0 @@
import {
Controller,
Get,
Post,
Param,
Body,
UseGuards,
UseInterceptors,
UploadedFiles,
BadRequestException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { CommentService } from './comment.service';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
import { multerOptions } from '../common/multer/multer.config';
@Controller('tickets/:ticketId/comments')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@Get()
@Roles('technician', 'collector')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Param('ticketId') ticketId: string,
) {
return this.commentService.findAll(user.tenantId, ticketId);
}
@Post()
@Roles('technician', 'collector')
@UseInterceptors(FileFieldsInterceptor([{ name: 'files', maxCount: 3 }], multerOptions))
async create(
@CurrentUser() user: CurrentUserPayload,
@Param('ticketId') ticketId: string,
@Body() body: any,
@UploadedFiles() files?: { files?: Express.Multer.File[] },
) {
let content = body?.content;
if (Array.isArray(content)) content = content[0];
if (typeof content !== 'string') content = String(content ?? '');
if (!content || content.length > 50000) {
throw new BadRequestException('Content must be between 1 and 50000 characters');
}
return this.commentService.create(
user.tenantId,
ticketId,
user.sub,
content,
files?.files,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,28 +14,24 @@ export class DashboardController {
@Get('kpis') @Get('kpis')
@Roles('manager') @Roles('manager')
async getKpis(@CurrentUser() user: CurrentUserPayload) { async getKpis(@CurrentUser() user: CurrentUserPayload) {
const data = await this.dashboardService.getKpis(user.tenantId); return this.dashboardService.getKpis(user.tenantId);
return { data };
} }
@Get('revenue-chart') @Get('revenue-chart')
@Roles('manager') @Roles('manager')
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) { async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
const data = await this.dashboardService.getRevenueChart(user.tenantId); return this.dashboardService.getRevenueChart(user.tenantId);
return { data };
} }
@Get('activity') @Get('activity')
@Roles('manager') @Roles('manager')
async getActivity(@CurrentUser() user: CurrentUserPayload) { async getActivity(@CurrentUser() user: CurrentUserPayload) {
const data = await this.dashboardService.getRecentActivity(user.tenantId); return this.dashboardService.getRecentActivity(user.tenantId);
return { data };
} }
@Get('financial-summary') @Get('financial-summary')
@Roles('manager') @Roles('manager')
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) { async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
const data = await this.dashboardService.getFinancialSummary(user.tenantId); return this.dashboardService.getFinancialSummary(user.tenantId);
return { data };
} }
} }

View File

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

View File

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

View File

@@ -2,17 +2,12 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import helmet from 'helmet'; import helmet from 'helmet';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { join } from 'path';
import * as express from 'express';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'], logger: ['error', 'warn', 'log'],
}); });
// Serve uploaded files before helmet so they're not blocked
app.use('/uploads', express.static(join(__dirname, '..', 'uploads')));
// Security headers // Security headers
app.use( app.use(
helmet({ helmet({

View File

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

View File

@@ -23,7 +23,7 @@ export class PaymentController {
constructor(private readonly paymentService: PaymentService) {} constructor(private readonly paymentService: PaymentService) {}
@Get() @Get()
@Roles('technician', 'collector') @Roles('technician')
async findAll( async findAll(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,
@Query('clientId') clientId?: string, @Query('clientId') clientId?: string,
@@ -32,7 +32,7 @@ export class PaymentController {
} }
@Post() @Post()
@Roles('technician', 'collector') @Roles('technician')
async record( async record(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,
@Body() dto: RecordPaymentDto, @Body() dto: RecordPaymentDto,
@@ -41,21 +41,19 @@ export class PaymentController {
} }
@Get('unremitted') @Get('unremitted')
@Roles('technician', 'collector') @Roles('technician')
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('all') all?: string) { async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('collectorId') collectorId?: string) {
const isManager = user.roles?.some((r: string) => ['manager', 'tenant_admin', 'super_admin'].includes(r)); return this.paymentService.getUnremittedPayments(user.tenantId, collectorId || user.sub);
// Managers see all unremitted; collectors see only their own
return this.paymentService.getUnremittedPayments(user.tenantId, isManager ? null : user.sub);
} }
@Get('remittances') @Get('remittances')
@Roles('technician', 'collector') @Roles('technician')
async findRemittances(@CurrentUser() user: CurrentUserPayload) { async findRemittances(@CurrentUser() user: CurrentUserPayload) {
return this.paymentService.findRemittances(user.tenantId); return this.paymentService.findRemittances(user.tenantId);
} }
@Post('remittances') @Post('remittances')
@Roles('technician', 'collector') @Roles('technician')
async submitRemittance( async submitRemittance(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateRemittanceDto, @Body() dto: CreateRemittanceDto,

View File

@@ -158,23 +158,15 @@ export class PaymentService {
})); }));
} }
async getUnremittedPayments(tenantId: string, collectorId: string | null) { async getUnremittedPayments(tenantId: string, collectorId: string) {
const remittedIds = (await this.prisma.remittancePayment.findMany({ const remittedIds = (await this.prisma.remittancePayment.findMany({ select: { paymentId: true } }))
where: { remittance: { tenantId } },
select: { paymentId: true },
}))
.map((r) => r.paymentId); .map((r) => r.paymentId);
return this.prisma.payment.findMany({ return this.prisma.payment.findMany({
where: { where: { tenantId, collectedById: collectorId, id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] } },
tenantId,
...(collectorId ? { collectedById: collectorId } : {}),
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
},
include: { include: {
client: { select: { firstName: true, lastName: true, accountNumber: true } }, client: { select: { firstName: true, lastName: true, accountNumber: true } },
invoice: { select: { id: true, number: true } }, invoice: { select: { number: true } },
collectedBy: { select: { id: true, firstName: true, lastName: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
@@ -219,17 +211,6 @@ export class PaymentService {
include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } }, include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } },
}); });
// Notify collector that their remittance was approved
if (remittance.collectorId) {
this.notificationService.create(tenantId, {
userId: remittance.collectorId,
type: 'in_app',
channel: 'remittance_approved',
title: 'Remittance Approved',
message: `Your remittance of ₱${Number(remittance.totalAmount).toLocaleString()} has been approved`,
}).catch(() => {});
}
this.audit.log({ this.audit.log({
tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId, tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId,
details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) }, details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) },
@@ -279,7 +260,7 @@ export class PaymentService {
} }
} }
const result = await this.prisma.remittance.update({ return this.prisma.remittance.update({
where: { id: remittanceId }, where: { id: remittanceId },
data: { data: {
status: 'rejected', status: 'rejected',
@@ -287,18 +268,5 @@ export class PaymentService {
confirmedAt: new Date(), confirmedAt: new Date(),
}, },
}); });
// Notify collector that their remittance was rejected
if (remittance.collectorId) {
this.notificationService.create(tenantId, {
userId: remittance.collectorId,
type: 'in_app',
channel: 'remittance_rejected',
title: 'Remittance Rejected',
message: `Your remittance of ₱${Number(remittance.totalAmount).toLocaleString()} has been rejected`,
}).catch(() => {});
}
return result;
} }
} }

View File

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

View File

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

View File

@@ -4,10 +4,8 @@ import {
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
import { CreateTicketDto } from './dto/create-ticket.dto'; import { CreateTicketDto } from './dto/create-ticket.dto';
import { UpdateTicketDto } from './dto/update-ticket.dto'; import { UpdateTicketDto } from './dto/update-ticket.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
export interface TicketResolvedEvent { export interface TicketResolvedEvent {
ticketId: string; ticketId: string;
@@ -22,10 +20,7 @@ export class TicketService {
onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null = onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null =
null; null;
constructor( constructor(private readonly prisma: PrismaService) {}
private readonly prisma: PrismaService,
private readonly notificationService: NotificationService,
) {}
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) { async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) {
const db = this.prisma.forTenant(tenantId); const db = this.prisma.forTenant(tenantId);
@@ -108,29 +103,6 @@ export class TicketService {
throw new NotFoundException('Ticket not found'); 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({ return this.prisma.ticket.update({
where: { id }, where: { id },
data: { data: {
@@ -145,7 +117,7 @@ export class TicketService {
}); });
} }
async resolve(tenantId: string, id: string, resolvedById: string, body?: { latitude?: number; longitude?: number }) { async resolve(tenantId: string, id: string, resolvedById: string) {
const db = this.prisma.forTenant(tenantId); const db = this.prisma.forTenant(tenantId);
const ticket = await db.ticket.findFirst({ where: { id } }); const ticket = await db.ticket.findFirst({ where: { id } });
@@ -167,8 +139,6 @@ export class TicketService {
status: 'resolved', status: 'resolved',
resolvedAt: new Date(), resolvedAt: new Date(),
assigneeId: resolvedById, assigneeId: resolvedById,
...(body?.latitude !== undefined && { latitude: body.latitude }),
...(body?.longitude !== undefined && { longitude: body.longitude }),
}, },
}); });
@@ -184,50 +154,4 @@ export class TicketService {
return resolved; return resolved;
} }
// Comments
async getComments(tenantId: string, ticketId: string) {
const db = this.prisma.forTenant(tenantId);
const comments = await db.ticketComment.findMany({
where: { ticketId },
include: {
author: {
select: { id: true, firstName: true, lastName: true },
},
},
orderBy: { createdAt: 'asc' },
});
return comments.map((c: any) => ({
...c,
createdByName: c.author
? `${c.author.firstName} ${c.author.lastName}`
: 'Unknown',
}));
}
async addComment(
tenantId: string,
ticketId: string,
userId: string,
dto: CreateCommentDto,
) {
const db = this.prisma.forTenant(tenantId);
const ticket = await db.ticket.findFirst({
where: { id: ticketId },
});
if (!ticket) {
throw new NotFoundException('Ticket not found');
}
return db.ticketComment.create({
data: {
tenantId,
ticketId,
userId,
content: dto.content,
},
});
}
} }

View File

@@ -27,12 +27,6 @@ export class UserController {
return this.userService.findAll(user.tenantId); return this.userService.findAll(user.tenantId);
} }
@Get('mention-list')
@Roles('tenant_admin', 'technician', 'collector')
async getMentionList(@CurrentUser() user: CurrentUserPayload) {
return this.userService.findForMention(user.tenantId);
}
@Get(':id') @Get(':id')
async findById( async findById(
@CurrentUser() user: CurrentUserPayload, @CurrentUser() user: CurrentUserPayload,

View File

@@ -51,16 +51,6 @@ export class UserService {
return users.map((u) => this.formatUser(u)); return users.map((u) => this.formatUser(u));
} }
async findForMention(tenantId: string) {
const db = this.prisma.forTenant(tenantId);
const users = await db.user.findMany({
where: { isActive: true },
select: { id: true, firstName: true, lastName: true },
orderBy: { firstName: 'asc' },
});
return users;
}
async findById(tenantId: string, userId: string) { async findById(tenantId: string, userId: string) {
const db = this.prisma.forTenant(tenantId); const db = this.prisma.forTenant(tenantId);
const user = await db.user.findFirst({ const user = await db.user.findFirst({