From 5382f3b4e5b06caa3c4bf1c8886b4bb3f98bf1a3 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 13 Apr 2026 09:37:07 +0800 Subject: [PATCH 1/4] initial: standalone repo from monorepo split --- .dockerignore | 5 + .env.example | 4 + .gitignore | 8 + Dockerfile | 33 + nest-cli.json | 8 + package.json | 47 ++ packages/admin-db/package.json | 28 + .../20260405090605_init/migration.sql | 135 ++++ .../prisma/migrations/migration_lock.toml | 3 + packages/admin-db/prisma/schema.prisma | 133 ++++ packages/admin-db/prisma/seed.ts | 29 + packages/admin-db/src/index.ts | 1 + packages/admin-db/tsconfig.json | 8 + packages/db/package.json | 28 + .../20260403015002_init/migration.sql | 361 +++++++++ .../migration.sql | 23 + .../migration.sql | 135 ++++ .../migration.sql | 90 +++ .../migration.sql | 3 + .../migration.sql | 14 + .../migration.sql | 183 +++++ .../migration.sql | 3 + .../db/prisma/migrations/migration_lock.toml | 3 + packages/db/prisma/schema.prisma | 682 ++++++++++++++++++ packages/db/prisma/seed.ts | 603 ++++++++++++++++ packages/db/src/index.ts | 19 + packages/db/tsconfig.json | 8 + packages/shared/package.json | 18 + packages/shared/src/constants/index.ts | 16 + packages/shared/src/constants/permissions.ts | 224 ++++++ packages/shared/src/constants/roles.ts | 36 + packages/shared/src/constants/statuses.ts | 67 ++ packages/shared/src/index.ts | 3 + packages/shared/src/schemas/auth.ts | 25 + packages/shared/src/schemas/index.ts | 5 + packages/shared/src/schemas/user.ts | 27 + packages/shared/src/types/api.ts | 36 + packages/shared/src/types/index.ts | 7 + packages/shared/tsconfig.json | 8 + src/app.module.ts | 43 ++ src/audit/audit.controller.ts | 29 + src/audit/audit.module.ts | 11 + src/audit/audit.service.ts | 80 ++ src/auth/auth.controller.ts | 62 ++ src/auth/auth.module.ts | 26 + src/auth/auth.service.ts | 155 ++++ src/auth/dto/login.dto.ts | 9 + src/auth/dto/refresh-token.dto.ts | 6 + src/auth/strategies/admin-jwt.strategy.ts | 28 + .../decorators/current-admin.decorator.ts | 9 + src/common/decorators/public.decorator.ts | 4 + src/common/filters/http-exception.filter.ts | 36 + src/common/guards/admin-auth.guard.ts | 60 ++ .../interceptors/response.interceptor.ts | 20 + src/dashboard/dashboard.controller.ts | 17 + src/dashboard/dashboard.module.ts | 9 + src/dashboard/dashboard.service.ts | 94 +++ src/health/health.controller.ts | 15 + src/health/health.module.ts | 7 + src/impersonate/impersonate.controller.ts | 23 + src/impersonate/impersonate.module.ts | 12 + src/impersonate/impersonate.service.ts | 80 ++ src/main.ts | 45 ++ src/prisma/admin-prisma.module.ts | 9 + src/prisma/admin-prisma.service.ts | 16 + src/prisma/tenant-prisma.module.ts | 9 + src/prisma/tenant-prisma.service.ts | 16 + .../public-support.controller.ts | 159 ++++ src/public-support/public-support.module.ts | 13 + src/support/dto/comment.dto.ts | 6 + src/support/dto/create-ticket.dto.ts | 17 + src/support/dto/list-query.dto.ts | 40 + src/support/dto/update-ticket.dto.ts | 19 + src/support/multer-options.ts | 40 + src/support/support.controller.ts | 119 +++ src/support/support.module.ts | 12 + src/support/support.service.ts | 240 ++++++ src/tenants/dto/create-tenant.dto.ts | 25 + src/tenants/dto/list-query.dto.ts | 24 + src/tenants/dto/update-tenant.dto.ts | 19 + src/tenants/tenants.controller.ts | 59 ++ src/tenants/tenants.module.ts | 9 + src/tenants/tenants.service.ts | 323 +++++++++ src/users/users.controller.ts | 35 + src/users/users.module.ts | 9 + src/users/users.service.ts | 91 +++ tsconfig.json | 20 + 87 files changed, 5278 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 nest-cli.json create mode 100644 package.json create mode 100644 packages/admin-db/package.json create mode 100644 packages/admin-db/prisma/migrations/20260405090605_init/migration.sql create mode 100644 packages/admin-db/prisma/migrations/migration_lock.toml create mode 100644 packages/admin-db/prisma/schema.prisma create mode 100644 packages/admin-db/prisma/seed.ts create mode 100644 packages/admin-db/src/index.ts create mode 100644 packages/admin-db/tsconfig.json create mode 100644 packages/db/package.json create mode 100644 packages/db/prisma/migrations/20260403015002_init/migration.sql create mode 100644 packages/db/prisma/migrations/20260403082101_add_audit_logs/migration.sql create mode 100644 packages/db/prisma/migrations/20260403122737_add_operations_accounting/migration.sql create mode 100644 packages/db/prisma/migrations/20260403132431_add_billing_accounting/migration.sql create mode 100644 packages/db/prisma/migrations/20260403223747_company_account_coa_link/migration.sql create mode 100644 packages/db/prisma/migrations/20260403233912_add_remittance_payments/migration.sql create mode 100644 packages/db/prisma/migrations/20260406000000_sync_schema_drift/migration.sql create mode 100644 packages/db/prisma/migrations/20260406120000_add_client_coordinates/migration.sql create mode 100644 packages/db/prisma/migrations/migration_lock.toml create mode 100644 packages/db/prisma/schema.prisma create mode 100644 packages/db/prisma/seed.ts create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants/index.ts create mode 100644 packages/shared/src/constants/permissions.ts create mode 100644 packages/shared/src/constants/roles.ts create mode 100644 packages/shared/src/constants/statuses.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas/auth.ts create mode 100644 packages/shared/src/schemas/index.ts create mode 100644 packages/shared/src/schemas/user.ts create mode 100644 packages/shared/src/types/api.ts create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 src/app.module.ts create mode 100644 src/audit/audit.controller.ts create mode 100644 src/audit/audit.module.ts create mode 100644 src/audit/audit.service.ts create mode 100644 src/auth/auth.controller.ts create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/auth.service.ts create mode 100644 src/auth/dto/login.dto.ts create mode 100644 src/auth/dto/refresh-token.dto.ts create mode 100644 src/auth/strategies/admin-jwt.strategy.ts create mode 100644 src/common/decorators/current-admin.decorator.ts create mode 100644 src/common/decorators/public.decorator.ts create mode 100644 src/common/filters/http-exception.filter.ts create mode 100644 src/common/guards/admin-auth.guard.ts create mode 100644 src/common/interceptors/response.interceptor.ts create mode 100644 src/dashboard/dashboard.controller.ts create mode 100644 src/dashboard/dashboard.module.ts create mode 100644 src/dashboard/dashboard.service.ts create mode 100644 src/health/health.controller.ts create mode 100644 src/health/health.module.ts create mode 100644 src/impersonate/impersonate.controller.ts create mode 100644 src/impersonate/impersonate.module.ts create mode 100644 src/impersonate/impersonate.service.ts create mode 100644 src/main.ts create mode 100644 src/prisma/admin-prisma.module.ts create mode 100644 src/prisma/admin-prisma.service.ts create mode 100644 src/prisma/tenant-prisma.module.ts create mode 100644 src/prisma/tenant-prisma.service.ts create mode 100644 src/public-support/public-support.controller.ts create mode 100644 src/public-support/public-support.module.ts create mode 100644 src/support/dto/comment.dto.ts create mode 100644 src/support/dto/create-ticket.dto.ts create mode 100644 src/support/dto/list-query.dto.ts create mode 100644 src/support/dto/update-ticket.dto.ts create mode 100644 src/support/multer-options.ts create mode 100644 src/support/support.controller.ts create mode 100644 src/support/support.module.ts create mode 100644 src/support/support.service.ts create mode 100644 src/tenants/dto/create-tenant.dto.ts create mode 100644 src/tenants/dto/list-query.dto.ts create mode 100644 src/tenants/dto/update-tenant.dto.ts create mode 100644 src/tenants/tenants.controller.ts create mode 100644 src/tenants/tenants.module.ts create mode 100644 src/tenants/tenants.service.ts create mode 100644 src/users/users.controller.ts create mode 100644 src/users/users.module.ts create mode 100644 src/users/users.service.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd5dc63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.env +*.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a52ce55 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DATABASE_ADMIN_URL=postgresql://postgres:postgres@localhost:5433/fiberops_admin +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fiberops +JWT_SECRET=your-admin-secret-key +PORT=3004 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20836cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +.env.local +.env.*.local +*.tsbuildinfo +generated/ +packages/admin-db/generated/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e88b8ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/db/package.json ./packages/db/ +COPY packages/admin-db/package.json ./packages/admin-db/ +RUN npm install +COPY packages/shared/ ./packages/shared/ +COPY packages/db/ ./packages/db/ +COPY packages/admin-db/ ./packages/admin-db/ +COPY . . +RUN cd packages/shared && npx tsc --module commonjs --moduleResolution node --outDir dist --declaration +RUN cd packages/admin-db && DATABASE_ADMIN_URL="postgresql://placeholder" npx prisma generate +RUN cd packages/db && DATABASE_URL="postgresql://placeholder" npx prisma generate +RUN npx 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/packages/admin-db/generated ./packages/admin-db/generated +COPY --from=builder /app/packages/admin-db/prisma ./packages/admin-db/prisma +COPY --from=builder /app/packages/admin-db/package.json ./packages/admin-db/ +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/dist ./dist +ENV NODE_ENV=production +EXPOSE 3004 +ENTRYPOINT ["dumb-init", "--"] +CMD ["sh", "-c", "cd packages/admin-db && npx prisma migrate deploy && cd /app && node dist/main.js"] diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9bc3c2d --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "fiberops-api-admin", + "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/admin-db && npx prisma generate", + "db:migrate": "cd packages/admin-db && npx prisma migrate dev", + "db:migrate:deploy": "cd packages/admin-db && npx prisma migrate deploy", + "db:seed": "cd packages/admin-db && npx prisma db seed", + "db:studio": "cd packages/admin-db && npx prisma studio" + }, + "dependencies": { + "@fiberops/admin-db": "workspace:*", + "@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/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" + }, + "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": "^2.1.0", + "@types/passport-jwt": "^4.0.1", + "typescript": "^5.7.0", + "vitest": "^3.1.0" + } +} diff --git a/packages/admin-db/package.json b/packages/admin-db/package.json new file mode 100644 index 0000000..7ecbb60 --- /dev/null +++ b/packages/admin-db/package.json @@ -0,0 +1,28 @@ +{ + "name": "@fiberops/admin-db", + "version": "0.1.0", + "private": true, + "main": "./generated/index.js", + "types": "./generated/index.d.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" + } +} diff --git a/packages/admin-db/prisma/migrations/20260405090605_init/migration.sql b/packages/admin-db/prisma/migrations/20260405090605_init/migration.sql new file mode 100644 index 0000000..ef30ce3 --- /dev/null +++ b/packages/admin-db/prisma/migrations/20260405090605_init/migration.sql @@ -0,0 +1,135 @@ +-- CreateTable +CREATE TABLE "super_admins" ( + "id" 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 "super_admins_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "admin_refresh_tokens" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "adminId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "admin_refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "platform_audit_logs" ( + "id" TEXT NOT NULL, + "adminId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "target" TEXT, + "details" JSONB NOT NULL DEFAULT '{}', + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "platform_audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "support_tickets" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "tenantName" TEXT NOT NULL, + "tenantSlug" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "createdByName" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "description" TEXT NOT NULL, + "category" TEXT NOT NULL DEFAULT 'general', + "priority" TEXT NOT NULL DEFAULT 'normal', + "status" TEXT NOT NULL DEFAULT 'open', + "assignedToId" TEXT, + "resolvedAt" TIMESTAMP(3), + "closedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "support_tickets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "support_ticket_comments" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "authorName" TEXT NOT NULL, + "authorType" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "support_ticket_comments_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "super_admins_email_key" ON "super_admins"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "admin_refresh_tokens_token_key" ON "admin_refresh_tokens"("token"); + +-- CreateIndex +CREATE INDEX "admin_refresh_tokens_adminId_idx" ON "admin_refresh_tokens"("adminId"); + +-- CreateIndex +CREATE INDEX "admin_refresh_tokens_expiresAt_idx" ON "admin_refresh_tokens"("expiresAt"); + +-- CreateIndex +CREATE INDEX "platform_audit_logs_adminId_idx" ON "platform_audit_logs"("adminId"); + +-- CreateIndex +CREATE INDEX "platform_audit_logs_action_idx" ON "platform_audit_logs"("action"); + +-- CreateIndex +CREATE INDEX "platform_audit_logs_target_idx" ON "platform_audit_logs"("target"); + +-- CreateIndex +CREATE INDEX "platform_audit_logs_createdAt_idx" ON "platform_audit_logs"("createdAt"); + +-- CreateIndex +CREATE INDEX "support_tickets_tenantId_idx" ON "support_tickets"("tenantId"); + +-- CreateIndex +CREATE INDEX "support_tickets_status_idx" ON "support_tickets"("status"); + +-- CreateIndex +CREATE INDEX "support_tickets_category_idx" ON "support_tickets"("category"); + +-- CreateIndex +CREATE INDEX "support_tickets_priority_idx" ON "support_tickets"("priority"); + +-- CreateIndex +CREATE INDEX "support_tickets_assignedToId_idx" ON "support_tickets"("assignedToId"); + +-- CreateIndex +CREATE INDEX "support_tickets_createdAt_idx" ON "support_tickets"("createdAt"); + +-- CreateIndex +CREATE INDEX "support_ticket_comments_ticketId_idx" ON "support_ticket_comments"("ticketId"); + +-- CreateIndex +CREATE INDEX "support_ticket_comments_authorId_idx" ON "support_ticket_comments"("authorId"); + +-- AddForeignKey +ALTER TABLE "admin_refresh_tokens" ADD CONSTRAINT "admin_refresh_tokens_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "super_admins"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "platform_audit_logs" ADD CONSTRAINT "platform_audit_logs_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "super_admins"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "support_tickets" ADD CONSTRAINT "support_tickets_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "super_admins"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "support_ticket_comments" ADD CONSTRAINT "support_ticket_comments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "support_tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "support_ticket_comments" ADD CONSTRAINT "support_ticket_comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "super_admins"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/admin-db/prisma/migrations/migration_lock.toml b/packages/admin-db/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/packages/admin-db/prisma/migrations/migration_lock.toml @@ -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" diff --git a/packages/admin-db/prisma/schema.prisma b/packages/admin-db/prisma/schema.prisma new file mode 100644 index 0000000..44a2c46 --- /dev/null +++ b/packages/admin-db/prisma/schema.prisma @@ -0,0 +1,133 @@ +generator client { + provider = "prisma-client-js" + output = "../generated" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_ADMIN_URL") +} + +// ─── Platform SuperAdmins ──────────────────────────── + +model SuperAdmin { + id String @id @default(uuid()) + email String @unique + password String + firstName String + lastName String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + refreshTokens AdminRefreshToken[] + auditLogs PlatformAuditLog[] + assignedTickets SupportTicket[] @relation("TicketAssignee") + ticketComments SupportTicketComment[] + + @@map("super_admins") +} + +model AdminRefreshToken { + id String @id @default(uuid()) + token String @unique + adminId String + expiresAt DateTime + createdAt DateTime @default(now()) + + admin SuperAdmin @relation(fields: [adminId], references: [id], onDelete: Cascade) + + @@index([adminId]) + @@index([expiresAt]) + @@map("admin_refresh_tokens") +} + +// ─── Platform Audit Log ────────────────────────────── + +model PlatformAuditLog { + id String @id @default(uuid()) + adminId String + action String + target String? + details Json @default("{}") + ipAddress String? + createdAt DateTime @default(now()) + + admin SuperAdmin @relation(fields: [adminId], references: [id]) + + @@index([adminId]) + @@index([action]) + @@index([target]) + @@index([createdAt]) + @@map("platform_audit_logs") +} + +// ─── Support Tickets (Tenant → Platform) ───────────── + +model SupportTicket { + id String @id @default(uuid()) + tenantId String + tenantName String + tenantSlug String + createdById String + createdByName String + subject String + description String + category String @default("general") // billing, technical, account, general, feature_request + priority String @default("normal") // low, normal, high, urgent + status String @default("open") // open, in_progress, waiting_tenant, resolved, closed + assignedToId String? + resolvedAt DateTime? + closedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + assignee SuperAdmin? @relation("TicketAssignee", fields: [assignedToId], references: [id]) + comments SupportTicketComment[] + attachments SupportTicketAttachment[] + + @@index([tenantId]) + @@index([status]) + @@index([category]) + @@index([priority]) + @@index([assignedToId]) + @@index([createdAt]) + @@map("support_tickets") +} + +model SupportTicketComment { + id String @id @default(uuid()) + ticketId String + authorId String + authorName String + authorType String // "super_admin" or "tenant_user" + content String + createdAt DateTime @default(now()) + + ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + author SuperAdmin? @relation(fields: [authorId], references: [id]) + attachments SupportTicketAttachment[] + + @@index([ticketId]) + @@index([authorId]) + @@map("support_ticket_comments") +} + +model SupportTicketAttachment { + id String @id @default(uuid()) + ticketId String + commentId String? + fileName String + originalName String + mimeType String + sizeBytes Int + uploadedBy String + createdAt DateTime @default(now()) + + ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + comment SupportTicketComment? @relation(fields: [commentId], references: [id], onDelete: Cascade) + + @@index([ticketId]) + @@index([commentId]) + @@map("support_ticket_attachments") +} diff --git a/packages/admin-db/prisma/seed.ts b/packages/admin-db/prisma/seed.ts new file mode 100644 index 0000000..c42e640 --- /dev/null +++ b/packages/admin-db/prisma/seed.ts @@ -0,0 +1,29 @@ +import { PrismaClient } from '../generated'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + const password = await bcrypt.hash('SuperAdmin@123', 12); + + const admin = await prisma.superAdmin.upsert({ + where: { email: 'superadmin@fiberops.dev' }, + update: {}, + create: { + email: 'superadmin@fiberops.dev', + password, + firstName: 'Super', + lastName: 'Admin', + isActive: true, + }, + }); + + console.log(`Seeded superadmin: ${admin.email}`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/packages/admin-db/src/index.ts b/packages/admin-db/src/index.ts new file mode 100644 index 0000000..c7adb93 --- /dev/null +++ b/packages/admin-db/src/index.ts @@ -0,0 +1 @@ +export { PrismaClient, Prisma } from '../generated'; diff --git a/packages/admin-db/tsconfig.json b/packages/admin-db/tsconfig.json new file mode 100644 index 0000000..d4e8bcf --- /dev/null +++ b/packages/admin-db/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src", "prisma"] +} diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..6baed8f --- /dev/null +++ b/packages/db/package.json @@ -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" + } +} diff --git a/packages/db/prisma/migrations/20260403015002_init/migration.sql b/packages/db/prisma/migrations/20260403015002_init/migration.sql new file mode 100644 index 0000000..8618410 --- /dev/null +++ b/packages/db/prisma/migrations/20260403015002_init/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20260403082101_add_audit_logs/migration.sql b/packages/db/prisma/migrations/20260403082101_add_audit_logs/migration.sql new file mode 100644 index 0000000..8091445 --- /dev/null +++ b/packages/db/prisma/migrations/20260403082101_add_audit_logs/migration.sql @@ -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"); diff --git a/packages/db/prisma/migrations/20260403122737_add_operations_accounting/migration.sql b/packages/db/prisma/migrations/20260403122737_add_operations_accounting/migration.sql new file mode 100644 index 0000000..761cf4f --- /dev/null +++ b/packages/db/prisma/migrations/20260403122737_add_operations_accounting/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20260403132431_add_billing_accounting/migration.sql b/packages/db/prisma/migrations/20260403132431_add_billing_accounting/migration.sql new file mode 100644 index 0000000..f651fc4 --- /dev/null +++ b/packages/db/prisma/migrations/20260403132431_add_billing_accounting/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20260403223747_company_account_coa_link/migration.sql b/packages/db/prisma/migrations/20260403223747_company_account_coa_link/migration.sql new file mode 100644 index 0000000..5a3bf7d --- /dev/null +++ b/packages/db/prisma/migrations/20260403223747_company_account_coa_link/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "company_accounts" ADD COLUMN "chartOfAccountId" TEXT, +ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/migrations/20260403233912_add_remittance_payments/migration.sql b/packages/db/prisma/migrations/20260403233912_add_remittance_payments/migration.sql new file mode 100644 index 0000000..8689836 --- /dev/null +++ b/packages/db/prisma/migrations/20260403233912_add_remittance_payments/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20260406000000_sync_schema_drift/migration.sql b/packages/db/prisma/migrations/20260406000000_sync_schema_drift/migration.sql new file mode 100644 index 0000000..35b914b --- /dev/null +++ b/packages/db/prisma/migrations/20260406000000_sync_schema_drift/migration.sql @@ -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 $$; diff --git a/packages/db/prisma/migrations/20260406120000_add_client_coordinates/migration.sql b/packages/db/prisma/migrations/20260406120000_add_client_coordinates/migration.sql new file mode 100644 index 0000000..bfabc68 --- /dev/null +++ b/packages/db/prisma/migrations/20260406120000_add_client_coordinates/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "clients" ADD COLUMN "latitude" DOUBLE PRECISION; +ALTER TABLE "clients" ADD COLUMN "longitude" DOUBLE PRECISION; diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/packages/db/prisma/migrations/migration_lock.toml @@ -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" diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma new file mode 100644 index 0000000..7db5f7a --- /dev/null +++ b/packages/db/prisma/schema.prisma @@ -0,0 +1,682 @@ +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[] + + @@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") + 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 // super_admin (platform-level only, legacy compat) + + 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 // clients, subscriptions, invoices, payments, tickets, employees, expenses, assets, payroll, accounts, accounting, reports, settings, users, areas, plans, dashboard, fund_transfers + 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") +} + +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 + areaId String? + latitude Float? + longitude Float? + 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") +} + +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 + 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]) + + @@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? + createdAt DateTime @default(now()) + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@index([tenantId]) + @@index([userId, isRead]) + @@map("notifications") +} + +// ─── 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], onDelete: Cascade) + employee Employee @relation(fields: [employeeId], references: [id]) + + @@unique([payrollRunId, employeeId]) + @@index([payrollRunId]) + @@index([employeeId]) + @@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]) + @@index([deletedAt]) + @@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], onDelete: Cascade) + account ChartOfAccount @relation(fields: [accountId], references: [id]) + + @@index([journalEntryId]) + @@index([accountId]) + @@map("journal_lines") +} diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts new file mode 100644 index 0000000..86b3d81 --- /dev/null +++ b/packages/db/prisma/seed.ts @@ -0,0 +1,603 @@ +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 { + 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 = {}; + const usersByEmail: Record = {}; + const userDefs = [ + { email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' }, + { email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' }, + { email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'technician' }, + { email: '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 = {}; + 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 = { + tenant_admin: 'tenant_admin', + manager: 'manager', + technician: 'technician', + 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 (20 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 }, + ]; + + 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 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, + latitude: areaCoords[c.area][0] + c.latOff, + longitude: areaCoords[c.area][1] + c.lngOff, + }, + }); + clients.push(client); + + // Create subscription + const installedAt = new Date(now); + installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago + + 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 2 invoices per client + for (let m = 0; m < 2; 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); + + const isPaid = m === 0 || Math.random() > 0.4; // First invoice always paid, second 60% chance + const balance = isPaid ? 0 : Number(plan.price); + + const invoice = await prisma.invoice.create({ + data: { + tenantId: tenant.id, + clientId: client.id, + number: `INV-${String(invoiceCount).padStart(6, '0')}`, + amount: plan.price, + balance, + status: isPaid ? 'paid' : (dueDate < now ? 'overdue' : 'sent'), + dueDate, + paidAt: isPaid ? new Date(dueDate.getTime() - 86400000 * 3) : null, + periodStart, + periodEnd, + }, + }); + + // Create payment for paid invoices + if (isPaid) { + const methods = ['gcash', 'maya', 'cash', 'bank_transfer']; + const method = methods[Math.floor(Math.random() * methods.length)]; + const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5)); + + await prisma.payment.create({ + data: { + tenantId: tenant.id, + clientId: client.id, + invoiceId: invoice.id, + collectedById: users.technician.id, + amount: plan.price, + method, + referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null, + createdAt: paidDate, + }, + }); + } + } + } + console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`); + + // ─── Open support tickets ────────────────────────────── + const supportTickets = [ + { clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high' }, + { clientIdx: 5, title: 'Slow speed during peak hours', desc: 'Speed drops to 5 Mbps from 8-10 PM', priority: 'normal' }, + { clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent' }, + { clientIdx: 11, title: 'Request for plan upgrade', desc: 'Would like to upgrade from Basic to Standard', priority: 'low' }, + { clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high' }, + ]; + + for (const t of supportTickets) { + await prisma.ticket.create({ + data: { + tenantId: tenant.id, + clientId: clients[t.clientIdx].id, + createdById: users.tenant_admin.id, + type: 'support', + title: t.title, + description: t.desc, + priority: t.priority, + status: 'open', + }, + }); + } + 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 ─────────────────────────────────────── + await prisma.remittance.create({ + data: { tenantId: tenant.id, collectorId: users.technician.id, confirmedById: users.tenant_admin.id, totalAmount: 8995, status: 'confirmed', confirmedAt: new Date() }, + }); + await prisma.remittance.create({ + data: { tenantId: tenant.id, collectorId: users.technician.id, totalAmount: 5497, status: 'pending' }, + }); + console.log('Remittances: 2'); + + 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🔑 Login: admin@demo-isp.com / admin123!`); + console.log(`🌐 Portal: C-000001 / 09171234567`); +} + +main() + .catch((e) => { console.error('Seed failed:', e); process.exit(1); }) + .finally(async () => { await prisma.$disconnect(); }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..85ac550 --- /dev/null +++ b/packages/db/src/index.ts @@ -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'; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..d4e8bcf --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src", "prisma"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..073de52 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fiberops/shared", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "zod": "^3.24.0" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts new file mode 100644 index 0000000..8c7be2b --- /dev/null +++ b/packages/shared/src/constants/index.ts @@ -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'; diff --git a/packages/shared/src/constants/permissions.ts b/packages/shared/src/constants/permissions.ts new file mode 100644 index 0000000..5a19140 --- /dev/null +++ b/packages/shared/src/constants/permissions.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 []; +} diff --git a/packages/shared/src/constants/roles.ts b/packages/shared/src/constants/roles.ts new file mode 100644 index 0000000..568ad34 --- /dev/null +++ b/packages/shared/src/constants/roles.ts @@ -0,0 +1,36 @@ +export const Role = { + SUPER_ADMIN: 'super_admin', + TENANT_ADMIN: 'tenant_admin', + MANAGER: 'manager', + TECHNICIAN: 'technician', + 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 = { + [Role.SUPER_ADMIN]: 100, + [Role.TENANT_ADMIN]: 80, + [Role.MANAGER]: 60, + [Role.TECHNICIAN]: 40, + [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); +} diff --git a/packages/shared/src/constants/statuses.ts b/packages/shared/src/constants/statuses.ts new file mode 100644 index 0000000..5f5934b --- /dev/null +++ b/packages/shared/src/constants/statuses.ts @@ -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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..0f31ffa --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './schemas'; +export * from './types'; diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts new file mode 100644 index 0000000..5b793fd --- /dev/null +++ b/packages/shared/src/schemas/auth.ts @@ -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; +export type RegisterTenantInput = z.infer; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000..e5095cd --- /dev/null +++ b/packages/shared/src/schemas/index.ts @@ -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'; diff --git a/packages/shared/src/schemas/user.ts b/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000..5a8e5c1 --- /dev/null +++ b/packages/shared/src/schemas/user.ts @@ -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; +export type UpdateUserInput = z.infer; diff --git a/packages/shared/src/types/api.ts b/packages/shared/src/types/api.ts new file mode 100644 index 0000000..735cd83 --- /dev/null +++ b/packages/shared/src/types/api.ts @@ -0,0 +1,36 @@ +export interface ApiResponse { + 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; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts new file mode 100644 index 0000000..ad6b9f0 --- /dev/null +++ b/packages/shared/src/types/index.ts @@ -0,0 +1,7 @@ +export type { + ApiResponse, + PaginationMeta, + PaginationQuery, + JwtPayload, + TokenResponse, +} from './api'; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..792172f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..a11e1c5 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { AdminPrismaModule } from './prisma/admin-prisma.module'; +import { TenantPrismaModule } from './prisma/tenant-prisma.module'; +import { AuthModule } from './auth/auth.module'; +import { DashboardModule } from './dashboard/dashboard.module'; +import { TenantsModule } from './tenants/tenants.module'; +import { UsersModule } from './users/users.module'; +import { SupportModule } from './support/support.module'; +import { AuditModule } from './audit/audit.module'; +import { ImpersonateModule } from './impersonate/impersonate.module'; +import { PublicSupportModule } from './public-support/public-support.module'; +import { HealthModule } from './health/health.module'; +import { GlobalExceptionFilter } from './common/filters/http-exception.filter'; +import { ResponseInterceptor } from './common/interceptors/response.interceptor'; +import { AdminAuthGuard } from './common/guards/admin-auth.guard'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '../../.env', + }), + AdminPrismaModule, + TenantPrismaModule, + AuthModule, + DashboardModule, + TenantsModule, + UsersModule, + SupportModule, + AuditModule, + ImpersonateModule, + PublicSupportModule, + HealthModule, + ], + providers: [ + { provide: APP_FILTER, useClass: GlobalExceptionFilter }, + { provide: APP_GUARD, useClass: AdminAuthGuard }, + { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, + ], +}) +export class AppModule {} diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts new file mode 100644 index 0000000..18d4ad1 --- /dev/null +++ b/src/audit/audit.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { AuditService } from './audit.service'; + +@Controller('audit-logs') +export class AuditController { + constructor(private readonly service: AuditService) {} + + @Get() + getTenantAuditLogs( + @Query('tenantId') tenantId?: string, + @Query('userId') userId?: string, + @Query('entity') entity?: string, + @Query('action') action?: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.service.getTenantAuditLogs({ tenantId, userId, entity, action, page, limit }); + } + + @Get('platform') + getPlatformAuditLogs( + @Query('adminId') adminId?: string, + @Query('action') action?: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.service.getPlatformAuditLogs({ adminId, action, page, limit }); + } +} diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts new file mode 100644 index 0000000..88eb9fd --- /dev/null +++ b/src/audit/audit.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { AuditController } from './audit.controller'; +import { AuditService } from './audit.service'; + +@Global() +@Module({ + controllers: [AuditController], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts new file mode 100644 index 0000000..ee7d8fc --- /dev/null +++ b/src/audit/audit.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { TenantPrismaService } from '../prisma/tenant-prisma.service'; +import { AdminPrismaService } from '../prisma/admin-prisma.service'; + +@Injectable() +export class AuditService { + constructor( + private readonly tenantDb: TenantPrismaService, + private readonly adminDb: AdminPrismaService, + ) {} + + async getTenantAuditLogs(query: { + tenantId?: string; + userId?: string; + entity?: string; + action?: string; + page?: number; + limit?: number; + }) { + const { tenantId, userId, entity, action, page = 1, limit = 20 } = query; + const skip = (page - 1) * limit; + + const where: any = {}; + if (tenantId) where.tenantId = tenantId; + if (userId) where.userId = userId; + if (entity) where.entity = entity; + if (action) where.action = action; + + const [items, total] = await Promise.all([ + this.tenantDb.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + this.tenantDb.auditLog.count({ where }), + ]); + + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async getPlatformAuditLogs(query: { + adminId?: string; + action?: string; + page?: number; + limit?: number; + }) { + const { adminId, action, page = 1, limit = 20 } = query; + const skip = (page - 1) * limit; + + const where: any = {}; + if (adminId) where.adminId = adminId; + if (action) where.action = action; + + const [items, total] = await Promise.all([ + this.adminDb.platformAuditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include: { admin: { select: { id: true, firstName: true, lastName: true, email: true } } }, + }), + this.adminDb.platformAuditLog.count({ where }), + ]); + + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async log(adminId: string, action: string, target?: string, details?: any, ipAddress?: string) { + return this.adminDb.platformAuditLog.create({ + data: { + adminId, + action, + target: target || null, + details: details || {}, + ipAddress: ipAddress || null, + }, + }); + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..6d4cf01 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + Req, + HttpCode, + HttpStatus, + SetMetadata, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +class LoginDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(6) + password: string; +} + +class RefreshDto { + @IsString() + refreshToken: string; +} + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('login') + @Public() + @HttpCode(HttpStatus.OK) + async login(@Body() dto: LoginDto) { + return this.authService.login(dto.email, dto.password); + } + + @Post('refresh') + @Public() + @HttpCode(HttpStatus.OK) + async refresh(@Body() dto: RefreshDto) { + return this.authService.refreshToken(dto.refreshToken); + } + + @Post('logout') + @HttpCode(HttpStatus.OK) + async logout(@Req() req: any) { + await this.authService.logout(req.admin.sub); + return { message: 'Logged out successfully' }; + } + + @Get('profile') + async profile(@Req() req: any) { + return this.authService.getProfile(req.admin.sub); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..7a41676 --- /dev/null +++ b/src/auth/auth.module.ts @@ -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 { AdminJwtStrategy } from './strategies/admin-jwt.strategy'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'admin-jwt' }), + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('ADMIN_JWT_SECRET', 'admin-jwt-secret'), + signOptions: { + expiresIn: config.get('ADMIN_JWT_EXPIRES_IN', '15m') as any, + }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, AdminJwtStrategy], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..accc86f --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,155 @@ +import { + Injectable, + UnauthorizedException, + ConflictException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import * as bcrypt from 'bcrypt'; +import { AdminPrismaService } from '../prisma/admin-prisma.service'; + +@Injectable() +export class AuthService { + constructor( + private readonly adminDb: AdminPrismaService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + ) {} + + async login(email: string, password: string) { + const admin = await this.adminDb.superAdmin.findUnique({ + where: { email }, + }); + + if (!admin || !admin.isActive) { + throw new UnauthorizedException('Invalid credentials'); + } + + const valid = await bcrypt.compare(password, admin.password); + if (!valid) { + throw new UnauthorizedException('Invalid credentials'); + } + + const tokens = await this.generateTokens(admin.id, admin.email); + + await this.adminDb.platformAuditLog.create({ + data: { + adminId: admin.id, + action: 'auth.login', + details: {}, + }, + }); + + return { + ...tokens, + admin: { + id: admin.id, + email: admin.email, + firstName: admin.firstName, + lastName: admin.lastName, + }, + }; + } + + async refreshToken(refreshToken: string) { + const stored = await this.adminDb.adminRefreshToken.findUnique({ + where: { token: refreshToken }, + }); + + if (!stored || stored.expiresAt < new Date()) { + if (stored) { + await this.adminDb.adminRefreshToken.delete({ + where: { id: stored.id }, + }); + } + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + await this.adminDb.adminRefreshToken.delete({ + where: { id: stored.id }, + }); + + const admin = await this.adminDb.superAdmin.findUnique({ + where: { id: stored.adminId }, + }); + + if (!admin || !admin.isActive) { + throw new UnauthorizedException('Account is inactive'); + } + + return this.generateTokens(admin.id, admin.email); + } + + async getProfile(adminId: string) { + const admin = await this.adminDb.superAdmin.findUnique({ + where: { id: adminId }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + isActive: true, + createdAt: true, + }, + }); + + if (!admin) { + throw new UnauthorizedException('Admin not found'); + } + + return admin; + } + + async logout(adminId: string) { + await this.adminDb.adminRefreshToken.deleteMany({ + where: { adminId }, + }); + + await this.adminDb.platformAuditLog.create({ + data: { + adminId, + action: 'auth.logout', + details: {}, + }, + }); + } + + private async generateTokens(adminId: string, email: string) { + const accessToken = this.jwt.sign({ + sub: adminId, + email, + type: 'super_admin', + }); + + const refreshSecret = this.config.get( + 'ADMIN_JWT_REFRESH_SECRET', + 'admin-refresh-secret', + ); + const refreshExpiresIn = this.config.get( + 'ADMIN_JWT_REFRESH_EXPIRES_IN', + '7d', + ); + + const refreshToken = this.jwt.sign( + { sub: adminId, email, type: 'super_admin' }, + { + secret: refreshSecret, + expiresIn: refreshExpiresIn as any, + }, + ); + + const days = parseInt(refreshExpiresIn) || 7; + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + days); + + await this.adminDb.adminRefreshToken.create({ + data: { + token: refreshToken, + adminId, + expiresAt, + }, + }); + + return { accessToken, refreshToken }; + } +} diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..7ea3863 --- /dev/null +++ b/src/auth/dto/login.dto.ts @@ -0,0 +1,9 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class LoginDto { + @IsEmail() + email!: string; + + @IsString() + password!: string; +} diff --git a/src/auth/dto/refresh-token.dto.ts b/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..752ff4f --- /dev/null +++ b/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class RefreshTokenDto { + @IsString() + refreshToken!: string; +} diff --git a/src/auth/strategies/admin-jwt.strategy.ts b/src/auth/strategies/admin-jwt.strategy.ts new file mode 100644 index 0000000..e82076c --- /dev/null +++ b/src/auth/strategies/admin-jwt.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +@Injectable() +export class AdminJwtStrategy extends PassportStrategy( + Strategy, + 'admin-jwt', +) { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: + config.get('ADMIN_JWT_SECRET', 'admin-jwt-secret'), + }); + } + + validate(payload: any) { + if (payload.type !== 'super_admin') return null; + return { + sub: payload.sub, + email: payload.email, + type: payload.type, + }; + } +} diff --git a/src/common/decorators/current-admin.decorator.ts b/src/common/decorators/current-admin.decorator.ts new file mode 100644 index 0000000..34c4eac --- /dev/null +++ b/src/common/decorators/current-admin.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentAdmin = createParamDecorator( + (data: string | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const admin = request['admin']; + return data ? admin?.[data] : admin; + }, +); diff --git a/src/common/decorators/public.decorator.ts b/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..4d2dbaa --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -0,0 +1,36 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + 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); + } + + response.status(status).json({ + success: false, + data: null, + error: Array.isArray(message) ? message.join(', ') : message, + }); + } +} diff --git a/src/common/guards/admin-auth.guard.ts b/src/common/guards/admin-auth.guard.ts new file mode 100644 index 0000000..3583a38 --- /dev/null +++ b/src/common/guards/admin-auth.guard.ts @@ -0,0 +1,60 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +@Injectable() +export class AdminAuthGuard implements CanActivate { + private jwtSecret: string; + + constructor( + private reflector: Reflector, + private jwtService: JwtService, + private config: ConfigService, + ) { + this.jwtSecret = this.config.get('ADMIN_JWT_SECRET', 'admin-jwt-secret')!; + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const url = request.url || ''; + + // Skip auth for public support endpoints + if (url.includes('/public/support')) { + return true; + } + + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const token = this.extractToken(request); + if (!token) throw new UnauthorizedException(); + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: this.jwtSecret, + }); + (request as any)['admin'] = payload; + } catch { + throw new UnauthorizedException(); + } + + return true; + } + + private extractToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/common/interceptors/response.interceptor.ts b/src/common/interceptors/response.interceptor.ts new file mode 100644 index 0000000..1b53e09 --- /dev/null +++ b/src/common/interceptors/response.interceptor.ts @@ -0,0 +1,20 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable, map } from 'rxjs'; + +@Injectable() +export class ResponseInterceptor implements NestInterceptor { + intercept(_context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + map((data) => ({ + success: true, + data, + error: null, + })), + ); + } +} diff --git a/src/dashboard/dashboard.controller.ts b/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..d58ec1b --- /dev/null +++ b/src/dashboard/dashboard.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { DashboardService } from './dashboard.service'; + +@Controller('dashboard') +export class DashboardController { + constructor(private readonly service: DashboardService) {} + + @Get('stats') + getStats() { + return this.service.getStats(); + } + + @Get('recent-activity') + getRecentActivity() { + return this.service.getRecentActivity(); + } +} diff --git a/src/dashboard/dashboard.module.ts b/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..c4a4a45 --- /dev/null +++ b/src/dashboard/dashboard.module.ts @@ -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 {} diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..af5217d --- /dev/null +++ b/src/dashboard/dashboard.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { TenantPrismaService } from '../prisma/tenant-prisma.service'; +import { AdminPrismaService } from '../prisma/admin-prisma.service'; + +@Injectable() +export class DashboardService { + constructor( + private readonly tenantDb: TenantPrismaService, + private readonly adminDb: AdminPrismaService, + ) {} + + async getStats() { + const [ + totalTenants, + activeTenants, + totalUsers, + totalClients, + totalSubscriptions, + activeSubscriptions, + totalInvoices, + totalPayments, + paymentAgg, + openTickets, + ] = await Promise.all([ + this.tenantDb.tenant.count({ where: { deletedAt: null } }), + this.tenantDb.tenant.count({ where: { deletedAt: null, isActive: true } }), + this.tenantDb.user.count({ where: { deletedAt: null } }), + this.tenantDb.client.count({ where: { deletedAt: null } }), + this.tenantDb.subscription.count({ where: { deletedAt: null } }), + this.tenantDb.subscription.count({ where: { deletedAt: null, status: 'active' } }), + this.tenantDb.invoice.count({ where: { deletedAt: null } }), + this.tenantDb.payment.count({ where: { deletedAt: null } }), + this.tenantDb.payment.aggregate({ + _sum: { amount: true }, + _count: true, + where: { deletedAt: null }, + }), + this.adminDb.supportTicket.count({ where: { status: { in: ['open', 'in_progress'] } } }), + ]); + + return { + totalTenants, + activeTenants, + inactiveTenants: totalTenants - activeTenants, + totalUsers, + totalClients, + totalSubscriptions, + activeSubscriptions, + totalInvoices, + totalPayments, + totalRevenue: paymentAgg._sum.amount || 0, + openSupportTickets: openTickets, + }; + } + + async getRecentActivity() { + const [recentTenants, recentAudit, recentTickets] = await Promise.all([ + this.tenantDb.tenant.findMany({ + where: { deletedAt: null }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { id: true, name: true, slug: true, isActive: true, createdAt: true }, + }), + this.tenantDb.auditLog.findMany({ + orderBy: { createdAt: 'desc' }, + take: 10, + select: { + id: true, + tenantId: true, + userId: true, + action: true, + entity: true, + createdAt: true, + }, + }), + this.adminDb.supportTicket.findMany({ + where: { status: { in: ['open', 'in_progress'] } }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + subject: true, + tenantName: true, + category: true, + priority: true, + status: true, + createdAt: true, + }, + }), + ]); + + return { recentTenants, recentAudit, recentTickets }; + } +} diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts new file mode 100644 index 0000000..48b05db --- /dev/null +++ b/src/health/health.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get } from '@nestjs/common'; +import { Public } from '../common/decorators/public.decorator'; + +@Controller('health') +export class HealthController { + @Get() + @Public() + check() { + return { + status: 'ok', + service: 'admin-api', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/impersonate/impersonate.controller.ts b/src/impersonate/impersonate.controller.ts new file mode 100644 index 0000000..be41c93 --- /dev/null +++ b/src/impersonate/impersonate.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Post, Delete, Param, Req } from '@nestjs/common'; +import { Request } from 'express'; +import { ImpersonateService } from './impersonate.service'; + +@Controller('impersonate') +export class ImpersonateController { + constructor(private readonly service: ImpersonateService) {} + + @Post(':tenantId') + start(@Param('tenantId') tenantId: string, @Req() req: Request & { admin: any }) { + const admin = req.admin; + return this.service.startImpersonation( + tenantId, + admin.sub, + `${admin.firstName || ''} ${admin.lastName || ''}`.trim(), + ); + } + + @Delete() + end(@Req() req: Request & { admin: any }) { + return this.service.endImpersonation(req.admin.sub); + } +} diff --git a/src/impersonate/impersonate.module.ts b/src/impersonate/impersonate.module.ts new file mode 100644 index 0000000..25583d9 --- /dev/null +++ b/src/impersonate/impersonate.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ImpersonateController } from './impersonate.controller'; +import { ImpersonateService } from './impersonate.service'; +import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuditModule, AuthModule], + controllers: [ImpersonateController], + providers: [ImpersonateService], +}) +export class ImpersonateModule {} diff --git a/src/impersonate/impersonate.service.ts b/src/impersonate/impersonate.service.ts new file mode 100644 index 0000000..4077922 --- /dev/null +++ b/src/impersonate/impersonate.service.ts @@ -0,0 +1,80 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { TenantPrismaService } from '../prisma/tenant-prisma.service'; +import { AuditService } from '../audit/audit.service'; + +@Injectable() +export class ImpersonateService { + private readonly mainJwtSecret: string; + + constructor( + private readonly tenantDb: TenantPrismaService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly audit: AuditService, + ) { + // Use the MAIN API's JWT secret so the token works with the tenant API + this.mainJwtSecret = this.config.get('JWT_SECRET', 'dev-jwt-secret-not-for-production'); + } + + async startImpersonation(tenantId: string, adminId: string, adminName: string) { + const tenant = await this.tenantDb.tenant.findUnique({ + where: { id: tenantId, deletedAt: null, isActive: true }, + }); + if (!tenant) throw new NotFoundException('Tenant not found or inactive'); + + // Find the tenant's first admin user + const adminUser = await this.tenantDb.user.findFirst({ + where: { + tenantId, + deletedAt: null, + isActive: true, + roles: { some: { role: 'tenant_admin' } }, + }, + }); + if (!adminUser) throw new NotFoundException('No tenant admin found for this tenant'); + + // Generate a JWT compatible with the main API + const token = this.jwt.sign( + { + sub: adminUser.id, + tenantId, + roles: ['tenant_admin'], + permissions: 'all', + impersonatedBy: adminId, + }, + { + secret: this.mainJwtSecret, + expiresIn: '1h', + }, + ); + + // Audit the impersonation + await this.audit.log(adminId, 'impersonate.start', tenantId, { + tenantName: tenant.name, + impersonatedUserId: adminUser.id, + }); + + return { + accessToken: token, + impersonatedUser: { + id: adminUser.id, + email: adminUser.email, + firstName: adminUser.firstName, + lastName: adminUser.lastName, + }, + tenant: { + id: tenant.id, + name: tenant.name, + slug: tenant.slug, + }, + expiresAt: new Date(Date.now() + 3600000).toISOString(), + }; + } + + async endImpersonation(adminId: string) { + await this.audit.log(adminId, 'impersonate.end'); + return { message: 'Impersonation ended' }; + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ed273c1 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,45 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + }), + ); + + const allowedOrigins = ( + process.env.CORS_ORIGIN || + 'http://localhost:3000,http://localhost:3002,http://localhost:3003' + ).split(','); + app.enableCors({ + origin: allowedOrigins, + credentials: true, + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'x-tenant-id'], + }); + + app.setGlobalPrefix('api'); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + + const port = process.env.ADMIN_API_PORT || 3004; + await app.listen(port, '0.0.0.0'); + console.log(`Admin API running on http://localhost:${port}/api`); +} + +bootstrap(); diff --git a/src/prisma/admin-prisma.module.ts b/src/prisma/admin-prisma.module.ts new file mode 100644 index 0000000..efba117 --- /dev/null +++ b/src/prisma/admin-prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { AdminPrismaService } from './admin-prisma.service'; + +@Global() +@Module({ + providers: [AdminPrismaService], + exports: [AdminPrismaService], +}) +export class AdminPrismaModule {} diff --git a/src/prisma/admin-prisma.service.ts b/src/prisma/admin-prisma.service.ts new file mode 100644 index 0000000..02ce5dc --- /dev/null +++ b/src/prisma/admin-prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@fiberops/admin-db'; + +@Injectable() +export class AdminPrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/src/prisma/tenant-prisma.module.ts b/src/prisma/tenant-prisma.module.ts new file mode 100644 index 0000000..6c2e2a0 --- /dev/null +++ b/src/prisma/tenant-prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { TenantPrismaService } from './tenant-prisma.service'; + +@Global() +@Module({ + providers: [TenantPrismaService], + exports: [TenantPrismaService], +}) +export class TenantPrismaModule {} diff --git a/src/prisma/tenant-prisma.service.ts b/src/prisma/tenant-prisma.service.ts new file mode 100644 index 0000000..2bd5040 --- /dev/null +++ b/src/prisma/tenant-prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class TenantPrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/src/public-support/public-support.controller.ts b/src/public-support/public-support.controller.ts new file mode 100644 index 0000000..f91a006 --- /dev/null +++ b/src/public-support/public-support.controller.ts @@ -0,0 +1,159 @@ +import { + Controller, + Get, + Post, + Param, + Body, + Query, + Headers, + Res, + UseInterceptors, + UploadedFiles, + UnauthorizedException, +} from '@nestjs/common'; +import { Response } from 'express'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { SupportService } from '../support/support.service'; +import { TenantPrismaService } from '../prisma/tenant-prisma.service'; +import { CreateTicketDto } from '../support/dto/create-ticket.dto'; +import { CommentDto } from '../support/dto/comment.dto'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { supportUploadOptions } from '../support/multer-options'; +import { createReadStream } from 'fs'; +import { Public } from '../common/decorators/public.decorator'; + +/** + * Public support endpoints for tenant users. + * These require a valid tenant JWT from the main API. + */ +@Controller('public/support') +export class PublicSupportController { + private readonly mainJwtSecret: string; + + constructor( + private readonly support: SupportService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly tenantDb: TenantPrismaService, + ) { + this.mainJwtSecret = this.config.get('JWT_SECRET', 'dev-jwt-secret-not-for-production'); + } + + private async resolveTenantUser(authorization?: string) { + if (!authorization) throw new UnauthorizedException(); + const token = authorization.replace('Bearer ', ''); + try { + const payload = await this.jwt.verifyAsync(token, { secret: this.mainJwtSecret }); + if (!payload.tenantId || !payload.sub) throw new UnauthorizedException(); + return { + userId: payload.sub, + tenantId: payload.tenantId, + name: `${payload.firstName || ''} ${payload.lastName || ''}`.trim(), + email: payload.email, + }; + } catch { + throw new UnauthorizedException(); + } + } + + @Public() + @Get('tickets') + async listTickets( + @Headers('authorization') authorization?: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + const user = await this.resolveTenantUser(authorization); + return this.support.getTenantTickets(user.tenantId); + } + + @Public() + @Get('tickets/:id') + async getTicket( + @Param('id') id: string, + @Headers('authorization') authorization?: string, + ) { + const user = await this.resolveTenantUser(authorization); + return this.support.getTenantTicketDetail(user.tenantId, id); + } + + @Public() + @Post('tickets') + async createTicket( + @Body() dto: CreateTicketDto, + @Headers('authorization') authorization?: string, + ) { + const user = await this.resolveTenantUser(authorization); + const tenant = await this.tenantDb.tenant.findUnique({ + where: { id: user.tenantId }, + select: { name: true, slug: true }, + }); + return this.support.createFromTenant( + user.tenantId, + tenant?.name || '', + tenant?.slug || '', + user.userId, + user.name, + dto, + ); + } + + @Public() + @Post('tickets/:id/comments') + async addComment( + @Param('id') id: string, + @Body() dto: CommentDto, + @Headers('authorization') authorization?: string, + ) { + const user = await this.resolveTenantUser(authorization); + return this.support.addComment(id, user.userId, user.name, 'tenant_user', dto); + } + + // ─── Attachments (Tenant-facing) ──────────────────── + + @Public() + @Post('tickets/:id/attachments') + @UseInterceptors(FilesInterceptor('files', 5, supportUploadOptions)) + async uploadAttachments( + @Param('id') id: string, + @UploadedFiles() files: Express.Multer.File[], + @Body() body: { commentId?: string }, + @Headers('authorization') authorization?: string, + ) { + const user = await this.resolveTenantUser(authorization); + await this.support.getTenantTicketDetail(user.tenantId, id); + const results = await Promise.all( + files.map((f) => this.support.addAttachment(id, f, user.userId, body.commentId)), + ); + return results; + } + + @Public() + @Get('tickets/:id/attachments') + async getAttachments( + @Param('id') id: string, + @Headers('authorization') authorization?: string, + ) { + const user = await this.resolveTenantUser(authorization); + await this.support.getTenantTicketDetail(user.tenantId, id); + return this.support.getAttachments(id); + } + + @Public() + @Get('uploads/:fileName') + async downloadFile( + @Param('fileName') fileName: string, + @Headers('authorization') authorization?: string, + @Res() res?: Response, + ) { + await this.resolveTenantUser(authorization); + const { filePath, attachment } = await this.support.getAttachment(fileName); + res!.setHeader('Content-Type', attachment.mimeType); + res!.setHeader( + 'Content-Disposition', + `inline; filename="${attachment.originalName}"`, + ); + createReadStream(filePath).pipe(res!); + } +} diff --git a/src/public-support/public-support.module.ts b/src/public-support/public-support.module.ts new file mode 100644 index 0000000..00ff4cc --- /dev/null +++ b/src/public-support/public-support.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PublicSupportController } from './public-support.controller'; +import { SupportService } from '../support/support.service'; +import { AuthModule } from '../auth/auth.module'; +import { AdminPrismaModule } from '../prisma/admin-prisma.module'; +import { TenantPrismaModule } from '../prisma/tenant-prisma.module'; + +@Module({ + imports: [AuthModule, AdminPrismaModule, TenantPrismaModule], + controllers: [PublicSupportController], + providers: [SupportService], +}) +export class PublicSupportModule {} diff --git a/src/support/dto/comment.dto.ts b/src/support/dto/comment.dto.ts new file mode 100644 index 0000000..463a7b0 --- /dev/null +++ b/src/support/dto/comment.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class CommentDto { + @IsString() + content!: string; +} diff --git a/src/support/dto/create-ticket.dto.ts b/src/support/dto/create-ticket.dto.ts new file mode 100644 index 0000000..cf8daa0 --- /dev/null +++ b/src/support/dto/create-ticket.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsOptional, IsIn } from 'class-validator'; + +export class CreateTicketDto { + @IsString() + subject!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsIn(['billing', 'technical', 'account', 'general', 'feature_request']) + category?: string = 'general'; + + @IsOptional() + @IsIn(['low', 'normal', 'high', 'urgent']) + priority?: string = 'normal'; +} diff --git a/src/support/dto/list-query.dto.ts b/src/support/dto/list-query.dto.ts new file mode 100644 index 0000000..540406d --- /dev/null +++ b/src/support/dto/list-query.dto.ts @@ -0,0 +1,40 @@ +import { IsOptional, IsString, IsInt, Min, IsIn } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class TicketListQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsIn(['open', 'in_progress', 'waiting_tenant', 'resolved', 'closed']) + status?: string; + + @IsOptional() + @IsIn(['billing', 'technical', 'account', 'general', 'feature_request']) + category?: string; + + @IsOptional() + @IsIn(['low', 'normal', 'high', 'urgent']) + priority?: string; + + @IsOptional() + @IsString() + tenantId?: string; + + @IsOptional() + @IsString() + assignedToId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; +} diff --git a/src/support/dto/update-ticket.dto.ts b/src/support/dto/update-ticket.dto.ts new file mode 100644 index 0000000..209422f --- /dev/null +++ b/src/support/dto/update-ticket.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsIn, IsString } from 'class-validator'; + +export class UpdateTicketDto { + @IsOptional() + @IsIn(['low', 'normal', 'high', 'urgent']) + priority?: string; + + @IsOptional() + @IsIn(['open', 'in_progress', 'waiting_tenant', 'resolved', 'closed']) + status?: string; + + @IsOptional() + @IsString() + assignedToId?: string; + + @IsOptional() + @IsIn(['billing', 'technical', 'account', 'general', 'feature_request']) + category?: string; +} diff --git a/src/support/multer-options.ts b/src/support/multer-options.ts new file mode 100644 index 0000000..0846d95 --- /dev/null +++ b/src/support/multer-options.ts @@ -0,0 +1,40 @@ +import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; +import { randomUUID } from 'crypto'; + +export const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads/support'; + +export const supportUploadOptions: MulterOptions = { + storage: diskStorage({ + destination: (_req, _file, cb) => { + const fs = require('fs'); + if (!fs.existsSync(UPLOAD_DIR)) { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + } + cb(null, UPLOAD_DIR); + }, + filename: (_req, file, cb) => { + const uniqueName = `${randomUUID()}${extname(file.originalname)}`; + cb(null, uniqueName); + }, + }), + limits: { + fileSize: 10 * 1024 * 1024, // 10 MB per file + files: 5, // max 5 files per request + }, + fileFilter: (_req, file, cb) => { + const allowed = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ]; + if (allowed.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`Unsupported file type: ${file.mimetype}`), false); + } + }, +}; diff --git a/src/support/support.controller.ts b/src/support/support.controller.ts new file mode 100644 index 0000000..2b4c149 --- /dev/null +++ b/src/support/support.controller.ts @@ -0,0 +1,119 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + Req, + UseInterceptors, + UploadedFiles, + Res, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { SupportService } from './support.service'; +import { UpdateTicketDto } from './dto/update-ticket.dto'; +import { CommentDto } from './dto/comment.dto'; +import { TicketListQueryDto } from './dto/list-query.dto'; +import { supportUploadOptions } from './multer-options'; +import { createReadStream } from 'fs'; + +@Controller('support/tickets') +export class SupportController { + constructor(private readonly service: SupportService) {} + + @Get() + findAll(@Query() query: TicketListQueryDto) { + return this.service.findAll(query); + } + + @Get('uploads/:fileName') + async downloadFile( + @Param('fileName') fileName: string, + @Res() res: Response, + ) { + const { filePath, attachment } = await this.service.getAttachment(fileName); + res.setHeader('Content-Type', attachment.mimeType); + res.setHeader( + 'Content-Disposition', + `inline; filename="${attachment.originalName}"`, + ); + createReadStream(filePath).pipe(res); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() dto: UpdateTicketDto) { + return this.service.update(id, dto); + } + + @Patch(':id/assign') + assign(@Param('id') id: string, @Body() body: { adminId: string }) { + return this.service.assign(id, body.adminId); + } + + @Patch(':id/resolve') + resolve(@Param('id') id: string) { + return this.service.resolve(id); + } + + @Patch(':id/close') + close(@Param('id') id: string) { + return this.service.close(id); + } + + @Post(':id/comments') + addComment( + @Param('id') id: string, + @Body() dto: CommentDto, + @Req() req: Request & { admin: any }, + ) { + const admin = req.admin; + return this.service.addComment( + id, + admin.sub, + `${admin.firstName || ''} ${admin.lastName || ''}`.trim(), + 'super_admin', + dto, + ); + } + + // ─── Attachments ──────────────────────────────── + + @Post(':id/attachments') + @UseInterceptors(FilesInterceptor('files', 5, supportUploadOptions)) + async uploadAttachments( + @Param('id') id: string, + @UploadedFiles() files: Express.Multer.File[], + @Body() body: { commentId?: string }, + @Req() req: Request & { admin: any }, + ) { + const admin = req.admin; + const results = await Promise.all( + files.map((f) => + this.service.addAttachment(id, f, admin.sub, body.commentId), + ), + ); + return results; + } + + @Get(':id/attachments') + getAttachments(@Param('id') id: string) { + return this.service.getAttachments(id); + } + + @Delete(':id/attachments/:attachmentId') + deleteAttachment( + @Param('id') id: string, + @Param('attachmentId') attachmentId: string, + ) { + return this.service.deleteAttachment(id, attachmentId); + } +} diff --git a/src/support/support.module.ts b/src/support/support.module.ts new file mode 100644 index 0000000..1e863e9 --- /dev/null +++ b/src/support/support.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SupportController } from './support.controller'; +import { SupportService } from './support.service'; +import { AdminPrismaModule } from '../prisma/admin-prisma.module'; +import { TenantPrismaModule } from '../prisma/tenant-prisma.module'; + +@Module({ + imports: [AdminPrismaModule, TenantPrismaModule], + controllers: [SupportController], + providers: [SupportService], +}) +export class SupportModule {} diff --git a/src/support/support.service.ts b/src/support/support.service.ts new file mode 100644 index 0000000..1c18f0f --- /dev/null +++ b/src/support/support.service.ts @@ -0,0 +1,240 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AdminPrismaService } from '../prisma/admin-prisma.service'; +import { TenantPrismaService } from '../prisma/tenant-prisma.service'; +import { CreateTicketDto } from './dto/create-ticket.dto'; +import { UpdateTicketDto } from './dto/update-ticket.dto'; +import { CommentDto } from './dto/comment.dto'; +import { TicketListQueryDto } from './dto/list-query.dto'; +import { UPLOAD_DIR } from './multer-options'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class SupportService { + constructor( + private readonly db: AdminPrismaService, + private readonly tenantDb: TenantPrismaService, + ) {} + + async findAll(query: TicketListQueryDto) { + const { search, status, category, priority, tenantId, assignedToId, page = 1, limit = 20 } = query; + const skip = (page - 1) * limit; + + const where: any = {}; + if (search) { + where.OR = [ + { subject: { contains: search, mode: 'insensitive' } }, + { tenantName: { contains: search, mode: 'insensitive' } }, + ]; + } + if (status) where.status = status; + if (category) where.category = category; + if (priority) where.priority = priority; + if (tenantId) where.tenantId = tenantId; + if (assignedToId) where.assignedToId = assignedToId; + + const [items, total] = await Promise.all([ + this.db.supportTicket.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include: { + assignee: { select: { id: true, firstName: true, lastName: true } }, + _count: { select: { comments: true } }, + }, + }), + this.db.supportTicket.count({ where }), + ]); + + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async findOne(id: string) { + const ticket = await this.db.supportTicket.findUnique({ + where: { id }, + include: { + assignee: { select: { id: true, firstName: true, lastName: true } }, + comments: { orderBy: { createdAt: 'asc' }, include: { attachments: true } }, + attachments: { where: { commentId: null }, orderBy: { createdAt: 'asc' } }, + }, + }); + if (!ticket) throw new NotFoundException('Ticket not found'); + return ticket; + } + + async createAsAdmin(tenantId: string, adminId: string, adminName: string, data: { subject: string; description: string; category?: string; priority?: string }) { + const tenant = await this.tenantDb.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true, slug: true } }); + if (!tenant) throw new NotFoundException('Tenant not found'); + + return this.db.supportTicket.create({ + data: { + tenantId: tenant.id, + tenantName: tenant.name, + tenantSlug: tenant.slug, + createdById: adminId, + createdByName: adminName, + subject: data.subject, + description: data.description, + category: data.category || 'general', + priority: data.priority || 'normal', + status: 'open', + }, + }); + } + + async createFromTenant(tenantId: string, tenantName: string, tenantSlug: string, userId: string, userName: string, dto: CreateTicketDto) { + return this.db.supportTicket.create({ + data: { + tenantId, + tenantName, + tenantSlug, + createdById: userId, + createdByName: userName, + subject: dto.subject, + description: dto.description, + category: dto.category || 'general', + priority: dto.priority || 'normal', + status: 'open', + }, + }); + } + + async update(id: string, dto: UpdateTicketDto) { + const ticket = await this.db.supportTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + const data: any = {}; + if (dto.priority) data.priority = dto.priority; + if (dto.status) data.status = dto.status; + if (dto.assignedToId !== undefined) data.assignedToId = dto.assignedToId || null; + if (dto.category) data.category = dto.category; + + if (dto.status === 'resolved') data.resolvedAt = new Date(); + if (dto.status === 'closed') data.closedAt = new Date(); + + return this.db.supportTicket.update({ where: { id }, data }); + } + + async assign(id: string, adminId: string) { + const ticket = await this.db.supportTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + return this.db.supportTicket.update({ + where: { id }, + data: { assignedToId: adminId, status: 'in_progress' }, + }); + } + + async resolve(id: string) { + const ticket = await this.db.supportTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + return this.db.supportTicket.update({ + where: { id }, + data: { status: 'resolved', resolvedAt: new Date() }, + }); + } + + async close(id: string) { + const ticket = await this.db.supportTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + return this.db.supportTicket.update({ + where: { id }, + data: { status: 'closed', closedAt: new Date() }, + }); + } + + async addComment(id: string, authorId: string, authorName: string, authorType: string, dto: CommentDto) { + const ticket = await this.db.supportTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + return this.db.supportTicketComment.create({ + data: { + ticketId: id, + authorId, + authorName, + authorType, + content: dto.content, + }, + }); + } + + // ─── Attachments ────────────────────────────────────── + + async addAttachment(ticketId: string, file: Express.Multer.File, uploadedBy: string, commentId?: string) { + const ticket = await this.db.supportTicket.findUnique({ where: { id: ticketId } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + if (commentId) { + const comment = await this.db.supportTicketComment.findFirst({ where: { id: commentId, ticketId } }); + if (!comment) throw new NotFoundException('Comment not found'); + } + + return this.db.supportTicketAttachment.create({ + data: { + ticketId, + commentId: commentId || null, + fileName: file.filename, + originalName: file.originalname, + mimeType: file.mimetype, + sizeBytes: file.size, + uploadedBy, + }, + }); + } + + async getAttachment(fileName: string) { + const attachment = await this.db.supportTicketAttachment.findFirst({ where: { fileName } }); + if (!attachment) throw new NotFoundException('Attachment not found'); + + const filePath = path.join(UPLOAD_DIR, fileName); + if (!fs.existsSync(filePath)) throw new NotFoundException('File not found on disk'); + + return { filePath, attachment }; + } + + async getAttachments(ticketId: string) { + return this.db.supportTicketAttachment.findMany({ + where: { ticketId }, + orderBy: { createdAt: 'asc' }, + }); + } + + async deleteAttachment(ticketId: string, attachmentId: string) { + const attachment = await this.db.supportTicketAttachment.findFirst({ + where: { id: attachmentId, ticketId }, + }); + if (!attachment) throw new NotFoundException('Attachment not found'); + + const filePath = path.join(UPLOAD_DIR, attachment.fileName); + try { fs.unlinkSync(filePath); } catch { /* already gone */ } + + return this.db.supportTicketAttachment.delete({ where: { id: attachmentId } }); + } + + async getTenantTickets(tenantId: string) { + return this.db.supportTicket.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + include: { + assignee: { select: { id: true, firstName: true, lastName: true } }, + _count: { select: { comments: true } }, + }, + }); + } + + async getTenantTicketDetail(tenantId: string, ticketId: string) { + const ticket = await this.db.supportTicket.findFirst({ + where: { id: ticketId, tenantId }, + include: { + assignee: { select: { id: true, firstName: true, lastName: true } }, + comments: { orderBy: { createdAt: 'asc' }, include: { attachments: true } }, + attachments: { where: { commentId: null }, orderBy: { createdAt: 'asc' } }, + }, + }); + if (!ticket) throw new NotFoundException('Ticket not found'); + return ticket; + } +} diff --git a/src/tenants/dto/create-tenant.dto.ts b/src/tenants/dto/create-tenant.dto.ts new file mode 100644 index 0000000..a9d5573 --- /dev/null +++ b/src/tenants/dto/create-tenant.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsEmail, IsObject } from 'class-validator'; + +export class CreateTenantDto { + @IsString() + name!: string; + + @IsString() + slug!: string; + + @IsEmail() + adminEmail!: string; + + @IsString() + adminPassword!: string; + + @IsString() + adminFirstName!: string; + + @IsString() + adminLastName!: string; + + @IsOptional() + @IsObject() + settings?: Record; +} diff --git a/src/tenants/dto/list-query.dto.ts b/src/tenants/dto/list-query.dto.ts new file mode 100644 index 0000000..279214c --- /dev/null +++ b/src/tenants/dto/list-query.dto.ts @@ -0,0 +1,24 @@ +import { IsOptional, IsString, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class TenantListQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + status?: 'active' | 'inactive' | 'all'; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; +} diff --git a/src/tenants/dto/update-tenant.dto.ts b/src/tenants/dto/update-tenant.dto.ts new file mode 100644 index 0000000..eeb041c --- /dev/null +++ b/src/tenants/dto/update-tenant.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsBoolean, IsObject } from 'class-validator'; + +export class UpdateTenantDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + slug?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsObject() + settings?: Record; +} diff --git a/src/tenants/tenants.controller.ts b/src/tenants/tenants.controller.ts new file mode 100644 index 0000000..223a8ba --- /dev/null +++ b/src/tenants/tenants.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, +} from '@nestjs/common'; +import { TenantsService } from './tenants.service'; +import { CreateTenantDto } from './dto/create-tenant.dto'; +import { UpdateTenantDto } from './dto/update-tenant.dto'; +import { TenantListQueryDto } from './dto/list-query.dto'; + +@Controller('tenants') +export class TenantsController { + constructor(private readonly service: TenantsService) {} + + @Get() + findAll(@Query() query: TenantListQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Post() + create(@Body() dto: CreateTenantDto) { + return this.service.create(dto); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() dto: UpdateTenantDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.service.remove(id); + } + + @Patch(':id/activate') + activate(@Param('id') id: string) { + return this.service.activate(id); + } + + @Patch(':id/deactivate') + deactivate(@Param('id') id: string) { + return this.service.deactivate(id); + } + + @Get(':id/users') + getTenantUsers(@Param('id') tenantId: string) { + return this.service.getTenantUsers(tenantId); + } +} diff --git a/src/tenants/tenants.module.ts b/src/tenants/tenants.module.ts new file mode 100644 index 0000000..22f3efd --- /dev/null +++ b/src/tenants/tenants.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TenantsController } from './tenants.controller'; +import { TenantsService } from './tenants.service'; + +@Module({ + controllers: [TenantsController], + providers: [TenantsService], +}) +export class TenantsModule {} diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts new file mode 100644 index 0000000..69f976c --- /dev/null +++ b/src/tenants/tenants.service.ts @@ -0,0 +1,323 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { TenantPrismaService } from '../prisma/tenant-prisma.service'; +import { AdminPrismaService } from '../prisma/admin-prisma.service'; +import { CreateTenantDto } from './dto/create-tenant.dto'; +import { UpdateTenantDto } from './dto/update-tenant.dto'; +import { TenantListQueryDto } from './dto/list-query.dto'; +import { DEFAULT_ROLE_PERMISSIONS } from '@fiberops/shared'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class TenantsService { + constructor( + private readonly tenantDb: TenantPrismaService, + private readonly adminDb: AdminPrismaService, + ) {} + + async findAll(query: TenantListQueryDto) { + const { search, status, page = 1, limit = 20 } = query; + const skip = (page - 1) * limit; + + const where: any = { deletedAt: null }; + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { slug: { contains: search, mode: 'insensitive' } }, + ]; + } + if (status === 'active') where.isActive = true; + if (status === 'inactive') where.isActive = false; + + const [tenants, total] = await Promise.all([ + this.tenantDb.tenant.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include: { + _count: { select: { users: { where: { deletedAt: null } }, clients: { where: { deletedAt: null } }, subscriptions: { where: { deletedAt: null } } } }, + }, + }), + this.tenantDb.tenant.count({ where }), + ]); + + return { + items: tenants.map((t) => ({ + id: t.id, + name: t.name, + slug: t.slug, + isActive: t.isActive, + settings: t.settings, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + _count: t._count, + })), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(id: string) { + const tenant = await this.tenantDb.tenant.findUnique({ + where: { id, deletedAt: null }, + include: { + users: { + where: { deletedAt: null }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + isActive: true, + roles: { select: { role: true } }, + tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } }, + }, + }, + _count: { + select: { + clients: { where: { deletedAt: null } }, + subscriptions: { where: { deletedAt: null } }, + invoices: { where: { deletedAt: null } }, + payments: { where: { deletedAt: null } }, + tickets: { where: { deletedAt: null } }, + }, + }, + }, + }); + + if (!tenant) throw new NotFoundException('Tenant not found'); + + // Revenue aggregation + const revenueAgg = await this.tenantDb.payment.aggregate({ + _sum: { amount: true }, + where: { tenantId: id, deletedAt: null }, + }); + + return { + ...tenant, + totalRevenue: revenueAgg._sum.amount || 0, + }; + } + + async create(dto: CreateTenantDto) { + const existing = await this.tenantDb.tenant.findUnique({ + where: { slug: dto.slug }, + }); + if (existing) throw new ConflictException('Tenant slug already taken'); + + const existingEmail = await this.tenantDb.user.findFirst({ + where: { email: dto.adminEmail }, + }); + if (existingEmail) throw new ConflictException('Admin email already in use'); + + const hashedPassword = await bcrypt.hash(dto.adminPassword, 12); + + const tenant = await this.tenantDb.tenant.create({ + data: { + name: dto.name, + slug: dto.slug, + settings: dto.settings || { + companyName: dto.name, + currency: 'PHP', + timezone: 'Asia/Manila', + }, + users: { + create: { + email: dto.adminEmail, + password: hashedPassword, + firstName: dto.adminFirstName, + lastName: dto.adminLastName, + mustChangePassword: true, + roles: { create: { role: 'tenant_admin' } }, + }, + }, + }, + include: { + users: { select: { id: true, email: true, firstName: true, lastName: true } }, + }, + }); + + const adminUser = tenant.users[0]; + await this.seedTenantDefaults(tenant.id, adminUser.id); + + return tenant; + } + + async update(id: string, dto: UpdateTenantDto) { + const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } }); + if (!tenant) throw new NotFoundException('Tenant not found'); + + if (dto.slug && dto.slug !== tenant.slug) { + const existing = await this.tenantDb.tenant.findUnique({ where: { slug: dto.slug } }); + if (existing) throw new ConflictException('Slug already taken'); + } + + return this.tenantDb.tenant.update({ + where: { id }, + data: { + ...(dto.name && { name: dto.name }), + ...(dto.slug && { slug: dto.slug }), + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + ...(dto.settings && { settings: dto.settings }), + }, + }); + } + + async remove(id: string) { + const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } }); + if (!tenant) throw new NotFoundException('Tenant not found'); + return this.tenantDb.tenant.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + } + + async activate(id: string) { + const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } }); + if (!tenant) throw new NotFoundException('Tenant not found'); + return this.tenantDb.tenant.update({ + where: { id }, + data: { isActive: true }, + }); + } + + async deactivate(id: string) { + const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } }); + if (!tenant) throw new NotFoundException('Tenant not found'); + return this.tenantDb.tenant.update({ + where: { id }, + data: { isActive: false }, + }); + } + + async getTenantUsers(tenantId: string) { + const tenant = await this.tenantDb.tenant.findUnique({ where: { id: tenantId, deletedAt: null } }); + if (!tenant) throw new NotFoundException('Tenant not found'); + + return this.tenantDb.user.findMany({ + where: { tenantId, deletedAt: null }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + isActive: true, + createdAt: true, + roles: { select: { role: true } }, + tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + private async seedTenantDefaults(tenantId: string, adminUserId: string) { + const ROLE_DEFS: { name: string; slug: string; description: string }[] = [ + { name: 'Tenant Admin', slug: 'tenant_admin', description: 'Full access to all modules' }, + { name: 'Manager', slug: 'manager', description: 'Operational management with approval rights' }, + { name: 'Technician', slug: 'technician', description: 'Field operations: clients, tickets, payments' }, + { name: 'Collector', slug: 'collector', description: 'Payment collection and client viewing' }, + ]; + + // Create roles with permissions + const roleMap: Record = {}; + for (const def of ROLE_DEFS) { + const perms = DEFAULT_ROLE_PERMISSIONS[def.slug] || []; + const role = await this.tenantDb.tenantRole.create({ + data: { + tenantId, + name: def.name, + slug: def.slug, + description: def.description, + isSystem: true, + permissions: { + create: perms.map((p) => ({ + module: p.module, + canView: p.canView, + canCreate: p.canCreate, + canUpdate: p.canUpdate, + canArchive: p.canArchive, + canApprove: p.canApprove, + canExport: p.canExport, + })), + }, + }, + }); + roleMap[def.slug] = role.id; + } + + // Assign admin user to tenant_admin role + await this.tenantDb.userTenantRole.create({ + data: { userId: adminUserId, tenantRoleId: roleMap['tenant_admin'] }, + }); + + // Areas + await Promise.all([ + this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 1 - Centro', description: 'Town center, commercial area' } }), + this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 2 - Poblacion', description: 'Residential zone near market' } }), + this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 3 - San Isidro', description: 'Agricultural and residential' } }), + this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 4 - Riverside', description: 'River-side residential' } }), + this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 5 - Hilltop', description: 'Elevated residential subdivision' } }), + ]); + + // Plans + await Promise.all([ + this.tenantDb.plan.create({ data: { tenantId, name: 'Lite 15', description: 'Entry-level 15 Mbps', speedDown: 15, speedUp: 15, price: 699, billingCycle: 30 } }), + this.tenantDb.plan.create({ data: { tenantId, name: 'Basic 25', description: '25 Mbps residential', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 } }), + this.tenantDb.plan.create({ data: { tenantId, name: 'Standard 50', description: '50 Mbps residential', speedDown: 50, speedUp: 50, price: 1499, billingCycle: 30 } }), + this.tenantDb.plan.create({ data: { tenantId, name: 'Premium 100', description: '100 Mbps business', speedDown: 100, speedUp: 100, price: 2499, billingCycle: 30 } }), + this.tenantDb.plan.create({ data: { tenantId, name: 'Enterprise 200', description: '200 Mbps dedicated', speedDown: 200, speedUp: 200, price: 4999, billingCycle: 30 } }), + ]); + + // Chart of Accounts + const coaDefs = [ + { code: '1000', name: 'Assets', type: 'asset' }, + { code: '1010', name: 'Cash on Hand', type: 'asset' }, + { code: '1020', name: 'GCash Business', type: 'asset' }, + { code: '1030', name: 'Maya Business', type: 'asset' }, + { code: '1040', name: 'Bank Account', type: 'asset' }, + { code: '1100', name: 'Accounts Receivable', type: 'asset' }, + { code: '1200', name: 'Equipment', type: 'asset' }, + { code: '2000', name: 'Liabilities', type: 'liability' }, + { code: '2010', name: 'Accounts Payable', type: 'liability' }, + { code: '3000', name: 'Equity', type: 'equity' }, + { code: '3010', name: "Owner's Equity", type: 'equity' }, + { code: '3020', name: 'Retained Earnings', type: 'equity' }, + { code: '4000', name: 'Revenue', type: 'revenue' }, + { code: '4010', name: 'Internet Service Revenue', type: 'revenue' }, + { code: '4020', name: 'Installation Fees', type: 'revenue' }, + { code: '5000', name: 'Expenses', type: 'expense' }, + { code: '5010', name: 'Utilities Expense', type: 'expense' }, + { code: '5020', name: 'Salaries Expense', type: 'expense' }, + { code: '5030', name: 'Maintenance Expense', type: 'expense' }, + { code: '5040', name: 'Transport Expense', type: 'expense' }, + { code: '5050', name: 'Supplies Expense', type: 'expense' }, + { code: '5060', name: 'Equipment Expense', type: 'expense' }, + ]; + + for (const a of coaDefs) { + await this.tenantDb.chartOfAccount.create({ + data: { tenantId, code: a.code, name: a.name, type: a.type as any, isSystem: true }, + }); + } + + // Company Accounts (linked to CoA) + const coa1010 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1010' } }); + const coa1020 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1020' } }); + const coa1030 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1030' } }); + const coa1040 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1040' } }); + + await Promise.all([ + this.tenantDb.companyAccount.create({ data: { tenantId, name: 'Cash on Hand', type: 'cash', balance: 0, isSystem: true, chartOfAccountId: coa1010?.id } }), + this.tenantDb.companyAccount.create({ data: { tenantId, name: 'GCash Business', type: 'e_wallet', balance: 0, chartOfAccountId: coa1020?.id } }), + this.tenantDb.companyAccount.create({ data: { tenantId, name: 'Maya Business', type: 'e_wallet', balance: 0, chartOfAccountId: coa1030?.id } }), + this.tenantDb.companyAccount.create({ data: { tenantId, name: 'BDO Savings', type: 'bank', balance: 0, chartOfAccountId: coa1040?.id } }), + ]); + + // Billing Settings + await this.tenantDb.billingSetting.create({ + data: { tenantId, autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, invoicePrefix: 'INV' }, + }); + } +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 0000000..284b333 --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Patch, Param, Body, Query } from '@nestjs/common'; +import { UsersService } from './users.service'; + +@Controller('users') +export class UsersController { + constructor(private readonly service: UsersService) {} + + @Get() + findAll( + @Query('search') search?: string, + @Query('tenantId') tenantId?: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.service.findAll({ search, tenantId, page, limit }); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: { isActive?: boolean; firstName?: string; lastName?: string }, + ) { + return this.service.update(id, dto); + } + + @Patch(':id/reset-password') + resetPassword(@Param('id') id: string, @Body() dto: { password: string }) { + return this.service.resetPassword(id, dto.password); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 0000000..440ef36 --- /dev/null +++ b/src/users/users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], +}) +export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 0000000..b76fadd --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,91 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { TenantPrismaService } from '../prisma/tenant-prisma.service'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class UsersService { + constructor(private readonly tenantDb: TenantPrismaService) {} + + async findAll(query: { search?: string; tenantId?: string; page?: number; limit?: number }) { + const { search, tenantId, page = 1, limit = 20 } = query; + const skip = (page - 1) * limit; + + const where: any = { deletedAt: null }; + if (search) { + where.OR = [ + { firstName: { contains: search, mode: 'insensitive' } }, + { lastName: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + ]; + } + if (tenantId) where.tenantId = tenantId; + + const [items, total] = await Promise.all([ + this.tenantDb.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + isActive: true, + createdAt: true, + tenant: { select: { id: true, name: true, slug: true } }, + roles: { select: { role: true } }, + tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } }, + }, + }), + this.tenantDb.user.count({ where }), + ]); + + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async findOne(id: string) { + const user = await this.tenantDb.user.findUnique({ + where: { id, deletedAt: null }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + isActive: true, + createdAt: true, + updatedAt: true, + tenant: { select: { id: true, name: true, slug: true } }, + roles: { select: { role: true } }, + tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } }, + }, + }); + if (!user) throw new NotFoundException('User not found'); + return user; + } + + async update(id: string, dto: { isActive?: boolean; firstName?: string; lastName?: string }) { + const user = await this.tenantDb.user.findUnique({ where: { id, deletedAt: null } }); + if (!user) throw new NotFoundException('User not found'); + + return this.tenantDb.user.update({ + where: { id }, + data: { + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + ...(dto.firstName && { firstName: dto.firstName }), + ...(dto.lastName && { lastName: dto.lastName }), + }, + }); + } + + async resetPassword(id: string, newPassword: string) { + const user = await this.tenantDb.user.findUnique({ where: { id, deletedAt: null } }); + if (!user) throw new NotFoundException('User not found'); + + const hashed = await bcrypt.hash(newPassword, 12); + return this.tenantDb.user.update({ + where: { id }, + data: { password: hashed }, + }); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c5c41a8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "target": "ES2022", + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "strictPropertyInitialization": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From a03d803963ee417d8a07954fcc5d04c8ae3dae2c Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 13 Apr 2026 12:04:26 +0800 Subject: [PATCH 2/4] fix: standalone tsconfig and Dockerfile for monorepo split --- Dockerfile | 11 +++++++++-- packages/shared/tsconfig.json | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index e88b8ad..7f68cbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,20 @@ FROM node:20-alpine AS builder WORKDIR /app + COPY package.json package-lock.json* ./ COPY packages/shared/package.json ./packages/shared/ COPY packages/db/package.json ./packages/db/ COPY packages/admin-db/package.json ./packages/admin-db/ RUN npm install + COPY packages/shared/ ./packages/shared/ COPY packages/db/ ./packages/db/ COPY packages/admin-db/ ./packages/admin-db/ -COPY . . -RUN cd packages/shared && npx tsc --module commonjs --moduleResolution node --outDir dist --declaration +COPY nest-cli.json ./ +COPY tsconfig.json ./ +COPY src/ ./src/ + +RUN cd packages/shared && npx tsc --outDir dist --declaration RUN cd packages/admin-db && DATABASE_ADMIN_URL="postgresql://placeholder" npx prisma generate RUN cd packages/db && DATABASE_URL="postgresql://placeholder" npx prisma generate RUN npx nest build @@ -17,6 +22,7 @@ RUN npx 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/packages/admin-db/generated ./packages/admin-db/generated COPY --from=builder /app/packages/admin-db/prisma ./packages/admin-db/prisma @@ -27,6 +33,7 @@ RUN node -e "const p=require('./packages/shared/package.json');p.main='./dist/in COPY --from=builder /app/packages/db/prisma ./packages/db/prisma COPY --from=builder /app/packages/db/package.json ./packages/db/ COPY --from=builder /app/dist ./dist + ENV NODE_ENV=production EXPOSE 3004 ENTRYPOINT ["dumb-init", "--"] diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 792172f..dae4070 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,8 +1,20 @@ { - "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true, "outDir": "./dist", "rootDir": "./src" }, - "include": ["src"] + "include": ["src"], + "exclude": ["node_modules", "dist"] } From d1ff02100755269a5467c9ec695ee58cc09b395b Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 13 Apr 2026 12:14:07 +0800 Subject: [PATCH 3/4] fix: use npx -p @nestjs/cli for nest build --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7f68cbf..e5ffdc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ COPY src/ ./src/ RUN cd packages/shared && npx tsc --outDir dist --declaration RUN cd packages/admin-db && DATABASE_ADMIN_URL="postgresql://placeholder" npx prisma generate RUN cd packages/db && DATABASE_URL="postgresql://placeholder" npx prisma generate -RUN npx nest build +RUN npx -p @nestjs/cli nest build FROM node:20-alpine AS runner WORKDIR /app From 8f46de9a288fcc2a4f6a51fe809238ee08d750d5 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 13 Apr 2026 12:36:28 +0800 Subject: [PATCH 4/4] fix: set NODE_ENV=development during build for devDependencies --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index e5ffdc9..3d335ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ 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/