Compare commits

9 Commits

Author SHA1 Message Date
kevin-asprec
357d162d97 revert: remove forced seed from CMD, use post_deployment_command instead 2026-04-15 10:14:50 +08:00
kevin-asprec
0696d37e9b feat: force seed for initial UAT deploy 2026-04-15 09:59:12 +08:00
kevin-asprec
91572829e7 feat: add RUN_SEED support to Dockerfile CMD 2026-04-15 09:48:26 +08:00
kevin-asprec
88148e6fbb fix: compile shared package as CommonJS for NestJS compatibility 2026-04-13 16:53:04 +08:00
kevin-asprec
4e69e7738b merge: develop into main 2026-04-13 13:04:45 +08:00
kevin-asprec
8f46de9a28 fix: set NODE_ENV=development during build for devDependencies 2026-04-13 12:36:28 +08:00
kevin-asprec
d1ff021007 fix: use npx -p @nestjs/cli for nest build 2026-04-13 12:14:07 +08:00
kevin-asprec
a03d803963 fix: standalone tsconfig and Dockerfile for monorepo split 2026-04-13 12:04:26 +08:00
kevin-asprec
5382f3b4e5 initial: standalone repo from monorepo split 2026-04-13 09:43:35 +08:00
87 changed files with 5299 additions and 0 deletions

5
.dockerignore Normal file
View File

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

4
.env.example Normal file
View File

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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.env
.env.local
.env.*.local
*.tsbuildinfo
generated/
packages/admin-db/generated/

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
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/
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 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 -p @nestjs/cli nest build
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/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/packages/admin-db/prisma/seed.ts ./packages/admin-db/prisma/seed.ts
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"]

8
nest-cli.json Normal file
View File

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

47
package.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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")
}

View File

@@ -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());

View File

@@ -0,0 +1 @@
export { PrismaClient, Prisma } from '../generated';

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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")
}

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

@@ -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<string> {
return bcrypt.hash(password, 12);
}
async function main() {
console.log('Seeding database...');
// Clean existing data (order matters for FK constraints)
await prisma.journalLine.deleteMany();
await prisma.journalEntry.deleteMany();
await prisma.chartOfAccount.deleteMany();
await prisma.billingSetting.deleteMany();
await prisma.fundTransfer.deleteMany();
await prisma.companyAccount.deleteMany();
await prisma.asset.deleteMany();
await prisma.expense.deleteMany();
await prisma.payslip.deleteMany();
await prisma.payrollRun.deleteMany();
await prisma.recurringExpense.deleteMany();
await prisma.employee.deleteMany();
await prisma.notification.deleteMany();
await prisma.remittancePayment.deleteMany();
await prisma.remittance.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.payment.deleteMany();
await prisma.invoice.deleteMany();
await prisma.ticket.deleteMany();
await prisma.subscription.deleteMany();
await prisma.client.deleteMany();
await prisma.plan.deleteMany();
await prisma.area.deleteMany();
await prisma.refreshToken.deleteMany();
await prisma.userTenantRole.deleteMany();
await prisma.rolePermission.deleteMany();
await prisma.tenantRole.deleteMany();
await prisma.userRole.deleteMany();
await prisma.user.deleteMany();
await prisma.tenant.deleteMany();
// ─── Tenant ────────────────────────────────────────────
const tenant = await prisma.tenant.create({
data: {
id: randomUUID(),
name: 'FiberNet Philippines',
slug: 'fibernet-ph',
settings: { companyName: 'FiberNet Philippines Inc.', currency: 'PHP', timezone: 'Asia/Manila' },
},
});
console.log(`Tenant: ${tenant.name}`);
// ─── Super Admin (platform-level, no tenant) ───────────
const superAdmin = await prisma.user.create({
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
});
await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } });
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
// ─── Tenant Users ─────────────────────────────────────
const users: Record<string, any> = {};
const usersByEmail: Record<string, any> = {};
const userDefs = [
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: '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<string, any> = {};
for (const def of DEFAULT_ROLES) {
const role = await prisma.tenantRole.create({
data: {
tenantId: tenant.id,
name: def.name,
slug: def.slug,
description: def.description,
isSystem: true,
permissions: {
create: def.perms.map((p) => ({
module: p.module,
canView: p.canView,
canCreate: p.canCreate,
canUpdate: p.canUpdate,
canArchive: p.canArchive,
canApprove: p.canApprove,
canExport: p.canExport,
})),
},
},
});
tenantRoles[def.slug] = role;
}
console.log(`Tenant Roles: ${DEFAULT_ROLES.length} (${DEFAULT_ROLES.map((r) => r.slug).join(', ')})`);
// ─── Assign Tenant Roles to Users ─────────────────────
// Map of old role names to new tenant role slugs
const userRoleMap: Record<string, string> = {
tenant_admin: 'tenant_admin',
manager: 'manager',
technician: 'technician',
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(); });

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<string, number> = {
[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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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 });
}
}

11
src/audit/audit.module.ts Normal file
View File

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

View File

@@ -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,
},
});
}
}

View File

@@ -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);
}
}

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

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

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

@@ -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<string>(
'ADMIN_JWT_REFRESH_SECRET',
'admin-refresh-secret',
);
const refreshExpiresIn = this.config.get<string>(
'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 };
}
}

View File

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

View File

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

View File

@@ -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<string>('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,
};
}
}

View File

@@ -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;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -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<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as { message?: string }).message || message;
} else {
console.error('[GlobalExceptionFilter] Unhandled exception:', exception);
}
response.status(status).json({
success: false,
data: null,
error: Array.isArray(message) ? message.join(', ') : message,
});
}
}

View File

@@ -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<string>('ADMIN_JWT_SECRET', 'admin-jwt-secret')!;
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const url = request.url || '';
// Skip auth for public support endpoints
if (url.includes('/public/support')) {
return true;
}
const isPublic = this.reflector.getAllAndOverride<boolean>(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;
}
}

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

@@ -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(),
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -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);
}
}

View File

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

View File

@@ -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<string>('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' };
}
}

45
src/main.ts Normal file
View File

@@ -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();

View File

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

View File

@@ -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();
}
}

View File

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

View File

@@ -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();
}
}

View File

@@ -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<string>('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!);
}
}

View File

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

View File

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

View File

@@ -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';
}

View File

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

View File

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

View File

@@ -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);
}
},
};

View File

@@ -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);
}
}

View File

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

View File

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

View File

@@ -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<string, any>;
}

View File

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

View File

@@ -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<string, any>;
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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<string, string> = {};
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' },
});
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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 },
});
}
}

20
tsconfig.json Normal file
View File

@@ -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"]
}