Compare commits

39 Commits

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
kevin-asprec
1c4424f2a6 fix: set NODE_ENV=development during build for devDependencies 2026-04-13 12:36:25 +08:00
kevin-asprec
6da4cac2e7 fix: use npx -p @nestjs/cli for nest build 2026-04-13 12:14:04 +08:00
kevin-asprec
2f4323ebea fix: standalone tsconfig and Dockerfile for monorepo split 2026-04-13 12:04:24 +08:00
kevin-asprec
4a8f1e9318 initial: standalone repo from monorepo split 2026-04-13 09:36:55 +08:00
175 changed files with 18300 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
.env
*.tsbuildinfo

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fiberops
JWT_SECRET=your-secret-key
PORT=3001

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
.env.*.local
*.tsbuildinfo

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM node:20-alpine AS builder
WORKDIR /app
ENV NODE_ENV=development
COPY package.json package-lock.json* ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/db/package.json ./packages/db/
RUN npm install
COPY packages/shared/ ./packages/shared/
COPY packages/db/ ./packages/db/
COPY nest-cli.json ./
COPY tsconfig.json ./
COPY src/ ./src/
RUN cd packages/shared && npx tsc --outDir dist --declaration
RUN cd packages/db && npx prisma generate
RUN npx -p @nestjs/cli nest build
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
RUN node -e "const p=require('./packages/shared/package.json');p.main='./dist/index.js';require('fs').writeFileSync('./packages/shared/package.json',JSON.stringify(p,null,2))"
COPY --from=builder /app/packages/db/prisma ./packages/db/prisma
COPY --from=builder /app/packages/db/package.json ./packages/db/
COPY --from=builder /app/packages/db/src ./packages/db/src
ENV NODE_ENV=production
EXPOSE 3001
ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "cd packages/db && npx prisma migrate resolve --applied 20260504100000_add_user_must_change_password 2>/dev/null; npx prisma migrate resolve --applied 20260506070000_add_client_coordinates 2>/dev/null; npx prisma migrate deploy && if [ \"$RUN_SEED\" = \"true\" ]; then echo 'Seeding database...' && npx tsx prisma/seed.ts; fi && cd /app && node dist/main"]

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

8
nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

7602
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "fiberops-api",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main",
"start:prod": "node dist/main",
"db:generate": "cd packages/db && npx prisma generate",
"db:migrate": "cd packages/db && npx prisma migrate dev",
"db:migrate:deploy": "cd packages/db && npx prisma migrate deploy",
"db:seed": "cd packages/db && npx prisma db seed",
"db:studio": "cd packages/db && npx prisma studio"
},
"dependencies": {
"@fiberops/db": "workspace:*",
"@fiberops/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.1",
"@nestjs/throttler": "^6.5.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"xlsx": "^0.18.5",
"zod": "^3.24.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@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"
}
}

28
packages/db/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@fiberops/db",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"generate": "prisma generate",
"migrate:dev": "prisma migrate dev",
"migrate:deploy": "prisma migrate deploy",
"seed": "tsx prisma/seed.ts",
"studio": "prisma studio",
"lint": "tsc --noEmit",
"clean": "rm -rf dist"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.5.0"
},
"devDependencies": {
"prisma": "^6.5.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,361 @@
-- CreateTable
CREATE TABLE "tenants" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"settings" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "areas" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "areas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plans" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"speedDown" INTEGER NOT NULL,
"speedUp" INTEGER NOT NULL,
"price" DECIMAL(10,2) NOT NULL,
"billingCycle" INTEGER NOT NULL DEFAULT 30,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "plans_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "clients" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"accountNumber" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"address" TEXT NOT NULL,
"areaId" TEXT,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscriptions" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"installedAt" TIMESTAMP(3),
"activatedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invoices" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"number" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"balance" DECIMAL(10,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"dueDate" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"periodStart" TIMESTAMP(3),
"periodEnd" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "invoices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"invoiceId" TEXT,
"collectedById" TEXT,
"amount" DECIMAL(10,2) NOT NULL,
"method" TEXT NOT NULL,
"referenceNo" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "remittances" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"collectorId" TEXT NOT NULL,
"confirmedById" TEXT,
"totalAmount" DECIMAL(10,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmedAt" TIMESTAMP(3),
"notes" TEXT,
CONSTRAINT "remittances_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tickets" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"clientId" TEXT,
"createdById" TEXT NOT NULL,
"assigneeId" TEXT,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
"title" TEXT NOT NULL,
"description" TEXT,
"priority" TEXT NOT NULL DEFAULT 'normal',
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT,
"clientId" TEXT,
"type" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"sentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tenants_slug_key" ON "tenants"("slug");
-- CreateIndex
CREATE INDEX "users_tenantId_idx" ON "users"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "users_tenantId_email_key" ON "users"("tenantId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_userId_role_key" ON "user_roles"("userId", "role");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- CreateIndex
CREATE INDEX "refresh_tokens_userId_idx" ON "refresh_tokens"("userId");
-- CreateIndex
CREATE INDEX "refresh_tokens_expiresAt_idx" ON "refresh_tokens"("expiresAt");
-- CreateIndex
CREATE INDEX "areas_tenantId_idx" ON "areas"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "areas_tenantId_name_key" ON "areas"("tenantId", "name");
-- CreateIndex
CREATE INDEX "plans_tenantId_idx" ON "plans"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "plans_tenantId_name_key" ON "plans"("tenantId", "name");
-- CreateIndex
CREATE INDEX "clients_tenantId_idx" ON "clients"("tenantId");
-- CreateIndex
CREATE INDEX "clients_tenantId_status_idx" ON "clients"("tenantId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "clients_tenantId_accountNumber_key" ON "clients"("tenantId", "accountNumber");
-- CreateIndex
CREATE INDEX "subscriptions_tenantId_idx" ON "subscriptions"("tenantId");
-- CreateIndex
CREATE INDEX "subscriptions_tenantId_status_idx" ON "subscriptions"("tenantId", "status");
-- CreateIndex
CREATE INDEX "subscriptions_clientId_idx" ON "subscriptions"("clientId");
-- CreateIndex
CREATE INDEX "invoices_tenantId_idx" ON "invoices"("tenantId");
-- CreateIndex
CREATE INDEX "invoices_tenantId_status_idx" ON "invoices"("tenantId", "status");
-- CreateIndex
CREATE INDEX "invoices_clientId_idx" ON "invoices"("clientId");
-- CreateIndex
CREATE UNIQUE INDEX "invoices_tenantId_number_key" ON "invoices"("tenantId", "number");
-- CreateIndex
CREATE INDEX "payments_tenantId_idx" ON "payments"("tenantId");
-- CreateIndex
CREATE INDEX "payments_clientId_idx" ON "payments"("clientId");
-- CreateIndex
CREATE INDEX "payments_invoiceId_idx" ON "payments"("invoiceId");
-- CreateIndex
CREATE INDEX "remittances_tenantId_idx" ON "remittances"("tenantId");
-- CreateIndex
CREATE INDEX "remittances_collectorId_idx" ON "remittances"("collectorId");
-- CreateIndex
CREATE INDEX "tickets_tenantId_idx" ON "tickets"("tenantId");
-- CreateIndex
CREATE INDEX "tickets_tenantId_type_status_idx" ON "tickets"("tenantId", "type", "status");
-- CreateIndex
CREATE INDEX "tickets_clientId_idx" ON "tickets"("clientId");
-- CreateIndex
CREATE INDEX "tickets_assigneeId_idx" ON "tickets"("assigneeId");
-- CreateIndex
CREATE INDEX "notifications_tenantId_idx" ON "notifications"("tenantId");
-- CreateIndex
CREATE INDEX "notifications_userId_isRead_idx" ON "notifications"("userId", "isRead");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "areas" ADD CONSTRAINT "areas_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "plans" ADD CONSTRAINT "plans_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_areaId_fkey" FOREIGN KEY ("areaId") REFERENCES "areas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planId_fkey" FOREIGN KEY ("planId") REFERENCES "plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_collectedById_fkey" FOREIGN KEY ("collectedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "remittances" ADD CONSTRAINT "remittances_collectorId_fkey" FOREIGN KEY ("collectorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "remittances" ADD CONSTRAINT "remittances_confirmedById_fkey" FOREIGN KEY ("confirmedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"details" JSONB NOT NULL DEFAULT '{}',
"ipAddress" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "audit_logs_tenantId_idx" ON "audit_logs"("tenantId");
-- CreateIndex
CREATE INDEX "audit_logs_tenantId_entity_idx" ON "audit_logs"("tenantId", "entity");
-- CreateIndex
CREATE INDEX "audit_logs_userId_idx" ON "audit_logs"("userId");

View File

@@ -0,0 +1,135 @@
-- CreateTable
CREATE TABLE "employees" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"position" TEXT NOT NULL,
"department" TEXT,
"employeeNo" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active',
"hireDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"terminatedAt" TIMESTAMP(3),
"salary" DECIMAL(10,2),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "employees_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "expenses" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"approvedById" TEXT,
"category" TEXT NOT NULL,
"description" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"receiptUrl" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"expenseDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"approvedAt" TIMESTAMP(3),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expenses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "company_accounts" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"accountNo" TEXT,
"balance" DECIMAL(12,2) NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "company_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fund_transfers" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"fromAccountId" TEXT NOT NULL,
"toAccountId" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"description" TEXT,
"transferredBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fund_transfers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "assets" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"serialNumber" TEXT,
"purchaseDate" TIMESTAMP(3),
"purchasePrice" DECIMAL(10,2),
"assignedToId" TEXT,
"status" TEXT NOT NULL DEFAULT 'available',
"location" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "assets_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "employees_tenantId_idx" ON "employees"("tenantId");
-- CreateIndex
CREATE INDEX "employees_tenantId_status_idx" ON "employees"("tenantId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "employees_tenantId_employeeNo_key" ON "employees"("tenantId", "employeeNo");
-- CreateIndex
CREATE INDEX "expenses_tenantId_idx" ON "expenses"("tenantId");
-- CreateIndex
CREATE INDEX "expenses_tenantId_status_idx" ON "expenses"("tenantId", "status");
-- CreateIndex
CREATE INDEX "expenses_tenantId_category_idx" ON "expenses"("tenantId", "category");
-- CreateIndex
CREATE INDEX "company_accounts_tenantId_idx" ON "company_accounts"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "company_accounts_tenantId_name_key" ON "company_accounts"("tenantId", "name");
-- CreateIndex
CREATE INDEX "fund_transfers_tenantId_idx" ON "fund_transfers"("tenantId");
-- CreateIndex
CREATE INDEX "assets_tenantId_idx" ON "assets"("tenantId");
-- CreateIndex
CREATE INDEX "assets_tenantId_status_idx" ON "assets"("tenantId", "status");
-- CreateIndex
CREATE INDEX "assets_tenantId_category_idx" ON "assets"("tenantId", "category");
-- AddForeignKey
ALTER TABLE "fund_transfers" ADD CONSTRAINT "fund_transfers_fromAccountId_fkey" FOREIGN KEY ("fromAccountId") REFERENCES "company_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fund_transfers" ADD CONSTRAINT "fund_transfers_toAccountId_fkey" FOREIGN KEY ("toAccountId") REFERENCES "company_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assets" ADD CONSTRAINT "assets_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "billing_settings" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"autoGenerate" BOOLEAN NOT NULL DEFAULT true,
"gracePeriodDays" INTEGER NOT NULL DEFAULT 7,
"dueDateOffsetDays" INTEGER NOT NULL DEFAULT 15,
"lateFeePercent" DECIMAL(5,2) NOT NULL DEFAULT 0,
"invoicePrefix" TEXT NOT NULL DEFAULT 'INV',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "billing_settings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "chart_of_accounts" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"parentId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"balance" DECIMAL(14,2) NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "chart_of_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "journal_entries" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"entryDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"description" TEXT NOT NULL,
"reference" TEXT,
"sourceType" TEXT,
"sourceId" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "journal_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "journal_lines" (
"id" TEXT NOT NULL,
"journalEntryId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"debit" DECIMAL(14,2) NOT NULL DEFAULT 0,
"credit" DECIMAL(14,2) NOT NULL DEFAULT 0,
CONSTRAINT "journal_lines_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "billing_settings_tenantId_key" ON "billing_settings"("tenantId");
-- CreateIndex
CREATE INDEX "chart_of_accounts_tenantId_idx" ON "chart_of_accounts"("tenantId");
-- CreateIndex
CREATE INDEX "chart_of_accounts_tenantId_type_idx" ON "chart_of_accounts"("tenantId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "chart_of_accounts_tenantId_code_key" ON "chart_of_accounts"("tenantId", "code");
-- CreateIndex
CREATE INDEX "journal_entries_tenantId_idx" ON "journal_entries"("tenantId");
-- CreateIndex
CREATE INDEX "journal_entries_tenantId_sourceType_sourceId_idx" ON "journal_entries"("tenantId", "sourceType", "sourceId");
-- CreateIndex
CREATE INDEX "journal_lines_journalEntryId_idx" ON "journal_lines"("journalEntryId");
-- CreateIndex
CREATE INDEX "journal_lines_accountId_idx" ON "journal_lines"("accountId");
-- AddForeignKey
ALTER TABLE "chart_of_accounts" ADD CONSTRAINT "chart_of_accounts_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "chart_of_accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "journal_lines" ADD CONSTRAINT "journal_lines_journalEntryId_fkey" FOREIGN KEY ("journalEntryId") REFERENCES "journal_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "journal_lines" ADD CONSTRAINT "journal_lines_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "chart_of_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "company_accounts" ADD COLUMN "chartOfAccountId" TEXT,
ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "remittance_payments" (
"id" TEXT NOT NULL,
"remittanceId" TEXT NOT NULL,
"paymentId" TEXT NOT NULL,
CONSTRAINT "remittance_payments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "remittance_payments_remittanceId_paymentId_key" ON "remittance_payments"("remittanceId", "paymentId");
-- AddForeignKey
ALTER TABLE "remittance_payments" ADD CONSTRAINT "remittance_payments_remittanceId_fkey" FOREIGN KEY ("remittanceId") REFERENCES "remittances"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,183 @@
-- Sync schema drift: soft-delete columns, mustChangePassword, RBAC tables,
-- recurring expenses, and payroll tables.
-- Uses IF NOT EXISTS / IF NOT EXISTS for idempotency (safe on fresh and db-pushed databases).
-- ── Soft-delete columns ──
ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "subscriptions" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "payments" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "remittances" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "tickets" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "areas" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "plans" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "expenses" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "assets" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "company_accounts" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "chart_of_accounts" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
-- Indexes for deletedAt
CREATE INDEX IF NOT EXISTS "tenants_deletedAt_idx" ON "tenants"("deletedAt");
CREATE INDEX IF NOT EXISTS "users_deletedAt_idx" ON "users"("deletedAt");
CREATE INDEX IF NOT EXISTS "subscriptions_deletedAt_idx" ON "subscriptions"("deletedAt");
CREATE INDEX IF NOT EXISTS "invoices_deletedAt_idx" ON "invoices"("deletedAt");
CREATE INDEX IF NOT EXISTS "payments_deletedAt_idx" ON "payments"("deletedAt");
CREATE INDEX IF NOT EXISTS "remittances_deletedAt_idx" ON "remittances"("deletedAt");
CREATE INDEX IF NOT EXISTS "tickets_deletedAt_idx" ON "tickets"("deletedAt");
CREATE INDEX IF NOT EXISTS "areas_deletedAt_idx" ON "areas"("deletedAt");
CREATE INDEX IF NOT EXISTS "plans_deletedAt_idx" ON "plans"("deletedAt");
CREATE INDEX IF NOT EXISTS "clients_deletedAt_idx" ON "clients"("deletedAt");
CREATE INDEX IF NOT EXISTS "employees_deletedAt_idx" ON "employees"("deletedAt");
CREATE INDEX IF NOT EXISTS "expenses_deletedAt_idx" ON "expenses"("deletedAt");
CREATE INDEX IF NOT EXISTS "assets_deletedAt_idx" ON "assets"("deletedAt");
CREATE INDEX IF NOT EXISTS "company_accounts_deletedAt_idx" ON "company_accounts"("deletedAt");
CREATE INDEX IF NOT EXISTS "chart_of_accounts_deletedAt_idx" ON "chart_of_accounts"("deletedAt");
-- ── RBAC: tenant_roles ──
CREATE TABLE IF NOT EXISTS "tenant_roles" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "tenant_roles_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "tenant_roles_tenantId_slug_key" ON "tenant_roles"("tenantId", "slug");
CREATE INDEX IF NOT EXISTS "tenant_roles_tenantId_idx" ON "tenant_roles"("tenantId");
CREATE INDEX IF NOT EXISTS "tenant_roles_deletedAt_idx" ON "tenant_roles"("deletedAt");
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tenant_roles_tenantId_fkey') THEN
ALTER TABLE "tenant_roles" ADD CONSTRAINT "tenant_roles_tenantId_fkey"
FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ── RBAC: role_permissions ──
CREATE TABLE IF NOT EXISTS "role_permissions" (
"id" TEXT NOT NULL,
"tenantRoleId" TEXT NOT NULL,
"module" TEXT NOT NULL,
"canView" BOOLEAN NOT NULL DEFAULT false,
"canCreate" BOOLEAN NOT NULL DEFAULT false,
"canUpdate" BOOLEAN NOT NULL DEFAULT false,
"canArchive" BOOLEAN NOT NULL DEFAULT false,
"canApprove" BOOLEAN NOT NULL DEFAULT false,
"canExport" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "role_permissions_tenantRoleId_module_key" ON "role_permissions"("tenantRoleId", "module");
CREATE INDEX IF NOT EXISTS "role_permissions_tenantRoleId_idx" ON "role_permissions"("tenantRoleId");
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'role_permissions_tenantRoleId_fkey') THEN
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_tenantRoleId_fkey"
FOREIGN KEY ("tenantRoleId") REFERENCES "tenant_roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ── RBAC: user_tenant_roles ──
CREATE TABLE IF NOT EXISTS "user_tenant_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tenantRoleId" TEXT NOT NULL,
CONSTRAINT "user_tenant_roles_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "user_tenant_roles_userId_tenantRoleId_key" ON "user_tenant_roles"("userId", "tenantRoleId");
CREATE INDEX IF NOT EXISTS "user_tenant_roles_userId_idx" ON "user_tenant_roles"("userId");
CREATE INDEX IF NOT EXISTS "user_tenant_roles_tenantRoleId_idx" ON "user_tenant_roles"("tenantRoleId");
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_tenant_roles_userId_fkey') THEN
ALTER TABLE "user_tenant_roles" ADD CONSTRAINT "user_tenant_roles_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_tenant_roles_tenantRoleId_fkey') THEN
ALTER TABLE "user_tenant_roles" ADD CONSTRAINT "user_tenant_roles_tenantRoleId_fkey"
FOREIGN KEY ("tenantRoleId") REFERENCES "tenant_roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ── Recurring Expenses ──
CREATE TABLE IF NOT EXISTS "recurring_expenses" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"category" TEXT NOT NULL,
"description" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"frequency" TEXT NOT NULL DEFAULT 'monthly',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"nextRunDate" TIMESTAMP(3) NOT NULL,
"lastRunDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "recurring_expenses_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "recurring_expenses_tenantId_idx" ON "recurring_expenses"("tenantId");
CREATE INDEX IF NOT EXISTS "recurring_expenses_deletedAt_idx" ON "recurring_expenses"("deletedAt");
-- ── Payroll Runs ──
CREATE TABLE IF NOT EXISTS "payroll_runs" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"totalAmount" DECIMAL(12,2) NOT NULL DEFAULT 0,
"processedBy" TEXT,
"processedAt" TIMESTAMP(3),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "payroll_runs_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "payroll_runs_tenantId_period_key" ON "payroll_runs"("tenantId", "period");
CREATE INDEX IF NOT EXISTS "payroll_runs_tenantId_idx" ON "payroll_runs"("tenantId");
CREATE INDEX IF NOT EXISTS "payroll_runs_deletedAt_idx" ON "payroll_runs"("deletedAt");
-- ── Payslips ──
CREATE TABLE IF NOT EXISTS "payslips" (
"id" TEXT NOT NULL,
"payrollRunId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"baseSalary" DECIMAL(10,2) NOT NULL,
"deductions" DECIMAL(10,2) NOT NULL DEFAULT 0,
"bonuses" DECIMAL(10,2) NOT NULL DEFAULT 0,
"netPay" DECIMAL(10,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"notes" TEXT,
CONSTRAINT "payslips_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "payslips_payrollRunId_employeeId_key" ON "payslips"("payrollRunId", "employeeId");
CREATE INDEX IF NOT EXISTS "payslips_payrollRunId_idx" ON "payslips"("payrollRunId");
CREATE INDEX IF NOT EXISTS "payslips_employeeId_idx" ON "payslips"("employeeId");
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payslips_payrollRunId_fkey') THEN
ALTER TABLE "payslips" ADD CONSTRAINT "payslips_payrollRunId_fkey"
FOREIGN KEY ("payrollRunId") REFERENCES "payroll_runs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payslips_employeeId_fkey') THEN
ALTER TABLE "payslips" ADD CONSTRAINT "payslips_employeeId_fkey"
FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON UPDATE CASCADE;
END IF;
END $$;

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

@@ -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

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,726 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Multi-Tenant Foundation ────────────────────────────────
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
isActive Boolean @default(true)
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
users User[]
tenantRoles TenantRole[]
areas Area[]
plans Plan[]
clients Client[]
subscriptions Subscription[]
invoices Invoice[]
payments Payment[]
tickets Ticket[]
notifications Notification[]
ticketComments TicketComment[]
@@index([deletedAt])
@@map("tenants")
}
// ─── Auth & Users ───────────────────────────────────────────
model User {
id String @id @default(uuid())
tenantId String? // null for super_admin (platform-level user)
email String
password String
firstName String
lastName String
isActive Boolean @default(true)
mustChangePassword Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
roles UserRole[]
tenantRoles UserTenantRole[]
assignedTickets Ticket[] @relation("TicketAssignee")
createdTickets Ticket[] @relation("TicketCreator")
authoredComments TicketComment[] @relation("CommentAuthor")
payments Payment[]
remittances Remittance[] @relation("RemittanceCollector")
confirmedRemittances Remittance[] @relation("RemittanceConfirmer")
@@unique([tenantId, email])
@@index([tenantId])
@@index([deletedAt])
@@map("users")
}
model UserRole {
id String @id @default(uuid())
userId String
role String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@map("user_roles")
}
// ─── Tenant-Scoped Role Management ─────────────────────────
model TenantRole {
id String @id @default(uuid())
tenantId String
name String
slug String
description String?
isSystem Boolean @default(false) // system roles can't be deleted
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
permissions RolePermission[]
users UserTenantRole[]
@@unique([tenantId, slug])
@@index([tenantId])
@@index([deletedAt])
@@map("tenant_roles")
}
model RolePermission {
id String @id @default(uuid())
tenantRoleId String
module String
canView Boolean @default(false)
canCreate Boolean @default(false)
canUpdate Boolean @default(false)
canArchive Boolean @default(false)
canApprove Boolean @default(false)
canExport Boolean @default(false)
tenantRole TenantRole @relation(fields: [tenantRoleId], references: [id], onDelete: Cascade)
@@unique([tenantRoleId, module])
@@index([tenantRoleId])
@@map("role_permissions")
}
model UserTenantRole {
id String @id @default(uuid())
userId String
tenantRoleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenantRole TenantRole @relation(fields: [tenantRoleId], references: [id], onDelete: Cascade)
@@unique([userId, tenantRoleId])
@@index([userId])
@@index([tenantRoleId])
@@map("user_tenant_roles")
}
// ─── Refresh Token ───────────────────────────────────
model RefreshToken {
id String @id @default(uuid())
token String @unique
userId String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
@@map("refresh_tokens")
}
// ─── Area & Zone Management ─────────────────────────────────
model Area {
id String @id @default(uuid())
tenantId String
name String
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
clients Client[]
@@unique([tenantId, name])
@@index([tenantId])
@@index([deletedAt])
@@map("areas")
}
// ─── Plan / Package Management ─────────────────────────
model Plan {
id String @id @default(uuid())
tenantId String
name String
description String?
speedDown Int // Mbps download
speedUp Int // Mbps upload
price Decimal @db.Decimal(10, 2)
billingCycle Int @default(30) // days
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
subscriptions Subscription[]
@@unique([tenantId, name])
@@index([tenantId])
@@index([deletedAt])
@@map("plans")
}
// ─── Client Profiling ─────────────────────────────────
model Client {
id String @id @default(uuid())
tenantId String
accountNumber String
firstName String
lastName String
email String?
phone String?
address String
latitude Float?
longitude Float?
areaId String?
status String @default("active") // active, inactive, suspended
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
area Area? @relation(fields: [areaId], references: [id])
subscriptions Subscription[]
invoices Invoice[]
payments Payment[]
tickets Ticket[]
@@unique([tenantId, accountNumber])
@@index([tenantId])
@@index([tenantId, status])
@@index([deletedAt])
@@map("clients")
}
// ─── Subscription Management ─────────────────────────
model Subscription {
id String @id @default(uuid())
tenantId String
clientId String
planId String
type String // prepaid, postpaid
status String @default("pending") // pending, active, suspended, cancelled, expired
startDate DateTime?
endDate DateTime?
installedAt DateTime?
activatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
plan Plan @relation(fields: [planId], references: [id])
@@index([tenantId])
@@index([tenantId, status])
@@index([clientId])
@@index([deletedAt])
@@map("subscriptions")
}
// ─── Billing & Invoicing ───────────────────────────────────
model Invoice {
id String @id @default(uuid())
tenantId String
clientId String
number String
amount Decimal @db.Decimal(10, 2)
balance Decimal @db.Decimal(10, 2) // remaining unpaid
status String @default("draft") // draft, sent, partial, paid, overdue, void
dueDate DateTime
paidAt DateTime?
periodStart DateTime?
periodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
payments Payment[]
@@unique([tenantId, number])
@@index([tenantId])
@@index([tenantId, status])
@@index([clientId])
@@index([deletedAt])
@@map("invoices")
}
// ─── Payment & Collection ─────────────────────────────────
model Payment {
id String @id @default(uuid())
tenantId String
clientId String
invoiceId String?
collectedById String?
amount Decimal @db.Decimal(10, 2)
method String // gcash, maya, cash, bank_transfer
referenceNo String?
notes String?
createdAt DateTime @default(now())
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
invoice Invoice? @relation(fields: [invoiceId], references: [id])
collectedBy User? @relation(fields: [collectedById], references: [id])
@@index([tenantId])
@@index([clientId])
@@index([invoiceId])
@@index([deletedAt])
@@map("payments")
}
// ─── Remittance ─────────────────────────────────────────
model Remittance {
id String @id @default(uuid())
tenantId String
collectorId String
confirmedById String?
totalAmount Decimal @db.Decimal(10, 2)
status String @default("pending") // pending, confirmed, rejected
submittedAt DateTime @default(now())
confirmedAt DateTime?
notes String?
deletedAt DateTime?
collector User @relation("RemittanceCollector", fields: [collectorId], references: [id])
confirmedBy User? @relation("RemittanceConfirmer", fields: [confirmedById], references: [id])
payments RemittancePayment[]
@@index([tenantId])
@@index([collectorId])
@@index([deletedAt])
@@map("remittances")
}
model RemittancePayment {
id String @id @default(uuid())
remittanceId String
paymentId String
remittance Remittance @relation(fields: [remittanceId], references: [id], onDelete: Cascade)
@@unique([remittanceId, paymentId])
@@map("remittance_payments")
}
// ─── Tickets (Work Orders) ─────────────────────────────────
model Ticket {
id String @id @default(uuid())
tenantId String
clientId String?
createdById String
assigneeId String?
type String // installation, activation, support, maintenance
status String @default("open") // open, in_progress, resolved, cancelled
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
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
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])
@@index([clientId])
@@index([assigneeId])
@@index([deletedAt])
@@map("tickets")
}
// ─── Notifications ─────────────────────────────────────────
model Notification {
id String @id @default(uuid())
tenantId String
userId String?
clientId String?
type String // sms, in_app
channel String // billing_reminder, payment_confirmation, ticket_update
title String
message String
isRead Boolean @default(false)
sentAt DateTime?
ticketId String?
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([userId, isRead])
@@map("notifications")
}
// ─── Ticket Comments ─────────────────────────────────
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
action String
entity String
entityId String
details Json @default("{}")
ipAddress String?
createdAt DateTime @default(now())
@@index([tenantId])
@@index([tenantId, entity])
@@index([userId])
@@map("audit_logs")
}
// ─── Employee Management ─────────────────────────
model Employee {
id String @id @default(uuid())
tenantId String
userId String? @unique // Linked user account (optional, 1:1)
firstName String
lastName String
email String?
phone String?
position String
department String?
employeeNo String
status String @default("active") // active, on_leave, terminated
hireDate DateTime @default(now())
terminatedAt DateTime?
salary Decimal? @db.Decimal(10, 2)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assets Asset[] @relation("AssetAssignee")
payslips Payslip[]
@@unique([tenantId, employeeNo])
@@index([tenantId])
@@index([tenantId, status])
@@index([deletedAt])
@@map("employees")
}
// ─── Payroll ─────────────────────────────────────────────
model PayrollRun {
id String @id @default(uuid())
tenantId String
period String
status String @default("draft") // draft, processing, completed
totalAmount Decimal @default(0) @db.Decimal(12, 2)
processedBy String?
processedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
payslips Payslip[]
@@unique([tenantId, period])
@@index([tenantId])
@@index([deletedAt])
@@map("payroll_runs")
}
model Payslip {
id String @id @default(uuid())
payrollRunId String
employeeId String
baseSalary Decimal @db.Decimal(10, 2)
deductions Decimal @default(0) @db.Decimal(10, 2)
bonuses Decimal @default(0) @db.Decimal(10, 2)
netPay Decimal @db.Decimal(10, 2)
status String @default("pending") // pending, paid
notes String?
payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id])
employee Employee @relation(fields: [employeeId], references: [id])
@@unique([payrollRunId, employeeId])
@@index([payrollRunId])
@@map("payslips")
}
// ─── Recurring Expenses ─────────────────────────
model RecurringExpense {
id String @id @default(uuid())
tenantId String
category String
description String
amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly") // monthly, quarterly, yearly
isActive Boolean @default(true)
nextRunDate DateTime
lastRunDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([tenantId])
@@index([deletedAt])
@@map("recurring_expenses")
}
// ─── Expense Management ─────────────────────────
model Expense {
id String @id @default(uuid())
tenantId String
createdById String
approvedById String?
category String
description String
amount Decimal @db.Decimal(10, 2)
receiptUrl String?
status String @default("pending") // pending, approved, rejected
expenseDate DateTime @default(now())
approvedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([tenantId])
@@index([tenantId, status])
@@index([tenantId, category])
@@index([deletedAt])
@@map("expenses")
}
// ─── Company Accounts & Fund Transfers ─────────────────
model CompanyAccount {
id String @id @default(uuid())
tenantId String
name String
type String // bank, e_wallet, cash
accountNo String?
balance Decimal @default(0) @db.Decimal(12, 2)
isActive Boolean @default(true)
isSystem Boolean @default(false)
chartOfAccountId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
outgoing FundTransfer[] @relation("TransferFrom")
incoming FundTransfer[] @relation("TransferTo")
@@unique([tenantId, name])
@@index([tenantId])
@@map("company_accounts")
}
model FundTransfer {
id String @id @default(uuid())
tenantId String
fromAccountId String
toAccountId String
amount Decimal @db.Decimal(12, 2)
description String?
transferredBy String
createdAt DateTime @default(now())
fromAccount CompanyAccount @relation("TransferFrom", fields: [fromAccountId], references: [id])
toAccount CompanyAccount @relation("TransferTo", fields: [toAccountId], references: [id])
@@index([tenantId])
@@map("fund_transfers")
}
// ─── Asset Management ─────────────────────────────────
model Asset {
id String @id @default(uuid())
tenantId String
name String
category String // router, olt, cable, tool, vehicle, computer, other
serialNumber String?
purchaseDate DateTime?
purchasePrice Decimal? @db.Decimal(10, 2)
assignedToId String?
status String @default("available") // available, in_use, maintenance, retired
location String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assignedTo Employee? @relation("AssetAssignee", fields: [assignedToId], references: [id])
@@index([tenantId])
@@index([tenantId, status])
@@index([tenantId, category])
@@index([deletedAt])
@@map("assets")
}
// ─── Billing Settings ─────────────────────────
model BillingSetting {
id String @id @default(uuid())
tenantId String @unique
autoGenerate Boolean @default(true)
gracePeriodDays Int @default(7)
dueDateOffsetDays Int @default(15)
lateFeePercent Decimal @default(0) @db.Decimal(5, 2)
invoicePrefix String @default("INV")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("billing_settings")
}
// ─── Chart of Accounts ─────────────────────────
model ChartOfAccount {
id String @id @default(uuid())
tenantId String
code String
name String
type String // asset, liability, equity, revenue, expense
parentId String?
isActive Boolean @default(true)
isSystem Boolean @default(false)
balance Decimal @default(0) @db.Decimal(14, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
parent ChartOfAccount? @relation("AccountTree", fields: [parentId], references: [id])
children ChartOfAccount[] @relation("AccountTree")
journalLines JournalLine[]
@@unique([tenantId, code])
@@index([tenantId])
@@index([tenantId, type])
@@index([deletedAt])
@@map("chart_of_accounts")
}
// ─── Journal Entries (append-only, no soft delete) ─────────
model JournalEntry {
id String @id @default(uuid())
tenantId String
entryDate DateTime @default(now())
description String
reference String?
sourceType String?
sourceId String?
createdById String?
createdAt DateTime @default(now())
lines JournalLine[]
@@index([tenantId])
@@index([tenantId, sourceType, sourceId])
@@map("journal_entries")
}
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])
account ChartOfAccount @relation(fields: [accountId], references: [id])
@@index([journalEntryId])
@@index([accountId])
@@map("journal_lines")
}

887
packages/db/prisma/seed.ts Normal file
View File

@@ -0,0 +1,887 @@
import { PrismaClient } from '@prisma/client';
import { randomUUID } from 'crypto';
import * as bcrypt from 'bcrypt';
// Default permission matrices for system roles
const MODULES = [
'dashboard', 'clients', 'subscriptions', 'invoices', 'payments', 'tickets',
'employees', 'payroll', 'expenses', 'assets', 'accounts', 'fund_transfers',
'accounting', 'reports', 'areas', 'plans', 'settings', 'users',
] as const;
type Module = (typeof MODULES)[number];
interface PermRow { module: Module; canView: boolean; canCreate: boolean; canUpdate: boolean; canArchive: boolean; canApprove: boolean; canExport: boolean; }
const DEFAULT_ROLES: { name: string; slug: string; description: string; perms: PermRow[] }[] = [
{
name: 'Tenant Admin', slug: 'tenant_admin', description: 'Full access to all modules',
perms: MODULES.map((m) => ({ module: m, canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: true, canExport: true })),
},
{
name: 'Manager', slug: 'manager', description: 'Operational management with approval rights',
perms: MODULES.map((m) => {
const noAccess: Module[] = ['users'];
const viewOnly: Module[] = ['dashboard', 'accounting', 'settings'];
if (noAccess.includes(m)) return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (viewOnly.includes(m)) return { module: m, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: m === 'accounting' };
return { module: m, canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: ['invoices', 'payments', 'expenses', 'payroll', 'fund_transfers'].includes(m), canExport: true };
}),
},
{
name: 'Technician', slug: 'technician', description: 'Field operations: tickets, payments, client/invoice viewing',
perms: MODULES.map((m) => {
const viewOnly: Module[] = ['clients', 'subscriptions', 'invoices', 'dashboard'];
const fullAccess: Module[] = ['tickets', 'payments'];
if (viewOnly.includes(m)) return { module: m, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (fullAccess.includes(m)) return { module: m, canView: true, canCreate: true, canUpdate: true, canArchive: false, canApprove: false, canExport: false };
return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}),
},
{
name: 'Collector', slug: 'collector', description: 'Payment collection and client viewing',
perms: MODULES.map((m) => {
const canWrite: Module[] = ['payments'];
const canViewMods: Module[] = ['dashboard', 'clients', 'invoices', 'payments'];
if (!canViewMods.includes(m)) return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
return { module: m, canView: true, canCreate: canWrite.includes(m), canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}),
},
];
const prisma = new PrismaClient();
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
async function main() {
console.log('Seeding database...');
// Clean existing data (order matters for FK constraints)
await prisma.journalLine.deleteMany();
await prisma.journalEntry.deleteMany();
await prisma.chartOfAccount.deleteMany();
await prisma.billingSetting.deleteMany();
await prisma.fundTransfer.deleteMany();
await prisma.companyAccount.deleteMany();
await prisma.asset.deleteMany();
await prisma.expense.deleteMany();
await prisma.payslip.deleteMany();
await prisma.payrollRun.deleteMany();
await prisma.recurringExpense.deleteMany();
await prisma.employee.deleteMany();
await prisma.notification.deleteMany();
await prisma.remittancePayment.deleteMany();
await prisma.remittance.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.payment.deleteMany();
await prisma.invoice.deleteMany();
await prisma.ticket.deleteMany();
await prisma.subscription.deleteMany();
await prisma.client.deleteMany();
await prisma.plan.deleteMany();
await prisma.area.deleteMany();
await prisma.refreshToken.deleteMany();
await prisma.userTenantRole.deleteMany();
await prisma.rolePermission.deleteMany();
await prisma.tenantRole.deleteMany();
await prisma.userRole.deleteMany();
await prisma.user.deleteMany();
await prisma.tenant.deleteMany();
// ─── Tenant ────────────────────────────────────────────
const tenant = await prisma.tenant.create({
data: {
id: randomUUID(),
name: 'FiberNet Philippines',
slug: 'fibernet-ph',
settings: { companyName: 'FiberNet Philippines Inc.', currency: 'PHP', timezone: 'Asia/Manila' },
},
});
console.log(`Tenant: ${tenant.name}`);
// ─── Super Admin (platform-level, no tenant) ───────────
const superAdmin = await prisma.user.create({
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
});
await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } });
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
// ─── Tenant Users ─────────────────────────────────────
const users: Record<string, any> = {};
const usersByEmail: Record<string, any> = {};
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: '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' },
];
for (const u of userDefs) {
const user = await prisma.user.create({
data: { tenantId: tenant.id, email: u.email, password: await hashPassword('admin123!'), firstName: u.first, lastName: u.last },
});
await prisma.userRole.create({ data: { userId: user.id, role: u.role } });
users[u.role] = user;
usersByEmail[u.email] = user;
console.log(`User: ${u.email} (${u.role})`);
}
// ─── Default Tenant Roles ──────────────────────────────
const tenantRoles: Record<string, any> = {};
for (const def of DEFAULT_ROLES) {
const role = await prisma.tenantRole.create({
data: {
tenantId: tenant.id,
name: def.name,
slug: def.slug,
description: def.description,
isSystem: true,
permissions: {
create: def.perms.map((p) => ({
module: p.module,
canView: p.canView,
canCreate: p.canCreate,
canUpdate: p.canUpdate,
canArchive: p.canArchive,
canApprove: p.canApprove,
canExport: p.canExport,
})),
},
},
});
tenantRoles[def.slug] = role;
}
console.log(`Tenant Roles: ${DEFAULT_ROLES.length} (${DEFAULT_ROLES.map((r) => r.slug).join(', ')})`);
// ─── Assign Tenant Roles to Users ─────────────────────
// Map of old role names to new tenant role slugs
const userRoleMap: Record<string, string> = {
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
};
// Assign roles from the map
for (const [oldRole, newRoleSlug] of Object.entries(userRoleMap)) {
if (users[oldRole] && tenantRoles[newRoleSlug]) {
await prisma.userTenantRole.create({
data: { userId: users[oldRole].id, tenantRoleId: tenantRoles[newRoleSlug].id },
});
}
}
// Additional assignments:
// - Assign technician users (collector, tech, tech2) to technician role
// - Assign viewer user to collector role
const technicianUsers = ['collector', 'tech', 'tech2'];
for (const email of technicianUsers) {
const userEmail = `${email}@demo-isp.com`;
const user = usersByEmail[userEmail];
if (user) {
const existing = await prisma.userTenantRole.findFirst({ where: { userId: user.id } });
if (!existing) {
await prisma.userTenantRole.create({
data: { userId: user.id, tenantRoleId: tenantRoles['technician'].id },
});
}
}
}
const viewerUserEmail = 'viewer@demo-isp.com';
const viewerUser = usersByEmail[viewerUserEmail];
if (viewerUser) {
const existing = await prisma.userTenantRole.findFirst({ where: { userId: viewerUser.id } });
if (!existing) {
await prisma.userTenantRole.create({
data: { userId: viewerUser.id, tenantRoleId: tenantRoles['collector'].id },
});
}
}
console.log('User-TenantRole assignments complete');
// ─── Areas ─────────────────────────────────────────────
const areas = await Promise.all([
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 1 - Centro', description: 'Town center, commercial area' } }),
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 2 - Poblacion', description: 'Residential zone near market' } }),
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 3 - San Isidro', description: 'Agricultural and residential' } }),
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 4 - Riverside', description: 'River-side residential' } }),
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 5 - Hilltop', description: 'Elevated residential subdivision' } }),
]);
console.log(`Areas: ${areas.length}`);
// ─── Plans ─────────────────────────────────────────────
const plans = await Promise.all([
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Lite 15', description: 'Entry-level 15 Mbps', speedDown: 15, speedUp: 15, price: 699, billingCycle: 30 } }),
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Basic 25', description: '25 Mbps residential', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 } }),
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Standard 50', description: '50 Mbps residential', speedDown: 50, speedUp: 50, price: 1499, billingCycle: 30 } }),
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Premium 100', description: '100 Mbps business', speedDown: 100, speedUp: 100, price: 2499, billingCycle: 30 } }),
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Enterprise 200', description: '200 Mbps dedicated', speedDown: 200, speedUp: 200, price: 4999, billingCycle: 30 } }),
]);
console.log(`Plans: ${plans.length}`);
// ─── 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
[14.0820, 121.1800], // Barangay 2 - Poblacion
[14.0850, 121.1700], // Barangay 3 - San Isidro
[14.0750, 121.1720], // Barangay 4 - Riverside
[14.0900, 121.1780], // Barangay 5 - Hilltop
];
const clientDefs = [
{ first: 'Juan', last: 'Dela Cruz', phone: '09171234567', email: 'juan@email.com', address: '123 Rizal St, Centro', area: 0, plan: 1, type: 'postpaid', latOff: 0.001, lngOff: 0.002 },
{ first: 'Maria', last: 'Santos', phone: '09181234567', email: 'maria@email.com', address: '456 Mabini St, Centro', area: 0, plan: 2, type: 'postpaid', latOff: -0.002, lngOff: 0.001 },
{ first: 'Jose', last: 'Garcia', phone: '09191234567', email: 'jose@email.com', address: '789 Bonifacio St, Poblacion', area: 1, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.001 },
{ first: 'Ana', last: 'Reyes', phone: '09201234567', email: 'ana@email.com', address: '12 Luna St, Poblacion', area: 1, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
{ first: 'Pedro', last: 'Aquino', phone: '09211234567', email: null, address: '34 Del Pilar St, San Isidro', area: 2, plan: 0, type: 'prepaid', latOff: 0.002, lngOff: -0.002 },
{ first: 'Rosa', last: 'Mendoza', phone: '09221234567', email: 'rosa@email.com', address: '56 Quezon Ave, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: -0.003, lngOff: 0.001 },
{ first: 'Carlos', last: 'Bautista', phone: '09231234567', email: null, address: '78 Magsaysay Blvd, Riverside', area: 3, plan: 1, type: 'postpaid', latOff: 0.001, lngOff: 0.002 },
{ first: 'Elena', last: 'Villanueva', phone: '09241234567', email: 'elena@email.com', address: '90 Roxas St, Riverside', area: 3, plan: 2, type: 'postpaid', latOff: -0.002, lngOff: -0.003 },
{ first: 'Roberto', last: 'Tan', phone: '09251234567', email: 'roberto@email.com', address: '11 Laurel St, Hilltop', area: 4, plan: 4, type: 'postpaid', latOff: 0.002, lngOff: 0.001 },
{ first: 'Carmen', last: 'Lim', phone: '09261234567', email: 'carmen@email.com', address: '22 Osmena Ave, Hilltop', area: 4, plan: 3, type: 'postpaid', latOff: -0.001, lngOff: 0.002 },
{ first: 'Miguel', last: 'Ramos', phone: '09271234567', email: null, address: '33 Aguinaldo St, Centro', area: 0, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.001 },
{ first: 'Isabel', last: 'Torres', phone: '09281234567', email: 'isabel@email.com', address: '44 Andres Blvd, Poblacion', area: 1, plan: 0, type: 'postpaid', latOff: -0.002, lngOff: 0.003 },
{ 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[] = [];
let invoiceCount = 0;
const now = new Date();
for (let i = 0; i < clientDefs.length; i++) {
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: {
tenantId: tenant.id,
accountNumber,
firstName: c.first,
lastName: c.last,
phone: c.phone,
email: c.email,
address: c.address,
areaId: areas[c.area].id,
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
const activatedAt = new Date(installedAt);
activatedAt.setDate(activatedAt.getDate() + 2);
await prisma.subscription.create({
data: {
tenantId: tenant.id,
clientId: client.id,
planId: plan.id,
type: c.type,
status: 'active',
installedAt,
activatedAt,
startDate: activatedAt,
},
});
// Create resolved installation + activation tickets
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: users.technician.id,
type: 'installation',
title: `Installation for ${c.first} ${c.last}`,
description: `Installation at ${c.address}`,
status: 'resolved',
priority: 'high',
resolvedAt: new Date(installedAt.getTime() + 86400000),
},
});
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: users.technician.id,
type: 'activation',
title: `Activation for ${c.first} ${c.last}`,
status: 'resolved',
priority: 'high',
resolvedAt: activatedAt,
},
});
// Create invoices per client (3-4 per client with varied statuses)
for (let m = 0; m < 4; m++) {
invoiceCount++;
const periodStart = new Date(activatedAt);
periodStart.setMonth(periodStart.getMonth() + m);
const periodEnd = new Date(periodStart);
periodEnd.setDate(periodEnd.getDate() + 30);
const dueDate = new Date(periodStart);
dueDate.setDate(dueDate.getDate() + 15);
// Determine invoice status based on month
let status: string;
let balance: number;
let paidAt: Date | null = null;
const amount = Number(plan.price);
if (m === 0) {
// Month 1: always paid
status = 'paid';
balance = 0;
paidAt = new Date(dueDate.getTime() - 86400000 * 3);
} else if (m === 1) {
// Month 2: overdue (unpaid, past due)
status = 'overdue';
balance = amount;
} else if (m === 2) {
// Month 3: 50% paid → partial
status = 'partial';
balance = Math.round(amount / 2);
} else {
// Month 4: upcoming (due in near future)
const futureDue = new Date(now);
futureDue.setDate(futureDue.getDate() + 3);
status = 'sent';
balance = amount;
dueDate.setTime(futureDue.getTime());
}
const invoice = await prisma.invoice.create({
data: {
tenantId: tenant.id,
clientId: client.id,
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount,
balance,
status,
dueDate,
paidAt,
periodStart,
periodEnd,
},
});
// Create payment(s) for paid/partial invoices
const collector = i % 2 === 0 ? usersByEmail['collector@demo-isp.com'] : usersByEmail['tech@demo-isp.com'];
if (status === 'paid') {
const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
const method = methods[Math.floor(Math.random() * methods.length)];
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
await prisma.payment.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: collector.id,
amount,
method,
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
createdAt: paidDate,
},
});
} else if (status === 'partial') {
const methods = ['gcash', 'cash'];
const method = methods[Math.floor(Math.random() * methods.length)];
const paidDate = new Date(dueDate.getTime() - 86400000 * 2);
await prisma.payment.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: collector.id,
amount: Math.round(amount / 2),
method,
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
createdAt: paidDate,
},
});
}
}
}
}
console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`);
// ─── Support tickets ───────────────────────────────────
const supportTickets = [
{ 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) {
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: clients[t.clientIdx].id,
createdById: users.tenant_admin.id,
type: t.type,
title: t.title,
description: t.desc,
priority: t.priority,
status: t.status,
assigneeId: t.assignee ? users[t.assignee]?.id ?? users.technician.id : null,
},
});
}
console.log(`Support tickets: ${supportTickets.length}`);
// ─── Employees ─────────────────────────────────────────
const empDefs = [
{ first: 'Pedro', last: 'Cruz', position: 'Senior Technician', dept: 'Operations', salary: 18000 },
{ first: 'Jose', last: 'Garcia', position: 'Field Technician', dept: 'Operations', salary: 15000 },
{ first: 'Maria', last: 'Reyes', position: 'Operations Manager', dept: 'Management', salary: 30000 },
{ first: 'Juan', last: 'Santos', position: 'Collection Officer', dept: 'Finance', salary: 16000 },
{ first: 'Ana', last: 'De Leon', position: 'Billing Clerk', dept: 'Finance', salary: 14000 },
{ first: 'Luis', last: 'Mercado', position: 'Network Engineer', dept: 'Technical', salary: 25000 },
{ first: 'Sofia', last: 'Pascual', position: 'Customer Service', dept: 'Support', salary: 14000 },
];
const employees: any[] = [];
for (let i = 0; i < empDefs.length; i++) {
const e = empDefs[i];
const emp = await prisma.employee.create({
data: { tenantId: tenant.id, employeeNo: `E-${String(i + 1).padStart(4, '0')}`, firstName: e.first, lastName: e.last, position: e.position, department: e.dept, salary: e.salary },
});
employees.push(emp);
}
console.log(`Employees: ${employees.length}`);
// ─── Expenses ──────────────────────────────────────────
const expDefs = [
{ cat: 'utilities', desc: 'Electricity bill - March 2026', amount: 12500, status: 'approved', days: -15 },
{ cat: 'utilities', desc: 'Internet backbone ISP bill', amount: 35000, status: 'approved', days: -10 },
{ cat: 'supplies', desc: 'Fiber optic cables (500m)', amount: 8500, status: 'approved', days: -8 },
{ cat: 'maintenance', desc: 'OLT maintenance and cleaning', amount: 3500, status: 'approved', days: -5 },
{ cat: 'transport', desc: 'Fuel for service vehicles', amount: 4200, status: 'approved', days: -3 },
{ cat: 'equipment', desc: '10x Mikrotik hEX S routers', amount: 28000, status: 'approved', days: -2 },
{ cat: 'supplies', desc: 'Office supplies and printer ink', amount: 2100, status: 'pending', days: -1 },
{ cat: 'maintenance', desc: 'Generator repair', amount: 7800, status: 'pending', days: 0 },
{ cat: 'transport', desc: 'Technician transport allowance - April', amount: 6000, status: 'pending', days: 0 },
];
for (const e of expDefs) {
const expDate = new Date();
expDate.setDate(expDate.getDate() + e.days);
await prisma.expense.create({
data: {
tenantId: tenant.id,
createdById: users.manager.id,
approvedById: e.status === 'approved' ? users.tenant_admin.id : null,
category: e.cat,
description: e.desc,
amount: e.amount,
status: e.status,
expenseDate: expDate,
approvedAt: e.status === 'approved' ? expDate : null,
},
});
}
console.log(`Expenses: ${expDefs.length}`);
// ─── Company Accounts ──────────────────────────────────
// Company accounts will be created after CoA so we can link them
console.log('Company accounts: deferred to after CoA');
// ─── Fund Transfers ────────────────────────────────────
// Fund transfers deferred to after company accounts
console.log('Fund transfers: deferred');
// ─── Assets ────────────────────────────────────────────
const assetDefs = [
{ name: 'Huawei MA5608T OLT', cat: 'olt', serial: 'HW-OLT-001', price: 85000, status: 'in_use', loc: 'Main Office' },
{ name: 'Mikrotik CCR1009', cat: 'router', serial: 'MK-CCR-001', price: 32000, status: 'in_use', loc: 'Main Office' },
{ name: 'Mikrotik hEX S #1', cat: 'router', serial: 'MK-HEX-001', price: 2800, status: 'in_use', empIdx: 0 },
{ name: 'Mikrotik hEX S #2', cat: 'router', serial: 'MK-HEX-002', price: 2800, status: 'in_use', empIdx: 1 },
{ name: 'Mikrotik hEX S #3', cat: 'router', serial: 'MK-HEX-003', price: 2800, status: 'available', loc: 'Warehouse' },
{ name: 'OTDR Tester', cat: 'tool', serial: 'OTDR-001', price: 45000, status: 'in_use', empIdx: 0 },
{ name: 'Fiber Splicer', cat: 'tool', serial: 'FS-001', price: 65000, status: 'in_use', empIdx: 5 },
{ name: 'Honda XRM 125 (Field)', cat: 'vehicle', serial: 'MV-2024-001', price: 68000, status: 'in_use', empIdx: 0 },
{ name: 'Honda Wave 110 (Field)', cat: 'vehicle', serial: 'MV-2024-002', price: 55000, status: 'in_use', empIdx: 1 },
{ name: 'Dell Latitude 5540', cat: 'computer', serial: 'DELL-LAP-001', price: 48000, status: 'in_use', empIdx: 2 },
{ name: 'Fiber Cable Spool 1km', cat: 'cable', price: 12000, status: 'available', loc: 'Warehouse' },
{ name: 'Fiber Cable Spool 500m', cat: 'cable', price: 6500, status: 'available', loc: 'Warehouse' },
{ name: 'UPS 1500VA', cat: 'other', serial: 'UPS-001', price: 8500, status: 'in_use', loc: 'Main Office' },
{ name: 'Old Mikrotik RB750', cat: 'router', serial: 'MK-OLD-001', price: 1500, status: 'retired' },
];
for (const a of assetDefs) {
await prisma.asset.create({
data: {
tenantId: tenant.id,
name: a.name,
category: a.cat,
serialNumber: a.serial || null,
purchasePrice: a.price,
status: a.status,
location: a.loc || null,
assignedToId: a.empIdx !== undefined ? employees[a.empIdx].id : null,
},
});
}
console.log(`Assets: ${assetDefs.length}`);
// ─── Billing Settings ──────────────────────────────────
await prisma.billingSetting.create({
data: { tenantId: tenant.id, autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, invoicePrefix: 'INV' },
});
console.log('Billing settings created');
// ─── Chart of Accounts (auto-seeded by API, but seed defaults) ──
const coaDefs = [
{ code: '1000', name: 'Assets', type: 'asset', sys: true },
{ code: '1010', name: 'Cash on Hand', type: 'asset', sys: true },
{ code: '1020', name: 'GCash Business', type: 'asset', sys: true },
{ code: '1030', name: 'Maya Business', type: 'asset', sys: true },
{ code: '1040', name: 'Bank Account', type: 'asset', sys: true },
{ code: '1100', name: 'Accounts Receivable', type: 'asset', sys: true },
{ code: '1200', name: 'Equipment', type: 'asset', sys: true },
{ code: '2000', name: 'Liabilities', type: 'liability', sys: true },
{ code: '2010', name: 'Accounts Payable', type: 'liability', sys: true },
{ code: '3000', name: 'Equity', type: 'equity', sys: true },
{ code: '3010', name: "Owner's Equity", type: 'equity', sys: true },
{ code: '3020', name: 'Retained Earnings', type: 'equity', sys: true },
{ code: '4000', name: 'Revenue', type: 'revenue', sys: true },
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', sys: true },
{ code: '4020', name: 'Installation Fees', type: 'revenue', sys: true },
{ code: '5000', name: 'Expenses', type: 'expense', sys: true },
{ code: '5010', name: 'Utilities Expense', type: 'expense', sys: true },
{ code: '5020', name: 'Salaries Expense', type: 'expense', sys: true },
{ code: '5030', name: 'Maintenance Expense', type: 'expense', sys: true },
{ code: '5040', name: 'Transport Expense', type: 'expense', sys: true },
{ code: '5050', name: 'Supplies Expense', type: 'expense', sys: true },
{ code: '5060', name: 'Equipment Expense', type: 'expense', sys: true },
];
for (const a of coaDefs) {
await prisma.chartOfAccount.create({
data: { tenantId: tenant.id, code: a.code, name: a.name, type: a.type, isSystem: a.sys },
});
}
console.log(`Chart of Accounts: ${coaDefs.length}`);
// ─── Custodial CoA per user ────────────────────────────
const methods = ['Cash', 'GCash', 'Maya', 'Bank'];
let custodialCode = 1500;
for (const u of userDefs) {
const user = users[u.role];
for (const m of methods) {
await prisma.chartOfAccount.create({
data: { tenantId: tenant.id, code: String(custodialCode++), name: `${u.first} ${u.last} - ${m}`, type: 'asset', isSystem: false },
});
}
}
console.log(`Custodial CoA accounts: ${userDefs.length * 4}`);
// ─── Company Accounts (linked to CoA) ──────────────────
const coa1010 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1010' } });
const coa1020 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1020' } });
const coa1030 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1030' } });
const coa1040 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1040' } });
const accts = await Promise.all([
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'Cash on Hand', type: 'cash', balance: 15000, isSystem: true, chartOfAccountId: coa1010?.id } }),
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'GCash Business', type: 'e_wallet', accountNo: '09171234567', balance: 42500, chartOfAccountId: coa1020?.id } }),
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'Maya Business', type: 'e_wallet', accountNo: '09181234567', balance: 18200, chartOfAccountId: coa1030?.id } }),
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'BDO Savings', type: 'bank', accountNo: '0012-3456-7890', balance: 285000, chartOfAccountId: coa1040?.id } }),
]);
console.log(`Company accounts: ${accts.length} (linked to CoA)`);
// ─── Fund Transfers ────────────────────────────────────
await prisma.fundTransfer.create({
data: { tenantId: tenant.id, fromAccountId: accts[1].id, toAccountId: accts[3].id, amount: 20000, description: 'GCash to BDO weekly transfer', transferredBy: users.tenant_admin.id },
});
await prisma.fundTransfer.create({
data: { tenantId: tenant.id, fromAccountId: accts[3].id, toAccountId: accts[0].id, amount: 5000, description: 'Petty cash replenishment', transferredBy: users.tenant_admin.id },
});
console.log('Fund transfers: 2');
// ─── 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' },
});
// Split payments: first 60% → remitted (confirmed), next 20% → remitted (pending), last 20% → unremitted
const confirmedEnd = Math.floor(allPayments.length * 0.6);
const pendingEnd = Math.floor(allPayments.length * 0.8);
const confirmedPayments = allPayments.slice(0, confirmedEnd);
const pendingPayments = allPayments.slice(confirmedEnd, pendingEnd);
// remaining payments (pendingEnd onward) stay unremitted
// Create confirmed remittance
if (confirmedPayments.length > 0) {
const confirmedTotal = confirmedPayments.reduce((s, p) => s + Number(p.amount), 0);
const confirmedRemittance = await prisma.remittance.create({
data: {
tenantId: tenant.id,
collectorId: users.technician.id,
confirmedById: users.tenant_admin.id,
totalAmount: confirmedTotal,
status: 'confirmed',
submittedAt: new Date(Date.now() - 86400000 * 7),
confirmedAt: new Date(Date.now() - 86400000 * 5),
payments: {
create: confirmedPayments.map((p) => ({ paymentId: p.id })),
},
},
});
console.log(`Remittance (confirmed): ₱${confirmedTotal} (${confirmedPayments.length} payments)`);
}
// Create pending remittance
if (pendingPayments.length > 0) {
const pendingTotal = pendingPayments.reduce((s, p) => s + Number(p.amount), 0);
const pendingRemittance = await prisma.remittance.create({
data: {
tenantId: tenant.id,
collectorId: users.technician.id,
totalAmount: pendingTotal,
status: 'pending',
submittedAt: new Date(Date.now() - 86400000 * 2),
payments: {
create: pendingPayments.map((p) => ({ paymentId: p.id })),
},
},
});
console.log(`Remittance (pending): ₱${pendingTotal} (${pendingPayments.length} payments)`);
}
const unremittedCount = allPayments.length - pendingEnd;
console.log(`Unremitted payments: ${unremittedCount} (from invoice loop)`);
// ─── Additional unremitted payments (recent, for testing) ──
const recentPaymentDefs = [
{ clientIdx: 0, amount: 699, method: 'gcash' as const, ref: 'GCASH-44221', daysAgo: 0 },
{ clientIdx: 2, amount: 999, method: 'cash' as const, ref: null, daysAgo: 0 },
{ clientIdx: 5, amount: 1499, method: 'maya' as const, ref: 'MAYA-88312', daysAgo: 1 },
{ clientIdx: 7, amount: 999, method: 'bank_transfer' as const, ref: 'BDO-10293', daysAgo: 1 },
{ clientIdx: 9, amount: 2499, method: 'gcash' as const, ref: 'GCASH-44228', daysAgo: 2 },
{ clientIdx: 10, amount: 699, method: 'cash' as const, ref: null, daysAgo: 2 },
{ clientIdx: 14, amount: 1499, method: 'gcash' as const, ref: 'GCASH-44235', daysAgo: 3 },
{ clientIdx: 3, amount: 999, method: 'maya' as const, ref: 'MAYA-88319', daysAgo: 4 },
];
// Find or create overdue invoices for these clients to attach payments to
for (const rp of recentPaymentDefs) {
const client = clients[rp.clientIdx];
const paidDate = new Date();
paidDate.setDate(paidDate.getDate() - rp.daysAgo);
// Find an existing overdue or partial invoice for this client
let invoice = await prisma.invoice.findFirst({
where: { tenantId: tenant.id, clientId: client.id, status: { in: ['overdue', 'partial'] } },
});
// If no overdue invoice, create one
if (!invoice) {
invoiceCount++;
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() - 5);
invoice = await prisma.invoice.create({
data: {
tenantId: tenant.id,
clientId: client.id,
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount: rp.amount,
balance: rp.amount,
status: 'overdue',
dueDate,
periodStart: new Date(dueDate.getTime() - 30 * 86400000),
periodEnd: dueDate,
},
});
}
await prisma.payment.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: users.collector.id,
amount: rp.amount,
method: rp.method,
referenceNo: rp.ref,
createdAt: paidDate,
},
});
}
console.log(`Recent unremitted payments: ${recentPaymentDefs.length}`);
// ─── Notifications (unread, for testing) ──────────────────
const notifDefs = [
{ userId: users.collector.id, type: 'in_app', channel: 'payment_confirmation', title: 'Payment Recorded', message: 'Payment of ₱699 for Juan Dela Cruz has been recorded.', daysAgo: 0 },
{ userId: users.collector.id, type: 'in_app', channel: 'billing_reminder', title: 'Overdue Reminder', message: '3 invoices are overdue in Barangay 1 - Centro.', daysAgo: 1 },
{ userId: users.collector.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Assigned', message: 'DNS resolution issues ticket has been assigned to you.', daysAgo: 1 },
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'New Ticket', message: 'Fiber cable repair - Poblacion ticket needs attention.', daysAgo: 0 },
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Resolved', message: 'Cannot connect after reboot ticket has been resolved.', daysAgo: 0 },
{ userId: users.manager.id, type: 'in_app', channel: 'billing_reminder', title: 'Weekly Summary', message: '12 payments collected this week totaling ₱14,988.', daysAgo: 2 },
{ userId: users.manager.id, type: 'in_app', channel: 'ticket_update', title: 'Urgent Maintenance', message: 'Node outage - Riverside sector affecting 8 subscribers.', daysAgo: 0 },
{ userId: users.tenant_admin.id, type: 'in_app', channel: 'payment_confirmation', title: 'Remittance Confirmed', message: 'Remittance of ₱5,000 has been confirmed by Admin.', daysAgo: 3 },
];
for (const n of notifDefs) {
const sentAt = new Date();
sentAt.setDate(sentAt.getDate() - n.daysAgo);
await prisma.notification.create({
data: {
tenantId: tenant.id,
userId: n.userId,
type: n.type,
channel: n.channel,
title: n.title,
message: n.message,
isRead: false,
sentAt,
},
});
}
console.log(`Notifications: ${notifDefs.length} (unread)`);
console.log('\n✅ Seed completed successfully!');
console.log(`\n📊 Summary:`);
console.log(` Tenant: ${tenant.name}`);
console.log(` Users: ${userDefs.length}`);
console.log(` Areas: ${areas.length}`);
console.log(` Plans: ${plans.length}`);
console.log(` Clients: ${clients.length} (with active subscriptions)`);
console.log(` Invoices: ${invoiceCount * 2} (paid + unpaid)`);
console.log(` Employees: ${empDefs.length}`);
console.log(` Expenses: ${expDefs.length} (approved + pending)`);
console.log(` Assets: ${assetDefs.length}`);
console.log(` Company Accounts: ${accts.length}`);
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()
.catch((e) => { console.error('Seed failed:', e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });

19
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export { PrismaClient };
export * from '@prisma/client';

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src", "prisma"]
}

View File

@@ -0,0 +1,18 @@
{
"name": "@fiberops/shared",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,16 @@
export { Role, ALL_ROLES, satisfiesRole } from './roles';
export {
Permission, getPermissionsForRoles,
MODULES, MODULE_LABELS, ACTIONS, ACTION_LABELS, MODULE_ACTIONS,
DEFAULT_ROLE_PERMISSIONS,
} from './permissions';
export type { Module, Action, PermissionRow, PermissionString } from './permissions';
export {
SubscriptionStatus,
SubscriptionType,
InvoiceStatus,
TicketType,
TicketStatus,
PaymentMethod,
AccountStatus,
} from './statuses';

View File

@@ -0,0 +1,224 @@
import { Role } from './roles';
/**
* All modules available in the permission matrix.
* Each module can have: view, create, update, archive, approve, export actions.
*/
export const MODULES = [
'dashboard',
'clients',
'subscriptions',
'invoices',
'payments',
'tickets',
'employees',
'payroll',
'expenses',
'assets',
'accounts',
'fund_transfers',
'accounting',
'reports',
'areas',
'plans',
'settings',
'users',
] as const;
export type Module = (typeof MODULES)[number];
export const MODULE_LABELS: Record<Module, string> = {
dashboard: 'Dashboard',
clients: 'Clients',
subscriptions: 'Subscriptions',
invoices: 'Invoices',
payments: 'Payments',
tickets: 'Tickets',
employees: 'Employees',
payroll: 'Payroll',
expenses: 'Expenses',
assets: 'Assets',
accounts: 'Company Accounts',
fund_transfers: 'Fund Transfers',
accounting: 'Accounting',
reports: 'Reports',
areas: 'Areas',
plans: 'Plans',
settings: 'Settings',
users: 'Users',
};
export const ACTIONS = ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'] as const;
export type Action = (typeof ACTIONS)[number];
export const ACTION_LABELS: Record<Action, string> = {
canView: 'View',
canCreate: 'Create',
canUpdate: 'Update',
canArchive: 'Archive',
canApprove: 'Approve',
canExport: 'Export',
};
/**
* Defines which actions are applicable per module.
* Only these checkboxes should be shown/enforced in the matrix.
*/
export const MODULE_ACTIONS: Record<Module, readonly Action[]> = {
dashboard: ['canView'],
clients: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
subscriptions: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
invoices: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'],
payments: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
tickets: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
employees: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
payroll: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
expenses: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'],
assets: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
accounts: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
fund_transfers: ['canView', 'canCreate', 'canApprove', 'canExport'],
accounting: ['canView', 'canExport'],
reports: ['canView', 'canExport'],
areas: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
plans: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
settings: ['canView', 'canUpdate'],
users: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
};
/**
* Permission matrix type — one row per module with boolean actions.
*/
export interface PermissionRow {
module: Module;
canView: boolean;
canCreate: boolean;
canUpdate: boolean;
canArchive: boolean;
canApprove: boolean;
canExport: boolean;
}
/**
* Default permission matrices for system roles seeded per tenant.
*/
function allTrue(modules: readonly Module[], actions: readonly Action[]): PermissionRow[] {
return MODULES.map((mod) => ({
module: mod,
canView: actions.includes('canView') && modules.includes(mod),
canCreate: actions.includes('canCreate') && modules.includes(mod),
canUpdate: actions.includes('canUpdate') && modules.includes(mod),
canArchive: actions.includes('canArchive') && modules.includes(mod),
canApprove: actions.includes('canApprove') && modules.includes(mod),
canExport: actions.includes('canExport') && modules.includes(mod),
}));
}
const ALL_MODULES = [...MODULES] as Module[];
export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionRow[]> = {
tenant_admin: MODULES.map((mod) => ({
module: mod,
canView: true,
canCreate: true,
canUpdate: true,
canArchive: true,
canApprove: true,
canExport: true,
})),
manager: MODULES.map((mod) => {
const noAccess: Module[] = ['users'];
const viewOnly: Module[] = ['dashboard', 'accounting', 'settings'];
if (noAccess.includes(mod)) return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (viewOnly.includes(mod)) return { module: mod, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: mod === 'accounting' };
return {
module: mod,
canView: true,
canCreate: true,
canUpdate: true,
canArchive: true,
canApprove: ['invoices', 'payments', 'expenses', 'payroll', 'fund_transfers'].includes(mod),
canExport: true,
};
}),
technician: MODULES.map((mod) => {
// Technicians can create/update tickets and record payments in the field.
// They can VIEW clients, subscriptions, invoices for context but creating/updating
// those requires manager-level API access (@Roles('manager') on POST/PATCH).
// Assets require manager via class-level @Roles('manager').
// Subscriptions require manager for all endpoints.
const viewOnly: Module[] = ['clients', 'subscriptions', 'invoices', 'dashboard'];
const fullAccess: Module[] = ['tickets', 'payments'];
if (viewOnly.includes(mod)) return { module: mod, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (fullAccess.includes(mod)) return { module: mod, canView: true, canCreate: true, canUpdate: true, canArchive: false, canApprove: false, canExport: false };
return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}),
collector: MODULES.map((mod) => {
const canWrite: Module[] = ['payments'];
const canView: Module[] = ['dashboard', 'clients', 'invoices', 'payments'];
if (!canView.includes(mod)) return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
return {
module: mod,
canView: true,
canCreate: canWrite.includes(mod),
canUpdate: false,
canArchive: false,
canApprove: false,
canExport: false,
};
}),
};
// ─── Legacy support (for super_admin which uses old UserRole system) ───
export const Permission = {
READ_DASHBOARD: 'read:dashboard',
READ_REPORTS: 'read:reports',
READ_CLIENTS: 'read:clients',
WRITE_CLIENTS: 'write:clients',
DELETE_CLIENTS: 'delete:clients',
READ_SUBSCRIPTIONS: 'read:subscriptions',
WRITE_SUBSCRIPTIONS: 'write:subscriptions',
READ_INVOICES: 'read:invoices',
WRITE_INVOICES: 'write:invoices',
VOID_INVOICES: 'void:invoices',
READ_PAYMENTS: 'read:payments',
WRITE_PAYMENTS: 'write:payments',
APPROVE_REMITTANCES: 'approve:remittances',
READ_TICKETS: 'read:tickets',
WRITE_TICKETS: 'write:tickets',
ASSIGN_TICKETS: 'assign:tickets',
READ_EMPLOYEES: 'read:employees',
WRITE_EMPLOYEES: 'write:employees',
READ_PAYROLL: 'read:payroll',
WRITE_PAYROLL: 'write:payroll',
READ_EXPENSES: 'read:expenses',
WRITE_EXPENSES: 'write:expenses',
APPROVE_EXPENSES: 'approve:expenses',
READ_ASSETS: 'read:assets',
WRITE_ASSETS: 'write:assets',
READ_ACCOUNTS: 'read:accounts',
WRITE_ACCOUNTS: 'write:accounts',
TRANSFER_ACCOUNTS: 'transfer:accounts',
READ_ACCOUNTING: 'read:accounting',
WRITE_ACCOUNTING: 'write:accounting',
READ_SETTINGS: 'read:settings',
WRITE_SETTINGS: 'write:settings',
MANAGE_USERS: 'manage:users',
MANAGE_TENANTS: 'manage:tenants',
} as const;
export type PermissionString = (typeof Permission)[keyof typeof Permission];
/**
* Get permissions for super_admin (all permissions).
*/
export function getPermissionsForRoles(roles: string[]): string[] {
if (roles.includes(Role.SUPER_ADMIN)) {
return Object.values(Permission);
}
// For tenant users, permissions come from TenantRole → RolePermission in DB
return [];
}

View File

@@ -0,0 +1,38 @@
export const Role = {
SUPER_ADMIN: 'super_admin',
TENANT_ADMIN: 'tenant_admin',
MANAGER: 'manager',
TECHNICIAN: 'technician',
COLLECTOR: 'collector',
VIEWER: 'viewer',
} as const;
export type Role = (typeof Role)[keyof typeof Role];
export const ALL_ROLES: readonly Role[] = Object.values(Role);
/**
* Numeric hierarchy level per role.
* Higher number = more powerful role.
* Used for hierarchy-aware authorization checks.
*/
const ROLE_LEVEL: Record<string, number> = {
[Role.SUPER_ADMIN]: 100,
[Role.TENANT_ADMIN]: 80,
[Role.MANAGER]: 60,
[Role.TECHNICIAN]: 40,
[Role.COLLECTOR]: 30,
[Role.VIEWER]: 20,
};
/**
* Check if any of the user's roles satisfies the required role level.
* A higher-level role always satisfies a lower-level requirement.
*
* Example: user with ['manager'] satisfies 'technician' because manager(60) >= technician(40).
*/
export function satisfiesRole(userRoles: string[], requiredRole: string): boolean {
const requiredLevel = ROLE_LEVEL[requiredRole];
if (requiredLevel === undefined) return false;
return userRoles.some((r) => (ROLE_LEVEL[r] ?? 0) >= requiredLevel);
}

View File

@@ -0,0 +1,67 @@
export const SubscriptionStatus = {
PENDING: 'pending',
ACTIVE: 'active',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
EXPIRED: 'expired',
} as const;
export type SubscriptionStatus =
(typeof SubscriptionStatus)[keyof typeof SubscriptionStatus];
export const SubscriptionType = {
PREPAID: 'prepaid',
POSTPAID: 'postpaid',
} as const;
export type SubscriptionType =
(typeof SubscriptionType)[keyof typeof SubscriptionType];
export const InvoiceStatus = {
DRAFT: 'draft',
SENT: 'sent',
PARTIAL: 'partial',
PAID: 'paid',
OVERDUE: 'overdue',
VOID: 'void',
} as const;
export type InvoiceStatus =
(typeof InvoiceStatus)[keyof typeof InvoiceStatus];
export const TicketType = {
INSTALLATION: 'installation',
ACTIVATION: 'activation',
SUPPORT: 'support',
MAINTENANCE: 'maintenance',
} as const;
export type TicketType = (typeof TicketType)[keyof typeof TicketType];
export const TicketStatus = {
OPEN: 'open',
IN_PROGRESS: 'in_progress',
RESOLVED: 'resolved',
CANCELLED: 'cancelled',
} as const;
export type TicketStatus = (typeof TicketStatus)[keyof typeof TicketStatus];
export const PaymentMethod = {
GCASH: 'gcash',
MAYA: 'maya',
CASH: 'cash',
BANK_TRANSFER: 'bank_transfer',
} as const;
export type PaymentMethod =
(typeof PaymentMethod)[keyof typeof PaymentMethod];
export const AccountStatus = {
ACTIVE: 'active',
INACTIVE: 'inactive',
SUSPENDED: 'suspended',
} as const;
export type AccountStatus =
(typeof AccountStatus)[keyof typeof AccountStatus];

View File

@@ -0,0 +1,3 @@
export * from './constants';
export * from './schemas';
export * from './types';

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export const registerTenantSchema = z.object({
tenantName: z.string().min(2, 'Tenant name must be at least 2 characters'),
slug: z
.string()
.min(2)
.max(50)
.regex(
/^[a-z0-9-]+$/,
'Slug must contain only lowercase letters, numbers, and hyphens',
),
adminEmail: z.string().email('Invalid email address'),
adminPassword: z.string().min(8, 'Password must be at least 8 characters'),
adminFirstName: z.string().min(1, 'First name is required'),
adminLastName: z.string().min(1, 'Last name is required'),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterTenantInput = z.infer<typeof registerTenantSchema>;

View File

@@ -0,0 +1,5 @@
export { loginSchema, registerTenantSchema } from './auth';
export type { LoginInput, RegisterTenantInput } from './auth';
export { createUserSchema, updateUserSchema } from './user';
export type { CreateUserInput, UpdateUserInput } from './user';

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
import { Role } from '../constants/roles';
const roleEnum = z.enum([
Role.SUPER_ADMIN,
Role.TENANT_ADMIN,
Role.MANAGER,
Role.TECHNICIAN,
Role.VIEWER,
]);
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
roles: z.array(roleEnum).min(1, 'At least one role is required'),
});
export const updateUserSchema = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
roles: z.array(roleEnum).min(1).optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

View File

@@ -0,0 +1,36 @@
export interface ApiResponse<T = unknown> {
success: boolean;
data: T | null;
error: string | null;
meta?: PaginationMeta;
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PaginationQuery {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface JwtPayload {
sub: string;
tenantId?: string | null;
roles: string[];
permissions: string[];
iat?: number;
exp?: number;
}
export interface TokenResponse {
accessToken: string;
refreshToken: string;
mustChangePassword?: boolean;
}

View File

@@ -0,0 +1,7 @@
export type {
ApiResponse,
PaginationMeta,
PaginationQuery,
JwtPayload,
TokenResponse,
} from './api';

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,47 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AccountService } from './account.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
import { TransferFundsDto } from './dto/transfer-funds.dto';
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('accounts')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('tenant_admin')
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserPayload) {
return this.accountService.findAll(user.tenantId);
}
@Post()
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
return this.accountService.create(user.tenantId, dto);
}
@Patch(':id')
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateAccountDto) {
return this.accountService.update(user.tenantId, id, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.accountService.remove(user.tenantId, id);
}
@Post('transfer')
async transfer(@CurrentUser() user: CurrentUserPayload, @Body() dto: TransferFundsDto) {
return this.accountService.transfer(user.tenantId, user.sub, dto);
}
@Get('transfers')
async getTransfers(@CurrentUser() user: CurrentUserPayload) {
return this.accountService.getTransfers(user.tenantId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { AccountingModule } from '../accounting/accounting.module';
@Module({
imports: [AccountingModule],
controllers: [AccountController],
providers: [AccountService],
exports: [AccountService],
})
export class AccountModule {}

View File

@@ -0,0 +1,137 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { JournalService } from '../accounting/journal.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
import { TransferFundsDto } from './dto/transfer-funds.dto';
@Injectable()
export class AccountService {
constructor(
private readonly prisma: PrismaService,
private readonly journal: JournalService,
) {}
async findAll(tenantId: string) {
return this.prisma.companyAccount.findMany({
where: { tenantId },
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
});
}
async create(tenantId: string, dto: CreateAccountDto) {
// Auto-generate a CoA code for this account
const existingCoa = await this.prisma.chartOfAccount.findMany({
where: { tenantId, type: 'asset', code: { startsWith: '10' } },
orderBy: { code: 'desc' },
take: 1,
});
const lastCode = existingCoa[0]?.code || '1040';
const nextCode = String(parseInt(lastCode) + 10);
// Create CoA entry first
const coa = await this.prisma.chartOfAccount.create({
data: { tenantId, code: nextCode, name: dto.name, type: 'asset', isSystem: false },
});
// Create company account linked to CoA
return this.prisma.companyAccount.create({
data: {
tenantId,
name: dto.name,
type: dto.type,
accountNo: dto.accountNo,
balance: dto.initialBalance || 0,
chartOfAccountId: coa.id,
},
});
}
async update(tenantId: string, id: string, dto: UpdateAccountDto) {
const account = await this.prisma.companyAccount.findFirst({ where: { id, tenantId } });
if (!account) throw new NotFoundException('Account not found');
if (account.isSystem && dto.name && dto.name !== account.name) {
throw new ForbiddenException('Cannot rename system account');
}
const updated = await this.prisma.companyAccount.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.accountNo !== undefined && { accountNo: dto.accountNo }),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
// Sync CoA name if changed
if (dto.name && account.chartOfAccountId) {
await this.prisma.chartOfAccount.update({
where: { id: account.chartOfAccountId },
data: { name: dto.name },
}).catch(() => {});
}
return updated;
}
async remove(tenantId: string, id: string) {
const account = await this.prisma.companyAccount.findFirst({ where: { id, tenantId } });
if (!account) throw new NotFoundException('Account not found');
if (account.isSystem) throw new ForbiddenException('Cannot delete system account (Cash on Hand)');
if (Number(account.balance) > 0) throw new BadRequestException('Transfer funds out before deleting');
// Delete linked CoA if exists
if (account.chartOfAccountId) {
await this.prisma.chartOfAccount.delete({ where: { id: account.chartOfAccountId } }).catch(() => {});
}
await this.prisma.companyAccount.delete({ where: { id } });
return { deleted: true };
}
async transfer(tenantId: string, transferredBy: string, dto: TransferFundsDto) {
const from = await this.prisma.companyAccount.findFirst({ where: { id: dto.fromAccountId, tenantId } });
const to = await this.prisma.companyAccount.findFirst({ where: { id: dto.toAccountId, tenantId } });
if (!from || !to) throw new NotFoundException('Account not found');
if (from.id === to.id) throw new BadRequestException('Cannot transfer to same account');
if (Number(from.balance) < dto.amount) throw new BadRequestException('Insufficient balance');
const [transfer] = await this.prisma.$transaction([
this.prisma.fundTransfer.create({
data: { tenantId, fromAccountId: dto.fromAccountId, toAccountId: dto.toAccountId, amount: dto.amount, description: dto.description, transferredBy },
}),
this.prisma.companyAccount.update({ where: { id: from.id }, data: { balance: { decrement: dto.amount } } }),
this.prisma.companyAccount.update({ where: { id: to.id }, data: { balance: { increment: dto.amount } } }),
]);
// Journal entry for fund transfer: DR destination CoA, CR source CoA
if (from.chartOfAccountId && to.chartOfAccountId) {
const fromCoa = await this.prisma.chartOfAccount.findUnique({ where: { id: from.chartOfAccountId } });
const toCoa = await this.prisma.chartOfAccount.findUnique({ where: { id: to.chartOfAccountId } });
if (fromCoa && toCoa) {
this.journal.createEntry(
tenantId,
`Fund transfer: ${from.name}${to.name}${dto.description ? `${dto.description}` : ''}`,
[
{ accountCode: toCoa.code, debit: dto.amount },
{ accountCode: fromCoa.code, credit: dto.amount },
],
{ reference: `TRF-${transfer.id.slice(0, 8)}`, sourceType: 'transfer', sourceId: transfer.id },
).catch(() => {});
}
}
return transfer;
}
async getTransfers(tenantId: string) {
return this.prisma.fundTransfer.findMany({
where: { tenantId },
include: {
fromAccount: { select: { name: true, type: true } },
toAccount: { select: { name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
});
}
}

View File

@@ -0,0 +1,8 @@
import { IsString, MinLength, IsOptional, IsNumber, IsIn } from 'class-validator';
export class CreateAccountDto {
@IsString() @MinLength(2) name: string;
@IsString() @IsIn(['bank', 'e_wallet', 'cash']) type: string;
@IsOptional() @IsString() accountNo?: string;
@IsOptional() @IsNumber() initialBalance?: number;
}

View File

@@ -0,0 +1,8 @@
import { IsString, IsNumber, IsPositive, IsOptional, IsUUID } from 'class-validator';
export class TransferFundsDto {
@IsUUID() fromAccountId: string;
@IsUUID() toAccountId: string;
@IsNumber() @IsPositive() amount: number;
@IsOptional() @IsString() description?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, IsOptional, IsBoolean, MinLength } from 'class-validator';
export class UpdateAccountDto {
@IsOptional() @IsString() @MinLength(2) name?: string;
@IsOptional() @IsString() accountNo?: string;
@IsOptional() @IsBoolean() isActive?: boolean;
}

View File

@@ -0,0 +1,45 @@
import { Controller, Get, Post, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AccountingService } from './accounting.service';
import { CreateAccountDto } from './dto/create-coa.dto';
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('accounting')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('tenant_admin')
export class AccountingController {
constructor(private readonly accountingService: AccountingService) {}
@Get('chart-of-accounts')
async getCoA(@CurrentUser() user: CurrentUserPayload) {
return this.accountingService.getChartOfAccounts(user.tenantId);
}
@Post('chart-of-accounts')
async createAccount(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
return this.accountingService.createAccount(user.tenantId, dto);
}
@Delete('chart-of-accounts/:id')
async deleteAccount(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.accountingService.deleteAccount(user.tenantId, id);
}
@Get('general-ledger')
async getLedger(@CurrentUser() user: CurrentUserPayload, @Query('accountId') accountId?: string) {
return this.accountingService.getGeneralLedger(user.tenantId, accountId);
}
@Get('trial-balance')
async getTrialBalance(@CurrentUser() user: CurrentUserPayload) {
return this.accountingService.getTrialBalance(user.tenantId);
}
@Get('overview')
async getOverview(@CurrentUser() user: CurrentUserPayload) {
return this.accountingService.getAccountingOverview(user.tenantId);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AccountingController } from './accounting.controller';
import { AccountingService } from './accounting.service';
import { JournalService } from './journal.service';
@Module({
controllers: [AccountingController],
providers: [AccountingService, JournalService],
exports: [AccountingService, JournalService],
})
export class AccountingModule {}

View File

@@ -0,0 +1,209 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAccountDto } from './dto/create-coa.dto';
// Default Chart of Accounts for new tenants
const DEFAULT_COA = [
{ code: '1000', name: 'Assets', type: 'asset', isSystem: true },
{ code: '1010', name: 'Cash on Hand', type: 'asset', isSystem: true },
{ code: '1020', name: 'GCash Business', type: 'asset', isSystem: true },
{ code: '1030', name: 'Maya Business', type: 'asset', isSystem: true },
{ code: '1040', name: 'Bank Account', type: 'asset', isSystem: true },
{ code: '1100', name: 'Accounts Receivable', type: 'asset', isSystem: true },
{ code: '1200', name: 'Equipment', type: 'asset', isSystem: true },
{ code: '2000', name: 'Liabilities', type: 'liability', isSystem: true },
{ code: '2010', name: 'Accounts Payable', type: 'liability', isSystem: true },
{ code: '3000', name: 'Equity', type: 'equity', isSystem: true },
{ code: '3010', name: 'Owner\'s Equity', type: 'equity', isSystem: true },
{ code: '3020', name: 'Retained Earnings', type: 'equity', isSystem: true },
{ code: '4000', name: 'Revenue', type: 'revenue', isSystem: true },
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', isSystem: true },
{ code: '4020', name: 'Installation Fees', type: 'revenue', isSystem: true },
{ code: '5000', name: 'Expenses', type: 'expense', isSystem: true },
{ code: '5010', name: 'Utilities Expense', type: 'expense', isSystem: true },
{ code: '5020', name: 'Salaries Expense', type: 'expense', isSystem: true },
{ code: '5030', name: 'Maintenance Expense', type: 'expense', isSystem: true },
{ code: '5040', name: 'Transport Expense', type: 'expense', isSystem: true },
{ code: '5050', name: 'Supplies Expense', type: 'expense', isSystem: true },
{ code: '5060', name: 'Equipment Expense', type: 'expense', isSystem: true },
];
@Injectable()
export class AccountingService {
constructor(private readonly prisma: PrismaService) {}
async getChartOfAccounts(tenantId: string) {
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
orderBy: { code: 'asc' },
});
// Auto-seed if empty
if (accounts.length === 0) {
await this.seedDefaultAccounts(tenantId);
return this.prisma.chartOfAccount.findMany({ where: { tenantId }, orderBy: { code: 'asc' } });
}
return accounts;
}
async seedDefaultAccounts(tenantId: string) {
for (const acc of DEFAULT_COA) {
await this.prisma.chartOfAccount.upsert({
where: { tenantId_code: { tenantId, code: acc.code } },
create: { tenantId, ...acc },
update: {},
});
}
}
async createAccount(tenantId: string, dto: CreateAccountDto) {
const existing = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, code: dto.code },
});
if (existing) throw new ConflictException('Account code already exists');
return this.prisma.chartOfAccount.create({
data: { tenantId, code: dto.code, name: dto.name, type: dto.type, parentId: dto.parentId },
});
}
async deleteAccount(tenantId: string, id: string) {
const account = await this.prisma.chartOfAccount.findFirst({ where: { id, tenantId } });
if (!account) throw new NotFoundException('Account not found');
if (account.isSystem) throw new BadRequestException('Cannot delete system account');
const hasEntries = await this.prisma.journalLine.count({ where: { accountId: id } });
if (hasEntries > 0) throw new BadRequestException('Cannot delete account with journal entries');
await this.prisma.chartOfAccount.delete({ where: { id } });
return { deleted: true };
}
async getGeneralLedger(tenantId: string, accountId?: string) {
const where: any = {};
if (accountId) {
where.accountId = accountId;
} else {
where.journalEntry = { tenantId };
}
return this.prisma.journalLine.findMany({
where,
include: {
account: { select: { code: true, name: true, type: true } },
journalEntry: { select: { entryDate: true, description: true, reference: true } },
},
orderBy: { journalEntry: { entryDate: 'desc' } },
take: 100,
});
}
async getTrialBalance(tenantId: string) {
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
include: {
journalLines: { select: { debit: true, credit: true } },
},
orderBy: { code: 'asc' },
});
return accounts.map((a) => {
const totalDebit = a.journalLines.reduce((s, l) => s + Number(l.debit), 0);
const totalCredit = a.journalLines.reduce((s, l) => s + Number(l.credit), 0);
return {
id: a.id,
code: a.code,
name: a.name,
type: a.type,
debit: totalDebit,
credit: totalCredit,
balance: totalDebit - totalCredit,
};
}).filter((a) => a.debit > 0 || a.credit > 0);
}
async getAccountingOverview(tenantId: string) {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
// All accounts with their full journal lines
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
include: {
journalLines: {
select: { debit: true, credit: true, journalEntry: { select: { entryDate: true } } },
},
},
orderBy: { code: 'asc' },
});
// Compute balances by type (from all journal entries)
const byType: Record<string, { debit: number; credit: number; balance: number }> = {};
const cashAccounts: { code: string; name: string; balance: number }[] = [];
for (const acc of accounts) {
const totalDebit = acc.journalLines.reduce((s, l) => s + Number(l.debit), 0);
const totalCredit = acc.journalLines.reduce((s, l) => s + Number(l.credit), 0);
const balance = totalDebit - totalCredit;
if (!byType[acc.type]) byType[acc.type] = { debit: 0, credit: 0, balance: 0 };
byType[acc.type].debit += totalDebit;
byType[acc.type].credit += totalCredit;
byType[acc.type].balance += balance;
// Track individual cash accounts (codes 1010-1040)
if (['1010', '1020', '1030', '1040'].includes(acc.code)) {
cashAccounts.push({ code: acc.code, name: acc.name, balance });
}
}
// Monthly income (revenue credits this month)
const monthlyRevenue = accounts
.filter((a) => a.type === 'revenue')
.reduce((sum, acc) => {
const monthCredits = acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.credit), 0);
return sum + monthCredits;
}, 0);
// Monthly expenses (expense debits this month)
const monthlyExpenses = accounts
.filter((a) => a.type === 'expense')
.reduce((sum, acc) => {
const monthDebits = acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.debit), 0);
return sum + monthDebits;
}, 0);
// Expense breakdown by category this month
const expenseBreakdown = accounts
.filter((a) => a.type === 'expense')
.map((acc) => ({
code: acc.code,
name: acc.name,
amount: acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.debit), 0),
}))
.filter((e) => e.amount > 0)
.sort((a, b) => b.amount - a.amount);
return {
totalAssets: byType['asset']?.balance || 0,
totalLiabilities: byType['liability']?.balance || 0,
totalEquity: byType['equity']?.balance || 0,
totalRevenue: byType['revenue']?.credit || 0,
totalExpenses: byType['expense']?.debit || 0,
accountsReceivable: accounts.find((a) => a.code === '1100')?.journalLines.reduce((s, l) => s + Number(l.debit) - Number(l.credit), 0) || 0,
cashAccounts,
cashOnHand: cashAccounts.reduce((s, a) => s + a.balance, 0),
monthlyRevenue,
monthlyExpenses,
netIncome: monthlyRevenue - monthlyExpenses,
expenseBreakdown,
};
}
}

View File

@@ -0,0 +1,8 @@
import { IsString, MinLength, IsOptional, IsIn, IsUUID } from 'class-validator';
export class CreateAccountDto {
@IsString() @MinLength(3) code: string;
@IsString() @MinLength(2) name: string;
@IsString() @IsIn(['asset', 'liability', 'equity', 'revenue', 'expense']) type: string;
@IsOptional() @IsUUID() parentId?: string;
}

View File

@@ -0,0 +1,259 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
interface JournalLineInput {
accountCode: string;
debit?: number;
credit?: number;
}
// Maps payment method to CoA code suffix
const METHOD_SUFFIX: Record<string, string> = {
cash: 'Cash',
gcash: 'GCash',
maya: 'Maya',
bank_transfer: 'Bank',
};
const METHOD_LABEL: Record<string, string> = {
cash: 'Cash',
gcash: 'GCash',
maya: 'Maya',
bank_transfer: 'Bank Transfer',
};
// Company-level CoA codes per method
const COMPANY_COA: Record<string, string> = {
cash: '1010',
gcash: '1020',
maya: '1030',
bank_transfer: '1040',
};
@Injectable()
export class JournalService {
private readonly logger = new Logger(JournalService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Creates a journal entry with debit/credit lines.
*/
async createEntry(
tenantId: string,
description: string,
lines: JournalLineInput[],
options?: { reference?: string; sourceType?: string; sourceId?: string; createdById?: string },
) {
const totalDebit = lines.reduce((s, l) => s + (l.debit || 0), 0);
const totalCredit = lines.reduce((s, l) => s + (l.credit || 0), 0);
if (Math.abs(totalDebit - totalCredit) > 0.01) {
this.logger.warn(`Unbalanced entry: DR ${totalDebit} != CR ${totalCredit}${description}`);
return null;
}
const resolvedLines = [];
for (const line of lines) {
const account = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, code: line.accountCode },
});
if (!account) {
this.logger.warn(`CoA ${line.accountCode} not found for tenant ${tenantId}`);
return null;
}
resolvedLines.push({ accountId: account.id, debit: line.debit || 0, credit: line.credit || 0 });
}
return this.prisma.journalEntry.create({
data: {
tenantId,
description,
reference: options?.reference,
sourceType: options?.sourceType,
sourceId: options?.sourceId,
createdById: options?.createdById,
lines: { create: resolvedLines },
},
include: { lines: true },
});
}
/**
* Create custodial CoA accounts for a user.
* Pattern: "UserName - Cash" with code 15XX where XX = sequential.
*/
async createCustodialAccounts(tenantId: string, userId: string, userName: string) {
// Find next available code block in 15XX range
const existing = await this.prisma.chartOfAccount.findMany({
where: { tenantId, code: { startsWith: '15' } },
orderBy: { code: 'desc' },
take: 1,
});
const baseCode = existing[0] ? parseInt(existing[0].code) + 10 : 1500;
const methods = ['Cash', 'GCash', 'Maya', 'Bank'];
const created = [];
for (let i = 0; i < methods.length; i++) {
const code = String(baseCode + i);
const name = `${userName} - ${methods[i]}`;
const account = await this.prisma.chartOfAccount.upsert({
where: { tenantId_code: { tenantId, code } },
create: { tenantId, code, name, type: 'asset', isSystem: false },
update: {},
});
created.push(account);
}
return created;
}
/**
* Get the custodial CoA code for a user + method.
* Looks up "UserName - Cash/GCash/Maya/Bank" in the CoA.
*/
async getCustodialCode(tenantId: string, collectorId: string, method: string): Promise<string | null> {
const user = await this.prisma.user.findUnique({ where: { id: collectorId } });
if (!user) return null;
const suffix = METHOD_SUFFIX[method] || 'Cash';
const searchName = `${user.firstName} ${user.lastName} - ${suffix}`;
const account = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, name: searchName },
});
return account?.code || null;
}
/**
* Payment collected → DR collector's custodial CoA, CR Accounts Receivable.
* Money sits in collector's custody until remittance is approved.
*/
async journalForPayment(
tenantId: string,
paymentId: string,
amount: number,
method: string,
context: { invoiceNumber?: string; clientName?: string; collectorId?: string; collectorName?: string },
) {
let debitCode: string;
if (context.collectorId) {
let custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method);
if (!custodialCode && context.collectorName) {
await this.createCustodialAccounts(tenantId, context.collectorId, context.collectorName);
custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method);
}
debitCode = custodialCode || COMPANY_COA[method] || '1010';
} else {
debitCode = COMPANY_COA[method] || '1010';
}
const description = [
`Payment collected via ${METHOD_LABEL[method] || method}`,
context.collectorName ? `by ${context.collectorName}` : '',
context.clientName ? `from ${context.clientName}` : '',
context.invoiceNumber ? `for ${context.invoiceNumber}` : '',
].filter(Boolean).join(' ');
return this.createEntry(tenantId, description, [
{ accountCode: debitCode, debit: amount },
{ accountCode: '1100', credit: amount },
], { reference: context.invoiceNumber, sourceType: 'payment', sourceId: paymentId });
}
/**
* Remittance approved → DR company CoA, CR collector's custodial CoA.
* Clears money from collector custody into company books.
*/
async journalForRemittance(
tenantId: string,
remittanceId: string,
collectorId: string,
collectorName: string,
paymentsByMethod: { method: string; total: number }[],
) {
const lines: JournalLineInput[] = [];
for (const pm of paymentsByMethod) {
let custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method);
// Auto-create custodial accounts if they don't exist yet
if (!custodialCode) {
this.logger.warn(`Custodial account missing for ${collectorName} (${pm.method}) — auto-creating`);
await this.createCustodialAccounts(tenantId, collectorId, collectorName);
custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method);
}
const companyCode = COMPANY_COA[pm.method] || '1010';
if (custodialCode) {
lines.push({ accountCode: companyCode, debit: pm.total });
lines.push({ accountCode: custodialCode, credit: pm.total });
} else {
this.logger.error(`Still no custodial account for ${collectorName} (${pm.method}) after auto-create`);
}
}
if (lines.length === 0) {
this.logger.warn(`No journal lines for remittance ${remittanceId} — skipping`);
return null;
}
const totalAmount = paymentsByMethod.reduce((s, p) => s + p.total, 0);
return this.createEntry(
tenantId,
`Remittance approved — ${collectorName} cleared PHP ${totalAmount.toLocaleString()}`,
lines,
{ reference: `REM-${remittanceId.slice(0, 8)}`, sourceType: 'remittance', sourceId: remittanceId },
);
}
/** Invoice issued → DR Accounts Receivable (1100), CR Service Revenue (4010) */
async journalForInvoice(tenantId: string, invoiceId: string, invoiceNumber: string, amount: number) {
return this.createEntry(tenantId, `Invoice ${invoiceNumber} issued`, [
{ accountCode: '1100', debit: amount },
{ accountCode: '4010', credit: amount },
], { reference: invoiceNumber, sourceType: 'invoice', sourceId: invoiceId });
}
/**
* Update CompanyAccount.balance when a journal entry affects cash accounts.
* Called after remittance confirmation (increment) and expense approval (decrement).
*/
async updateCompanyAccountBalance(
tenantId: string,
coaCode: string,
amount: number,
direction: 'increment' | 'decrement',
) {
const coa = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, code: coaCode },
});
if (!coa) return;
const account = await this.prisma.companyAccount.findFirst({
where: { tenantId, chartOfAccountId: coa.id, isActive: true },
});
if (!account) return;
await this.prisma.companyAccount.update({
where: { id: account.id },
data: { balance: { [direction]: amount } },
});
}
/** Expense approved → DR Expense Category, CR Cash (1010) */
async journalForExpense(tenantId: string, expenseId: string, amount: number, category: string) {
const expenseAccountCode: Record<string, string> = {
utilities: '5010', salary: '5020', maintenance: '5030',
transport: '5040', supplies: '5050', equipment: '5060', other: '5000',
};
return this.createEntry(tenantId, `Expense: ${category}`, [
{ accountCode: expenseAccountCode[category] || '5000', debit: amount },
{ accountCode: '1010', credit: amount },
], { sourceType: 'expense', sourceId: expenseId });
}
}

85
src/app.module.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { TenantModule } from './tenant/tenant.module';
import { AreaModule } from './area/area.module';
import { PlanModule } from './plan/plan.module';
import { ClientModule } from './client/client.module';
import { TicketModule } from './ticket/ticket.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { InvoiceModule } from './invoice/invoice.module';
import { PaymentModule } from './payment/payment.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ReportModule } from './report/report.module';
import { NotificationModule } from './notification/notification.module';
import { AuditModule } from './audit/audit.module';
import { EmployeeModule } from './employee/employee.module';
import { ExpenseModule } from './expense/expense.module';
import { AccountModule } from './account/account.module';
import { AssetModule } from './asset/asset.module';
import { PortalModule } from './portal/portal.module';
import { BillingModule } from './billing/billing.module';
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';
import { AccessGuard } from './common/guards/access.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../../.env',
}),
ThrottlerModule.forRoot([
{ name: 'short', ttl: 1000, limit: 50 }, // 50 req/sec
{ name: 'medium', ttl: 60000, limit: 500 }, // 500 req/min
]),
PrismaModule,
HealthModule,
AuthModule,
UserModule,
TenantModule,
AreaModule,
PlanModule,
ClientModule,
TicketModule,
SubscriptionModule,
InvoiceModule,
PaymentModule,
DashboardModule,
ReportModule,
NotificationModule,
AuditModule,
EmployeeModule,
ExpenseModule,
AccountModule,
AssetModule,
PortalModule,
BillingModule,
SchedulerModule,
AccountingModule,
PayrollModule,
RoleModule,
CommentModule,
ImportModule,
],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: AccessGuard },
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
],
})
export class AppModule {}

View File

@@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AreaService } from './area.service';
import { CreateAreaDto } from './dto/create-area.dto';
import { UpdateAreaDto } from './dto/update-area.dto';
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('areas')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('manager')
export class AreaController {
constructor(private readonly areaService: AreaService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserPayload) {
return this.areaService.findAll(user.tenantId);
}
@Get(':id')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.areaService.findById(user.tenantId, id);
}
@Post()
async create(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateAreaDto,
) {
return this.areaService.create(user.tenantId, dto);
}
@Patch(':id')
async update(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
@Body() dto: UpdateAreaDto,
) {
return this.areaService.update(user.tenantId, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.areaService.remove(user.tenantId, id);
}
}

10
src/area/area.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AreaController } from './area.controller';
import { AreaService } from './area.service';
@Module({
controllers: [AreaController],
providers: [AreaService],
exports: [AreaService],
})
export class AreaModule {}

105
src/area/area.service.ts Normal file
View File

@@ -0,0 +1,105 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAreaDto } from './dto/create-area.dto';
import { UpdateAreaDto } from './dto/update-area.dto';
@Injectable()
export class AreaService {
constructor(private readonly prisma: PrismaService) {}
async findAll(tenantId: string) {
const db = this.prisma.forTenant(tenantId);
return db.area.findMany({
orderBy: { name: 'asc' },
include: {
_count: { select: { clients: true } },
},
});
}
async findById(tenantId: string, id: string) {
const db = this.prisma.forTenant(tenantId);
const area = await db.area.findFirst({
where: { id },
include: {
_count: { select: { clients: true } },
},
});
if (!area) {
throw new NotFoundException('Area not found');
}
return area;
}
async create(tenantId: string, dto: CreateAreaDto) {
const existing = await this.prisma.area.findFirst({
where: { tenantId, name: dto.name },
});
if (existing) {
throw new ConflictException('Area name already exists');
}
return this.prisma.area.create({
data: {
tenantId,
name: dto.name,
description: dto.description,
},
});
}
async update(tenantId: string, id: string, dto: UpdateAreaDto) {
const db = this.prisma.forTenant(tenantId);
const existing = await db.area.findFirst({ where: { id } });
if (!existing) {
throw new NotFoundException('Area not found');
}
if (dto.name && dto.name !== existing.name) {
const duplicate = await this.prisma.area.findFirst({
where: { tenantId, name: dto.name },
});
if (duplicate) {
throw new ConflictException('Area name already exists');
}
}
return this.prisma.area.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
}
async remove(tenantId: string, id: string) {
const db = this.prisma.forTenant(tenantId);
const existing = await db.area.findFirst({
where: { id },
include: { _count: { select: { clients: true } } },
});
if (!existing) {
throw new NotFoundException('Area not found');
}
if (existing._count.clients > 0) {
throw new ConflictException(
'Cannot delete area with assigned clients. Deactivate it instead.',
);
}
await this.prisma.area.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, MinLength, IsOptional } from 'class-validator';
export class CreateAreaDto {
@IsString()
@MinLength(2)
name: string;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,16 @@
import { IsString, MinLength, IsOptional, IsBoolean } from 'class-validator';
export class UpdateAreaDto {
@IsOptional()
@IsString()
@MinLength(2)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Post, Patch, Param, Body, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AssetService } from './asset.service';
import { CreateAssetDto } from './dto/create-asset.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
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('assets')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('manager')
export class AssetController {
constructor(private readonly assetService: AssetService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserPayload, @Query('category') category?: string, @Query('status') status?: string) {
return this.assetService.findAll(user.tenantId, { category, status });
}
@Post()
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAssetDto) {
return this.assetService.create(user.tenantId, dto);
}
@Patch(':id')
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateAssetDto) {
return this.assetService.update(user.tenantId, id, dto);
}
}

10
src/asset/asset.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AssetController } from './asset.controller';
import { AssetService } from './asset.service';
@Module({
controllers: [AssetController],
providers: [AssetService],
exports: [AssetService],
})
export class AssetModule {}

View File

@@ -0,0 +1,53 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAssetDto } from './dto/create-asset.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
@Injectable()
export class AssetService {
constructor(private readonly prisma: PrismaService) {}
async findAll(tenantId: string, filters?: { category?: string; status?: string }) {
return this.prisma.asset.findMany({
where: {
tenantId,
...(filters?.category && { category: filters.category }),
...(filters?.status && { status: filters.status }),
},
include: { assignedTo: { select: { id: true, firstName: true, lastName: true, employeeNo: true } } },
orderBy: { createdAt: 'desc' },
});
}
async create(tenantId: string, dto: CreateAssetDto) {
return this.prisma.asset.create({
data: {
tenantId,
name: dto.name,
category: dto.category,
serialNumber: dto.serialNumber,
purchaseDate: dto.purchaseDate ? new Date(dto.purchaseDate) : null,
purchasePrice: dto.purchasePrice || null,
location: dto.location,
notes: dto.notes,
},
});
}
async update(tenantId: string, id: string, dto: UpdateAssetDto) {
const existing = await this.prisma.asset.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException('Asset not found');
return this.prisma.asset.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.status && { status: dto.status }),
...(dto.assignedToId !== undefined && { assignedToId: dto.assignedToId || null }),
...(dto.location !== undefined && { location: dto.location }),
...(dto.notes !== undefined && { notes: dto.notes }),
},
include: { assignedTo: { select: { id: true, firstName: true, lastName: true } } },
});
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, MinLength, IsOptional, IsNumber, IsIn } from 'class-validator';
export class CreateAssetDto {
@IsString() @MinLength(2) name: string;
@IsString() @IsIn(['router', 'olt', 'cable', 'tool', 'vehicle', 'computer', 'other']) category: string;
@IsOptional() @IsString() serialNumber?: string;
@IsOptional() @IsString() purchaseDate?: string;
@IsOptional() @IsNumber() purchasePrice?: number;
@IsOptional() @IsString() location?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,9 @@
import { IsString, IsOptional, IsIn, IsUUID } from 'class-validator';
export class UpdateAssetDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() @IsIn(['available', 'in_use', 'maintenance', 'retired']) status?: string;
@IsOptional() @IsUUID() assignedToId?: string;
@IsOptional() @IsString() location?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface AuditEntry {
tenantId: string;
userId: string;
action: string;
entity: string;
entityId: string;
details?: Record<string, unknown>;
ipAddress?: string;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(entry: AuditEntry) {
return this.prisma.$queryRawUnsafe(
`INSERT INTO audit_logs (id, "tenantId", "userId", action, entity, "entityId", details, "ipAddress", "createdAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6::jsonb, $7, NOW())`,
entry.tenantId,
entry.userId,
entry.action,
entry.entity,
entry.entityId,
JSON.stringify(entry.details || {}),
entry.ipAddress || null,
);
}
async findByTenant(tenantId: string, limit = 50) {
return this.prisma.$queryRawUnsafe(
`SELECT * FROM audit_logs WHERE "tenantId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
tenantId,
limit,
);
}
}

View File

@@ -0,0 +1,62 @@
import {
Controller,
Post,
Get,
Body,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterTenantDto } from './dto/register-tenant.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
@Throttle({ short: { ttl: 60000, limit: 50 } }) // 50 login attempts per minute per IP
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('register')
@Throttle({ short: { ttl: 60000, limit: 3 } }) // 3 registrations per minute
async register(@Body() dto: RegisterTenantDto) {
return this.authService.registerTenant(dto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshTokens(dto.refreshToken);
}
@Post('logout')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
async logout(@Req() req: any) {
await this.authService.logout(req.user.sub);
return { message: 'Logged out successfully' };
}
@Get('profile')
@UseGuards(AuthGuard('jwt'))
async profile(@Req() req: any) {
return this.authService.getProfile(req.user.sub);
}
@Post('change-password')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
return this.authService.changePassword(req.user.sub, dto.currentPassword, dto.newPassword);
}
}

26
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_EXPIRES_IN', '15m') as any,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

267
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,267 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { JwtPayload, TokenResponse, getPermissionsForRoles } from '@fiberops/shared';
import { LoginDto } from './dto/login.dto';
import { RegisterTenantDto } from './dto/register-tenant.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async login(dto: LoginDto): Promise<TokenResponse> {
const user = await this.prisma.user.findFirst({
where: { email: dto.email, isActive: true },
include: { roles: true, tenant: true },
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Super admin may not have a tenant; regular users must have active tenant
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordValid = await bcrypt.compare(dto.password, user.password);
if (!passwordValid) {
throw new UnauthorizedException('Invalid credentials');
}
const roles = user.roles.map((r) => r.role);
const tokens = await this.generateTokens({
sub: user.id,
tenantId: user.tenantId,
roles,
permissions: getPermissionsForRoles(roles),
});
return {
...tokens,
mustChangePassword: user.mustChangePassword,
};
}
async registerTenant(dto: RegisterTenantDto) {
const existing = await this.prisma.tenant.findUnique({
where: { slug: dto.slug },
});
if (existing) {
throw new ConflictException('Tenant slug already taken');
}
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
const tenant = await this.prisma.tenant.create({
data: {
name: dto.tenantName,
slug: dto.slug,
settings: {
companyName: dto.tenantName,
currency: 'PHP',
timezone: 'Asia/Manila',
},
users: {
create: {
email: dto.adminEmail,
password: hashedPassword,
firstName: dto.adminFirstName,
lastName: dto.adminLastName,
roles: {
create: { role: 'tenant_admin' },
},
},
},
},
include: {
users: {
include: { roles: true },
},
},
});
const admin = tenant.users[0]!;
const roles = admin.roles.map((r) => r.role);
const tokens = await this.generateTokens({
sub: admin.id,
tenantId: tenant.id,
roles,
permissions: getPermissionsForRoles(roles),
});
return {
tenant: {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
},
user: {
id: admin.id,
email: admin.email,
firstName: admin.firstName,
lastName: admin.lastName,
roles,
},
...tokens,
};
}
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
const stored = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
});
if (!stored || stored.expiresAt < new Date()) {
if (stored) {
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
}
throw new UnauthorizedException('Invalid or expired refresh token');
}
// Delete the used refresh token (rotation)
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
const user = await this.prisma.user.findUnique({
where: { id: stored.userId },
include: { roles: true, tenant: true },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('Account is inactive');
}
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
throw new UnauthorizedException('Account is inactive');
}
const roles = user.roles.map((r) => r.role);
return this.generateTokens({
sub: user.id,
tenantId: user.tenantId,
roles,
permissions: getPermissionsForRoles(roles),
});
}
async logout(userId: string): Promise<void> {
await this.prisma.refreshToken.deleteMany({
where: { userId },
});
}
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: true,
tenant: { select: { id: true, name: true, slug: true, settings: true } },
tenantRoles: {
include: {
tenantRole: {
include: { permissions: true },
},
},
},
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
const roles = user.roles.map((r) => r.role);
const isSuperAdmin = roles.includes('super_admin');
// Resolve permission matrix from tenant roles
const accessMap: Record<string, Record<string, boolean>> = {};
if (!isSuperAdmin) {
for (const utr of user.tenantRoles) {
if (!utr.tenantRole.isActive || utr.tenantRole.deletedAt) continue;
for (const p of utr.tenantRole.permissions) {
if (!accessMap[p.module]) {
accessMap[p.module] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}
if (p.canView) accessMap[p.module].canView = true;
if (p.canCreate) accessMap[p.module].canCreate = true;
if (p.canUpdate) accessMap[p.module].canUpdate = true;
if (p.canArchive) accessMap[p.module].canArchive = true;
if (p.canApprove) accessMap[p.module].canApprove = true;
if (p.canExport) accessMap[p.module].canExport = true;
}
}
}
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
mustChangePassword: user.mustChangePassword,
roles,
permissions: getPermissionsForRoles(roles),
accessMap: isSuperAdmin ? 'all' : accessMap,
tenantRoles: user.tenantRoles.map((tr) => ({
id: tr.tenantRole.id,
name: tr.tenantRole.name,
slug: tr.tenantRole.slug,
})),
tenant: user.tenant,
};
}
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException('User not found');
const valid = await bcrypt.compare(currentPassword, user.password);
if (!valid) throw new UnauthorizedException('Current password is incorrect');
const hashed = await bcrypt.hash(newPassword, 12);
await this.prisma.user.update({
where: { id: userId },
data: { password: hashed, mustChangePassword: false },
});
return { success: true };
}
private async generateTokens(payload: JwtPayload): Promise<TokenResponse> {
const jti = crypto.randomUUID();
const accessToken = this.jwt.sign({ ...payload, jti });
const refreshJti = crypto.randomUUID();
const refreshToken = this.jwt.sign({ ...payload, jti: refreshJti }, {
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d') as any,
});
const expiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
const expiresAt = new Date();
const days = parseInt(expiresIn) || 7;
expiresAt.setDate(expiresAt.getDate() + days);
await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId: payload.sub,
expiresAt,
},
});
return { accessToken, refreshToken };
}
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
currentPassword: string;
@IsString()
@MinLength(8)
newPassword: string;
}

10
src/auth/dto/login.dto.ts Normal file
View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}

View File

@@ -0,0 +1,30 @@
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class RegisterTenantDto {
@IsString()
@MinLength(2)
tenantName: string;
@IsString()
@MinLength(2)
@MaxLength(50)
@Matches(/^[a-z0-9-]+$/, {
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
})
slug: string;
@IsEmail()
adminEmail: string;
@IsString()
@MinLength(8)
adminPassword: string;
@IsString()
@MinLength(1)
adminFirstName: string;
@IsString()
@MinLength(1)
adminLastName: string;
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '@fiberops/shared';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') || 'fallback',
});
}
validate(payload: JwtPayload) {
return {
sub: payload.sub,
tenantId: payload.tenantId || null,
roles: payload.roles || [],
permissions: payload.permissions || [],
};
}
}

View File

@@ -0,0 +1,25 @@
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { BillingService } from './billing.service';
import { UpdateBillingSettingsDto } from './dto/update-billing-settings.dto';
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('billing-settings')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('tenant_admin')
export class BillingController {
constructor(private readonly billingService: BillingService) {}
@Get()
async getSettings(@CurrentUser() user: CurrentUserPayload) {
return this.billingService.getSettings(user.tenantId);
}
@Patch()
async updateSettings(@CurrentUser() user: CurrentUserPayload, @Body() dto: UpdateBillingSettingsDto) {
return this.billingService.updateSettings(user.tenantId, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateBillingSettingsDto } from './dto/update-billing-settings.dto';
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
async getSettings(tenantId: string) {
let settings = await this.prisma.billingSetting.findUnique({ where: { tenantId } });
if (!settings) {
settings = await this.prisma.billingSetting.create({ data: { tenantId } });
}
return settings;
}
async updateSettings(tenantId: string, dto: UpdateBillingSettingsDto) {
await this.prisma.billingSetting.upsert({
where: { tenantId },
create: { tenantId, ...dto },
update: dto,
});
return this.getSettings(tenantId);
}
}

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min, Max } from 'class-validator';
export class UpdateBillingSettingsDto {
@IsOptional() @IsBoolean() autoGenerate?: boolean;
@IsOptional() @IsInt() @Min(0) @Max(90) gracePeriodDays?: number;
@IsOptional() @IsInt() @Min(1) @Max(60) dueDateOffsetDays?: number;
@IsOptional() @IsNumber() @Min(0) @Max(100) lateFeePercent?: number;
@IsOptional() @IsString() invoicePrefix?: string;
}

View File

@@ -0,0 +1,65 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ClientService } from './client.service';
import { CreateClientDto } from './dto/create-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
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('clients')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class ClientController {
constructor(private readonly clientService: ClientService) {}
@Get()
@Roles('technician', 'collector')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('areaId') areaId?: string,
@Query('status') status?: string,
@Query('search') search?: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.clientService.findAll(user.tenantId, { areaId, status, search, page, limit });
}
@Get(':id')
@Roles('technician', 'collector')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.clientService.findById(user.tenantId, id);
}
@Post()
@Roles('manager')
async create(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateClientDto,
) {
return this.clientService.create(user.tenantId, user.sub, dto);
}
@Patch(':id')
@Roles('manager', 'technician', 'collector')
async update(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
@Body() dto: UpdateClientDto,
) {
return this.clientService.update(user.tenantId, id, dto);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ClientController } from './client.controller';
import { ClientService } from './client.service';
import { TicketModule } from '../ticket/ticket.module';
@Module({
imports: [TicketModule],
controllers: [ClientController],
providers: [ClientService],
exports: [ClientService],
})
export class ClientModule {}

View File

@@ -0,0 +1,164 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TicketService } from '../ticket/ticket.service';
import { CreateClientDto } from './dto/create-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
import { paginationArgs, paginatedResult } from '../common/dto/pagination.dto';
@Injectable()
export class ClientService {
constructor(
private readonly prisma: PrismaService,
private readonly ticketService: TicketService,
) {}
async findAll(tenantId: string, filters?: { areaId?: string; status?: string; search?: string; page?: number; limit?: number }) {
const { skip, take, page, limit } = paginationArgs({ page: filters?.page, limit: filters?.limit });
const db = this.prisma.forTenant(tenantId);
const where = {
...(filters?.areaId && { areaId: filters.areaId }),
...(filters?.status && { status: filters.status }),
...(filters?.search && {
OR: [
{ firstName: { contains: filters.search, mode: 'insensitive' as any } },
{ lastName: { contains: filters.search, mode: 'insensitive' as any } },
{ accountNumber: { contains: filters.search, mode: 'insensitive' as any } },
{ email: { contains: filters.search, mode: 'insensitive' as any } },
],
}),
};
const [items, total] = await Promise.all([
db.client.findMany({
where,
skip,
take,
include: {
area: { select: { id: true, name: true } },
_count: { select: { subscriptions: true, tickets: true } },
},
orderBy: { createdAt: 'desc' },
}),
db.client.count({ where }),
]);
return paginatedResult(items, total, page, limit);
}
async findById(tenantId: string, id: string) {
const db = this.prisma.forTenant(tenantId);
const client = await db.client.findFirst({
where: { id },
include: {
area: { select: { id: true, name: true } },
subscriptions: {
include: { plan: { select: { id: true, name: true, price: true, speedDown: true, speedUp: true } } },
orderBy: { createdAt: 'desc' },
},
tickets: {
orderBy: { createdAt: 'desc' },
take: 10,
include: {
assignee: { select: { id: true, firstName: true, lastName: true } },
},
},
},
});
if (!client) {
throw new NotFoundException('Client not found');
}
return client;
}
async create(tenantId: string, createdById: string, dto: CreateClientDto) {
// Generate account number
const count = await this.prisma.client.count({ where: { tenantId } });
const accountNumber = `C-${String(count + 1).padStart(6, '0')}`;
// Check unique account number
const existingAccount = await this.prisma.client.findFirst({
where: { tenantId, accountNumber },
});
if (existingAccount) {
throw new ConflictException('Account number conflict, please retry');
}
const client = await this.prisma.client.create({
data: {
tenantId,
accountNumber,
firstName: dto.firstName,
lastName: dto.lastName,
email: dto.email,
phone: dto.phone,
address: dto.address,
areaId: dto.areaId,
},
include: {
area: { select: { id: true, name: true } },
},
});
// Validate plan exists
const plan = await this.prisma.plan.findFirst({
where: { id: dto.planId, tenantId, isActive: true },
});
if (!plan) {
throw new NotFoundException('Plan not found or inactive');
}
// Auto-create subscription (onboarding = client + plan + type)
await this.prisma.subscription.create({
data: {
tenantId,
clientId: client.id,
planId: dto.planId,
type: dto.subscriptionType,
status: 'pending',
},
});
// Auto-create INSTALLATION ticket → kicks off the workflow
// Postpaid: install → activation ticket → activate + invoice
// Prepaid: install → invoice → payment → activation ticket → activate + next invoice
await this.ticketService.createSystemTicket(tenantId, createdById, {
clientId: client.id,
type: 'installation',
title: `Installation for ${client.firstName} ${client.lastName}`,
description: `New client installation at ${client.address}. Plan: ${plan.name} (${dto.subscriptionType})`,
});
return client;
}
async update(tenantId: string, id: string, dto: UpdateClientDto) {
const db = this.prisma.forTenant(tenantId);
const existing = await db.client.findFirst({ where: { id } });
if (!existing) {
throw new NotFoundException('Client not found');
}
return this.prisma.client.update({
where: { id },
data: {
...(dto.firstName && { firstName: dto.firstName }),
...(dto.lastName && { lastName: dto.lastName }),
...(dto.email !== undefined && { email: dto.email }),
...(dto.phone !== undefined && { phone: dto.phone }),
...(dto.address && { address: dto.address }),
...(dto.areaId !== undefined && { areaId: dto.areaId }),
...(dto.latitude !== undefined && { latitude: dto.latitude }),
...(dto.longitude !== undefined && { longitude: dto.longitude }),
...(dto.status && { status: dto.status }),
},
include: {
area: { select: { id: true, name: true } },
},
});
}
}

View File

@@ -0,0 +1,34 @@
import { IsString, MinLength, IsOptional, IsEmail, IsUUID, IsIn } from 'class-validator';
export class CreateClientDto {
@IsString()
@MinLength(1)
firstName: string;
@IsString()
@MinLength(1)
lastName: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsString()
@MinLength(5)
address: string;
@IsOptional()
@IsUUID()
areaId?: string;
@IsUUID()
planId: string;
@IsString()
@IsIn(['prepaid', 'postpaid'])
subscriptionType: string;
}

View File

@@ -0,0 +1,47 @@
import { IsString, MinLength, IsOptional, IsEmail, IsUUID, IsIn, IsNumber, Min, Max } from 'class-validator';
export class UpdateClientDto {
@IsOptional()
@IsString()
@MinLength(1)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
lastName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
@MinLength(5)
address?: string;
@IsOptional()
@IsUUID()
areaId?: string;
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@IsOptional()
@IsString()
@IsIn(['active', 'inactive', 'suspended'])
status?: 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,15 @@
import { SetMetadata } from '@nestjs/common';
export interface AccessRequirement {
module: string;
action: 'canView' | 'canCreate' | 'canUpdate' | 'canArchive' | 'canApprove' | 'canExport';
}
export const ACCESS_KEY = 'access_requirement';
/**
* Require the user's tenant role to grant a specific module+action.
* Example: @RequireAccess('clients', 'canCreate')
*/
export const RequireAccess = (module: string, action: AccessRequirement['action']) =>
SetMetadata(ACCESS_KEY, { module, action } as AccessRequirement);

View File

@@ -0,0 +1,20 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserPayload {
sub: string;
tenantId: string;
roles: string[];
permissions: string[];
}
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserPayload;
// For tenantId, prefer request.tenantId which TenantGuard may have set (e.g., super_admin x-tenant-id header)
if (data === 'tenantId') {
return request.tenantId || user?.tenantId;
}
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '@fiberops/shared';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,55 @@
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
export interface PaginatedResult<T> {
items: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export function paginationArgs(dto: PaginationDto) {
const page = dto.page || 1;
const limit = dto.limit || 20;
return {
skip: (page - 1) * limit,
take: limit,
page,
limit,
};
}
export function paginatedResult<T>(
items: T[],
total: number,
page: number,
limit: number,
): PaginatedResult<T> {
return {
items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}

View File

@@ -0,0 +1,39 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiResponse } from '@fiberops/shared';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as { message?: string }).message || message;
} else {
console.error('[GlobalExceptionFilter] Unhandled exception:', exception);
}
const body: ApiResponse = {
success: false,
data: null,
error: Array.isArray(message) ? message.join(', ') : message,
};
response.status(status).json(body);
}
}

View File

@@ -0,0 +1,53 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ACCESS_KEY, AccessRequirement } from '../decorators/access.decorator';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AccessGuard implements CanActivate {
constructor(
private reflector: Reflector,
private prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requirement = this.reflector.getAllAndOverride<AccessRequirement>(ACCESS_KEY, [
context.getHandler(),
context.getClass(),
]);
// No @RequireAccess decorator → allow
if (!requirement) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) return false;
// Super admin bypasses all access checks
if (user.roles?.includes('super_admin')) return true;
// Resolve user's tenant role permissions from DB
const assignments = await this.prisma.userTenantRole.findMany({
where: { userId: user.sub },
include: {
tenantRole: {
include: { permissions: true },
},
},
});
// Check if any assigned role grants the required module+action
for (const a of assignments) {
if (!a.tenantRole.isActive || a.tenantRole.deletedAt) continue;
for (const p of a.tenantRole.permissions) {
if (p.module === requirement.module && p[requirement.action]) {
return true;
}
}
}
throw new ForbiddenException(
`Access denied: requires ${requirement.action} on ${requirement.module}`,
);
}
}

View File

@@ -0,0 +1,34 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) return false;
// Super admin bypasses all permission checks
if (user.roles?.includes('super_admin')) return true;
const userPerms: string[] = user.permissions || [];
const missing = required.filter((p) => !userPerms.includes(p));
if (missing.length > 0) {
throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`);
}
return true;
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role, satisfiesRole } from '@fiberops/shared';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.roles) {
return false;
}
// Hierarchy-aware check: user with 'manager' satisfies 'technician' requirement
return requiredRoles.some((role) => satisfiesRole(user.roles, role));
}
}

View File

@@ -0,0 +1,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('Authentication required');
}
// Super admin: can switch tenant context via x-tenant-id header
const isSuperAdmin = user.roles?.includes('super_admin');
if (isSuperAdmin) {
const headerTenantId = request.headers['x-tenant-id'];
// Use header tenant if provided, otherwise fall back to user's own tenant (if any)
request.tenantId = headerTenantId || user.tenantId || null;
return true;
}
// Regular users must have a tenantId from JWT
if (!user.tenantId) {
throw new ForbiddenException('Tenant context required');
}
request.tenantId = user.tenantId;
return true;
}
}

View File

@@ -0,0 +1,24 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { ApiResponse } from '@fiberops/shared';
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
error: null,
})),
);
}
}

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,20 @@
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown) {
try {
return this.schema.parse(value);
} catch (error) {
if (error instanceof ZodError) {
const messages = error.errors.map(
(e) => `${e.path.join('.')}: ${e.message}`,
);
throw new BadRequestException(messages.join('; '));
}
throw new BadRequestException('Validation failed');
}
}
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { DashboardService } from './dashboard.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('dashboard')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get('kpis')
@Roles('manager')
async getKpis(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getKpis(user.tenantId);
}
@Get('revenue-chart')
@Roles('manager')
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getRevenueChart(user.tenantId);
}
@Get('activity')
@Roles('manager')
async getActivity(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getRecentActivity(user.tenantId);
}
@Get('financial-summary')
@Roles('manager')
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getFinancialSummary(user.tenantId);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

Some files were not shown because too many files have changed in this diff Show More