36 Commits
develop ... dev

Author SHA1 Message Date
john kevin asprec
fc89ed124f feat: add DashboardController with authenticated KPI and financial summary endpoints 2026-06-16 22:32:54 +08:00
kevin-asprec
d4d9249933 Update Task #39: Creation date already implemented in remittance history 2026-05-07 06:35:16 +08:00
kevin-asprec
6ea1f412a1 feat: data import module and payment fixes
- Add import module with CSV/Excel template download and bulk import endpoints
- Support client+subscription and outstanding invoice imports with per-row error handling
- Templates include Field Guide sheet with valid plan/area names
- Add manager role to unremitted payments endpoint
- Add createdAt field to remittance history response
2026-05-07 06:19:43 +08:00
kevin-asprec
7f9beaac2b feat: unremitted per-user filtering, EOD unremitted reminders, remittance breakdown
- Filter unremitted payments by current user only (remove manager exception)
- Add PaymentScheduler with 5PM daily cron for unremitted reminders
- Add getUnremittedBreakdown service for per-collector dashboard totals
- Register PaymentScheduler in SchedulerModule
2026-05-06 22:43:44 +08:00
kevin-asprec
85aea30730 fix: bypass ValidationPipe for multipart comments, auto-create uploads dir, expand client roles
- Use @Body() body: any in comment controller to skip global ValidationPipe
  which was rejecting multipart form bodies with forbidNonWhitelisted
- Auto-create uploads/ directory on startup to prevent ENOENT on file uploads
- Restrict file uploads to images only (remove pdf)
- Allow technician/collector roles to update clients (was manager-only)
2026-05-06 18:20:14 +08:00
kevin-asprec
da9707f3fb fix(comment): Create proper DTO class for comment creation
- Create CreateCommentDto class with proper validation decorators
- Update comment controller to use the DTO for request body
- This resolves the global ValidationPipe rejection due to forbidNonWhitelisted
- Set MaxLength to 50000 to match manual validation logic

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 17:38:48 +08:00
kevin-asprec
59ee1fbe33 feat: add notification triggers, comment roles, mention list, and static file serving
- Notify users on ticket assignment and remittance confirm/reject
- Add collector role to comment endpoints
- Add /users/mention-list endpoint for @mention support
- Serve uploaded files via express.static
2026-05-06 14:30:08 +08:00
kevin-asprec
248f6b51ea fix: include invoice id in unremitted payments query
The invoice select was missing `id`, causing InvoiceSummary.fromJson
to crash on null — silently swallowing the error and showing an
empty unremitted list in the mobile app.
2026-05-06 07:22:36 +08:00
kevin-asprec
5f4a4c6874 feat: sync seed data — 45 clients, 21 diverse tickets, collector alternation 2026-05-06 05:20:22 +08:00
kevin-asprec
8fdfa6b7ba fix: assign collector role correctly and distribute payments between collector and tech 2026-05-06 04:51:32 +08:00
kevin-asprec
ade5df1c32 fix: resolve duplicate client_coordinates migration on deploy 2026-05-06 04:33:47 +08:00
kevin-asprec
d2864961de fix: add mustChangePassword to User Prisma schema 2026-05-06 04:12:19 +08:00
kevin-asprec
91ea365cf9 feat: add ticket comment endpoints to controller 2026-05-06 03:56:13 +08:00
kevin-asprec
58c01d4580 fix: mobile app bugs - payment screen, remittance isolation, invoice sorting, ticket creation
- Fix record payment screen fetching invoice directly instead of relying on collection provider
- Fix getUnremittedPayments tenant isolation (remittance tenantId filter)
- Add invoice status filter and dueDate sort support in controller/service
- Allow technicians to create tickets (role decorator fix)
- Enhance seed data with 10 more pending-installation clients and installation tickets
2026-05-06 03:13:32 +08:00
kevin-asprec
58d77fc67f fix: add missing CreateCommentDto for ticket comments 2026-05-06 02:31:03 +08:00
kevin-asprec
c14521a41d fix: add collector role to API endpoints for mobile dashboard access
Collector users were blocked from ticket, payment, invoice, and client
endpoints requiring 'technician' role. Added 'collector' to @Roles
decorators and the COLLECTOR role to the shared role hierarchy.
2026-05-06 02:15:20 +08:00
kevin-asprec
83ad0cb8d5 fix: resolve TS compilation errors for dev deployment
- Add latitude/longitude Float fields to Client model in schema.prisma
- Rewrite dashboard controller (remove duplicate const data, use getRecentActivity)
- Remove invalid files field from ticket comment create
- Add migration for client coordinates
2026-05-06 01:02:43 +08:00
kevin-asprec
469fa6bb28 feat: add more seed data - 15 clients, 15 tickets with varied statuses 2026-05-04 18:42:16 +08:00
kevin-asprec
94528841d9 fix: mark mustChangePassword migration as applied (column already exists) 2026-05-04 18:37:01 +08:00
kevin-asprec
a66fff68de fix: resolve failed mustChangePassword migration on startup 2026-05-04 18:32:00 +08:00
kevin-asprec
40f30b4a5d fix: resolve TS build errors - missing schema fields, types, and packages
- Add mustChangePassword field to User model in schema.prisma
- Add latitude/longitude fields to Client model (matching existing migration)
- Remove @nestjs/serve-static import from app.module (not in dependencies)
- Add @types/multer to devDependencies and type multer config explicitly
- Create migration for mustChangePassword column

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 17:39:01 +08:00
kevin-asprec
bb3baeb6ac fix: add missing TicketComment reverse relation on Tenant model 2026-05-04 17:16:28 +08:00
kevin-asprec
ff6295e1fc chore: trigger redeploy for comments and notifications 2026-05-04 17:11:34 +08:00
kevin-asprec
555327a891 feat: add TicketComment, TicketAttachment models + notification ticketId 2026-05-04 16:36:55 +08:00
kevin-asprec
29c6b70878 feat: add ticket comments, file attachments, mention notifications
- Comment module with CRUD and file upload via multer
- @mention parsing with user notification
- Assignment notification on ticket update
- Notification service enhanced with ticketId linking
- ServeStaticModule for uploaded files
2026-05-04 16:35:33 +08:00
kevin-asprec
d7ab370bb5 chore: sync seed with overdue invoices for new signups 2026-05-04 14:55:55 +08:00
kevin-asprec
1a0b4916d0 fix: propagate ticket location to client record on save 2026-05-04 14:52:57 +08:00
kevin-asprec
3b032ab7c4 feat: add latitude/longitude columns to tickets table 2026-05-04 14:36:51 +08:00
kevin-asprec
d792a9a9ed fix: accept optional lat/lng body in ticket resolve endpoint 2026-05-04 14:20:58 +08:00
kevin-asprec
c923285c7e feat: add latitude/longitude to ticket update endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 13:21:54 +08:00
kevin-asprec
ff0dd2e418 fix: include phone, latitude, longitude in invoice client data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 12:34:11 +08:00
kevin-asprec
a5ed2cc666 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 14:35:27 +08:00
kevin-asprec
bef320f32e feat: add RUN_SEED support and nullable tenantId migration
- New migration: make users.tenantId nullable for super_admin
- Dockerfile: RUN_SEED=true env var triggers seed after migrations
- Seed uses npx tsx (available in node_modules)
2026-04-15 06:04:57 +08:00
kevin-asprec
555bd4a9f8 fix: compile shared package as CommonJS for NestJS compatibility 2026-04-13 16:52:48 +08:00
kevin-asprec
fc704c2d23 merge: develop into main 2026-04-13 13:04:32 +08:00
0321239266 Initial commit 2026-04-13 00:50:56 +00:00
40 changed files with 9933 additions and 850 deletions

View File

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

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# fiberops-api-new

11
alter-tenant-id.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
cd packages/db
node -e "
const { Client } = require('pg');
const url = process.env.DATABASE_URL.replace(/%21/g, '!');
const c = new Client(url);
c.connect().then(() => c.query('ALTER TABLE users ALTER COLUMN tenantId DROP NOT NULL'))
.then(() => { console.log('tenantId nullable OK'); return c.end(); })
.catch(e => { console.log('Error:', e.message.substring(0,100)); return c.end(); });
"
npx tsx prisma/seed.ts

7602
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
generator client {
generator client {
provider = "prisma-client-js"
}
}
datasource db {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
}
// ─── Multi-Tenant Foundation ────────────────────────────────────────
// ─── Multi-Tenant Foundation ────────────────────────────────
model Tenant {
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
@@ -29,14 +29,15 @@ model Tenant {
payments Payment[]
tickets Ticket[]
notifications Notification[]
ticketComments TicketComment[]
@@index([deletedAt])
@@map("tenants")
}
}
// ─── Auth & Users ───────────────────────────────────────────────────
// ─── Auth & Users ───────────────────────────────────────────
model User {
model User {
id String @id @default(uuid())
tenantId String? // null for super_admin (platform-level user)
email String
@@ -54,6 +55,7 @@ model User {
tenantRoles UserTenantRole[]
assignedTickets Ticket[] @relation("TicketAssignee")
createdTickets Ticket[] @relation("TicketCreator")
authoredComments TicketComment[] @relation("CommentAuthor")
payments Payment[]
remittances Remittance[] @relation("RemittanceCollector")
confirmedRemittances Remittance[] @relation("RemittanceConfirmer")
@@ -62,22 +64,22 @@ model User {
@@index([tenantId])
@@index([deletedAt])
@@map("users")
}
}
model UserRole {
model UserRole {
id String @id @default(uuid())
userId String
role String // super_admin (platform-level only, legacy compat)
role String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@map("user_roles")
}
}
// ─── Tenant-Scoped Role Management ─────────────────────────────────
// ─── Tenant-Scoped Role Management ─────────────────────────
model TenantRole {
model TenantRole {
id String @id @default(uuid())
tenantId String
name String
@@ -97,12 +99,12 @@ model TenantRole {
@@index([tenantId])
@@index([deletedAt])
@@map("tenant_roles")
}
}
model RolePermission {
model RolePermission {
id String @id @default(uuid())
tenantRoleId String
module String // clients, subscriptions, invoices, payments, tickets, employees, expenses, assets, payroll, accounts, accounting, reports, settings, users, areas, plans, dashboard, fund_transfers
module String
canView Boolean @default(false)
canCreate Boolean @default(false)
canUpdate Boolean @default(false)
@@ -115,9 +117,9 @@ model RolePermission {
@@unique([tenantRoleId, module])
@@index([tenantRoleId])
@@map("role_permissions")
}
}
model UserTenantRole {
model UserTenantRole {
id String @id @default(uuid())
userId String
tenantRoleId String
@@ -129,9 +131,11 @@ model UserTenantRole {
@@index([userId])
@@index([tenantRoleId])
@@map("user_tenant_roles")
}
}
model RefreshToken {
// ─── Refresh Token ───────────────────────────────────
model RefreshToken {
id String @id @default(uuid())
token String @unique
userId String
@@ -141,11 +145,11 @@ model RefreshToken {
@@index([userId])
@@index([expiresAt])
@@map("refresh_tokens")
}
}
// ─── Area & Zone Management ─────────────────────────────────────────
// ─── Area & Zone Management ─────────────────────────────────
model Area {
model Area {
id String @id @default(uuid())
tenantId String
name String
@@ -162,11 +166,11 @@ model Area {
@@index([tenantId])
@@index([deletedAt])
@@map("areas")
}
}
// ─── Plan / Package Management ──────────────────────────────────────
// ─── Plan / Package Management ─────────────────────────
model Plan {
model Plan {
id String @id @default(uuid())
tenantId String
name String
@@ -187,11 +191,11 @@ model Plan {
@@index([tenantId])
@@index([deletedAt])
@@map("plans")
}
}
// ─── Client Profiling ───────────────────────────────────────────────
// ─── Client Profiling ─────────────────────────────────
model Client {
model Client {
id String @id @default(uuid())
tenantId String
accountNumber String
@@ -200,9 +204,9 @@ model Client {
email String?
phone String?
address String
areaId String?
latitude Float?
longitude Float?
areaId String?
status String @default("active") // active, inactive, suspended
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -220,11 +224,11 @@ model Client {
@@index([tenantId, status])
@@index([deletedAt])
@@map("clients")
}
}
// ─── Subscription Management ────────────────────────────────────────
// ─── Subscription Management ─────────────────────────
model Subscription {
model Subscription {
id String @id @default(uuid())
tenantId String
clientId String
@@ -248,11 +252,11 @@ model Subscription {
@@index([clientId])
@@index([deletedAt])
@@map("subscriptions")
}
}
// ─── Billing & Invoicing ────────────────────────────────────────────
// ─── Billing & Invoicing ───────────────────────────────────
model Invoice {
model Invoice {
id String @id @default(uuid())
tenantId String
clientId String
@@ -278,11 +282,11 @@ model Invoice {
@@index([clientId])
@@index([deletedAt])
@@map("invoices")
}
}
// ─── Payment & Collection ───────────────────────────────────────────
// ─── Payment & Collection ─────────────────────────────────
model Payment {
model Payment {
id String @id @default(uuid())
tenantId String
clientId String
@@ -305,9 +309,11 @@ model Payment {
@@index([invoiceId])
@@index([deletedAt])
@@map("payments")
}
}
model Remittance {
// ─── Remittance ─────────────────────────────────────────
model Remittance {
id String @id @default(uuid())
tenantId String
collectorId String
@@ -327,9 +333,9 @@ model Remittance {
@@index([collectorId])
@@index([deletedAt])
@@map("remittances")
}
}
model RemittancePayment {
model RemittancePayment {
id String @id @default(uuid())
remittanceId String
paymentId String
@@ -338,11 +344,11 @@ model RemittancePayment {
@@unique([remittanceId, paymentId])
@@map("remittance_payments")
}
}
// ─── Tickets (Work Orders) ──────────────────────────────────────────
// ─── Tickets (Work Orders) ─────────────────────────────────
model Ticket {
model Ticket {
id String @id @default(uuid())
tenantId String
clientId String?
@@ -353,6 +359,8 @@ model Ticket {
title String
description String?
priority String @default("normal") // low, normal, high, urgent
latitude Float?
longitude Float?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -362,6 +370,7 @@ model Ticket {
client Client? @relation(fields: [clientId], references: [id])
createdBy User @relation("TicketCreator", fields: [createdById], references: [id])
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
comments TicketComment[]
@@index([tenantId])
@@index([tenantId, type, status])
@@ -369,11 +378,11 @@ model Ticket {
@@index([assigneeId])
@@index([deletedAt])
@@map("tickets")
}
}
// ─── Notifications ──────────────────────────────────────────────────
// ─── Notifications ─────────────────────────────────────────
model Notification {
model Notification {
id String @id @default(uuid())
tenantId String
userId String?
@@ -384,6 +393,7 @@ model Notification {
message String
isRead Boolean @default(false)
sentAt DateTime?
ticketId String?
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@ -391,11 +401,47 @@ model Notification {
@@index([tenantId])
@@index([userId, isRead])
@@map("notifications")
}
}
// ─── Audit Log (append-only, no soft delete) ────────────────────────
// ─── Ticket Comments ─────────────────────────────────
model AuditLog {
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 {
id String @id @default(uuid())
tenantId String
userId String
@@ -410,11 +456,11 @@ model AuditLog {
@@index([tenantId, entity])
@@index([userId])
@@map("audit_logs")
}
}
// ─── Employee Management ────────────────────────────────────────────
// ─── Employee Management ─────────────────────────
model Employee {
model Employee {
id String @id @default(uuid())
tenantId String
userId String? @unique // Linked user account (optional, 1:1)
@@ -442,11 +488,11 @@ model Employee {
@@index([tenantId, status])
@@index([deletedAt])
@@map("employees")
}
}
// ─── Payroll ────────────────────────────────────────────────────────
// ─── Payroll ─────────────────────────────────────────────
model PayrollRun {
model PayrollRun {
id String @id @default(uuid())
tenantId String
period String
@@ -465,9 +511,9 @@ model PayrollRun {
@@index([tenantId])
@@index([deletedAt])
@@map("payroll_runs")
}
}
model Payslip {
model Payslip {
id String @id @default(uuid())
payrollRunId String
employeeId String
@@ -478,18 +524,17 @@ model Payslip {
status String @default("pending") // pending, paid
notes String?
payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id], onDelete: Cascade)
payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id])
employee Employee @relation(fields: [employeeId], references: [id])
@@unique([payrollRunId, employeeId])
@@index([payrollRunId])
@@index([employeeId])
@@map("payslips")
}
}
// ─── Recurring Expenses ─────────────────────────────────────────────
// ─── Recurring Expenses ─────────────────────────
model RecurringExpense {
model RecurringExpense {
id String @id @default(uuid())
tenantId String
category String
@@ -506,11 +551,11 @@ model RecurringExpense {
@@index([tenantId])
@@index([deletedAt])
@@map("recurring_expenses")
}
}
// ─── Expense Management ─────────────────────────────────────────────
// ─── Expense Management ─────────────────────────
model Expense {
model Expense {
id String @id @default(uuid())
tenantId String
createdById String
@@ -532,11 +577,11 @@ model Expense {
@@index([tenantId, category])
@@index([deletedAt])
@@map("expenses")
}
}
// ─── Company Accounts & Fund Transfers ──────────────────────────────
// ─── Company Accounts & Fund Transfers ─────────────────
model CompanyAccount {
model CompanyAccount {
id String @id @default(uuid())
tenantId String
name String
@@ -555,11 +600,10 @@ model CompanyAccount {
@@unique([tenantId, name])
@@index([tenantId])
@@index([deletedAt])
@@map("company_accounts")
}
}
model FundTransfer {
model FundTransfer {
id String @id @default(uuid())
tenantId String
fromAccountId String
@@ -574,11 +618,11 @@ model FundTransfer {
@@index([tenantId])
@@map("fund_transfers")
}
}
// ─── Asset Management ───────────────────────────────────────────────
// ─── Asset Management ─────────────────────────────────
model Asset {
model Asset {
id String @id @default(uuid())
tenantId String
name String
@@ -601,11 +645,11 @@ model Asset {
@@index([tenantId, category])
@@index([deletedAt])
@@map("assets")
}
}
// ─── Billing Settings ───────────────────────────────────────────────
// ─── Billing Settings ─────────────────────────
model BillingSetting {
model BillingSetting {
id String @id @default(uuid())
tenantId String @unique
autoGenerate Boolean @default(true)
@@ -617,11 +661,11 @@ model BillingSetting {
updatedAt DateTime @updatedAt
@@map("billing_settings")
}
}
// ─── Chart of Accounts ──────────────────────────────────────────────
// ─── Chart of Accounts ─────────────────────────
model ChartOfAccount {
model ChartOfAccount {
id String @id @default(uuid())
tenantId String
code String
@@ -644,11 +688,11 @@ model ChartOfAccount {
@@index([tenantId, type])
@@index([deletedAt])
@@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())
tenantId String
entryDate DateTime @default(now())
@@ -664,19 +708,19 @@ model JournalEntry {
@@index([tenantId])
@@index([tenantId, sourceType, sourceId])
@@map("journal_entries")
}
}
model JournalLine {
model JournalLine {
id String @id @default(uuid())
journalEntryId String
accountId String
debit Decimal @default(0) @db.Decimal(14, 2)
credit Decimal @default(0) @db.Decimal(14, 2)
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade)
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id])
account ChartOfAccount @relation(fields: [accountId], references: [id])
@@index([journalEntryId])
@@index([accountId])
@@map("journal_lines")
}
}

View File

@@ -113,7 +113,7 @@ async function main() {
const userDefs = [
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'technician' },
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'collector' },
{ email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' },
{ email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' },
{ email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' },
@@ -162,6 +162,7 @@ async function main() {
tenant_admin: 'tenant_admin',
manager: 'manager',
technician: 'technician',
collector: 'collector', // collector user gets collector tenant role
viewer: 'collector', // viewer user gets collector role for demo
};
@@ -224,7 +225,7 @@ async function main() {
]);
console.log(`Plans: ${plans.length}`);
// ─── Clients (20 clients across various areas/plans) ──
// ─── Clients (45 clients across various areas/plans) ──
// Area center coordinates (Lipa City, Batangas area)
const areaCoords: [number, number][] = [
[14.0785, 121.1760], // Barangay 1 - Centro
@@ -250,6 +251,38 @@ async function main() {
{ first: 'Ricardo', last: 'Flores', phone: '09291234567', email: null, address: '55 Katipunan Rd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
{ first: 'Teresa', last: 'Navarro', phone: '09301234567', email: 'teresa@email.com', address: '66 Makabayan St, Riverside', area: 3, plan: 1, type: 'prepaid', latOff: -0.003, lngOff: 0.001 },
{ first: 'Fernando', last: 'Castillo', phone: '09311234567', email: null, address: '77 Silang Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
// New signups — pending installation (no lat/lng, pending status, open tickets)
{ first: 'Rafael', last: 'Dimaculangan', phone: '09321234567', email: null, address: '88 Burgos St, Centro', area: 0, plan: 1, type: 'postpaid', latOff: 0.0015, lngOff: -0.001 },
{ first: 'Lorna', last: 'Perez', phone: '09331234567', email: 'lorna@email.com', address: '99 Villareal St, Poblacion', area: 1, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.002 },
{ first: 'Danilo', last: 'Rivera', phone: '09341234567', email: null, address: '101 Kapitan St, San Isidro', area: 2, plan: 0, type: 'prepaid', latOff: 0.002, lngOff: 0.001 },
{ first: 'Grace', last: 'Sison', phone: '09351234567', email: 'grace@email.com', address: '202 Magdalena St, Riverside', area: 3, plan: 3, type: 'postpaid', latOff: -0.001, lngOff: -0.002 },
{ first: 'Allan', last: 'Vergara', phone: '09361234567', email: null, address: '303 Gomez St, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: 0.0015 },
// Additional clients (indices 20-29)
{ first: 'Angelo', last: 'Manalo', phone: '09371234567', email: 'angelo@email.com', address: '15 Rizal Ave, Centro', area: 0, plan: 3, type: 'postpaid', latOff: 0.0025, lngOff: -0.0015 },
{ first: 'Bella', last: 'Cruz', phone: '09381234567', email: 'bella@email.com', address: '26 Mabini Ext, Poblacion', area: 1, plan: 1, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
{ first: 'Claudio', last: 'Diaz', phone: '09391234567', email: null, address: '37 Bonifacio Rd, San Isidro', area: 2, plan: 4, type: 'postpaid', latOff: 0.001, lngOff: 0.003 },
{ first: 'Diana', last: 'Espiritu', phone: '09401234567', email: 'diana@email.com', address: '48 Luna Ext, Riverside', area: 3, plan: 0, type: 'prepaid', latOff: -0.002, lngOff: -0.001 },
{ first: 'Eduardo', last: 'Fernandez', phone: '09411234567', email: null, address: '59 Del Pilar St, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.003, lngOff: 0.001 },
{ first: 'Flora', last: 'Gonzales', phone: '09421234567', email: 'flora@email.com', address: '60 Quezon Blvd, Centro', area: 0, plan: 1, type: 'postpaid', latOff: -0.001, lngOff: -0.002 },
{ first: 'Gilbert', last: 'Hernandez', phone: '09431234567', email: null, address: '71 Magsaysay St, Poblacion', area: 1, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: 0.001 },
{ first: 'Helen', last: 'Ibañez', phone: '09441234567', email: 'helen@email.com', address: '82 Roxas Blvd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: -0.003, lngOff: -0.001 },
{ first: 'Ivan', last: 'Jimenez', phone: '09451234567', email: null, address: '93 Laurel Ave, Riverside', area: 3, plan: 4, type: 'postpaid', latOff: 0.0015, lngOff: 0.002 },
{ first: 'Julia', last: 'Kho', phone: '09461234567', email: 'julia@email.com', address: '104 Osmena St, Hilltop', area: 4, plan: 1, type: 'prepaid', latOff: -0.002, lngOff: 0.0015 },
{ first: 'Kenneth', last: 'Lopez', phone: '09471234567', email: null, address: '115 Aguinaldo Blvd, Centro', area: 0, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.003 },
{ first: 'Linda', last: 'Madrid', phone: '09481234567', email: 'linda@email.com', address: '126 Andres St, Poblacion', area: 1, plan: 0, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
{ first: 'Mario', last: 'Ng', phone: '09491234567', email: null, address: '137 Katipunan Rd, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: -0.002 },
{ first: 'Nancy', last: 'Ong', phone: '09501234567', email: 'nancy@email.com', address: '148 Makabayan Blvd, Riverside', area: 3, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
// New signups — pending installation (indices 30-44)
{ first: 'Oscar', last: 'Pineda', phone: '09511234567', email: 'oscar@email.com', address: '159 Rizal Ext, Centro', area: 0, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
{ first: 'Patricia', last: 'Quintos', phone: '09521234567', email: 'patricia@email.com', address: '170 Mabini Rd, Poblacion', area: 1, plan: 1, type: 'postpaid', latOff: -0.002, lngOff: 0.001 },
{ first: 'Quentin', last: 'Reyes Jr', phone: '09531234567', email: null, address: '181 Bonifacio Ext, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: 0.002 },
{ first: 'Rita', last: 'Santillan', phone: '09541234567', email: 'rita@email.com', address: '192 Luna St, Riverside', area: 3, plan: 0, type: 'prepaid', latOff: -0.001, lngOff: -0.001 },
{ first: 'Samuel', last: 'Torres', phone: '09551234567', email: null, address: '203 Del Pilar Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.0015, lngOff: 0.001 },
{ first: 'Tina', last: 'Uy', phone: '09561234567', email: 'tina@email.com', address: '214 Quezon Rd, Centro', area: 0, plan: 4, type: 'postpaid', latOff: -0.002, lngOff: -0.0015 },
{ first: 'Ulysses', last: 'Velasco', phone: '09571234567', email: null, address: '225 Magsaysay Ext, Poblacion', area: 1, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.002 },
{ first: 'Vivian', last: 'Walsh', phone: '09581234567', email: 'vivian@email.com', address: '236 Roxas Ave, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
{ first: 'Walter', last: 'Xavier', phone: '09591234567', email: null, address: '247 Laurel Blvd, Riverside', area: 3, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
{ first: 'Yolanda', last: 'Yap', phone: '09601234567', email: 'yolanda@email.com', address: '258 Osmena Rd, Hilltop', area: 4, plan: 1, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
];
const clients: any[] = [];
@@ -260,6 +293,7 @@ async function main() {
const c = clientDefs[i];
const accountNumber = `C-${String(i + 1).padStart(6, '0')}`;
const plan = plans[c.plan];
const isNewSignup = i >= 30; // 15 new signups pending installation
const client = await prisma.client.create({
data: {
@@ -271,12 +305,62 @@ 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);
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
const installedAt = new Date(now);
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
@@ -327,8 +411,8 @@ async function main() {
},
});
// Create 2 invoices per client
for (let m = 0; m < 2; m++) {
// 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);
@@ -337,26 +421,52 @@ async function main() {
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);
// 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,
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount: plan.price,
amount,
balance,
status: isPaid ? 'paid' : (dueDate < now ? 'overdue' : 'sent'),
status,
dueDate,
paidAt: isPaid ? new Date(dueDate.getTime() - 86400000 * 3) : null,
paidAt,
periodStart,
periodEnd,
},
});
// Create payment for paid invoices
if (isPaid) {
// 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));
@@ -366,8 +476,25 @@ async function main() {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: users.technician.id,
amount: plan.price,
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,
@@ -376,15 +503,32 @@ async function main() {
}
}
}
}
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' },
// 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('Remittances: 2');
console.log(`Remittance (confirmed): ₱${confirmedTotal} (${confirmedPayments.length} payments)`);
}
// Create pending remittance
if (pendingPayments.length > 0) {
const pendingTotal = pendingPayments.reduce((s, p) => s + Number(p.amount), 0);
const pendingRemittance = await prisma.remittance.create({
data: {
tenantId: tenant.id,
collectorId: users.technician.id,
totalAmount: pendingTotal,
status: 'pending',
submittedAt: new Date(Date.now() - 86400000 * 2),
payments: {
create: pendingPayments.map((p) => ({ paymentId: p.id })),
},
},
});
console.log(`Remittance (pending): ₱${pendingTotal} (${pendingPayments.length} payments)`);
}
const unremittedCount = allPayments.length - pendingEnd;
console.log(`Unremitted payments: ${unremittedCount} (from invoice loop)`);
// ─── Additional unremitted payments (recent, for testing) ──
const recentPaymentDefs = [
{ clientIdx: 0, amount: 699, method: 'gcash' as const, ref: 'GCASH-44221', daysAgo: 0 },
{ clientIdx: 2, amount: 999, method: 'cash' as const, ref: null, daysAgo: 0 },
{ clientIdx: 5, amount: 1499, method: 'maya' as const, ref: 'MAYA-88312', daysAgo: 1 },
{ clientIdx: 7, amount: 999, method: 'bank_transfer' as const, ref: 'BDO-10293', daysAgo: 1 },
{ clientIdx: 9, amount: 2499, method: 'gcash' as const, ref: 'GCASH-44228', daysAgo: 2 },
{ clientIdx: 10, amount: 699, method: 'cash' as const, ref: null, daysAgo: 2 },
{ clientIdx: 14, amount: 1499, method: 'gcash' as const, ref: 'GCASH-44235', daysAgo: 3 },
{ clientIdx: 3, amount: 999, method: 'maya' as const, ref: 'MAYA-88319', daysAgo: 4 },
];
// Find or create overdue invoices for these clients to attach payments to
for (const rp of recentPaymentDefs) {
const client = clients[rp.clientIdx];
const paidDate = new Date();
paidDate.setDate(paidDate.getDate() - rp.daysAgo);
// Find an existing overdue or partial invoice for this client
let invoice = await prisma.invoice.findFirst({
where: { tenantId: tenant.id, clientId: client.id, status: { in: ['overdue', 'partial'] } },
});
// If no overdue invoice, create one
if (!invoice) {
invoiceCount++;
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() - 5);
invoice = await prisma.invoice.create({
data: {
tenantId: tenant.id,
clientId: client.id,
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount: rp.amount,
balance: rp.amount,
status: 'overdue',
dueDate,
periodStart: new Date(dueDate.getTime() - 30 * 86400000),
periodEnd: dueDate,
},
});
}
await prisma.payment.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: users.collector.id,
amount: rp.amount,
method: rp.method,
referenceNo: rp.ref,
createdAt: paidDate,
},
});
}
console.log(`Recent unremitted payments: ${recentPaymentDefs.length}`);
// ─── Notifications (unread, for testing) ──────────────────
const notifDefs = [
{ userId: users.collector.id, type: 'in_app', channel: 'payment_confirmation', title: 'Payment Recorded', message: 'Payment of ₱699 for Juan Dela Cruz has been recorded.', daysAgo: 0 },
{ userId: users.collector.id, type: 'in_app', channel: 'billing_reminder', title: 'Overdue Reminder', message: '3 invoices are overdue in Barangay 1 - Centro.', daysAgo: 1 },
{ userId: users.collector.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Assigned', message: 'DNS resolution issues ticket has been assigned to you.', daysAgo: 1 },
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'New Ticket', message: 'Fiber cable repair - Poblacion ticket needs attention.', daysAgo: 0 },
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Resolved', message: 'Cannot connect after reboot ticket has been resolved.', daysAgo: 0 },
{ userId: users.manager.id, type: 'in_app', channel: 'billing_reminder', title: 'Weekly Summary', message: '12 payments collected this week totaling ₱14,988.', daysAgo: 2 },
{ userId: users.manager.id, type: 'in_app', channel: 'ticket_update', title: 'Urgent Maintenance', message: 'Node outage - Riverside sector affecting 8 subscribers.', daysAgo: 0 },
{ userId: users.tenant_admin.id, type: 'in_app', channel: 'payment_confirmation', title: 'Remittance Confirmed', message: 'Remittance of ₱5,000 has been confirmed by Admin.', daysAgo: 3 },
];
for (const n of notifDefs) {
const sentAt = new Date();
sentAt.setDate(sentAt.getDate() - n.daysAgo);
await prisma.notification.create({
data: {
tenantId: tenant.id,
userId: n.userId,
type: n.type,
channel: n.channel,
title: n.title,
message: n.message,
isRead: false,
sentAt,
},
});
}
console.log(`Notifications: ${notifDefs.length} (unread)`);
console.log('\n✅ Seed completed successfully!');
console.log(`\n📊 Summary:`);
@@ -594,8 +874,12 @@ async function main() {
console.log(` Expenses: ${expDefs.length} (approved + pending)`);
console.log(` Assets: ${assetDefs.length}`);
console.log(` Company Accounts: ${accts.length}`);
console.log(`\n🔑 Login: admin@demo-isp.com / admin123!`);
console.log(`🌐 Portal: C-000001 / 09171234567`);
console.log(`\n🔑 Logins (all passwords: admin123!):`);
console.log(` admin@demo-isp.com (tenant_admin) - full access`);
console.log(` manager@demo-isp.com (manager) - operational management`);
console.log(` collector@demo-isp.com (collector) - payment collection`);
console.log(` tech@demo-isp.com (technician) - field operations`);
console.log(` tech2@demo-isp.com (technician) - field operations`);
}
main()

View File

@@ -2,8 +2,8 @@
"name": "@fiberops/shared",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",

View File

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

View File

@@ -1,15 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,91 @@
import {
Controller,
Post,
Get,
Param,
UseGuards,
UseInterceptors,
UploadedFile,
Res,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { ImportService } from './import.service';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('import')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class ImportController {
constructor(private readonly importService: ImportService) {}
@Get('template/:type')
@Roles('tenant_admin')
async downloadTemplate(
@Param('type') type: string,
@CurrentUser() user: CurrentUserPayload,
@Res() res: Response,
) {
const { buffer, filename, contentType } =
await this.importService.generateTemplate(user.tenantId, type);
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Post('clients')
@Roles('tenant_admin')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = file.originalname.split('.').pop()?.toLowerCase();
if (['csv', 'xlsx', 'xls'].includes(ext || '')) {
cb(null, true);
} else {
cb(new Error('Only CSV and Excel files are allowed'), false);
}
},
}),
)
async importClients(
@CurrentUser() user: CurrentUserPayload,
@UploadedFile() file: Express.Multer.File,
) {
return this.importService.importClients(
user.tenantId,
user.sub,
file.buffer,
file.originalname,
);
}
@Post('invoices')
@Roles('tenant_admin')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = file.originalname.split('.').pop()?.toLowerCase();
if (['csv', 'xlsx', 'xls'].includes(ext || '')) {
cb(null, true);
} else {
cb(new Error('Only CSV and Excel files are allowed'), false);
}
},
}),
)
async importInvoices(
@CurrentUser() user: CurrentUserPayload,
@UploadedFile() file: Express.Multer.File,
) {
return this.importService.importInvoices(
user.tenantId,
file.buffer,
file.originalname,
);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ImportController],
providers: [ImportService],
})
export class ImportModule {}

View File

@@ -0,0 +1,427 @@
import {
Injectable,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as XLSX from 'xlsx';
interface RowResult {
row: number;
status: 'ok' | 'error';
message?: string;
}
interface ImportResult {
total: number;
imported: number;
errors: number;
details: RowResult[];
}
@Injectable()
export class ImportService {
constructor(private readonly prisma: PrismaService) {}
/* ------------------------------------------------------------------ */
/* Template generation */
/* ------------------------------------------------------------------ */
async generateTemplate(
tenantId: string,
type: string,
): Promise<{ buffer: Buffer; filename: string; contentType: string }> {
if (type === 'clients') {
return this.generateClientsTemplate(tenantId);
}
if (type === 'invoices') {
return this.generateInvoicesTemplate(tenantId);
}
throw new BadRequestException(
`Unknown template type "${type}". Use "clients" or "invoices".`,
);
}
private async generateClientsTemplate(tenantId: string) {
const [plans, areas] = await Promise.all([
this.prisma.plan.findMany({
where: { tenantId, isActive: true, deletedAt: null },
select: { name: true },
orderBy: { name: 'asc' },
}),
this.prisma.area.findMany({
where: { tenantId, deletedAt: null },
select: { name: true },
orderBy: { name: 'asc' },
}),
]);
const headers = [
'firstName*',
'lastName*',
'email',
'phone',
'address*',
'accountNumber',
'planName*',
'subscriptionType*',
'areaName',
];
const exampleRow = [
'Juan',
'Dela Cruz',
'juan@example.com',
'+639171234567',
'123 Main Street, Barangay 1',
'C-000001',
plans[0]?.name || 'Plan 1',
'postpaid',
areas[0]?.name || '',
];
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
// Add data-validation notes in a second sheet
const notesData = [
['Field', 'Required', 'Description', 'Valid Values'],
['firstName', 'Yes', 'Client first name', ''],
['lastName', 'Yes', 'Client last name', ''],
['email', 'No', 'Email address', ''],
['phone', 'No', 'Phone number', ''],
['address', 'Yes', 'Full address', ''],
['accountNumber', 'No', 'Existing account number (auto-generated if empty)', ''],
['planName', 'Yes', 'Must match an existing plan name', plans.map((p) => p.name).join(', ') || '(no plans created yet)'],
['subscriptionType', 'Yes', 'Subscription type', 'prepaid, postpaid'],
['areaName', 'No', 'Must match an existing area name', areas.map((a) => a.name).join(', ') || '(no areas created yet)'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notesData);
// Set column widths
ws['!cols'] = headers.map(() => ({ wch: 25 }));
notesWs['!cols'] = [
{ wch: 20 },
{ wch: 10 },
{ wch: 45 },
{ wch: 50 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Clients');
XLSX.utils.book_append_sheet(wb, notesWs, 'Field Guide');
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
return {
buffer,
filename: 'fiberops_clients_template.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
}
private async generateInvoicesTemplate(tenantId: string) {
const headers = [
'accountNumber*',
'invoiceNumber',
'amount*',
'balance*',
'dueDate*',
'status*',
];
const exampleRow = [
'C-000001',
'',
'1500.00',
'1500.00',
'2026-06-01',
'unpaid',
];
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
const notesData = [
['Field', 'Required', 'Description', 'Valid Values'],
['accountNumber', 'Yes', 'Must match an existing client account number', ''],
['invoiceNumber', 'No', 'Invoice number (auto-generated if empty)', ''],
['amount', 'Yes', 'Invoice total amount', ''],
['balance', 'Yes', 'Outstanding balance', ''],
['dueDate', 'Yes', 'Due date in YYYY-MM-DD format', ''],
['status', 'Yes', 'Invoice status', 'unpaid, partial'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notesData);
ws['!cols'] = headers.map(() => ({ wch: 25 }));
notesWs['!cols'] = [
{ wch: 20 },
{ wch: 10 },
{ wch: 50 },
{ wch: 30 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Invoices');
XLSX.utils.book_append_sheet(wb, notesWs, 'Field Guide');
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
return {
buffer,
filename: 'fiberops_invoices_template.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
}
/* ------------------------------------------------------------------ */
/* Client + Subscription import */
/* ------------------------------------------------------------------ */
async importClients(
tenantId: string,
userId: string,
fileBuffer: Buffer,
filename: string,
): Promise<ImportResult> {
const rows = this.parseFile(fileBuffer, filename);
if (rows.length === 0) {
throw new BadRequestException('File is empty');
}
// Prefetch plans and areas for lookup
const [plans, areas] = await Promise.all([
this.prisma.plan.findMany({
where: { tenantId, isActive: true, deletedAt: null },
select: { id: true, name: true },
}),
this.prisma.area.findMany({
where: { tenantId, deletedAt: null },
select: { id: true, name: true },
}),
]);
const planMap = new Map(plans.map((p) => [p.name.toLowerCase(), p.id]));
const areaMap = new Map(areas.map((a) => [a.name.toLowerCase(), a.id]));
const details: RowResult[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNumber = i + 2; // Excel is 1-indexed, +1 for header
try {
const firstName = this.req(row, 'firstName', rowNumber);
const lastName = this.req(row, 'lastName', rowNumber);
const address = this.req(row, 'address', rowNumber);
const planName = this.req(row, 'planName', rowNumber);
const subscriptionType = this.req(row, 'subscriptionType', rowNumber);
if (!['prepaid', 'postpaid'].includes(subscriptionType.toLowerCase())) {
throw new Error(`Invalid subscriptionType "${subscriptionType}". Must be "prepaid" or "postpaid".`);
}
const planId = planMap.get(planName.toLowerCase());
if (!planId) {
throw new Error(`Plan "${planName}" not found. Available plans: ${plans.map((p) => p.name).join(', ') || '(none)'}`);
}
const areaName = this.opt(row, 'areaName');
const areaId = areaName ? areaMap.get(areaName.toLowerCase()) : undefined;
if (areaName && !areaId) {
throw new Error(`Area "${areaName}" not found. Available areas: ${areas.map((a) => a.name).join(', ') || '(none)'}`);
}
const accountNumber = this.opt(row, 'accountNumber') || undefined;
// Check account number uniqueness if provided
if (accountNumber) {
const existing = await this.prisma.client.findFirst({
where: { tenantId, accountNumber },
});
if (existing) {
throw new Error(`Account number "${accountNumber}" already exists for client ${existing.firstName} ${existing.lastName}`);
}
}
// Generate account number if not provided
const finalAccountNumber =
accountNumber ||
(await this.generateAccountNumber(tenantId));
const client = await this.prisma.client.create({
data: {
tenantId,
accountNumber: finalAccountNumber,
firstName,
lastName,
email: this.opt(row, 'email'),
phone: this.opt(row, 'phone'),
address,
areaId,
},
});
// Create subscription
await this.prisma.subscription.create({
data: {
tenantId,
clientId: client.id,
planId,
type: subscriptionType.toLowerCase(),
status: 'pending',
},
});
details.push({ row: rowNumber, status: 'ok', message: `Created ${firstName} ${lastName} (${finalAccountNumber})` });
} catch (err: any) {
details.push({ row: rowNumber, status: 'error', message: err.message });
}
}
const imported = details.filter((d) => d.status === 'ok').length;
const errors = details.filter((d) => d.status === 'error').length;
// Log audit
if (imported > 0) {
await this.prisma.auditLog.create({
data: {
tenantId,
userId,
action: 'IMPORT_CLIENTS',
entity: 'client',
entityId: 'bulk',
details: { imported, errors, total: rows.length },
},
});
}
return { total: rows.length, imported, errors, details };
}
/* ------------------------------------------------------------------ */
/* Invoice import */
/* ------------------------------------------------------------------ */
async importInvoices(
tenantId: string,
fileBuffer: Buffer,
filename: string,
): Promise<ImportResult> {
const rows = this.parseFile(fileBuffer, filename);
if (rows.length === 0) {
throw new BadRequestException('File is empty');
}
const details: RowResult[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNumber = i + 2;
try {
const accountNumber = this.req(row, 'accountNumber', rowNumber);
const amountStr = this.req(row, 'amount', rowNumber);
const balanceStr = this.req(row, 'balance', rowNumber);
const dueDateStr = this.req(row, 'dueDate', rowNumber);
const status = this.req(row, 'status', rowNumber);
// Find client by account number
const client = await this.prisma.client.findFirst({
where: { tenantId, accountNumber },
});
if (!client) {
throw new Error(`Client with account number "${accountNumber}" not found`);
}
const amount = parseFloat(amountStr);
const balance = parseFloat(balanceStr);
if (isNaN(amount) || amount <= 0) {
throw new Error(`Invalid amount "${amountStr}"`);
}
if (isNaN(balance) || balance < 0) {
throw new Error(`Invalid balance "${balanceStr}"`);
}
const dueDate = new Date(dueDateStr);
if (isNaN(dueDate.getTime())) {
throw new Error(`Invalid dueDate "${dueDateStr}". Use YYYY-MM-DD format.`);
}
if (!['unpaid', 'partial'].includes(status.toLowerCase())) {
throw new Error(`Invalid status "${status}". Must be "unpaid" or "partial".`);
}
// Generate invoice number
const invoiceNumber =
this.opt(row, 'invoiceNumber') ||
(await this.generateInvoiceNumber(tenantId));
await this.prisma.invoice.create({
data: {
tenantId,
clientId: client.id,
number: invoiceNumber,
amount,
balance,
status: status.toLowerCase(),
dueDate,
},
});
details.push({
row: rowNumber,
status: 'ok',
message: `Invoice ${invoiceNumber} for ${client.firstName} ${client.lastName} (${accountNumber})`,
});
} catch (err: any) {
details.push({ row: rowNumber, status: 'error', message: err.message });
}
}
const imported = details.filter((d) => d.status === 'ok').length;
const errors = details.filter((d) => d.status === 'error').length;
return { total: rows.length, imported, errors, details };
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
private parseFile(buffer: Buffer, filename: string): Record<string, string>[] {
const ext = filename.split('.').pop()?.toLowerCase();
const wb = XLSX.read(buffer, { type: 'buffer' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows: Record<string, string>[] = XLSX.utils.sheet_to_json(ws, {
defval: '',
});
// Normalize headers: strip asterisks and whitespace
return rows.map((row) => {
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(row)) {
normalized[key.replace(/\*/g, '').trim()] = String(value).trim();
}
return normalized;
});
}
private req(row: Record<string, string>, field: string, rowNumber: number): string {
const value = row[field];
if (!value) {
throw new Error(`Missing required field "${field}"`);
}
return value;
}
private opt(row: Record<string, string>, field: string): string | undefined {
const value = row[field];
return value || undefined;
}
private async generateAccountNumber(tenantId: string): Promise<string> {
const count = await this.prisma.client.count({ where: { tenantId } });
return `C-${String(count + 1).padStart(6, '0')}`;
}
private async generateInvoiceNumber(tenantId: string): Promise<string> {
const count = await this.prisma.invoice.count({ where: { tenantId } });
return `INV-${String(count + 1).padStart(6, '0')}`;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -124,7 +124,7 @@ export class PaymentService {
// ─── Remittance (custodial clearing) ─────────────────────────
async findRemittances(tenantId: string) {
return this.prisma.remittance.findMany({
const remittances = await this.prisma.remittance.findMany({
where: { tenantId },
include: {
collector: { select: { id: true, firstName: true, lastName: true } },
@@ -133,17 +133,49 @@ export class PaymentService {
},
orderBy: { submittedAt: 'desc' },
});
// Collect all payment IDs across remittances
const paymentIds = remittances.flatMap((r) => r.payments.map((p) => p.paymentId));
if (paymentIds.length === 0) return remittances;
// Fetch full payment details in one query
const payments = await this.prisma.payment.findMany({
where: { id: { in: paymentIds } },
include: {
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
invoice: { select: { id: true, number: true } },
},
});
const paymentMap = new Map(payments.map((p) => [p.id, p]));
// Attach full payment details to each remittance's join records
return remittances.map((r) => ({
...r,
createdAt: r.submittedAt.toISOString(),
payments: r.payments.map((rp) => ({
...rp,
payment: paymentMap.get(rp.paymentId) ?? null,
})),
}));
}
async getUnremittedPayments(tenantId: string, collectorId: string) {
const remittedIds = (await this.prisma.remittancePayment.findMany({ select: { paymentId: true } }))
async getUnremittedPayments(tenantId: string, collectorId: string | null) {
const remittedIds = (await this.prisma.remittancePayment.findMany({
where: { remittance: { tenantId } },
select: { paymentId: true },
}))
.map((r) => r.paymentId);
return this.prisma.payment.findMany({
where: { tenantId, collectedById: collectorId, id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] } },
where: {
tenantId,
...(collectorId ? { collectedById: collectorId } : {}),
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
},
include: {
client: { select: { firstName: true, lastName: true, accountNumber: true } },
invoice: { select: { number: true } },
invoice: { select: { id: true, number: true } },
collectedBy: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
});
@@ -188,6 +220,17 @@ export class PaymentService {
include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } },
});
// Notify collector that their remittance was approved
if (remittance.collectorId) {
this.notificationService.create(tenantId, {
userId: remittance.collectorId,
type: 'in_app',
channel: 'remittance_approved',
title: 'Remittance Approved',
message: `Your remittance of ₱${Number(remittance.totalAmount).toLocaleString()} has been approved`,
}).catch(() => {});
}
this.audit.log({
tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId,
details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) },
@@ -237,7 +280,7 @@ export class PaymentService {
}
}
return this.prisma.remittance.update({
const result = await this.prisma.remittance.update({
where: { id: remittanceId },
data: {
status: 'rejected',
@@ -245,5 +288,50 @@ export class PaymentService {
confirmedAt: new Date(),
},
});
// Notify collector that their remittance was rejected
if (remittance.collectorId) {
this.notificationService.create(tenantId, {
userId: remittance.collectorId,
type: 'in_app',
channel: 'remittance_rejected',
title: 'Remittance Rejected',
message: `Your remittance of ₱${Number(remittance.totalAmount).toLocaleString()} has been rejected`,
}).catch(() => {});
}
return result;
}
async getUnremittedBreakdown(tenantId: string) {
const remittedIds = (await this.prisma.remittancePayment.findMany({
where: { remittance: { tenantId } },
select: { paymentId: true },
})).map((r) => r.paymentId);
const grouped = await this.prisma.payment.groupBy({
by: ['collectedById'],
where: {
tenantId,
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
collectedById: { not: null },
},
_sum: { amount: true },
_count: true,
});
const collectorIds = grouped.map((g) => g.collectedById!).filter(Boolean);
const collectors = await this.prisma.user.findMany({
where: { id: { in: collectorIds } },
select: { id: true, firstName: true, lastName: true },
});
const nameMap = new Map(collectors.map((c) => [c.id, `${c.firstName} ${c.lastName}`]));
return grouped.map((g) => ({
collectorId: g.collectedById,
collectorName: nameMap.get(g.collectedById!) ?? 'Unknown',
totalAmount: Number(g._sum.amount ?? 0),
count: g._count,
}));
}
}

View File

@@ -0,0 +1,86 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
@Injectable()
export class PaymentScheduler {
private readonly logger = new Logger(PaymentScheduler.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationService: NotificationService,
) {}
@Cron('0 17 * * *')
async sendUnremittedReminders() {
this.logger.log('Sending unremitted payment reminders...');
const tenants = await this.prisma.tenant.findMany({ where: { isActive: true } });
for (const tenant of tenants) {
try {
await this._remindForTenant(tenant.id);
} catch (error) {
this.logger.error(`Failed to send reminders for tenant ${tenant.slug}:`, error);
}
}
this.logger.log('Unremitted payment reminders complete.');
}
private async _remindForTenant(tenantId: string) {
// Find remitted payment IDs
const remittedIds = (await this.prisma.remittancePayment.findMany({
where: { remittance: { tenantId } },
select: { paymentId: true },
})).map((r) => r.paymentId);
// Find unremitted payments grouped by collector
const unremitted = await this.prisma.payment.findMany({
where: {
tenantId,
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
collectedById: { not: null },
},
select: { collectedById: true, amount: true },
});
if (unremitted.length === 0) return;
// Group by collector
const byCollector = new Map<string, number>();
for (const p of unremitted) {
const id = p.collectedById!;
byCollector.set(id, (byCollector.get(id) ?? 0) + Number(p.amount));
}
// Check which collectors already got a reminder today
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const alreadyReminded = await this.prisma.notification.findMany({
where: {
tenantId,
channel: 'unremitted_reminder',
createdAt: { gte: todayStart },
},
select: { userId: true },
});
const remindedSet = new Set(alreadyReminded.map((n) => n.userId).filter(Boolean));
for (const [collectorId, total] of byCollector) {
if (remindedSet.has(collectorId)) continue;
this.notificationService.create(tenantId, {
userId: collectorId,
type: 'in_app',
channel: 'unremitted_reminder',
title: 'End-of-Day Reminder',
message: `You have ₱${total.toLocaleString()} in unremitted payments. Please submit your remittance.`,
}).catch((err) =>
this.logger.error(`Failed to notify ${collectorId}: ${err.message}`),
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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