Compare commits
39 Commits
0321239266
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc89ed124f | ||
|
|
d4d9249933 | ||
|
|
6ea1f412a1 | ||
|
|
7f9beaac2b | ||
|
|
85aea30730 | ||
|
|
da9707f3fb | ||
|
|
59ee1fbe33 | ||
|
|
248f6b51ea | ||
|
|
5f4a4c6874 | ||
|
|
8fdfa6b7ba | ||
|
|
ade5df1c32 | ||
|
|
d2864961de | ||
|
|
91ea365cf9 | ||
|
|
58c01d4580 | ||
|
|
58d77fc67f | ||
|
|
c14521a41d | ||
|
|
83ad0cb8d5 | ||
|
|
469fa6bb28 | ||
|
|
94528841d9 | ||
|
|
a66fff68de | ||
|
|
40f30b4a5d | ||
|
|
bb3baeb6ac | ||
|
|
ff6295e1fc | ||
|
|
555327a891 | ||
|
|
29c6b70878 | ||
|
|
d7ab370bb5 | ||
|
|
1a0b4916d0 | ||
|
|
3b032ab7c4 | ||
|
|
d792a9a9ed | ||
|
|
c923285c7e | ||
|
|
ff0dd2e418 | ||
|
|
a5ed2cc666 | ||
|
|
bef320f32e | ||
|
|
555bd4a9f8 | ||
|
|
fc704c2d23 | ||
|
|
1c4424f2a6 | ||
|
|
6da4cac2e7 | ||
|
|
2f4323ebea | ||
|
|
4a8f1e9318 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fiberops
|
||||
JWT_SECRET=your-secret-key
|
||||
PORT=3001
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.tsbuildinfo
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=development
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
RUN npm install
|
||||
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY packages/db/ ./packages/db/
|
||||
COPY nest-cli.json ./
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
|
||||
RUN cd packages/shared && npx tsc --outDir dist --declaration
|
||||
RUN cd packages/db && npx prisma generate
|
||||
RUN npx -p @nestjs/cli nest build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
||||
RUN node -e "const p=require('./packages/shared/package.json');p.main='./dist/index.js';require('fs').writeFileSync('./packages/shared/package.json',JSON.stringify(p,null,2))"
|
||||
COPY --from=builder /app/packages/db/prisma ./packages/db/prisma
|
||||
COPY --from=builder /app/packages/db/package.json ./packages/db/
|
||||
COPY --from=builder /app/packages/db/src ./packages/db/src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3001
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "cd packages/db && npx prisma migrate resolve --applied 20260504100000_add_user_must_change_password 2>/dev/null; npx prisma migrate resolve --applied 20260506070000_add_client_coordinates 2>/dev/null; npx prisma migrate deploy && if [ \"$RUN_SEED\" = \"true\" ]; then echo 'Seeding database...' && npx tsx prisma/seed.ts; fi && cd /app && node dist/main"]
|
||||
11
alter-tenant-id.sh
Normal file
11
alter-tenant-id.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
cd packages/db
|
||||
node -e "
|
||||
const { Client } = require('pg');
|
||||
const url = process.env.DATABASE_URL.replace(/%21/g, '!');
|
||||
const c = new Client(url);
|
||||
c.connect().then(() => c.query('ALTER TABLE users ALTER COLUMN tenantId DROP NOT NULL'))
|
||||
.then(() => { console.log('tenantId nullable OK'); return c.end(); })
|
||||
.catch(e => { console.log('Error:', e.message.substring(0,100)); return c.end(); });
|
||||
"
|
||||
npx tsx prisma/seed.ts
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
7602
package-lock.json
generated
Normal file
7602
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "fiberops-api",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main",
|
||||
"start:prod": "node dist/main",
|
||||
"db:generate": "cd packages/db && npx prisma generate",
|
||||
"db:migrate": "cd packages/db && npx prisma migrate dev",
|
||||
"db:migrate:deploy": "cd packages/db && npx prisma migrate deploy",
|
||||
"db:seed": "cd packages/db && npx prisma db seed",
|
||||
"db:studio": "cd packages/db && npx prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fiberops/db": "workspace:*",
|
||||
"@fiberops/shared": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"helmet": "^8.0.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
28
packages/db/package.json
Normal file
28
packages/db/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
361
packages/db/prisma/migrations/20260403015002_init/migration.sql
Normal file
361
packages/db/prisma/migrations/20260403015002_init/migration.sql
Normal 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;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "company_accounts" ADD COLUMN "chartOfAccountId" TEXT,
|
||||
ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "clients" ADD COLUMN "latitude" DOUBLE PRECISION;
|
||||
ALTER TABLE "clients" ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- AlterTable: make tenantId nullable for super_admin users
|
||||
ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL;
|
||||
|
||||
-- Fix FK to allow NULL (super_admin has no tenant)
|
||||
ALTER TABLE "users" DROP CONSTRAINT "users_tenantId_fkey";
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Drop unique index that requires tenantId (email must be unique globally for super_admin)
|
||||
DROP INDEX IF EXISTS "users_tenantId_email_key";
|
||||
CREATE UNIQUE INDEX "users_tenantId_email_key" ON "users"("tenantId", "email") WHERE "tenantId" IS NOT NULL;
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email") WHERE "tenantId" IS NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tickets" ADD COLUMN "latitude" DOUBLE PRECISION,
|
||||
ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ticket_comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ticket_comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ticket_attachments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"commentId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"fileType" TEXT NOT NULL,
|
||||
"fileSize" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ticket_attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AlterTable: add ticketId to notifications
|
||||
ALTER TABLE "notifications" ADD COLUMN "ticketId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ticket_comments_ticketId_idx" ON "ticket_comments"("ticketId");
|
||||
CREATE INDEX "ticket_comments_tenantId_idx" ON "ticket_comments"("tenantId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "ticket_attachments" ADD CONSTRAINT "ticket_attachments_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "ticket_comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "clients" ADD COLUMN "latitude" DOUBLE PRECISION;
|
||||
ALTER TABLE "clients" ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||
3
packages/db/prisma/migrations/migration_lock.toml
Normal file
3
packages/db/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
726
packages/db/prisma/schema.prisma
Normal file
726
packages/db/prisma/schema.prisma
Normal file
@@ -0,0 +1,726 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── Multi-Tenant Foundation ────────────────────────────────
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
isActive Boolean @default(true)
|
||||
settings Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
users User[]
|
||||
tenantRoles TenantRole[]
|
||||
areas Area[]
|
||||
plans Plan[]
|
||||
clients Client[]
|
||||
subscriptions Subscription[]
|
||||
invoices Invoice[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
notifications Notification[]
|
||||
ticketComments TicketComment[]
|
||||
|
||||
@@index([deletedAt])
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
// ─── Auth & Users ───────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
tenantId String? // null for super_admin (platform-level user)
|
||||
email String
|
||||
password String
|
||||
firstName String
|
||||
lastName String
|
||||
isActive Boolean @default(true)
|
||||
mustChangePassword Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
roles UserRole[]
|
||||
tenantRoles UserTenantRole[]
|
||||
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||
createdTickets Ticket[] @relation("TicketCreator")
|
||||
authoredComments TicketComment[] @relation("CommentAuthor")
|
||||
payments Payment[]
|
||||
remittances Remittance[] @relation("RemittanceCollector")
|
||||
confirmedRemittances Remittance[] @relation("RemittanceConfirmer")
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
@@index([deletedAt])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
role String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, role])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
// ─── Tenant-Scoped Role Management ─────────────────────────
|
||||
|
||||
model TenantRole {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
slug String
|
||||
description String?
|
||||
isSystem Boolean @default(false) // system roles can't be deleted
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
permissions RolePermission[]
|
||||
users UserTenantRole[]
|
||||
|
||||
@@unique([tenantId, slug])
|
||||
@@index([tenantId])
|
||||
@@index([deletedAt])
|
||||
@@map("tenant_roles")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
id String @id @default(uuid())
|
||||
tenantRoleId String
|
||||
module String
|
||||
canView Boolean @default(false)
|
||||
canCreate Boolean @default(false)
|
||||
canUpdate Boolean @default(false)
|
||||
canArchive Boolean @default(false)
|
||||
canApprove Boolean @default(false)
|
||||
canExport Boolean @default(false)
|
||||
|
||||
tenantRole TenantRole @relation(fields: [tenantRoleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantRoleId, module])
|
||||
@@index([tenantRoleId])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
model UserTenantRole {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tenantRoleId String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenantRole TenantRole @relation(fields: [tenantRoleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, tenantRoleId])
|
||||
@@index([userId])
|
||||
@@index([tenantRoleId])
|
||||
@@map("user_tenant_roles")
|
||||
}
|
||||
|
||||
// ─── Refresh Token ───────────────────────────────────
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
token String @unique
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// ─── Area & Zone Management ─────────────────────────────────
|
||||
|
||||
model Area {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
clients Client[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
@@index([deletedAt])
|
||||
@@map("areas")
|
||||
}
|
||||
|
||||
// ─── Plan / Package Management ─────────────────────────
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
description String?
|
||||
speedDown Int // Mbps download
|
||||
speedUp Int // Mbps upload
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
billingCycle Int @default(30) // days
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
subscriptions Subscription[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
@@index([deletedAt])
|
||||
@@map("plans")
|
||||
}
|
||||
|
||||
// ─── Client Profiling ─────────────────────────────────
|
||||
|
||||
model Client {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
accountNumber String
|
||||
firstName String
|
||||
lastName String
|
||||
email String?
|
||||
phone String?
|
||||
address String
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
areaId String?
|
||||
status String @default("active") // active, inactive, suspended
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
area Area? @relation(fields: [areaId], references: [id])
|
||||
subscriptions Subscription[]
|
||||
invoices Invoice[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
|
||||
@@unique([tenantId, accountNumber])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, status])
|
||||
@@index([deletedAt])
|
||||
@@map("clients")
|
||||
}
|
||||
|
||||
// ─── Subscription Management ─────────────────────────
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
clientId String
|
||||
planId String
|
||||
type String // prepaid, postpaid
|
||||
status String @default("pending") // pending, active, suspended, cancelled, expired
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
installedAt DateTime?
|
||||
activatedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
plan Plan @relation(fields: [planId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, status])
|
||||
@@index([clientId])
|
||||
@@index([deletedAt])
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
// ─── Billing & Invoicing ───────────────────────────────────
|
||||
|
||||
model Invoice {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
clientId String
|
||||
number String
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
balance Decimal @db.Decimal(10, 2) // remaining unpaid
|
||||
status String @default("draft") // draft, sent, partial, paid, overdue, void
|
||||
dueDate DateTime
|
||||
paidAt DateTime?
|
||||
periodStart DateTime?
|
||||
periodEnd DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
payments Payment[]
|
||||
|
||||
@@unique([tenantId, number])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, status])
|
||||
@@index([clientId])
|
||||
@@index([deletedAt])
|
||||
@@map("invoices")
|
||||
}
|
||||
|
||||
// ─── Payment & Collection ─────────────────────────────────
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
clientId String
|
||||
invoiceId String?
|
||||
collectedById String?
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
method String // gcash, maya, cash, bank_transfer
|
||||
referenceNo String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
invoice Invoice? @relation(fields: [invoiceId], references: [id])
|
||||
collectedBy User? @relation(fields: [collectedById], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([clientId])
|
||||
@@index([invoiceId])
|
||||
@@index([deletedAt])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
// ─── Remittance ─────────────────────────────────────────
|
||||
|
||||
model Remittance {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
collectorId String
|
||||
confirmedById String?
|
||||
totalAmount Decimal @db.Decimal(10, 2)
|
||||
status String @default("pending") // pending, confirmed, rejected
|
||||
submittedAt DateTime @default(now())
|
||||
confirmedAt DateTime?
|
||||
notes String?
|
||||
deletedAt DateTime?
|
||||
|
||||
collector User @relation("RemittanceCollector", fields: [collectorId], references: [id])
|
||||
confirmedBy User? @relation("RemittanceConfirmer", fields: [confirmedById], references: [id])
|
||||
payments RemittancePayment[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([collectorId])
|
||||
@@index([deletedAt])
|
||||
@@map("remittances")
|
||||
}
|
||||
|
||||
model RemittancePayment {
|
||||
id String @id @default(uuid())
|
||||
remittanceId String
|
||||
paymentId String
|
||||
|
||||
remittance Remittance @relation(fields: [remittanceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([remittanceId, paymentId])
|
||||
@@map("remittance_payments")
|
||||
}
|
||||
|
||||
// ─── Tickets (Work Orders) ─────────────────────────────────
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
clientId String?
|
||||
createdById String
|
||||
assigneeId String?
|
||||
type String // installation, activation, support, maintenance
|
||||
status String @default("open") // open, in_progress, resolved, cancelled
|
||||
title String
|
||||
description String?
|
||||
priority String @default("normal") // low, normal, high, urgent
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
client Client? @relation(fields: [clientId], references: [id])
|
||||
createdBy User @relation("TicketCreator", fields: [createdById], references: [id])
|
||||
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
|
||||
comments TicketComment[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, type, status])
|
||||
@@index([clientId])
|
||||
@@index([assigneeId])
|
||||
@@index([deletedAt])
|
||||
@@map("tickets")
|
||||
}
|
||||
|
||||
// ─── Notifications ─────────────────────────────────────────
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
userId String?
|
||||
clientId String?
|
||||
type String // sms, in_app
|
||||
channel String // billing_reminder, payment_confirmation, ticket_update
|
||||
title String
|
||||
message String
|
||||
isRead Boolean @default(false)
|
||||
sentAt DateTime?
|
||||
ticketId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([userId, isRead])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
// ─── Ticket Comments ─────────────────────────────────
|
||||
|
||||
model TicketComment {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
ticketId String
|
||||
userId String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
author User @relation("CommentAuthor", fields: [userId], references: [id])
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
attachments TicketAttachment[]
|
||||
|
||||
@@index([ticketId])
|
||||
@@map("ticket_comments")
|
||||
}
|
||||
|
||||
// ─── Ticket Attachments ─────────────────────────────────
|
||||
|
||||
model TicketAttachment {
|
||||
id String @id @default(uuid())
|
||||
commentId String
|
||||
fileName String
|
||||
filePath String
|
||||
fileType String
|
||||
fileSize Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
comment TicketComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("ticket_attachments")
|
||||
}
|
||||
|
||||
// ─── Audit Log (append-only, no soft delete) ─────────────────
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
userId String
|
||||
action String
|
||||
entity String
|
||||
entityId String
|
||||
details Json @default("{}")
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, entity])
|
||||
@@index([userId])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// ─── Employee Management ─────────────────────────
|
||||
|
||||
model Employee {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
userId String? @unique // Linked user account (optional, 1:1)
|
||||
firstName String
|
||||
lastName String
|
||||
email String?
|
||||
phone String?
|
||||
position String
|
||||
department String?
|
||||
employeeNo String
|
||||
status String @default("active") // active, on_leave, terminated
|
||||
hireDate DateTime @default(now())
|
||||
terminatedAt DateTime?
|
||||
salary Decimal? @db.Decimal(10, 2)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
assets Asset[] @relation("AssetAssignee")
|
||||
payslips Payslip[]
|
||||
|
||||
@@unique([tenantId, employeeNo])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, status])
|
||||
@@index([deletedAt])
|
||||
@@map("employees")
|
||||
}
|
||||
|
||||
// ─── Payroll ─────────────────────────────────────────────
|
||||
|
||||
model PayrollRun {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
period String
|
||||
status String @default("draft") // draft, processing, completed
|
||||
totalAmount Decimal @default(0) @db.Decimal(12, 2)
|
||||
processedBy String?
|
||||
processedAt DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
payslips Payslip[]
|
||||
|
||||
@@unique([tenantId, period])
|
||||
@@index([tenantId])
|
||||
@@index([deletedAt])
|
||||
@@map("payroll_runs")
|
||||
}
|
||||
|
||||
model Payslip {
|
||||
id String @id @default(uuid())
|
||||
payrollRunId String
|
||||
employeeId String
|
||||
baseSalary Decimal @db.Decimal(10, 2)
|
||||
deductions Decimal @default(0) @db.Decimal(10, 2)
|
||||
bonuses Decimal @default(0) @db.Decimal(10, 2)
|
||||
netPay Decimal @db.Decimal(10, 2)
|
||||
status String @default("pending") // pending, paid
|
||||
notes String?
|
||||
|
||||
payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id])
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
|
||||
@@unique([payrollRunId, employeeId])
|
||||
@@index([payrollRunId])
|
||||
@@map("payslips")
|
||||
}
|
||||
|
||||
// ─── Recurring Expenses ─────────────────────────
|
||||
|
||||
model RecurringExpense {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
category String
|
||||
description String
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
frequency String @default("monthly") // monthly, quarterly, yearly
|
||||
isActive Boolean @default(true)
|
||||
nextRunDate DateTime
|
||||
lastRunDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([deletedAt])
|
||||
@@map("recurring_expenses")
|
||||
}
|
||||
|
||||
// ─── Expense Management ─────────────────────────
|
||||
|
||||
model Expense {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
createdById String
|
||||
approvedById String?
|
||||
category String
|
||||
description String
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
receiptUrl String?
|
||||
status String @default("pending") // pending, approved, rejected
|
||||
expenseDate DateTime @default(now())
|
||||
approvedAt DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, category])
|
||||
@@index([deletedAt])
|
||||
@@map("expenses")
|
||||
}
|
||||
|
||||
// ─── Company Accounts & Fund Transfers ─────────────────
|
||||
|
||||
model CompanyAccount {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
type String // bank, e_wallet, cash
|
||||
accountNo String?
|
||||
balance Decimal @default(0) @db.Decimal(12, 2)
|
||||
isActive Boolean @default(true)
|
||||
isSystem Boolean @default(false)
|
||||
chartOfAccountId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
outgoing FundTransfer[] @relation("TransferFrom")
|
||||
incoming FundTransfer[] @relation("TransferTo")
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
@@map("company_accounts")
|
||||
}
|
||||
|
||||
model FundTransfer {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
fromAccountId String
|
||||
toAccountId String
|
||||
amount Decimal @db.Decimal(12, 2)
|
||||
description String?
|
||||
transferredBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
fromAccount CompanyAccount @relation("TransferFrom", fields: [fromAccountId], references: [id])
|
||||
toAccount CompanyAccount @relation("TransferTo", fields: [toAccountId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("fund_transfers")
|
||||
}
|
||||
|
||||
// ─── Asset Management ─────────────────────────────────
|
||||
|
||||
model Asset {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
category String // router, olt, cable, tool, vehicle, computer, other
|
||||
serialNumber String?
|
||||
purchaseDate DateTime?
|
||||
purchasePrice Decimal? @db.Decimal(10, 2)
|
||||
assignedToId String?
|
||||
status String @default("available") // available, in_use, maintenance, retired
|
||||
location String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
assignedTo Employee? @relation("AssetAssignee", fields: [assignedToId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, category])
|
||||
@@index([deletedAt])
|
||||
@@map("assets")
|
||||
}
|
||||
|
||||
// ─── Billing Settings ─────────────────────────
|
||||
|
||||
model BillingSetting {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
autoGenerate Boolean @default(true)
|
||||
gracePeriodDays Int @default(7)
|
||||
dueDateOffsetDays Int @default(15)
|
||||
lateFeePercent Decimal @default(0) @db.Decimal(5, 2)
|
||||
invoicePrefix String @default("INV")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("billing_settings")
|
||||
}
|
||||
|
||||
// ─── Chart of Accounts ─────────────────────────
|
||||
|
||||
model ChartOfAccount {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
code String
|
||||
name String
|
||||
type String // asset, liability, equity, revenue, expense
|
||||
parentId String?
|
||||
isActive Boolean @default(true)
|
||||
isSystem Boolean @default(false)
|
||||
balance Decimal @default(0) @db.Decimal(14, 2)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
parent ChartOfAccount? @relation("AccountTree", fields: [parentId], references: [id])
|
||||
children ChartOfAccount[] @relation("AccountTree")
|
||||
journalLines JournalLine[]
|
||||
|
||||
@@unique([tenantId, code])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, type])
|
||||
@@index([deletedAt])
|
||||
@@map("chart_of_accounts")
|
||||
}
|
||||
|
||||
// ─── Journal Entries (append-only, no soft delete) ─────────
|
||||
|
||||
model JournalEntry {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
entryDate DateTime @default(now())
|
||||
description String
|
||||
reference String?
|
||||
sourceType String?
|
||||
sourceId String?
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
lines JournalLine[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, sourceType, sourceId])
|
||||
@@map("journal_entries")
|
||||
}
|
||||
|
||||
model JournalLine {
|
||||
id String @id @default(uuid())
|
||||
journalEntryId String
|
||||
accountId String
|
||||
debit Decimal @default(0) @db.Decimal(14, 2)
|
||||
credit Decimal @default(0) @db.Decimal(14, 2)
|
||||
|
||||
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id])
|
||||
account ChartOfAccount @relation(fields: [accountId], references: [id])
|
||||
|
||||
@@index([journalEntryId])
|
||||
@@index([accountId])
|
||||
@@map("journal_lines")
|
||||
}
|
||||
887
packages/db/prisma/seed.ts
Normal file
887
packages/db/prisma/seed.ts
Normal file
@@ -0,0 +1,887 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
// Default permission matrices for system roles
|
||||
const MODULES = [
|
||||
'dashboard', 'clients', 'subscriptions', 'invoices', 'payments', 'tickets',
|
||||
'employees', 'payroll', 'expenses', 'assets', 'accounts', 'fund_transfers',
|
||||
'accounting', 'reports', 'areas', 'plans', 'settings', 'users',
|
||||
] as const;
|
||||
type Module = (typeof MODULES)[number];
|
||||
|
||||
interface PermRow { module: Module; canView: boolean; canCreate: boolean; canUpdate: boolean; canArchive: boolean; canApprove: boolean; canExport: boolean; }
|
||||
|
||||
const DEFAULT_ROLES: { name: string; slug: string; description: string; perms: PermRow[] }[] = [
|
||||
{
|
||||
name: 'Tenant Admin', slug: 'tenant_admin', description: 'Full access to all modules',
|
||||
perms: MODULES.map((m) => ({ module: m, canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: true, canExport: true })),
|
||||
},
|
||||
{
|
||||
name: 'Manager', slug: 'manager', description: 'Operational management with approval rights',
|
||||
perms: MODULES.map((m) => {
|
||||
const noAccess: Module[] = ['users'];
|
||||
const viewOnly: Module[] = ['dashboard', 'accounting', 'settings'];
|
||||
if (noAccess.includes(m)) return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||
if (viewOnly.includes(m)) return { module: m, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: m === 'accounting' };
|
||||
return { module: m, canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: ['invoices', 'payments', 'expenses', 'payroll', 'fund_transfers'].includes(m), canExport: true };
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Technician', slug: 'technician', description: 'Field operations: tickets, payments, client/invoice viewing',
|
||||
perms: MODULES.map((m) => {
|
||||
const viewOnly: Module[] = ['clients', 'subscriptions', 'invoices', 'dashboard'];
|
||||
const fullAccess: Module[] = ['tickets', 'payments'];
|
||||
if (viewOnly.includes(m)) return { module: m, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||
if (fullAccess.includes(m)) return { module: m, canView: true, canCreate: true, canUpdate: true, canArchive: false, canApprove: false, canExport: false };
|
||||
return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Collector', slug: 'collector', description: 'Payment collection and client viewing',
|
||||
perms: MODULES.map((m) => {
|
||||
const canWrite: Module[] = ['payments'];
|
||||
const canViewMods: Module[] = ['dashboard', 'clients', 'invoices', 'payments'];
|
||||
if (!canViewMods.includes(m)) return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||
return { module: m, canView: true, canCreate: canWrite.includes(m), canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database...');
|
||||
|
||||
// Clean existing data (order matters for FK constraints)
|
||||
await prisma.journalLine.deleteMany();
|
||||
await prisma.journalEntry.deleteMany();
|
||||
await prisma.chartOfAccount.deleteMany();
|
||||
await prisma.billingSetting.deleteMany();
|
||||
await prisma.fundTransfer.deleteMany();
|
||||
await prisma.companyAccount.deleteMany();
|
||||
await prisma.asset.deleteMany();
|
||||
await prisma.expense.deleteMany();
|
||||
await prisma.payslip.deleteMany();
|
||||
await prisma.payrollRun.deleteMany();
|
||||
await prisma.recurringExpense.deleteMany();
|
||||
await prisma.employee.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
await prisma.remittancePayment.deleteMany();
|
||||
await prisma.remittance.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.payment.deleteMany();
|
||||
await prisma.invoice.deleteMany();
|
||||
await prisma.ticket.deleteMany();
|
||||
await prisma.subscription.deleteMany();
|
||||
await prisma.client.deleteMany();
|
||||
await prisma.plan.deleteMany();
|
||||
await prisma.area.deleteMany();
|
||||
await prisma.refreshToken.deleteMany();
|
||||
await prisma.userTenantRole.deleteMany();
|
||||
await prisma.rolePermission.deleteMany();
|
||||
await prisma.tenantRole.deleteMany();
|
||||
await prisma.userRole.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.tenant.deleteMany();
|
||||
|
||||
// ─── Tenant ────────────────────────────────────────────
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
name: 'FiberNet Philippines',
|
||||
slug: 'fibernet-ph',
|
||||
settings: { companyName: 'FiberNet Philippines Inc.', currency: 'PHP', timezone: 'Asia/Manila' },
|
||||
},
|
||||
});
|
||||
console.log(`Tenant: ${tenant.name}`);
|
||||
|
||||
// ─── Super Admin (platform-level, no tenant) ───────────
|
||||
const superAdmin = await prisma.user.create({
|
||||
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
|
||||
});
|
||||
await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } });
|
||||
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
|
||||
|
||||
// ─── Tenant Users ─────────────────────────────────────
|
||||
const users: Record<string, any> = {};
|
||||
const usersByEmail: Record<string, any> = {};
|
||||
const userDefs = [
|
||||
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
|
||||
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
|
||||
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'collector' },
|
||||
{ email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' },
|
||||
{ email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' },
|
||||
{ email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' },
|
||||
];
|
||||
|
||||
for (const u of userDefs) {
|
||||
const user = await prisma.user.create({
|
||||
data: { tenantId: tenant.id, email: u.email, password: await hashPassword('admin123!'), firstName: u.first, lastName: u.last },
|
||||
});
|
||||
await prisma.userRole.create({ data: { userId: user.id, role: u.role } });
|
||||
users[u.role] = user;
|
||||
usersByEmail[u.email] = user;
|
||||
console.log(`User: ${u.email} (${u.role})`);
|
||||
}
|
||||
|
||||
// ─── Default Tenant Roles ──────────────────────────────
|
||||
const tenantRoles: Record<string, any> = {};
|
||||
for (const def of DEFAULT_ROLES) {
|
||||
const role = await prisma.tenantRole.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: def.name,
|
||||
slug: def.slug,
|
||||
description: def.description,
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
create: def.perms.map((p) => ({
|
||||
module: p.module,
|
||||
canView: p.canView,
|
||||
canCreate: p.canCreate,
|
||||
canUpdate: p.canUpdate,
|
||||
canArchive: p.canArchive,
|
||||
canApprove: p.canApprove,
|
||||
canExport: p.canExport,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
tenantRoles[def.slug] = role;
|
||||
}
|
||||
console.log(`Tenant Roles: ${DEFAULT_ROLES.length} (${DEFAULT_ROLES.map((r) => r.slug).join(', ')})`);
|
||||
|
||||
// ─── Assign Tenant Roles to Users ─────────────────────
|
||||
// Map of old role names to new tenant role slugs
|
||||
const userRoleMap: Record<string, string> = {
|
||||
tenant_admin: 'tenant_admin',
|
||||
manager: 'manager',
|
||||
technician: 'technician',
|
||||
collector: 'collector', // collector user gets collector tenant role
|
||||
viewer: 'collector', // viewer user gets collector role for demo
|
||||
};
|
||||
|
||||
// Assign roles from the map
|
||||
for (const [oldRole, newRoleSlug] of Object.entries(userRoleMap)) {
|
||||
if (users[oldRole] && tenantRoles[newRoleSlug]) {
|
||||
await prisma.userTenantRole.create({
|
||||
data: { userId: users[oldRole].id, tenantRoleId: tenantRoles[newRoleSlug].id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Additional assignments:
|
||||
// - Assign technician users (collector, tech, tech2) to technician role
|
||||
// - Assign viewer user to collector role
|
||||
const technicianUsers = ['collector', 'tech', 'tech2'];
|
||||
for (const email of technicianUsers) {
|
||||
const userEmail = `${email}@demo-isp.com`;
|
||||
const user = usersByEmail[userEmail];
|
||||
if (user) {
|
||||
const existing = await prisma.userTenantRole.findFirst({ where: { userId: user.id } });
|
||||
if (!existing) {
|
||||
await prisma.userTenantRole.create({
|
||||
data: { userId: user.id, tenantRoleId: tenantRoles['technician'].id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const viewerUserEmail = 'viewer@demo-isp.com';
|
||||
const viewerUser = usersByEmail[viewerUserEmail];
|
||||
if (viewerUser) {
|
||||
const existing = await prisma.userTenantRole.findFirst({ where: { userId: viewerUser.id } });
|
||||
if (!existing) {
|
||||
await prisma.userTenantRole.create({
|
||||
data: { userId: viewerUser.id, tenantRoleId: tenantRoles['collector'].id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('User-TenantRole assignments complete');
|
||||
|
||||
// ─── Areas ─────────────────────────────────────────────
|
||||
const areas = await Promise.all([
|
||||
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 1 - Centro', description: 'Town center, commercial area' } }),
|
||||
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 2 - Poblacion', description: 'Residential zone near market' } }),
|
||||
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 3 - San Isidro', description: 'Agricultural and residential' } }),
|
||||
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 4 - Riverside', description: 'River-side residential' } }),
|
||||
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 5 - Hilltop', description: 'Elevated residential subdivision' } }),
|
||||
]);
|
||||
console.log(`Areas: ${areas.length}`);
|
||||
|
||||
// ─── Plans ─────────────────────────────────────────────
|
||||
const plans = await Promise.all([
|
||||
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Lite 15', description: 'Entry-level 15 Mbps', speedDown: 15, speedUp: 15, price: 699, billingCycle: 30 } }),
|
||||
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Basic 25', description: '25 Mbps residential', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 } }),
|
||||
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Standard 50', description: '50 Mbps residential', speedDown: 50, speedUp: 50, price: 1499, billingCycle: 30 } }),
|
||||
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Premium 100', description: '100 Mbps business', speedDown: 100, speedUp: 100, price: 2499, billingCycle: 30 } }),
|
||||
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Enterprise 200', description: '200 Mbps dedicated', speedDown: 200, speedUp: 200, price: 4999, billingCycle: 30 } }),
|
||||
]);
|
||||
console.log(`Plans: ${plans.length}`);
|
||||
|
||||
// ─── Clients (45 clients across various areas/plans) ──
|
||||
// Area center coordinates (Lipa City, Batangas area)
|
||||
const areaCoords: [number, number][] = [
|
||||
[14.0785, 121.1760], // Barangay 1 - Centro
|
||||
[14.0820, 121.1800], // Barangay 2 - Poblacion
|
||||
[14.0850, 121.1700], // Barangay 3 - San Isidro
|
||||
[14.0750, 121.1720], // Barangay 4 - Riverside
|
||||
[14.0900, 121.1780], // Barangay 5 - Hilltop
|
||||
];
|
||||
|
||||
const clientDefs = [
|
||||
{ first: 'Juan', last: 'Dela Cruz', phone: '09171234567', email: 'juan@email.com', address: '123 Rizal St, Centro', area: 0, plan: 1, type: 'postpaid', latOff: 0.001, lngOff: 0.002 },
|
||||
{ first: 'Maria', last: 'Santos', phone: '09181234567', email: 'maria@email.com', address: '456 Mabini St, Centro', area: 0, plan: 2, type: 'postpaid', latOff: -0.002, lngOff: 0.001 },
|
||||
{ first: 'Jose', last: 'Garcia', phone: '09191234567', email: 'jose@email.com', address: '789 Bonifacio St, Poblacion', area: 1, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.001 },
|
||||
{ first: 'Ana', last: 'Reyes', phone: '09201234567', email: 'ana@email.com', address: '12 Luna St, Poblacion', area: 1, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
|
||||
{ first: 'Pedro', last: 'Aquino', phone: '09211234567', email: null, address: '34 Del Pilar St, San Isidro', area: 2, plan: 0, type: 'prepaid', latOff: 0.002, lngOff: -0.002 },
|
||||
{ first: 'Rosa', last: 'Mendoza', phone: '09221234567', email: 'rosa@email.com', address: '56 Quezon Ave, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: -0.003, lngOff: 0.001 },
|
||||
{ first: 'Carlos', last: 'Bautista', phone: '09231234567', email: null, address: '78 Magsaysay Blvd, Riverside', area: 3, plan: 1, type: 'postpaid', latOff: 0.001, lngOff: 0.002 },
|
||||
{ first: 'Elena', last: 'Villanueva', phone: '09241234567', email: 'elena@email.com', address: '90 Roxas St, Riverside', area: 3, plan: 2, type: 'postpaid', latOff: -0.002, lngOff: -0.003 },
|
||||
{ first: 'Roberto', last: 'Tan', phone: '09251234567', email: 'roberto@email.com', address: '11 Laurel St, Hilltop', area: 4, plan: 4, type: 'postpaid', latOff: 0.002, lngOff: 0.001 },
|
||||
{ first: 'Carmen', last: 'Lim', phone: '09261234567', email: 'carmen@email.com', address: '22 Osmena Ave, Hilltop', area: 4, plan: 3, type: 'postpaid', latOff: -0.001, lngOff: 0.002 },
|
||||
{ first: 'Miguel', last: 'Ramos', phone: '09271234567', email: null, address: '33 Aguinaldo St, Centro', area: 0, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.001 },
|
||||
{ first: 'Isabel', last: 'Torres', phone: '09281234567', email: 'isabel@email.com', address: '44 Andres Blvd, Poblacion', area: 1, plan: 0, type: 'postpaid', latOff: -0.002, lngOff: 0.003 },
|
||||
{ first: 'Ricardo', last: 'Flores', phone: '09291234567', email: null, address: '55 Katipunan Rd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
|
||||
{ first: 'Teresa', last: 'Navarro', phone: '09301234567', email: 'teresa@email.com', address: '66 Makabayan St, Riverside', area: 3, plan: 1, type: 'prepaid', latOff: -0.003, lngOff: 0.001 },
|
||||
{ first: 'Fernando', last: 'Castillo', phone: '09311234567', email: null, address: '77 Silang Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
|
||||
// New signups — pending installation (no lat/lng, pending status, open tickets)
|
||||
{ first: 'Rafael', last: 'Dimaculangan', phone: '09321234567', email: null, address: '88 Burgos St, Centro', area: 0, plan: 1, type: 'postpaid', latOff: 0.0015, lngOff: -0.001 },
|
||||
{ first: 'Lorna', last: 'Perez', phone: '09331234567', email: 'lorna@email.com', address: '99 Villareal St, Poblacion', area: 1, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.002 },
|
||||
{ first: 'Danilo', last: 'Rivera', phone: '09341234567', email: null, address: '101 Kapitan St, San Isidro', area: 2, plan: 0, type: 'prepaid', latOff: 0.002, lngOff: 0.001 },
|
||||
{ first: 'Grace', last: 'Sison', phone: '09351234567', email: 'grace@email.com', address: '202 Magdalena St, Riverside', area: 3, plan: 3, type: 'postpaid', latOff: -0.001, lngOff: -0.002 },
|
||||
{ first: 'Allan', last: 'Vergara', phone: '09361234567', email: null, address: '303 Gomez St, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: 0.0015 },
|
||||
// Additional clients (indices 20-29)
|
||||
{ first: 'Angelo', last: 'Manalo', phone: '09371234567', email: 'angelo@email.com', address: '15 Rizal Ave, Centro', area: 0, plan: 3, type: 'postpaid', latOff: 0.0025, lngOff: -0.0015 },
|
||||
{ first: 'Bella', last: 'Cruz', phone: '09381234567', email: 'bella@email.com', address: '26 Mabini Ext, Poblacion', area: 1, plan: 1, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
|
||||
{ first: 'Claudio', last: 'Diaz', phone: '09391234567', email: null, address: '37 Bonifacio Rd, San Isidro', area: 2, plan: 4, type: 'postpaid', latOff: 0.001, lngOff: 0.003 },
|
||||
{ first: 'Diana', last: 'Espiritu', phone: '09401234567', email: 'diana@email.com', address: '48 Luna Ext, Riverside', area: 3, plan: 0, type: 'prepaid', latOff: -0.002, lngOff: -0.001 },
|
||||
{ first: 'Eduardo', last: 'Fernandez', phone: '09411234567', email: null, address: '59 Del Pilar St, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.003, lngOff: 0.001 },
|
||||
{ first: 'Flora', last: 'Gonzales', phone: '09421234567', email: 'flora@email.com', address: '60 Quezon Blvd, Centro', area: 0, plan: 1, type: 'postpaid', latOff: -0.001, lngOff: -0.002 },
|
||||
{ first: 'Gilbert', last: 'Hernandez', phone: '09431234567', email: null, address: '71 Magsaysay St, Poblacion', area: 1, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: 0.001 },
|
||||
{ first: 'Helen', last: 'Ibañez', phone: '09441234567', email: 'helen@email.com', address: '82 Roxas Blvd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: -0.003, lngOff: -0.001 },
|
||||
{ first: 'Ivan', last: 'Jimenez', phone: '09451234567', email: null, address: '93 Laurel Ave, Riverside', area: 3, plan: 4, type: 'postpaid', latOff: 0.0015, lngOff: 0.002 },
|
||||
{ first: 'Julia', last: 'Kho', phone: '09461234567', email: 'julia@email.com', address: '104 Osmena St, Hilltop', area: 4, plan: 1, type: 'prepaid', latOff: -0.002, lngOff: 0.0015 },
|
||||
{ first: 'Kenneth', last: 'Lopez', phone: '09471234567', email: null, address: '115 Aguinaldo Blvd, Centro', area: 0, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.003 },
|
||||
{ first: 'Linda', last: 'Madrid', phone: '09481234567', email: 'linda@email.com', address: '126 Andres St, Poblacion', area: 1, plan: 0, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
|
||||
{ first: 'Mario', last: 'Ng', phone: '09491234567', email: null, address: '137 Katipunan Rd, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: -0.002 },
|
||||
{ first: 'Nancy', last: 'Ong', phone: '09501234567', email: 'nancy@email.com', address: '148 Makabayan Blvd, Riverside', area: 3, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
|
||||
// New signups — pending installation (indices 30-44)
|
||||
{ first: 'Oscar', last: 'Pineda', phone: '09511234567', email: 'oscar@email.com', address: '159 Rizal Ext, Centro', area: 0, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
|
||||
{ first: 'Patricia', last: 'Quintos', phone: '09521234567', email: 'patricia@email.com', address: '170 Mabini Rd, Poblacion', area: 1, plan: 1, type: 'postpaid', latOff: -0.002, lngOff: 0.001 },
|
||||
{ first: 'Quentin', last: 'Reyes Jr', phone: '09531234567', email: null, address: '181 Bonifacio Ext, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: 0.002 },
|
||||
{ first: 'Rita', last: 'Santillan', phone: '09541234567', email: 'rita@email.com', address: '192 Luna St, Riverside', area: 3, plan: 0, type: 'prepaid', latOff: -0.001, lngOff: -0.001 },
|
||||
{ first: 'Samuel', last: 'Torres', phone: '09551234567', email: null, address: '203 Del Pilar Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.0015, lngOff: 0.001 },
|
||||
{ first: 'Tina', last: 'Uy', phone: '09561234567', email: 'tina@email.com', address: '214 Quezon Rd, Centro', area: 0, plan: 4, type: 'postpaid', latOff: -0.002, lngOff: -0.0015 },
|
||||
{ first: 'Ulysses', last: 'Velasco', phone: '09571234567', email: null, address: '225 Magsaysay Ext, Poblacion', area: 1, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.002 },
|
||||
{ first: 'Vivian', last: 'Walsh', phone: '09581234567', email: 'vivian@email.com', address: '236 Roxas Ave, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
|
||||
{ first: 'Walter', last: 'Xavier', phone: '09591234567', email: null, address: '247 Laurel Blvd, Riverside', area: 3, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
|
||||
{ first: 'Yolanda', last: 'Yap', phone: '09601234567', email: 'yolanda@email.com', address: '258 Osmena Rd, Hilltop', area: 4, plan: 1, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
|
||||
];
|
||||
|
||||
const clients: any[] = [];
|
||||
let invoiceCount = 0;
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < clientDefs.length; i++) {
|
||||
const c = clientDefs[i];
|
||||
const accountNumber = `C-${String(i + 1).padStart(6, '0')}`;
|
||||
const plan = plans[c.plan];
|
||||
const isNewSignup = i >= 30; // 15 new signups pending installation
|
||||
|
||||
const client = await prisma.client.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
accountNumber,
|
||||
firstName: c.first,
|
||||
lastName: c.last,
|
||||
phone: c.phone,
|
||||
email: c.email,
|
||||
address: c.address,
|
||||
areaId: areas[c.area].id,
|
||||
status: isNewSignup ? 'pending' : 'active',
|
||||
latitude: isNewSignup ? null : (areaCoords[c.area][0] + c.latOff),
|
||||
longitude: isNewSignup ? null : (areaCoords[c.area][1] + c.lngOff),
|
||||
},
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
if (isNewSignup) {
|
||||
// ── New signup: pending subscription + open installation ticket ──
|
||||
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
planId: plan.id,
|
||||
type: c.type,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
// Open installation ticket (alternating assigned/unassigned)
|
||||
const assignedTech = i % 2 === 0 ? users.technician.id : null;
|
||||
await prisma.ticket.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
createdById: users.tenant_admin.id,
|
||||
assigneeId: assignedTech,
|
||||
type: 'installation',
|
||||
title: `Installation for ${c.first} ${c.last}`,
|
||||
description: `New installation at ${c.address}`,
|
||||
status: assignedTech ? 'in_progress' : 'open',
|
||||
priority: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
// Overdue invoice for new signup (installation fee / first billing)
|
||||
invoiceCount++;
|
||||
const overdueDate = new Date(now);
|
||||
overdueDate.setDate(overdueDate.getDate() - 7);
|
||||
await prisma.invoice.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
|
||||
amount: plan ? Number(plan.price) : 999,
|
||||
balance: plan ? Number(plan.price) : 999,
|
||||
status: 'overdue',
|
||||
dueDate: overdueDate,
|
||||
periodStart: new Date(now.getTime() - 30 * 86400000),
|
||||
periodEnd: new Date(now.getTime()),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// ── Existing client: active subscription, resolved tickets, invoices ──
|
||||
|
||||
// Create subscription
|
||||
const installedAt = new Date(now);
|
||||
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
|
||||
|
||||
const activatedAt = new Date(installedAt);
|
||||
activatedAt.setDate(activatedAt.getDate() + 2);
|
||||
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
planId: plan.id,
|
||||
type: c.type,
|
||||
status: 'active',
|
||||
installedAt,
|
||||
activatedAt,
|
||||
startDate: activatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Create resolved installation + activation tickets
|
||||
await prisma.ticket.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
createdById: users.tenant_admin.id,
|
||||
assigneeId: users.technician.id,
|
||||
type: 'installation',
|
||||
title: `Installation for ${c.first} ${c.last}`,
|
||||
description: `Installation at ${c.address}`,
|
||||
status: 'resolved',
|
||||
priority: 'high',
|
||||
resolvedAt: new Date(installedAt.getTime() + 86400000),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.ticket.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
createdById: users.tenant_admin.id,
|
||||
assigneeId: users.technician.id,
|
||||
type: 'activation',
|
||||
title: `Activation for ${c.first} ${c.last}`,
|
||||
status: 'resolved',
|
||||
priority: 'high',
|
||||
resolvedAt: activatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Create invoices per client (3-4 per client with varied statuses)
|
||||
for (let m = 0; m < 4; m++) {
|
||||
invoiceCount++;
|
||||
const periodStart = new Date(activatedAt);
|
||||
periodStart.setMonth(periodStart.getMonth() + m);
|
||||
const periodEnd = new Date(periodStart);
|
||||
periodEnd.setDate(periodEnd.getDate() + 30);
|
||||
const dueDate = new Date(periodStart);
|
||||
dueDate.setDate(dueDate.getDate() + 15);
|
||||
|
||||
// Determine invoice status based on month
|
||||
let status: string;
|
||||
let balance: number;
|
||||
let paidAt: Date | null = null;
|
||||
const amount = Number(plan.price);
|
||||
|
||||
if (m === 0) {
|
||||
// Month 1: always paid
|
||||
status = 'paid';
|
||||
balance = 0;
|
||||
paidAt = new Date(dueDate.getTime() - 86400000 * 3);
|
||||
} else if (m === 1) {
|
||||
// Month 2: overdue (unpaid, past due)
|
||||
status = 'overdue';
|
||||
balance = amount;
|
||||
} else if (m === 2) {
|
||||
// Month 3: 50% paid → partial
|
||||
status = 'partial';
|
||||
balance = Math.round(amount / 2);
|
||||
} else {
|
||||
// Month 4: upcoming (due in near future)
|
||||
const futureDue = new Date(now);
|
||||
futureDue.setDate(futureDue.getDate() + 3);
|
||||
status = 'sent';
|
||||
balance = amount;
|
||||
dueDate.setTime(futureDue.getTime());
|
||||
}
|
||||
|
||||
const invoice = await prisma.invoice.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
|
||||
amount,
|
||||
balance,
|
||||
status,
|
||||
dueDate,
|
||||
paidAt,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
},
|
||||
});
|
||||
|
||||
// Create payment(s) for paid/partial invoices
|
||||
const collector = i % 2 === 0 ? usersByEmail['collector@demo-isp.com'] : usersByEmail['tech@demo-isp.com'];
|
||||
if (status === 'paid') {
|
||||
const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
|
||||
const method = methods[Math.floor(Math.random() * methods.length)];
|
||||
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
|
||||
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
invoiceId: invoice.id,
|
||||
collectedById: collector.id,
|
||||
amount,
|
||||
method,
|
||||
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
|
||||
createdAt: paidDate,
|
||||
},
|
||||
});
|
||||
} else if (status === 'partial') {
|
||||
const methods = ['gcash', 'cash'];
|
||||
const method = methods[Math.floor(Math.random() * methods.length)];
|
||||
const paidDate = new Date(dueDate.getTime() - 86400000 * 2);
|
||||
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
invoiceId: invoice.id,
|
||||
collectedById: collector.id,
|
||||
amount: Math.round(amount / 2),
|
||||
method,
|
||||
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
|
||||
createdAt: paidDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`);
|
||||
|
||||
// ─── Support tickets ───────────────────────────────────
|
||||
const supportTickets = [
|
||||
{ clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high', status: 'open', type: 'support', assignee: null },
|
||||
{ clientIdx: 5, title: 'Slow speed during peak hours', desc: 'Speed drops to 5 Mbps from 8-10 PM', priority: 'normal', status: 'open', type: 'support', assignee: null },
|
||||
{ clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent', status: 'in_progress', type: 'support', assignee: 'tech' },
|
||||
{ clientIdx: 11, title: 'Request for plan upgrade', desc: 'Would like to upgrade from Basic to Standard', priority: 'low', status: 'open', type: 'support', assignee: null },
|
||||
{ clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high', status: 'in_progress', type: 'support', assignee: 'tech2' },
|
||||
{ clientIdx: 20, title: 'Fiber cable damaged by construction', desc: 'Backhoe hit the fiber line on Rizal Ave', priority: 'urgent', status: 'in_progress', type: 'maintenance', assignee: 'tech' },
|
||||
{ clientIdx: 22, title: 'Billing discrepancy - double charged', desc: 'Customer was charged twice for March billing', priority: 'high', status: 'open', type: 'support', assignee: null },
|
||||
{ clientIdx: 25, title: 'New access point installation request', desc: 'Needs additional AP for 2nd floor', priority: 'normal', status: 'open', type: 'installation', assignee: null },
|
||||
{ clientIdx: 18, title: 'Connection slow after rain', desc: 'Speed degrades significantly during/after rainfall', priority: 'normal', status: 'in_progress', type: 'maintenance', assignee: 'tech2' },
|
||||
{ clientIdx: 28, title: 'Account suspension appeal', desc: 'Customer requests reconnection, willing to pay balance', priority: 'high', status: 'open', type: 'support', assignee: null },
|
||||
{ clientIdx: 23, title: 'Router firmware update needed', desc: 'Current firmware causing intermittent WiFi drops', priority: 'normal', status: 'open', type: 'maintenance', assignee: null },
|
||||
{ clientIdx: 15, title: 'Relocation request - new address', desc: 'Moving to Barangay 4, wants service transferred', priority: 'low', status: 'open', type: 'support', assignee: null },
|
||||
{ clientIdx: 26, title: 'High latency for gaming', desc: 'Ping above 100ms during evenings', priority: 'normal', status: 'in_progress', type: 'support', assignee: 'tech' },
|
||||
{ clientIdx: 19, title: 'ONT replacement needed', desc: 'ONT showing red fault light intermittently', priority: 'high', status: 'open', type: 'maintenance', assignee: null },
|
||||
{ clientIdx: 21, title: 'Monthly service credit request', desc: 'Requesting credit for 2-day outage last month', priority: 'low', status: 'open', type: 'support', assignee: null },
|
||||
{ clientIdx: 3, title: 'Second floor extension installation', desc: 'Client wants additional fiber drop to 2nd floor office', priority: 'normal', status: 'open', type: 'installation', assignee: null },
|
||||
{ clientIdx: 9, title: 'Fiber relocation due to renovation', desc: 'House renovation requires moving fiber entry point', priority: 'normal', status: 'in_progress', type: 'installation', assignee: 'tech2' },
|
||||
{ clientIdx: 14, title: 'ONT upgrade to GPON', desc: 'Current ONT outdated, needs GPON-compatible replacement', priority: 'low', status: 'open', type: 'installation', assignee: null },
|
||||
{ clientIdx: 7, title: 'New branch office fiber install', desc: 'Client opened sari-sari store next door, wants 2nd connection', priority: 'high', status: 'open', type: 'installation', assignee: null },
|
||||
{ clientIdx: 24, title: 'Intermittent packet loss', desc: 'Ping shows 5-10% packet loss during daytime', priority: 'high', status: 'in_progress', type: 'maintenance', assignee: 'tech' },
|
||||
{ clientIdx: 12, title: 'Cable exposed across driveway', desc: 'Fiber cable hanging low across client driveway, safety hazard', priority: 'urgent', status: 'open', type: 'maintenance', assignee: null },
|
||||
];
|
||||
|
||||
for (const t of supportTickets) {
|
||||
await prisma.ticket.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: clients[t.clientIdx].id,
|
||||
createdById: users.tenant_admin.id,
|
||||
type: t.type,
|
||||
title: t.title,
|
||||
description: t.desc,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
assigneeId: t.assignee ? users[t.assignee]?.id ?? users.technician.id : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`Support tickets: ${supportTickets.length}`);
|
||||
|
||||
// ─── Employees ─────────────────────────────────────────
|
||||
const empDefs = [
|
||||
{ first: 'Pedro', last: 'Cruz', position: 'Senior Technician', dept: 'Operations', salary: 18000 },
|
||||
{ first: 'Jose', last: 'Garcia', position: 'Field Technician', dept: 'Operations', salary: 15000 },
|
||||
{ first: 'Maria', last: 'Reyes', position: 'Operations Manager', dept: 'Management', salary: 30000 },
|
||||
{ first: 'Juan', last: 'Santos', position: 'Collection Officer', dept: 'Finance', salary: 16000 },
|
||||
{ first: 'Ana', last: 'De Leon', position: 'Billing Clerk', dept: 'Finance', salary: 14000 },
|
||||
{ first: 'Luis', last: 'Mercado', position: 'Network Engineer', dept: 'Technical', salary: 25000 },
|
||||
{ first: 'Sofia', last: 'Pascual', position: 'Customer Service', dept: 'Support', salary: 14000 },
|
||||
];
|
||||
|
||||
const employees: any[] = [];
|
||||
for (let i = 0; i < empDefs.length; i++) {
|
||||
const e = empDefs[i];
|
||||
const emp = await prisma.employee.create({
|
||||
data: { tenantId: tenant.id, employeeNo: `E-${String(i + 1).padStart(4, '0')}`, firstName: e.first, lastName: e.last, position: e.position, department: e.dept, salary: e.salary },
|
||||
});
|
||||
employees.push(emp);
|
||||
}
|
||||
console.log(`Employees: ${employees.length}`);
|
||||
|
||||
// ─── Expenses ──────────────────────────────────────────
|
||||
const expDefs = [
|
||||
{ cat: 'utilities', desc: 'Electricity bill - March 2026', amount: 12500, status: 'approved', days: -15 },
|
||||
{ cat: 'utilities', desc: 'Internet backbone ISP bill', amount: 35000, status: 'approved', days: -10 },
|
||||
{ cat: 'supplies', desc: 'Fiber optic cables (500m)', amount: 8500, status: 'approved', days: -8 },
|
||||
{ cat: 'maintenance', desc: 'OLT maintenance and cleaning', amount: 3500, status: 'approved', days: -5 },
|
||||
{ cat: 'transport', desc: 'Fuel for service vehicles', amount: 4200, status: 'approved', days: -3 },
|
||||
{ cat: 'equipment', desc: '10x Mikrotik hEX S routers', amount: 28000, status: 'approved', days: -2 },
|
||||
{ cat: 'supplies', desc: 'Office supplies and printer ink', amount: 2100, status: 'pending', days: -1 },
|
||||
{ cat: 'maintenance', desc: 'Generator repair', amount: 7800, status: 'pending', days: 0 },
|
||||
{ cat: 'transport', desc: 'Technician transport allowance - April', amount: 6000, status: 'pending', days: 0 },
|
||||
];
|
||||
|
||||
for (const e of expDefs) {
|
||||
const expDate = new Date();
|
||||
expDate.setDate(expDate.getDate() + e.days);
|
||||
await prisma.expense.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
createdById: users.manager.id,
|
||||
approvedById: e.status === 'approved' ? users.tenant_admin.id : null,
|
||||
category: e.cat,
|
||||
description: e.desc,
|
||||
amount: e.amount,
|
||||
status: e.status,
|
||||
expenseDate: expDate,
|
||||
approvedAt: e.status === 'approved' ? expDate : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`Expenses: ${expDefs.length}`);
|
||||
|
||||
// ─── Company Accounts ──────────────────────────────────
|
||||
// Company accounts will be created after CoA so we can link them
|
||||
console.log('Company accounts: deferred to after CoA');
|
||||
|
||||
// ─── Fund Transfers ────────────────────────────────────
|
||||
// Fund transfers deferred to after company accounts
|
||||
console.log('Fund transfers: deferred');
|
||||
|
||||
// ─── Assets ────────────────────────────────────────────
|
||||
const assetDefs = [
|
||||
{ name: 'Huawei MA5608T OLT', cat: 'olt', serial: 'HW-OLT-001', price: 85000, status: 'in_use', loc: 'Main Office' },
|
||||
{ name: 'Mikrotik CCR1009', cat: 'router', serial: 'MK-CCR-001', price: 32000, status: 'in_use', loc: 'Main Office' },
|
||||
{ name: 'Mikrotik hEX S #1', cat: 'router', serial: 'MK-HEX-001', price: 2800, status: 'in_use', empIdx: 0 },
|
||||
{ name: 'Mikrotik hEX S #2', cat: 'router', serial: 'MK-HEX-002', price: 2800, status: 'in_use', empIdx: 1 },
|
||||
{ name: 'Mikrotik hEX S #3', cat: 'router', serial: 'MK-HEX-003', price: 2800, status: 'available', loc: 'Warehouse' },
|
||||
{ name: 'OTDR Tester', cat: 'tool', serial: 'OTDR-001', price: 45000, status: 'in_use', empIdx: 0 },
|
||||
{ name: 'Fiber Splicer', cat: 'tool', serial: 'FS-001', price: 65000, status: 'in_use', empIdx: 5 },
|
||||
{ name: 'Honda XRM 125 (Field)', cat: 'vehicle', serial: 'MV-2024-001', price: 68000, status: 'in_use', empIdx: 0 },
|
||||
{ name: 'Honda Wave 110 (Field)', cat: 'vehicle', serial: 'MV-2024-002', price: 55000, status: 'in_use', empIdx: 1 },
|
||||
{ name: 'Dell Latitude 5540', cat: 'computer', serial: 'DELL-LAP-001', price: 48000, status: 'in_use', empIdx: 2 },
|
||||
{ name: 'Fiber Cable Spool 1km', cat: 'cable', price: 12000, status: 'available', loc: 'Warehouse' },
|
||||
{ name: 'Fiber Cable Spool 500m', cat: 'cable', price: 6500, status: 'available', loc: 'Warehouse' },
|
||||
{ name: 'UPS 1500VA', cat: 'other', serial: 'UPS-001', price: 8500, status: 'in_use', loc: 'Main Office' },
|
||||
{ name: 'Old Mikrotik RB750', cat: 'router', serial: 'MK-OLD-001', price: 1500, status: 'retired' },
|
||||
];
|
||||
|
||||
for (const a of assetDefs) {
|
||||
await prisma.asset.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: a.name,
|
||||
category: a.cat,
|
||||
serialNumber: a.serial || null,
|
||||
purchasePrice: a.price,
|
||||
status: a.status,
|
||||
location: a.loc || null,
|
||||
assignedToId: a.empIdx !== undefined ? employees[a.empIdx].id : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`Assets: ${assetDefs.length}`);
|
||||
|
||||
// ─── Billing Settings ──────────────────────────────────
|
||||
await prisma.billingSetting.create({
|
||||
data: { tenantId: tenant.id, autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, invoicePrefix: 'INV' },
|
||||
});
|
||||
console.log('Billing settings created');
|
||||
|
||||
// ─── Chart of Accounts (auto-seeded by API, but seed defaults) ──
|
||||
const coaDefs = [
|
||||
{ code: '1000', name: 'Assets', type: 'asset', sys: true },
|
||||
{ code: '1010', name: 'Cash on Hand', type: 'asset', sys: true },
|
||||
{ code: '1020', name: 'GCash Business', type: 'asset', sys: true },
|
||||
{ code: '1030', name: 'Maya Business', type: 'asset', sys: true },
|
||||
{ code: '1040', name: 'Bank Account', type: 'asset', sys: true },
|
||||
{ code: '1100', name: 'Accounts Receivable', type: 'asset', sys: true },
|
||||
{ code: '1200', name: 'Equipment', type: 'asset', sys: true },
|
||||
{ code: '2000', name: 'Liabilities', type: 'liability', sys: true },
|
||||
{ code: '2010', name: 'Accounts Payable', type: 'liability', sys: true },
|
||||
{ code: '3000', name: 'Equity', type: 'equity', sys: true },
|
||||
{ code: '3010', name: "Owner's Equity", type: 'equity', sys: true },
|
||||
{ code: '3020', name: 'Retained Earnings', type: 'equity', sys: true },
|
||||
{ code: '4000', name: 'Revenue', type: 'revenue', sys: true },
|
||||
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', sys: true },
|
||||
{ code: '4020', name: 'Installation Fees', type: 'revenue', sys: true },
|
||||
{ code: '5000', name: 'Expenses', type: 'expense', sys: true },
|
||||
{ code: '5010', name: 'Utilities Expense', type: 'expense', sys: true },
|
||||
{ code: '5020', name: 'Salaries Expense', type: 'expense', sys: true },
|
||||
{ code: '5030', name: 'Maintenance Expense', type: 'expense', sys: true },
|
||||
{ code: '5040', name: 'Transport Expense', type: 'expense', sys: true },
|
||||
{ code: '5050', name: 'Supplies Expense', type: 'expense', sys: true },
|
||||
{ code: '5060', name: 'Equipment Expense', type: 'expense', sys: true },
|
||||
];
|
||||
|
||||
for (const a of coaDefs) {
|
||||
await prisma.chartOfAccount.create({
|
||||
data: { tenantId: tenant.id, code: a.code, name: a.name, type: a.type, isSystem: a.sys },
|
||||
});
|
||||
}
|
||||
console.log(`Chart of Accounts: ${coaDefs.length}`);
|
||||
|
||||
// ─── Custodial CoA per user ────────────────────────────
|
||||
const methods = ['Cash', 'GCash', 'Maya', 'Bank'];
|
||||
let custodialCode = 1500;
|
||||
for (const u of userDefs) {
|
||||
const user = users[u.role];
|
||||
for (const m of methods) {
|
||||
await prisma.chartOfAccount.create({
|
||||
data: { tenantId: tenant.id, code: String(custodialCode++), name: `${u.first} ${u.last} - ${m}`, type: 'asset', isSystem: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`Custodial CoA accounts: ${userDefs.length * 4}`);
|
||||
|
||||
// ─── Company Accounts (linked to CoA) ──────────────────
|
||||
const coa1010 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1010' } });
|
||||
const coa1020 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1020' } });
|
||||
const coa1030 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1030' } });
|
||||
const coa1040 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1040' } });
|
||||
|
||||
const accts = await Promise.all([
|
||||
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'Cash on Hand', type: 'cash', balance: 15000, isSystem: true, chartOfAccountId: coa1010?.id } }),
|
||||
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'GCash Business', type: 'e_wallet', accountNo: '09171234567', balance: 42500, chartOfAccountId: coa1020?.id } }),
|
||||
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'Maya Business', type: 'e_wallet', accountNo: '09181234567', balance: 18200, chartOfAccountId: coa1030?.id } }),
|
||||
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'BDO Savings', type: 'bank', accountNo: '0012-3456-7890', balance: 285000, chartOfAccountId: coa1040?.id } }),
|
||||
]);
|
||||
console.log(`Company accounts: ${accts.length} (linked to CoA)`);
|
||||
|
||||
// ─── Fund Transfers ────────────────────────────────────
|
||||
await prisma.fundTransfer.create({
|
||||
data: { tenantId: tenant.id, fromAccountId: accts[1].id, toAccountId: accts[3].id, amount: 20000, description: 'GCash to BDO weekly transfer', transferredBy: users.tenant_admin.id },
|
||||
});
|
||||
await prisma.fundTransfer.create({
|
||||
data: { tenantId: tenant.id, fromAccountId: accts[3].id, toAccountId: accts[0].id, amount: 5000, description: 'Petty cash replenishment', transferredBy: users.tenant_admin.id },
|
||||
});
|
||||
console.log('Fund transfers: 2');
|
||||
|
||||
// ─── Remittances (properly linked to payments) ──────────
|
||||
// Get all payments that were for paid invoices (these are candidates for remittances)
|
||||
const allPayments = await prisma.payment.findMany({
|
||||
where: { tenantId: tenant.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// Split payments: first 60% → remitted (confirmed), next 20% → remitted (pending), last 20% → unremitted
|
||||
const confirmedEnd = Math.floor(allPayments.length * 0.6);
|
||||
const pendingEnd = Math.floor(allPayments.length * 0.8);
|
||||
|
||||
const confirmedPayments = allPayments.slice(0, confirmedEnd);
|
||||
const pendingPayments = allPayments.slice(confirmedEnd, pendingEnd);
|
||||
// remaining payments (pendingEnd onward) stay unremitted
|
||||
|
||||
// Create confirmed remittance
|
||||
if (confirmedPayments.length > 0) {
|
||||
const confirmedTotal = confirmedPayments.reduce((s, p) => s + Number(p.amount), 0);
|
||||
const confirmedRemittance = await prisma.remittance.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
collectorId: users.technician.id,
|
||||
confirmedById: users.tenant_admin.id,
|
||||
totalAmount: confirmedTotal,
|
||||
status: 'confirmed',
|
||||
submittedAt: new Date(Date.now() - 86400000 * 7),
|
||||
confirmedAt: new Date(Date.now() - 86400000 * 5),
|
||||
payments: {
|
||||
create: confirmedPayments.map((p) => ({ paymentId: p.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`Remittance (confirmed): ₱${confirmedTotal} (${confirmedPayments.length} payments)`);
|
||||
}
|
||||
|
||||
// Create pending remittance
|
||||
if (pendingPayments.length > 0) {
|
||||
const pendingTotal = pendingPayments.reduce((s, p) => s + Number(p.amount), 0);
|
||||
const pendingRemittance = await prisma.remittance.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
collectorId: users.technician.id,
|
||||
totalAmount: pendingTotal,
|
||||
status: 'pending',
|
||||
submittedAt: new Date(Date.now() - 86400000 * 2),
|
||||
payments: {
|
||||
create: pendingPayments.map((p) => ({ paymentId: p.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`Remittance (pending): ₱${pendingTotal} (${pendingPayments.length} payments)`);
|
||||
}
|
||||
|
||||
const unremittedCount = allPayments.length - pendingEnd;
|
||||
console.log(`Unremitted payments: ${unremittedCount} (from invoice loop)`);
|
||||
|
||||
// ─── Additional unremitted payments (recent, for testing) ──
|
||||
const recentPaymentDefs = [
|
||||
{ clientIdx: 0, amount: 699, method: 'gcash' as const, ref: 'GCASH-44221', daysAgo: 0 },
|
||||
{ clientIdx: 2, amount: 999, method: 'cash' as const, ref: null, daysAgo: 0 },
|
||||
{ clientIdx: 5, amount: 1499, method: 'maya' as const, ref: 'MAYA-88312', daysAgo: 1 },
|
||||
{ clientIdx: 7, amount: 999, method: 'bank_transfer' as const, ref: 'BDO-10293', daysAgo: 1 },
|
||||
{ clientIdx: 9, amount: 2499, method: 'gcash' as const, ref: 'GCASH-44228', daysAgo: 2 },
|
||||
{ clientIdx: 10, amount: 699, method: 'cash' as const, ref: null, daysAgo: 2 },
|
||||
{ clientIdx: 14, amount: 1499, method: 'gcash' as const, ref: 'GCASH-44235', daysAgo: 3 },
|
||||
{ clientIdx: 3, amount: 999, method: 'maya' as const, ref: 'MAYA-88319', daysAgo: 4 },
|
||||
];
|
||||
|
||||
// Find or create overdue invoices for these clients to attach payments to
|
||||
for (const rp of recentPaymentDefs) {
|
||||
const client = clients[rp.clientIdx];
|
||||
const paidDate = new Date();
|
||||
paidDate.setDate(paidDate.getDate() - rp.daysAgo);
|
||||
|
||||
// Find an existing overdue or partial invoice for this client
|
||||
let invoice = await prisma.invoice.findFirst({
|
||||
where: { tenantId: tenant.id, clientId: client.id, status: { in: ['overdue', 'partial'] } },
|
||||
});
|
||||
|
||||
// If no overdue invoice, create one
|
||||
if (!invoice) {
|
||||
invoiceCount++;
|
||||
const dueDate = new Date();
|
||||
dueDate.setDate(dueDate.getDate() - 5);
|
||||
invoice = await prisma.invoice.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
|
||||
amount: rp.amount,
|
||||
balance: rp.amount,
|
||||
status: 'overdue',
|
||||
dueDate,
|
||||
periodStart: new Date(dueDate.getTime() - 30 * 86400000),
|
||||
periodEnd: dueDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
clientId: client.id,
|
||||
invoiceId: invoice.id,
|
||||
collectedById: users.collector.id,
|
||||
amount: rp.amount,
|
||||
method: rp.method,
|
||||
referenceNo: rp.ref,
|
||||
createdAt: paidDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`Recent unremitted payments: ${recentPaymentDefs.length}`);
|
||||
|
||||
// ─── Notifications (unread, for testing) ──────────────────
|
||||
const notifDefs = [
|
||||
{ userId: users.collector.id, type: 'in_app', channel: 'payment_confirmation', title: 'Payment Recorded', message: 'Payment of ₱699 for Juan Dela Cruz has been recorded.', daysAgo: 0 },
|
||||
{ userId: users.collector.id, type: 'in_app', channel: 'billing_reminder', title: 'Overdue Reminder', message: '3 invoices are overdue in Barangay 1 - Centro.', daysAgo: 1 },
|
||||
{ userId: users.collector.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Assigned', message: 'DNS resolution issues ticket has been assigned to you.', daysAgo: 1 },
|
||||
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'New Ticket', message: 'Fiber cable repair - Poblacion ticket needs attention.', daysAgo: 0 },
|
||||
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Resolved', message: 'Cannot connect after reboot ticket has been resolved.', daysAgo: 0 },
|
||||
{ userId: users.manager.id, type: 'in_app', channel: 'billing_reminder', title: 'Weekly Summary', message: '12 payments collected this week totaling ₱14,988.', daysAgo: 2 },
|
||||
{ userId: users.manager.id, type: 'in_app', channel: 'ticket_update', title: 'Urgent Maintenance', message: 'Node outage - Riverside sector affecting 8 subscribers.', daysAgo: 0 },
|
||||
{ userId: users.tenant_admin.id, type: 'in_app', channel: 'payment_confirmation', title: 'Remittance Confirmed', message: 'Remittance of ₱5,000 has been confirmed by Admin.', daysAgo: 3 },
|
||||
];
|
||||
|
||||
for (const n of notifDefs) {
|
||||
const sentAt = new Date();
|
||||
sentAt.setDate(sentAt.getDate() - n.daysAgo);
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
userId: n.userId,
|
||||
type: n.type,
|
||||
channel: n.channel,
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
isRead: false,
|
||||
sentAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`Notifications: ${notifDefs.length} (unread)`);
|
||||
|
||||
console.log('\n✅ Seed completed successfully!');
|
||||
console.log(`\n📊 Summary:`);
|
||||
console.log(` Tenant: ${tenant.name}`);
|
||||
console.log(` Users: ${userDefs.length}`);
|
||||
console.log(` Areas: ${areas.length}`);
|
||||
console.log(` Plans: ${plans.length}`);
|
||||
console.log(` Clients: ${clients.length} (with active subscriptions)`);
|
||||
console.log(` Invoices: ${invoiceCount * 2} (paid + unpaid)`);
|
||||
console.log(` Employees: ${empDefs.length}`);
|
||||
console.log(` Expenses: ${expDefs.length} (approved + pending)`);
|
||||
console.log(` Assets: ${assetDefs.length}`);
|
||||
console.log(` Company Accounts: ${accts.length}`);
|
||||
console.log(`\n🔑 Logins (all passwords: admin123!):`);
|
||||
console.log(` admin@demo-isp.com (tenant_admin) - full access`);
|
||||
console.log(` manager@demo-isp.com (manager) - operational management`);
|
||||
console.log(` collector@demo-isp.com (collector) - payment collection`);
|
||||
console.log(` tech@demo-isp.com (technician) - field operations`);
|
||||
console.log(` tech2@demo-isp.com (technician) - field operations`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error('Seed failed:', e); process.exit(1); })
|
||||
.finally(async () => { await prisma.$disconnect(); });
|
||||
19
packages/db/src/index.ts
Normal file
19
packages/db/src/index.ts
Normal 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';
|
||||
8
packages/db/tsconfig.json
Normal file
8
packages/db/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src", "prisma"]
|
||||
}
|
||||
18
packages/shared/package.json
Normal file
18
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
16
packages/shared/src/constants/index.ts
Normal file
16
packages/shared/src/constants/index.ts
Normal 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';
|
||||
224
packages/shared/src/constants/permissions.ts
Normal file
224
packages/shared/src/constants/permissions.ts
Normal 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 [];
|
||||
}
|
||||
38
packages/shared/src/constants/roles.ts
Normal file
38
packages/shared/src/constants/roles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const Role = {
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
TENANT_ADMIN: 'tenant_admin',
|
||||
MANAGER: 'manager',
|
||||
TECHNICIAN: 'technician',
|
||||
COLLECTOR: 'collector',
|
||||
VIEWER: 'viewer',
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
|
||||
export const ALL_ROLES: readonly Role[] = Object.values(Role);
|
||||
|
||||
/**
|
||||
* Numeric hierarchy level per role.
|
||||
* Higher number = more powerful role.
|
||||
* Used for hierarchy-aware authorization checks.
|
||||
*/
|
||||
const ROLE_LEVEL: Record<string, number> = {
|
||||
[Role.SUPER_ADMIN]: 100,
|
||||
[Role.TENANT_ADMIN]: 80,
|
||||
[Role.MANAGER]: 60,
|
||||
[Role.TECHNICIAN]: 40,
|
||||
[Role.COLLECTOR]: 30,
|
||||
[Role.VIEWER]: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if any of the user's roles satisfies the required role level.
|
||||
* A higher-level role always satisfies a lower-level requirement.
|
||||
*
|
||||
* Example: user with ['manager'] satisfies 'technician' because manager(60) >= technician(40).
|
||||
*/
|
||||
export function satisfiesRole(userRoles: string[], requiredRole: string): boolean {
|
||||
const requiredLevel = ROLE_LEVEL[requiredRole];
|
||||
if (requiredLevel === undefined) return false;
|
||||
return userRoles.some((r) => (ROLE_LEVEL[r] ?? 0) >= requiredLevel);
|
||||
}
|
||||
67
packages/shared/src/constants/statuses.ts
Normal file
67
packages/shared/src/constants/statuses.ts
Normal 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];
|
||||
3
packages/shared/src/index.ts
Normal file
3
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './constants';
|
||||
export * from './schemas';
|
||||
export * from './types';
|
||||
25
packages/shared/src/schemas/auth.ts
Normal file
25
packages/shared/src/schemas/auth.ts
Normal 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>;
|
||||
5
packages/shared/src/schemas/index.ts
Normal file
5
packages/shared/src/schemas/index.ts
Normal 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';
|
||||
27
packages/shared/src/schemas/user.ts
Normal file
27
packages/shared/src/schemas/user.ts
Normal 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>;
|
||||
36
packages/shared/src/types/api.ts
Normal file
36
packages/shared/src/types/api.ts
Normal 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;
|
||||
}
|
||||
7
packages/shared/src/types/index.ts
Normal file
7
packages/shared/src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
ApiResponse,
|
||||
PaginationMeta,
|
||||
PaginationQuery,
|
||||
JwtPayload,
|
||||
TokenResponse,
|
||||
} from './api';
|
||||
19
packages/shared/tsconfig.json
Normal file
19
packages/shared/tsconfig.json
Normal 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"]
|
||||
}
|
||||
47
src/account/account.controller.ts
Normal file
47
src/account/account.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AccountService } from './account.service';
|
||||
import { CreateAccountDto } from './dto/create-account.dto';
|
||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||
import { TransferFundsDto } from './dto/transfer-funds.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('accounts')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('tenant_admin')
|
||||
export class AccountController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
|
||||
return this.accountService.create(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
||||
return this.accountService.update(user.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.accountService.remove(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post('transfer')
|
||||
async transfer(@CurrentUser() user: CurrentUserPayload, @Body() dto: TransferFundsDto) {
|
||||
return this.accountService.transfer(user.tenantId, user.sub, dto);
|
||||
}
|
||||
|
||||
@Get('transfers')
|
||||
async getTransfers(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountService.getTransfers(user.tenantId);
|
||||
}
|
||||
}
|
||||
12
src/account/account.module.ts
Normal file
12
src/account/account.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountingModule],
|
||||
controllers: [AccountController],
|
||||
providers: [AccountService],
|
||||
exports: [AccountService],
|
||||
})
|
||||
export class AccountModule {}
|
||||
137
src/account/account.service.ts
Normal file
137
src/account/account.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JournalService } from '../accounting/journal.service';
|
||||
import { CreateAccountDto } from './dto/create-account.dto';
|
||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||
import { TransferFundsDto } from './dto/transfer-funds.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly journal: JournalService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string) {
|
||||
return this.prisma.companyAccount.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateAccountDto) {
|
||||
// Auto-generate a CoA code for this account
|
||||
const existingCoa = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId, type: 'asset', code: { startsWith: '10' } },
|
||||
orderBy: { code: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
const lastCode = existingCoa[0]?.code || '1040';
|
||||
const nextCode = String(parseInt(lastCode) + 10);
|
||||
|
||||
// Create CoA entry first
|
||||
const coa = await this.prisma.chartOfAccount.create({
|
||||
data: { tenantId, code: nextCode, name: dto.name, type: 'asset', isSystem: false },
|
||||
});
|
||||
|
||||
// Create company account linked to CoA
|
||||
return this.prisma.companyAccount.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
accountNo: dto.accountNo,
|
||||
balance: dto.initialBalance || 0,
|
||||
chartOfAccountId: coa.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateAccountDto) {
|
||||
const account = await this.prisma.companyAccount.findFirst({ where: { id, tenantId } });
|
||||
if (!account) throw new NotFoundException('Account not found');
|
||||
if (account.isSystem && dto.name && dto.name !== account.name) {
|
||||
throw new ForbiddenException('Cannot rename system account');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.companyAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name && { name: dto.name }),
|
||||
...(dto.accountNo !== undefined && { accountNo: dto.accountNo }),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
|
||||
// Sync CoA name if changed
|
||||
if (dto.name && account.chartOfAccountId) {
|
||||
await this.prisma.chartOfAccount.update({
|
||||
where: { id: account.chartOfAccountId },
|
||||
data: { name: dto.name },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
const account = await this.prisma.companyAccount.findFirst({ where: { id, tenantId } });
|
||||
if (!account) throw new NotFoundException('Account not found');
|
||||
if (account.isSystem) throw new ForbiddenException('Cannot delete system account (Cash on Hand)');
|
||||
if (Number(account.balance) > 0) throw new BadRequestException('Transfer funds out before deleting');
|
||||
|
||||
// Delete linked CoA if exists
|
||||
if (account.chartOfAccountId) {
|
||||
await this.prisma.chartOfAccount.delete({ where: { id: account.chartOfAccountId } }).catch(() => {});
|
||||
}
|
||||
|
||||
await this.prisma.companyAccount.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async transfer(tenantId: string, transferredBy: string, dto: TransferFundsDto) {
|
||||
const from = await this.prisma.companyAccount.findFirst({ where: { id: dto.fromAccountId, tenantId } });
|
||||
const to = await this.prisma.companyAccount.findFirst({ where: { id: dto.toAccountId, tenantId } });
|
||||
if (!from || !to) throw new NotFoundException('Account not found');
|
||||
if (from.id === to.id) throw new BadRequestException('Cannot transfer to same account');
|
||||
if (Number(from.balance) < dto.amount) throw new BadRequestException('Insufficient balance');
|
||||
|
||||
const [transfer] = await this.prisma.$transaction([
|
||||
this.prisma.fundTransfer.create({
|
||||
data: { tenantId, fromAccountId: dto.fromAccountId, toAccountId: dto.toAccountId, amount: dto.amount, description: dto.description, transferredBy },
|
||||
}),
|
||||
this.prisma.companyAccount.update({ where: { id: from.id }, data: { balance: { decrement: dto.amount } } }),
|
||||
this.prisma.companyAccount.update({ where: { id: to.id }, data: { balance: { increment: dto.amount } } }),
|
||||
]);
|
||||
|
||||
// Journal entry for fund transfer: DR destination CoA, CR source CoA
|
||||
if (from.chartOfAccountId && to.chartOfAccountId) {
|
||||
const fromCoa = await this.prisma.chartOfAccount.findUnique({ where: { id: from.chartOfAccountId } });
|
||||
const toCoa = await this.prisma.chartOfAccount.findUnique({ where: { id: to.chartOfAccountId } });
|
||||
if (fromCoa && toCoa) {
|
||||
this.journal.createEntry(
|
||||
tenantId,
|
||||
`Fund transfer: ${from.name} → ${to.name}${dto.description ? ` — ${dto.description}` : ''}`,
|
||||
[
|
||||
{ accountCode: toCoa.code, debit: dto.amount },
|
||||
{ accountCode: fromCoa.code, credit: dto.amount },
|
||||
],
|
||||
{ reference: `TRF-${transfer.id.slice(0, 8)}`, sourceType: 'transfer', sourceId: transfer.id },
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return transfer;
|
||||
}
|
||||
|
||||
async getTransfers(tenantId: string) {
|
||||
return this.prisma.fundTransfer.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
fromAccount: { select: { name: true, type: true } },
|
||||
toAccount: { select: { name: true, type: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/account/dto/create-account.dto.ts
Normal file
8
src/account/dto/create-account.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, MinLength, IsOptional, IsNumber, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString() @MinLength(2) name: string;
|
||||
@IsString() @IsIn(['bank', 'e_wallet', 'cash']) type: string;
|
||||
@IsOptional() @IsString() accountNo?: string;
|
||||
@IsOptional() @IsNumber() initialBalance?: number;
|
||||
}
|
||||
8
src/account/dto/transfer-funds.dto.ts
Normal file
8
src/account/dto/transfer-funds.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, IsNumber, IsPositive, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class TransferFundsDto {
|
||||
@IsUUID() fromAccountId: string;
|
||||
@IsUUID() toAccountId: string;
|
||||
@IsNumber() @IsPositive() amount: number;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
}
|
||||
7
src/account/dto/update-account.dto.ts
Normal file
7
src/account/dto/update-account.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsOptional, IsBoolean, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsOptional() @IsString() @MinLength(2) name?: string;
|
||||
@IsOptional() @IsString() accountNo?: string;
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
45
src/accounting/accounting.controller.ts
Normal file
45
src/accounting/accounting.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Post, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AccountingService } from './accounting.service';
|
||||
import { CreateAccountDto } from './dto/create-coa.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('accounting')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('tenant_admin')
|
||||
export class AccountingController {
|
||||
constructor(private readonly accountingService: AccountingService) {}
|
||||
|
||||
@Get('chart-of-accounts')
|
||||
async getCoA(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getChartOfAccounts(user.tenantId);
|
||||
}
|
||||
|
||||
@Post('chart-of-accounts')
|
||||
async createAccount(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
|
||||
return this.accountingService.createAccount(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Delete('chart-of-accounts/:id')
|
||||
async deleteAccount(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.accountingService.deleteAccount(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('general-ledger')
|
||||
async getLedger(@CurrentUser() user: CurrentUserPayload, @Query('accountId') accountId?: string) {
|
||||
return this.accountingService.getGeneralLedger(user.tenantId, accountId);
|
||||
}
|
||||
|
||||
@Get('trial-balance')
|
||||
async getTrialBalance(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getTrialBalance(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
async getOverview(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getAccountingOverview(user.tenantId);
|
||||
}
|
||||
}
|
||||
11
src/accounting/accounting.module.ts
Normal file
11
src/accounting/accounting.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccountingController } from './accounting.controller';
|
||||
import { AccountingService } from './accounting.service';
|
||||
import { JournalService } from './journal.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountingController],
|
||||
providers: [AccountingService, JournalService],
|
||||
exports: [AccountingService, JournalService],
|
||||
})
|
||||
export class AccountingModule {}
|
||||
209
src/accounting/accounting.service.ts
Normal file
209
src/accounting/accounting.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateAccountDto } from './dto/create-coa.dto';
|
||||
|
||||
// Default Chart of Accounts for new tenants
|
||||
const DEFAULT_COA = [
|
||||
{ code: '1000', name: 'Assets', type: 'asset', isSystem: true },
|
||||
{ code: '1010', name: 'Cash on Hand', type: 'asset', isSystem: true },
|
||||
{ code: '1020', name: 'GCash Business', type: 'asset', isSystem: true },
|
||||
{ code: '1030', name: 'Maya Business', type: 'asset', isSystem: true },
|
||||
{ code: '1040', name: 'Bank Account', type: 'asset', isSystem: true },
|
||||
{ code: '1100', name: 'Accounts Receivable', type: 'asset', isSystem: true },
|
||||
{ code: '1200', name: 'Equipment', type: 'asset', isSystem: true },
|
||||
{ code: '2000', name: 'Liabilities', type: 'liability', isSystem: true },
|
||||
{ code: '2010', name: 'Accounts Payable', type: 'liability', isSystem: true },
|
||||
{ code: '3000', name: 'Equity', type: 'equity', isSystem: true },
|
||||
{ code: '3010', name: 'Owner\'s Equity', type: 'equity', isSystem: true },
|
||||
{ code: '3020', name: 'Retained Earnings', type: 'equity', isSystem: true },
|
||||
{ code: '4000', name: 'Revenue', type: 'revenue', isSystem: true },
|
||||
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', isSystem: true },
|
||||
{ code: '4020', name: 'Installation Fees', type: 'revenue', isSystem: true },
|
||||
{ code: '5000', name: 'Expenses', type: 'expense', isSystem: true },
|
||||
{ code: '5010', name: 'Utilities Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5020', name: 'Salaries Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5030', name: 'Maintenance Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5040', name: 'Transport Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5050', name: 'Supplies Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5060', name: 'Equipment Expense', type: 'expense', isSystem: true },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AccountingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getChartOfAccounts(tenantId: string) {
|
||||
const accounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
// Auto-seed if empty
|
||||
if (accounts.length === 0) {
|
||||
await this.seedDefaultAccounts(tenantId);
|
||||
return this.prisma.chartOfAccount.findMany({ where: { tenantId }, orderBy: { code: 'asc' } });
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
async seedDefaultAccounts(tenantId: string) {
|
||||
for (const acc of DEFAULT_COA) {
|
||||
await this.prisma.chartOfAccount.upsert({
|
||||
where: { tenantId_code: { tenantId, code: acc.code } },
|
||||
create: { tenantId, ...acc },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(tenantId: string, dto: CreateAccountDto) {
|
||||
const existing = await this.prisma.chartOfAccount.findFirst({
|
||||
where: { tenantId, code: dto.code },
|
||||
});
|
||||
if (existing) throw new ConflictException('Account code already exists');
|
||||
|
||||
return this.prisma.chartOfAccount.create({
|
||||
data: { tenantId, code: dto.code, name: dto.name, type: dto.type, parentId: dto.parentId },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAccount(tenantId: string, id: string) {
|
||||
const account = await this.prisma.chartOfAccount.findFirst({ where: { id, tenantId } });
|
||||
if (!account) throw new NotFoundException('Account not found');
|
||||
if (account.isSystem) throw new BadRequestException('Cannot delete system account');
|
||||
|
||||
const hasEntries = await this.prisma.journalLine.count({ where: { accountId: id } });
|
||||
if (hasEntries > 0) throw new BadRequestException('Cannot delete account with journal entries');
|
||||
|
||||
await this.prisma.chartOfAccount.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async getGeneralLedger(tenantId: string, accountId?: string) {
|
||||
const where: any = {};
|
||||
if (accountId) {
|
||||
where.accountId = accountId;
|
||||
} else {
|
||||
where.journalEntry = { tenantId };
|
||||
}
|
||||
|
||||
return this.prisma.journalLine.findMany({
|
||||
where,
|
||||
include: {
|
||||
account: { select: { code: true, name: true, type: true } },
|
||||
journalEntry: { select: { entryDate: true, description: true, reference: true } },
|
||||
},
|
||||
orderBy: { journalEntry: { entryDate: 'desc' } },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async getTrialBalance(tenantId: string) {
|
||||
const accounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
journalLines: { select: { debit: true, credit: true } },
|
||||
},
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
return accounts.map((a) => {
|
||||
const totalDebit = a.journalLines.reduce((s, l) => s + Number(l.debit), 0);
|
||||
const totalCredit = a.journalLines.reduce((s, l) => s + Number(l.credit), 0);
|
||||
return {
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
debit: totalDebit,
|
||||
credit: totalCredit,
|
||||
balance: totalDebit - totalCredit,
|
||||
};
|
||||
}).filter((a) => a.debit > 0 || a.credit > 0);
|
||||
}
|
||||
|
||||
async getAccountingOverview(tenantId: string) {
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// All accounts with their full journal lines
|
||||
const accounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
journalLines: {
|
||||
select: { debit: true, credit: true, journalEntry: { select: { entryDate: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
// Compute balances by type (from all journal entries)
|
||||
const byType: Record<string, { debit: number; credit: number; balance: number }> = {};
|
||||
const cashAccounts: { code: string; name: string; balance: number }[] = [];
|
||||
|
||||
for (const acc of accounts) {
|
||||
const totalDebit = acc.journalLines.reduce((s, l) => s + Number(l.debit), 0);
|
||||
const totalCredit = acc.journalLines.reduce((s, l) => s + Number(l.credit), 0);
|
||||
const balance = totalDebit - totalCredit;
|
||||
|
||||
if (!byType[acc.type]) byType[acc.type] = { debit: 0, credit: 0, balance: 0 };
|
||||
byType[acc.type].debit += totalDebit;
|
||||
byType[acc.type].credit += totalCredit;
|
||||
byType[acc.type].balance += balance;
|
||||
|
||||
// Track individual cash accounts (codes 1010-1040)
|
||||
if (['1010', '1020', '1030', '1040'].includes(acc.code)) {
|
||||
cashAccounts.push({ code: acc.code, name: acc.name, balance });
|
||||
}
|
||||
}
|
||||
|
||||
// Monthly income (revenue credits this month)
|
||||
const monthlyRevenue = accounts
|
||||
.filter((a) => a.type === 'revenue')
|
||||
.reduce((sum, acc) => {
|
||||
const monthCredits = acc.journalLines
|
||||
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
|
||||
.reduce((s, l) => s + Number(l.credit), 0);
|
||||
return sum + monthCredits;
|
||||
}, 0);
|
||||
|
||||
// Monthly expenses (expense debits this month)
|
||||
const monthlyExpenses = accounts
|
||||
.filter((a) => a.type === 'expense')
|
||||
.reduce((sum, acc) => {
|
||||
const monthDebits = acc.journalLines
|
||||
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
|
||||
.reduce((s, l) => s + Number(l.debit), 0);
|
||||
return sum + monthDebits;
|
||||
}, 0);
|
||||
|
||||
// Expense breakdown by category this month
|
||||
const expenseBreakdown = accounts
|
||||
.filter((a) => a.type === 'expense')
|
||||
.map((acc) => ({
|
||||
code: acc.code,
|
||||
name: acc.name,
|
||||
amount: acc.journalLines
|
||||
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
|
||||
.reduce((s, l) => s + Number(l.debit), 0),
|
||||
}))
|
||||
.filter((e) => e.amount > 0)
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
return {
|
||||
totalAssets: byType['asset']?.balance || 0,
|
||||
totalLiabilities: byType['liability']?.balance || 0,
|
||||
totalEquity: byType['equity']?.balance || 0,
|
||||
totalRevenue: byType['revenue']?.credit || 0,
|
||||
totalExpenses: byType['expense']?.debit || 0,
|
||||
accountsReceivable: accounts.find((a) => a.code === '1100')?.journalLines.reduce((s, l) => s + Number(l.debit) - Number(l.credit), 0) || 0,
|
||||
cashAccounts,
|
||||
cashOnHand: cashAccounts.reduce((s, a) => s + a.balance, 0),
|
||||
monthlyRevenue,
|
||||
monthlyExpenses,
|
||||
netIncome: monthlyRevenue - monthlyExpenses,
|
||||
expenseBreakdown,
|
||||
};
|
||||
}
|
||||
}
|
||||
8
src/accounting/dto/create-coa.dto.ts
Normal file
8
src/accounting/dto/create-coa.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, MinLength, IsOptional, IsIn, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString() @MinLength(3) code: string;
|
||||
@IsString() @MinLength(2) name: string;
|
||||
@IsString() @IsIn(['asset', 'liability', 'equity', 'revenue', 'expense']) type: string;
|
||||
@IsOptional() @IsUUID() parentId?: string;
|
||||
}
|
||||
259
src/accounting/journal.service.ts
Normal file
259
src/accounting/journal.service.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface JournalLineInput {
|
||||
accountCode: string;
|
||||
debit?: number;
|
||||
credit?: number;
|
||||
}
|
||||
|
||||
// Maps payment method to CoA code suffix
|
||||
const METHOD_SUFFIX: Record<string, string> = {
|
||||
cash: 'Cash',
|
||||
gcash: 'GCash',
|
||||
maya: 'Maya',
|
||||
bank_transfer: 'Bank',
|
||||
};
|
||||
|
||||
const METHOD_LABEL: Record<string, string> = {
|
||||
cash: 'Cash',
|
||||
gcash: 'GCash',
|
||||
maya: 'Maya',
|
||||
bank_transfer: 'Bank Transfer',
|
||||
};
|
||||
|
||||
// Company-level CoA codes per method
|
||||
const COMPANY_COA: Record<string, string> = {
|
||||
cash: '1010',
|
||||
gcash: '1020',
|
||||
maya: '1030',
|
||||
bank_transfer: '1040',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JournalService {
|
||||
private readonly logger = new Logger(JournalService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Creates a journal entry with debit/credit lines.
|
||||
*/
|
||||
async createEntry(
|
||||
tenantId: string,
|
||||
description: string,
|
||||
lines: JournalLineInput[],
|
||||
options?: { reference?: string; sourceType?: string; sourceId?: string; createdById?: string },
|
||||
) {
|
||||
const totalDebit = lines.reduce((s, l) => s + (l.debit || 0), 0);
|
||||
const totalCredit = lines.reduce((s, l) => s + (l.credit || 0), 0);
|
||||
|
||||
if (Math.abs(totalDebit - totalCredit) > 0.01) {
|
||||
this.logger.warn(`Unbalanced entry: DR ${totalDebit} != CR ${totalCredit} — ${description}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedLines = [];
|
||||
for (const line of lines) {
|
||||
const account = await this.prisma.chartOfAccount.findFirst({
|
||||
where: { tenantId, code: line.accountCode },
|
||||
});
|
||||
if (!account) {
|
||||
this.logger.warn(`CoA ${line.accountCode} not found for tenant ${tenantId}`);
|
||||
return null;
|
||||
}
|
||||
resolvedLines.push({ accountId: account.id, debit: line.debit || 0, credit: line.credit || 0 });
|
||||
}
|
||||
|
||||
return this.prisma.journalEntry.create({
|
||||
data: {
|
||||
tenantId,
|
||||
description,
|
||||
reference: options?.reference,
|
||||
sourceType: options?.sourceType,
|
||||
sourceId: options?.sourceId,
|
||||
createdById: options?.createdById,
|
||||
lines: { create: resolvedLines },
|
||||
},
|
||||
include: { lines: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custodial CoA accounts for a user.
|
||||
* Pattern: "UserName - Cash" with code 15XX where XX = sequential.
|
||||
*/
|
||||
async createCustodialAccounts(tenantId: string, userId: string, userName: string) {
|
||||
// Find next available code block in 15XX range
|
||||
const existing = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId, code: { startsWith: '15' } },
|
||||
orderBy: { code: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
const baseCode = existing[0] ? parseInt(existing[0].code) + 10 : 1500;
|
||||
|
||||
const methods = ['Cash', 'GCash', 'Maya', 'Bank'];
|
||||
const created = [];
|
||||
|
||||
for (let i = 0; i < methods.length; i++) {
|
||||
const code = String(baseCode + i);
|
||||
const name = `${userName} - ${methods[i]}`;
|
||||
const account = await this.prisma.chartOfAccount.upsert({
|
||||
where: { tenantId_code: { tenantId, code } },
|
||||
create: { tenantId, code, name, type: 'asset', isSystem: false },
|
||||
update: {},
|
||||
});
|
||||
created.push(account);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the custodial CoA code for a user + method.
|
||||
* Looks up "UserName - Cash/GCash/Maya/Bank" in the CoA.
|
||||
*/
|
||||
async getCustodialCode(tenantId: string, collectorId: string, method: string): Promise<string | null> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: collectorId } });
|
||||
if (!user) return null;
|
||||
|
||||
const suffix = METHOD_SUFFIX[method] || 'Cash';
|
||||
const searchName = `${user.firstName} ${user.lastName} - ${suffix}`;
|
||||
|
||||
const account = await this.prisma.chartOfAccount.findFirst({
|
||||
where: { tenantId, name: searchName },
|
||||
});
|
||||
|
||||
return account?.code || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment collected → DR collector's custodial CoA, CR Accounts Receivable.
|
||||
* Money sits in collector's custody until remittance is approved.
|
||||
*/
|
||||
async journalForPayment(
|
||||
tenantId: string,
|
||||
paymentId: string,
|
||||
amount: number,
|
||||
method: string,
|
||||
context: { invoiceNumber?: string; clientName?: string; collectorId?: string; collectorName?: string },
|
||||
) {
|
||||
let debitCode: string;
|
||||
|
||||
if (context.collectorId) {
|
||||
let custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method);
|
||||
if (!custodialCode && context.collectorName) {
|
||||
await this.createCustodialAccounts(tenantId, context.collectorId, context.collectorName);
|
||||
custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method);
|
||||
}
|
||||
debitCode = custodialCode || COMPANY_COA[method] || '1010';
|
||||
} else {
|
||||
debitCode = COMPANY_COA[method] || '1010';
|
||||
}
|
||||
|
||||
const description = [
|
||||
`Payment collected via ${METHOD_LABEL[method] || method}`,
|
||||
context.collectorName ? `by ${context.collectorName}` : '',
|
||||
context.clientName ? `from ${context.clientName}` : '',
|
||||
context.invoiceNumber ? `for ${context.invoiceNumber}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return this.createEntry(tenantId, description, [
|
||||
{ accountCode: debitCode, debit: amount },
|
||||
{ accountCode: '1100', credit: amount },
|
||||
], { reference: context.invoiceNumber, sourceType: 'payment', sourceId: paymentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remittance approved → DR company CoA, CR collector's custodial CoA.
|
||||
* Clears money from collector custody into company books.
|
||||
*/
|
||||
async journalForRemittance(
|
||||
tenantId: string,
|
||||
remittanceId: string,
|
||||
collectorId: string,
|
||||
collectorName: string,
|
||||
paymentsByMethod: { method: string; total: number }[],
|
||||
) {
|
||||
const lines: JournalLineInput[] = [];
|
||||
|
||||
for (const pm of paymentsByMethod) {
|
||||
let custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method);
|
||||
|
||||
// Auto-create custodial accounts if they don't exist yet
|
||||
if (!custodialCode) {
|
||||
this.logger.warn(`Custodial account missing for ${collectorName} (${pm.method}) — auto-creating`);
|
||||
await this.createCustodialAccounts(tenantId, collectorId, collectorName);
|
||||
custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method);
|
||||
}
|
||||
|
||||
const companyCode = COMPANY_COA[pm.method] || '1010';
|
||||
|
||||
if (custodialCode) {
|
||||
lines.push({ accountCode: companyCode, debit: pm.total });
|
||||
lines.push({ accountCode: custodialCode, credit: pm.total });
|
||||
} else {
|
||||
this.logger.error(`Still no custodial account for ${collectorName} (${pm.method}) after auto-create`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
this.logger.warn(`No journal lines for remittance ${remittanceId} — skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalAmount = paymentsByMethod.reduce((s, p) => s + p.total, 0);
|
||||
return this.createEntry(
|
||||
tenantId,
|
||||
`Remittance approved — ${collectorName} cleared PHP ${totalAmount.toLocaleString()}`,
|
||||
lines,
|
||||
{ reference: `REM-${remittanceId.slice(0, 8)}`, sourceType: 'remittance', sourceId: remittanceId },
|
||||
);
|
||||
}
|
||||
|
||||
/** Invoice issued → DR Accounts Receivable (1100), CR Service Revenue (4010) */
|
||||
async journalForInvoice(tenantId: string, invoiceId: string, invoiceNumber: string, amount: number) {
|
||||
return this.createEntry(tenantId, `Invoice ${invoiceNumber} issued`, [
|
||||
{ accountCode: '1100', debit: amount },
|
||||
{ accountCode: '4010', credit: amount },
|
||||
], { reference: invoiceNumber, sourceType: 'invoice', sourceId: invoiceId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CompanyAccount.balance when a journal entry affects cash accounts.
|
||||
* Called after remittance confirmation (increment) and expense approval (decrement).
|
||||
*/
|
||||
async updateCompanyAccountBalance(
|
||||
tenantId: string,
|
||||
coaCode: string,
|
||||
amount: number,
|
||||
direction: 'increment' | 'decrement',
|
||||
) {
|
||||
const coa = await this.prisma.chartOfAccount.findFirst({
|
||||
where: { tenantId, code: coaCode },
|
||||
});
|
||||
if (!coa) return;
|
||||
|
||||
const account = await this.prisma.companyAccount.findFirst({
|
||||
where: { tenantId, chartOfAccountId: coa.id, isActive: true },
|
||||
});
|
||||
if (!account) return;
|
||||
|
||||
await this.prisma.companyAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { balance: { [direction]: amount } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Expense approved → DR Expense Category, CR Cash (1010) */
|
||||
async journalForExpense(tenantId: string, expenseId: string, amount: number, category: string) {
|
||||
const expenseAccountCode: Record<string, string> = {
|
||||
utilities: '5010', salary: '5020', maintenance: '5030',
|
||||
transport: '5040', supplies: '5050', equipment: '5060', other: '5000',
|
||||
};
|
||||
return this.createEntry(tenantId, `Expense: ${category}`, [
|
||||
{ accountCode: expenseAccountCode[category] || '5000', debit: amount },
|
||||
{ accountCode: '1010', credit: amount },
|
||||
], { sourceType: 'expense', sourceId: expenseId });
|
||||
}
|
||||
}
|
||||
85
src/app.module.ts
Normal file
85
src/app.module.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { AreaModule } from './area/area.module';
|
||||
import { PlanModule } from './plan/plan.module';
|
||||
import { ClientModule } from './client/client.module';
|
||||
import { TicketModule } from './ticket/ticket.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { InvoiceModule } from './invoice/invoice.module';
|
||||
import { PaymentModule } from './payment/payment.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { ReportModule } from './report/report.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { EmployeeModule } from './employee/employee.module';
|
||||
import { ExpenseModule } from './expense/expense.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { AssetModule } from './asset/asset.module';
|
||||
import { PortalModule } from './portal/portal.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
||||
import { AccountingModule } from './accounting/accounting.module';
|
||||
import { PayrollModule } from './payroll/payroll.module';
|
||||
import { RoleModule } from './role/role.module';
|
||||
import { CommentModule } from './comment/comment.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||
import { AccessGuard } from './common/guards/access.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '../../.env',
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{ name: 'short', ttl: 1000, limit: 50 }, // 50 req/sec
|
||||
{ name: 'medium', ttl: 60000, limit: 500 }, // 500 req/min
|
||||
]),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
TenantModule,
|
||||
AreaModule,
|
||||
PlanModule,
|
||||
ClientModule,
|
||||
TicketModule,
|
||||
SubscriptionModule,
|
||||
InvoiceModule,
|
||||
PaymentModule,
|
||||
DashboardModule,
|
||||
ReportModule,
|
||||
NotificationModule,
|
||||
AuditModule,
|
||||
EmployeeModule,
|
||||
ExpenseModule,
|
||||
AccountModule,
|
||||
AssetModule,
|
||||
PortalModule,
|
||||
BillingModule,
|
||||
SchedulerModule,
|
||||
AccountingModule,
|
||||
PayrollModule,
|
||||
RoleModule,
|
||||
CommentModule,
|
||||
ImportModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ provide: APP_GUARD, useClass: AccessGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
63
src/area/area.controller.ts
Normal file
63
src/area/area.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AreaService } from './area.service';
|
||||
import { CreateAreaDto } from './dto/create-area.dto';
|
||||
import { UpdateAreaDto } from './dto/update-area.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('areas')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class AreaController {
|
||||
constructor(private readonly areaService: AreaService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.areaService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findById(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.areaService.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Body() dto: CreateAreaDto,
|
||||
) {
|
||||
return this.areaService.create(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAreaDto,
|
||||
) {
|
||||
return this.areaService.update(user.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.areaService.remove(user.tenantId, id);
|
||||
}
|
||||
}
|
||||
10
src/area/area.module.ts
Normal file
10
src/area/area.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AreaController } from './area.controller';
|
||||
import { AreaService } from './area.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AreaController],
|
||||
providers: [AreaService],
|
||||
exports: [AreaService],
|
||||
})
|
||||
export class AreaModule {}
|
||||
105
src/area/area.service.ts
Normal file
105
src/area/area.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateAreaDto } from './dto/create-area.dto';
|
||||
import { UpdateAreaDto } from './dto/update-area.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AreaService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
return db.area.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: { select: { clients: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const area = await db.area.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { clients: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
throw new NotFoundException('Area not found');
|
||||
}
|
||||
|
||||
return area;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateAreaDto) {
|
||||
const existing = await this.prisma.area.findFirst({
|
||||
where: { tenantId, name: dto.name },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Area name already exists');
|
||||
}
|
||||
|
||||
return this.prisma.area.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateAreaDto) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const existing = await db.area.findFirst({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Area not found');
|
||||
}
|
||||
|
||||
if (dto.name && dto.name !== existing.name) {
|
||||
const duplicate = await this.prisma.area.findFirst({
|
||||
where: { tenantId, name: dto.name },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new ConflictException('Area name already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.area.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const existing = await db.area.findFirst({
|
||||
where: { id },
|
||||
include: { _count: { select: { clients: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Area not found');
|
||||
}
|
||||
|
||||
if (existing._count.clients > 0) {
|
||||
throw new ConflictException(
|
||||
'Cannot delete area with assigned clients. Deactivate it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.area.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
11
src/area/dto/create-area.dto.ts
Normal file
11
src/area/dto/create-area.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, MinLength, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateAreaDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
16
src/area/dto/update-area.dto.ts
Normal file
16
src/area/dto/update-area.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsString, MinLength, IsOptional, IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdateAreaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
31
src/asset/asset.controller.ts
Normal file
31
src/asset/asset.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AssetService } from './asset.service';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('assets')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class AssetController {
|
||||
constructor(private readonly assetService: AssetService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload, @Query('category') category?: string, @Query('status') status?: string) {
|
||||
return this.assetService.findAll(user.tenantId, { category, status });
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAssetDto) {
|
||||
return this.assetService.create(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateAssetDto) {
|
||||
return this.assetService.update(user.tenantId, id, dto);
|
||||
}
|
||||
}
|
||||
10
src/asset/asset.module.ts
Normal file
10
src/asset/asset.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetController } from './asset.controller';
|
||||
import { AssetService } from './asset.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AssetController],
|
||||
providers: [AssetService],
|
||||
exports: [AssetService],
|
||||
})
|
||||
export class AssetModule {}
|
||||
53
src/asset/asset.service.ts
Normal file
53
src/asset/asset.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AssetService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { category?: string; status?: string }) {
|
||||
return this.prisma.asset.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(filters?.category && { category: filters.category }),
|
||||
...(filters?.status && { status: filters.status }),
|
||||
},
|
||||
include: { assignedTo: { select: { id: true, firstName: true, lastName: true, employeeNo: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateAssetDto) {
|
||||
return this.prisma.asset.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
category: dto.category,
|
||||
serialNumber: dto.serialNumber,
|
||||
purchaseDate: dto.purchaseDate ? new Date(dto.purchaseDate) : null,
|
||||
purchasePrice: dto.purchasePrice || null,
|
||||
location: dto.location,
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateAssetDto) {
|
||||
const existing = await this.prisma.asset.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) throw new NotFoundException('Asset not found');
|
||||
|
||||
return this.prisma.asset.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name && { name: dto.name }),
|
||||
...(dto.status && { status: dto.status }),
|
||||
...(dto.assignedToId !== undefined && { assignedToId: dto.assignedToId || null }),
|
||||
...(dto.location !== undefined && { location: dto.location }),
|
||||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||||
},
|
||||
include: { assignedTo: { select: { id: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
11
src/asset/dto/create-asset.dto.ts
Normal file
11
src/asset/dto/create-asset.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, MinLength, IsOptional, IsNumber, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@IsString() @MinLength(2) name: string;
|
||||
@IsString() @IsIn(['router', 'olt', 'cable', 'tool', 'vehicle', 'computer', 'other']) category: string;
|
||||
@IsOptional() @IsString() serialNumber?: string;
|
||||
@IsOptional() @IsString() purchaseDate?: string;
|
||||
@IsOptional() @IsNumber() purchasePrice?: number;
|
||||
@IsOptional() @IsString() location?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
9
src/asset/dto/update-asset.dto.ts
Normal file
9
src/asset/dto/update-asset.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString, IsOptional, IsIn, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateAssetDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() @IsIn(['available', 'in_use', 'maintenance', 'retired']) status?: string;
|
||||
@IsOptional() @IsUUID() assignedToId?: string;
|
||||
@IsOptional() @IsString() location?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
9
src/audit/audit.module.ts
Normal file
9
src/audit/audit.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
39
src/audit/audit.service.ts
Normal file
39
src/audit/audit.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface AuditEntry {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
action: string;
|
||||
entity: string;
|
||||
entityId: string;
|
||||
details?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async log(entry: AuditEntry) {
|
||||
return this.prisma.$queryRawUnsafe(
|
||||
`INSERT INTO audit_logs (id, "tenantId", "userId", action, entity, "entityId", details, "ipAddress", "createdAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6::jsonb, $7, NOW())`,
|
||||
entry.tenantId,
|
||||
entry.userId,
|
||||
entry.action,
|
||||
entry.entity,
|
||||
entry.entityId,
|
||||
JSON.stringify(entry.details || {}),
|
||||
entry.ipAddress || null,
|
||||
);
|
||||
}
|
||||
|
||||
async findByTenant(tenantId: string, limit = 50) {
|
||||
return this.prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM audit_logs WHERE "tenantId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
|
||||
tenantId,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
62
src/auth/auth.controller.ts
Normal file
62
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterTenantDto } from './dto/register-tenant.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ short: { ttl: 60000, limit: 50 } }) // 50 login attempts per minute per IP
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@Throttle({ short: { ttl: 60000, limit: 3 } }) // 3 registrations per minute
|
||||
async register(@Body() dto: RegisterTenantDto) {
|
||||
return this.authService.registerTenant(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refreshTokens(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async logout(@Req() req: any) {
|
||||
await this.authService.logout(req.user.sub);
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
async profile(@Req() req: any) {
|
||||
return this.authService.getProfile(req.user.sub);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
|
||||
return this.authService.changePassword(req.user.sub, dto.currentPassword, dto.newPassword);
|
||||
}
|
||||
}
|
||||
26
src/auth/auth.module.ts
Normal file
26
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.get<string>('JWT_EXPIRES_IN', '15m') as any,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
267
src/auth/auth.service.ts
Normal file
267
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JwtPayload, TokenResponse, getPermissionsForRoles } from '@fiberops/shared';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterTenantDto } from './dto/register-tenant.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto): Promise<TokenResponse> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email, isActive: true },
|
||||
include: { roles: true, tenant: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Super admin may not have a tenant; regular users must have active tenant
|
||||
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
|
||||
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(dto.password, user.password);
|
||||
if (!passwordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
const tokens = await this.generateTokens({
|
||||
sub: user.id,
|
||||
tenantId: user.tenantId,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
async registerTenant(dto: RegisterTenantDto) {
|
||||
const existing = await this.prisma.tenant.findUnique({
|
||||
where: { slug: dto.slug },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Tenant slug already taken');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
|
||||
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: {
|
||||
name: dto.tenantName,
|
||||
slug: dto.slug,
|
||||
settings: {
|
||||
companyName: dto.tenantName,
|
||||
currency: 'PHP',
|
||||
timezone: 'Asia/Manila',
|
||||
},
|
||||
users: {
|
||||
create: {
|
||||
email: dto.adminEmail,
|
||||
password: hashedPassword,
|
||||
firstName: dto.adminFirstName,
|
||||
lastName: dto.adminLastName,
|
||||
roles: {
|
||||
create: { role: 'tenant_admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
include: { roles: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const admin = tenant.users[0]!;
|
||||
const roles = admin.roles.map((r) => r.role);
|
||||
const tokens = await this.generateTokens({
|
||||
sub: admin.id,
|
||||
tenantId: tenant.id,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
},
|
||||
user: {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
roles,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
});
|
||||
|
||||
if (!stored || stored.expiresAt < new Date()) {
|
||||
if (stored) {
|
||||
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
}
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
// Delete the used refresh token (rotation)
|
||||
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: stored.userId },
|
||||
include: { roles: true, tenant: true },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
|
||||
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
|
||||
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
return this.generateTokens({
|
||||
sub: user.id,
|
||||
tenantId: user.tenantId,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
}
|
||||
|
||||
async logout(userId: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: true,
|
||||
tenant: { select: { id: true, name: true, slug: true, settings: true } },
|
||||
tenantRoles: {
|
||||
include: {
|
||||
tenantRole: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
const isSuperAdmin = roles.includes('super_admin');
|
||||
|
||||
// Resolve permission matrix from tenant roles
|
||||
const accessMap: Record<string, Record<string, boolean>> = {};
|
||||
if (!isSuperAdmin) {
|
||||
for (const utr of user.tenantRoles) {
|
||||
if (!utr.tenantRole.isActive || utr.tenantRole.deletedAt) continue;
|
||||
for (const p of utr.tenantRole.permissions) {
|
||||
if (!accessMap[p.module]) {
|
||||
accessMap[p.module] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||
}
|
||||
if (p.canView) accessMap[p.module].canView = true;
|
||||
if (p.canCreate) accessMap[p.module].canCreate = true;
|
||||
if (p.canUpdate) accessMap[p.module].canUpdate = true;
|
||||
if (p.canArchive) accessMap[p.module].canArchive = true;
|
||||
if (p.canApprove) accessMap[p.module].canApprove = true;
|
||||
if (p.canExport) accessMap[p.module].canExport = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
accessMap: isSuperAdmin ? 'all' : accessMap,
|
||||
tenantRoles: user.tenantRoles.map((tr) => ({
|
||||
id: tr.tenantRole.id,
|
||||
name: tr.tenantRole.name,
|
||||
slug: tr.tenantRole.slug,
|
||||
})),
|
||||
tenant: user.tenant,
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!valid) throw new UnauthorizedException('Current password is incorrect');
|
||||
|
||||
const hashed = await bcrypt.hash(newPassword, 12);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashed, mustChangePassword: false },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async generateTokens(payload: JwtPayload): Promise<TokenResponse> {
|
||||
const jti = crypto.randomUUID();
|
||||
const accessToken = this.jwt.sign({ ...payload, jti });
|
||||
|
||||
const refreshJti = crypto.randomUUID();
|
||||
const refreshToken = this.jwt.sign({ ...payload, jti: refreshJti }, {
|
||||
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d') as any,
|
||||
});
|
||||
|
||||
const expiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
|
||||
const expiresAt = new Date();
|
||||
const days = parseInt(expiresIn) || 7;
|
||||
expiresAt.setDate(expiresAt.getDate() + days);
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token: refreshToken,
|
||||
userId: payload.sub,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
10
src/auth/dto/change-password.dto.ts
Normal file
10
src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
currentPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
10
src/auth/dto/login.dto.ts
Normal file
10
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
}
|
||||
6
src/auth/dto/refresh-token.dto.ts
Normal file
6
src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
30
src/auth/dto/register-tenant.dto.ts
Normal file
30
src/auth/dto/register-tenant.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
|
||||
|
||||
export class RegisterTenantDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
tenantName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(50)
|
||||
@Matches(/^[a-z0-9-]+$/, {
|
||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
slug: string;
|
||||
|
||||
@IsEmail()
|
||||
adminEmail: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
adminPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
adminFirstName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
adminLastName: string;
|
||||
}
|
||||
25
src/auth/strategies/jwt.strategy.ts
Normal file
25
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '@fiberops/shared';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>('JWT_SECRET') || 'fallback',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload) {
|
||||
return {
|
||||
sub: payload.sub,
|
||||
tenantId: payload.tenantId || null,
|
||||
roles: payload.roles || [],
|
||||
permissions: payload.permissions || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/billing/billing.controller.ts
Normal file
25
src/billing/billing.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { BillingService } from './billing.service';
|
||||
import { UpdateBillingSettingsDto } from './dto/update-billing-settings.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('billing-settings')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('tenant_admin')
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get()
|
||||
async getSettings(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.billingService.getSettings(user.tenantId);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
async updateSettings(@CurrentUser() user: CurrentUserPayload, @Body() dto: UpdateBillingSettingsDto) {
|
||||
return this.billingService.updateSettings(user.tenantId, dto);
|
||||
}
|
||||
}
|
||||
10
src/billing/billing.module.ts
Normal file
10
src/billing/billing.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BillingController } from './billing.controller';
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
25
src/billing/billing.service.ts
Normal file
25
src/billing/billing.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdateBillingSettingsDto } from './dto/update-billing-settings.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getSettings(tenantId: string) {
|
||||
let settings = await this.prisma.billingSetting.findUnique({ where: { tenantId } });
|
||||
if (!settings) {
|
||||
settings = await this.prisma.billingSetting.create({ data: { tenantId } });
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
async updateSettings(tenantId: string, dto: UpdateBillingSettingsDto) {
|
||||
await this.prisma.billingSetting.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...dto },
|
||||
update: dto,
|
||||
});
|
||||
return this.getSettings(tenantId);
|
||||
}
|
||||
}
|
||||
9
src/billing/dto/update-billing-settings.dto.ts
Normal file
9
src/billing/dto/update-billing-settings.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min, Max } from 'class-validator';
|
||||
|
||||
export class UpdateBillingSettingsDto {
|
||||
@IsOptional() @IsBoolean() autoGenerate?: boolean;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(90) gracePeriodDays?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(60) dueDateOffsetDays?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(100) lateFeePercent?: number;
|
||||
@IsOptional() @IsString() invoicePrefix?: string;
|
||||
}
|
||||
65
src/client/client.controller.ts
Normal file
65
src/client/client.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ClientService } from './client.service';
|
||||
import { CreateClientDto } from './dto/create-client.dto';
|
||||
import { UpdateClientDto } from './dto/update-client.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('clients')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
export class ClientController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('technician', 'collector')
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Query('areaId') areaId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.clientService.findAll(user.tenantId, { areaId, status, search, page, limit });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('technician', 'collector')
|
||||
async findById(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.clientService.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('manager')
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Body() dto: CreateClientDto,
|
||||
) {
|
||||
return this.clientService.create(user.tenantId, user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('manager', 'technician', 'collector')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateClientDto,
|
||||
) {
|
||||
return this.clientService.update(user.tenantId, id, dto);
|
||||
}
|
||||
}
|
||||
12
src/client/client.module.ts
Normal file
12
src/client/client.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClientController } from './client.controller';
|
||||
import { ClientService } from './client.service';
|
||||
import { TicketModule } from '../ticket/ticket.module';
|
||||
|
||||
@Module({
|
||||
imports: [TicketModule],
|
||||
controllers: [ClientController],
|
||||
providers: [ClientService],
|
||||
exports: [ClientService],
|
||||
})
|
||||
export class ClientModule {}
|
||||
164
src/client/client.service.ts
Normal file
164
src/client/client.service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TicketService } from '../ticket/ticket.service';
|
||||
import { CreateClientDto } from './dto/create-client.dto';
|
||||
import { UpdateClientDto } from './dto/update-client.dto';
|
||||
import { paginationArgs, paginatedResult } from '../common/dto/pagination.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ticketService: TicketService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { areaId?: string; status?: string; search?: string; page?: number; limit?: number }) {
|
||||
const { skip, take, page, limit } = paginationArgs({ page: filters?.page, limit: filters?.limit });
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const where = {
|
||||
...(filters?.areaId && { areaId: filters.areaId }),
|
||||
...(filters?.status && { status: filters.status }),
|
||||
...(filters?.search && {
|
||||
OR: [
|
||||
{ firstName: { contains: filters.search, mode: 'insensitive' as any } },
|
||||
{ lastName: { contains: filters.search, mode: 'insensitive' as any } },
|
||||
{ accountNumber: { contains: filters.search, mode: 'insensitive' as any } },
|
||||
{ email: { contains: filters.search, mode: 'insensitive' as any } },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
db.client.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
area: { select: { id: true, name: true } },
|
||||
_count: { select: { subscriptions: true, tickets: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
db.client.count({ where }),
|
||||
]);
|
||||
return paginatedResult(items, total, page, limit);
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const client = await db.client.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
area: { select: { id: true, name: true } },
|
||||
subscriptions: {
|
||||
include: { plan: { select: { id: true, name: true, price: true, speedDown: true, speedUp: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
tickets: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async create(tenantId: string, createdById: string, dto: CreateClientDto) {
|
||||
// Generate account number
|
||||
const count = await this.prisma.client.count({ where: { tenantId } });
|
||||
const accountNumber = `C-${String(count + 1).padStart(6, '0')}`;
|
||||
|
||||
// Check unique account number
|
||||
const existingAccount = await this.prisma.client.findFirst({
|
||||
where: { tenantId, accountNumber },
|
||||
});
|
||||
if (existingAccount) {
|
||||
throw new ConflictException('Account number conflict, please retry');
|
||||
}
|
||||
|
||||
const client = await this.prisma.client.create({
|
||||
data: {
|
||||
tenantId,
|
||||
accountNumber,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
address: dto.address,
|
||||
areaId: dto.areaId,
|
||||
},
|
||||
include: {
|
||||
area: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Validate plan exists
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: { id: dto.planId, tenantId, isActive: true },
|
||||
});
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found or inactive');
|
||||
}
|
||||
|
||||
// Auto-create subscription (onboarding = client + plan + type)
|
||||
await this.prisma.subscription.create({
|
||||
data: {
|
||||
tenantId,
|
||||
clientId: client.id,
|
||||
planId: dto.planId,
|
||||
type: dto.subscriptionType,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-create INSTALLATION ticket → kicks off the workflow
|
||||
// Postpaid: install → activation ticket → activate + invoice
|
||||
// Prepaid: install → invoice → payment → activation ticket → activate + next invoice
|
||||
await this.ticketService.createSystemTicket(tenantId, createdById, {
|
||||
clientId: client.id,
|
||||
type: 'installation',
|
||||
title: `Installation for ${client.firstName} ${client.lastName}`,
|
||||
description: `New client installation at ${client.address}. Plan: ${plan.name} (${dto.subscriptionType})`,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateClientDto) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const existing = await db.client.findFirst({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
return this.prisma.client.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.firstName && { firstName: dto.firstName }),
|
||||
...(dto.lastName && { lastName: dto.lastName }),
|
||||
...(dto.email !== undefined && { email: dto.email }),
|
||||
...(dto.phone !== undefined && { phone: dto.phone }),
|
||||
...(dto.address && { address: dto.address }),
|
||||
...(dto.areaId !== undefined && { areaId: dto.areaId }),
|
||||
...(dto.latitude !== undefined && { latitude: dto.latitude }),
|
||||
...(dto.longitude !== undefined && { longitude: dto.longitude }),
|
||||
...(dto.status && { status: dto.status }),
|
||||
},
|
||||
include: {
|
||||
area: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
34
src/client/dto/create-client.dto.ts
Normal file
34
src/client/dto/create-client.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { IsString, MinLength, IsOptional, IsEmail, IsUUID, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateClientDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
address: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
areaId?: string;
|
||||
|
||||
@IsUUID()
|
||||
planId: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['prepaid', 'postpaid'])
|
||||
subscriptionType: string;
|
||||
}
|
||||
47
src/client/dto/update-client.dto.ts
Normal file
47
src/client/dto/update-client.dto.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { IsString, MinLength, IsOptional, IsEmail, IsUUID, IsIn, IsNumber, Min, Max } from 'class-validator';
|
||||
|
||||
export class UpdateClientDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
areaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['active', 'inactive', 'suspended'])
|
||||
status?: string;
|
||||
}
|
||||
58
src/comment/comment.controller.ts
Normal file
58
src/comment/comment.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { CommentService } from './comment.service';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
import { multerOptions } from '../common/multer/multer.config';
|
||||
|
||||
@Controller('tickets/:ticketId/comments')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
export class CommentController {
|
||||
constructor(private readonly commentService: CommentService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('technician', 'collector')
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('ticketId') ticketId: string,
|
||||
) {
|
||||
return this.commentService.findAll(user.tenantId, ticketId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('technician', 'collector')
|
||||
@UseInterceptors(FileFieldsInterceptor([{ name: 'files', maxCount: 3 }], multerOptions))
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() body: any,
|
||||
@UploadedFiles() files?: { files?: Express.Multer.File[] },
|
||||
) {
|
||||
let content = body?.content;
|
||||
if (Array.isArray(content)) content = content[0];
|
||||
if (typeof content !== 'string') content = String(content ?? '');
|
||||
if (!content || content.length > 50000) {
|
||||
throw new BadRequestException('Content must be between 1 and 50000 characters');
|
||||
}
|
||||
return this.commentService.create(
|
||||
user.tenantId,
|
||||
ticketId,
|
||||
user.sub,
|
||||
content,
|
||||
files?.files,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/comment/comment.module.ts
Normal file
13
src/comment/comment.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommentController } from './comment.controller';
|
||||
import { CommentService } from './comment.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationModule],
|
||||
controllers: [CommentController],
|
||||
providers: [CommentService],
|
||||
exports: [CommentService],
|
||||
})
|
||||
export class CommentModule {}
|
||||
117
src/comment/comment.service.ts
Normal file
117
src/comment/comment.service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, ticketId: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const ticket = await db.ticket.findFirst({ where: { id: ticketId } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
return this.prisma.ticketComment.findMany({
|
||||
where: { tenantId, ticketId },
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
attachments: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
tenantId: string,
|
||||
ticketId: string,
|
||||
userId: string,
|
||||
content: string,
|
||||
files?: Express.Multer.File[],
|
||||
) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const ticket = await db.ticket.findFirst({ where: { id: ticketId } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
const comment = await this.prisma.ticketComment.create({
|
||||
data: {
|
||||
tenantId,
|
||||
ticketId,
|
||||
userId,
|
||||
content,
|
||||
attachments: files?.length
|
||||
? {
|
||||
create: files.map((f) => ({
|
||||
fileName: f.originalname,
|
||||
filePath: f.filename,
|
||||
fileType: f.mimetype,
|
||||
fileSize: f.size,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
author: { select: { id: true, firstName: true, lastName: true } },
|
||||
attachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Parse @mentions and notify mentioned users
|
||||
const mentions = this.parseMentions(content);
|
||||
if (mentions.length > 0) {
|
||||
const db2 = this.prisma.forTenant(tenantId);
|
||||
const users = await db2.user.findMany({
|
||||
where: { tenantId, isActive: true },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
|
||||
const commenterName = `${comment.author.firstName} ${comment.author.lastName}`;
|
||||
|
||||
for (const mention of mentions) {
|
||||
const mentionedUser = users.find(
|
||||
(u) =>
|
||||
`${u.firstName} ${u.lastName}`.toLowerCase() === mention.toLowerCase() ||
|
||||
u.firstName.toLowerCase() === mention.toLowerCase(),
|
||||
);
|
||||
if (mentionedUser && mentionedUser.id !== userId) {
|
||||
await this.notificationService.create(tenantId, {
|
||||
userId: mentionedUser.id,
|
||||
type: 'in_app',
|
||||
channel: 'mention',
|
||||
title: 'You were mentioned',
|
||||
message: `${commenterName} mentioned you in "${ticket.title}"`,
|
||||
ticketId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify ticket creator (if not the commenter)
|
||||
if (ticket.createdById && ticket.createdById !== userId) {
|
||||
const commenterName = `${comment.author.firstName} ${comment.author.lastName}`;
|
||||
await this.notificationService.create(tenantId, {
|
||||
userId: ticket.createdById,
|
||||
type: 'in_app',
|
||||
channel: 'comment_added',
|
||||
title: 'New comment on your ticket',
|
||||
message: `${commenterName} commented on "${ticket.title}"`,
|
||||
ticketId,
|
||||
});
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
/** Extract @FirstName or @FirstNameLastName from content */
|
||||
private parseMentions(content: string): string[] {
|
||||
const regex = /@(\w+(?:\s+\w+)?)/g;
|
||||
const matches: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
return [...new Set(matches)];
|
||||
}
|
||||
}
|
||||
7
src/comment/dto/create-comment.dto.bak
Normal file
7
src/comment/dto/create-comment.dto.bak
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
content!: string;
|
||||
}
|
||||
19
src/comment/dto/create-comment.dto.ts
Normal file
19
src/comment/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(50000)
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
files?: string[];
|
||||
}
|
||||
15
src/common/decorators/access.decorator.ts
Normal file
15
src/common/decorators/access.decorator.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export interface AccessRequirement {
|
||||
module: string;
|
||||
action: 'canView' | 'canCreate' | 'canUpdate' | 'canArchive' | 'canApprove' | 'canExport';
|
||||
}
|
||||
|
||||
export const ACCESS_KEY = 'access_requirement';
|
||||
|
||||
/**
|
||||
* Require the user's tenant role to grant a specific module+action.
|
||||
* Example: @RequireAccess('clients', 'canCreate')
|
||||
*/
|
||||
export const RequireAccess = (module: string, action: AccessRequirement['action']) =>
|
||||
SetMetadata(ACCESS_KEY, { module, action } as AccessRequirement);
|
||||
20
src/common/decorators/current-user.decorator.ts
Normal file
20
src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserPayload {
|
||||
sub: string;
|
||||
tenantId: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user as CurrentUserPayload;
|
||||
// For tenantId, prefer request.tenantId which TenantGuard may have set (e.g., super_admin x-tenant-id header)
|
||||
if (data === 'tenantId') {
|
||||
return request.tenantId || user?.tenantId;
|
||||
}
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
5
src/common/decorators/permissions.decorator.ts
Normal file
5
src/common/decorators/permissions.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '@fiberops/shared';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
55
src/common/dto/pagination.dto.ts
Normal file
55
src/common/dto/pagination.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PaginationDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function paginationArgs(dto: PaginationDto) {
|
||||
const page = dto.page || 1;
|
||||
const limit = dto.limit || 20;
|
||||
return {
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
export function paginatedResult<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): PaginatedResult<T> {
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
39
src/common/filters/http-exception.filter.ts
Normal file
39
src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiResponse } from '@fiberops/shared';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as { message?: string }).message || message;
|
||||
} else {
|
||||
console.error('[GlobalExceptionFilter] Unhandled exception:', exception);
|
||||
}
|
||||
|
||||
const body: ApiResponse = {
|
||||
success: false,
|
||||
data: null,
|
||||
error: Array.isArray(message) ? message.join(', ') : message,
|
||||
};
|
||||
|
||||
response.status(status).json(body);
|
||||
}
|
||||
}
|
||||
53
src/common/guards/access.guard.ts
Normal file
53
src/common/guards/access.guard.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ACCESS_KEY, AccessRequirement } from '../decorators/access.decorator';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AccessGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requirement = this.reflector.getAllAndOverride<AccessRequirement>(ACCESS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// No @RequireAccess decorator → allow
|
||||
if (!requirement) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
if (!user) return false;
|
||||
|
||||
// Super admin bypasses all access checks
|
||||
if (user.roles?.includes('super_admin')) return true;
|
||||
|
||||
// Resolve user's tenant role permissions from DB
|
||||
const assignments = await this.prisma.userTenantRole.findMany({
|
||||
where: { userId: user.sub },
|
||||
include: {
|
||||
tenantRole: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if any assigned role grants the required module+action
|
||||
for (const a of assignments) {
|
||||
if (!a.tenantRole.isActive || a.tenantRole.deletedAt) continue;
|
||||
for (const p of a.tenantRole.permissions) {
|
||||
if (p.module === requirement.module && p[requirement.action]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ForbiddenException(
|
||||
`Access denied: requires ${requirement.action} on ${requirement.module}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/common/guards/permissions.guard.ts
Normal file
34
src/common/guards/permissions.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!required || required.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user) return false;
|
||||
|
||||
// Super admin bypasses all permission checks
|
||||
if (user.roles?.includes('super_admin')) return true;
|
||||
|
||||
const userPerms: string[] = user.permissions || [];
|
||||
const missing = required.filter((p) => !userPerms.includes(p));
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
28
src/common/guards/roles.guard.ts
Normal file
28
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role, satisfiesRole } from '@fiberops/shared';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user || !user.roles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hierarchy-aware check: user with 'manager' satisfies 'technician' requirement
|
||||
return requiredRoles.some((role) => satisfiesRole(user.roles, role));
|
||||
}
|
||||
}
|
||||
36
src/common/guards/tenant.guard.ts
Normal file
36
src/common/guards/tenant.guard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class TenantGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('Authentication required');
|
||||
}
|
||||
|
||||
// Super admin: can switch tenant context via x-tenant-id header
|
||||
const isSuperAdmin = user.roles?.includes('super_admin');
|
||||
|
||||
if (isSuperAdmin) {
|
||||
const headerTenantId = request.headers['x-tenant-id'];
|
||||
// Use header tenant if provided, otherwise fall back to user's own tenant (if any)
|
||||
request.tenantId = headerTenantId || user.tenantId || null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Regular users must have a tenantId from JWT
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException('Tenant context required');
|
||||
}
|
||||
|
||||
request.tenantId = user.tenantId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
24
src/common/interceptors/response.interceptor.ts
Normal file
24
src/common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { ApiResponse } from '@fiberops/shared';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
success: true,
|
||||
data,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/common/multer/multer.config.ts
Normal file
36
src/common/multer/multer.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, resolve } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { Request } from 'express';
|
||||
|
||||
const uploadDir = resolve('./uploads');
|
||||
if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
export const multerOptions: MulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: (_req: Request, _file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
cb(null, uniqueSuffix + extname(file.originalname));
|
||||
},
|
||||
}),
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB per file
|
||||
},
|
||||
fileFilter: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, acceptFile: boolean) => void) => {
|
||||
const allowed = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`File type ${file.mimetype} not allowed`), false);
|
||||
}
|
||||
},
|
||||
};
|
||||
20
src/common/pipes/zod-validation.pipe.ts
Normal file
20
src/common/pipes/zod-validation.pipe.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PipeTransform, BadRequestException } from '@nestjs/common';
|
||||
import { ZodSchema, ZodError } from 'zod';
|
||||
|
||||
export class ZodValidationPipe implements PipeTransform {
|
||||
constructor(private schema: ZodSchema) {}
|
||||
|
||||
transform(value: unknown) {
|
||||
try {
|
||||
return this.schema.parse(value);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const messages = error.errors.map(
|
||||
(e) => `${e.path.join('.')}: ${e.message}`,
|
||||
);
|
||||
throw new BadRequestException(messages.join('; '));
|
||||
}
|
||||
throw new BadRequestException('Validation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/dashboard/dashboard.controller.ts
Normal file
37
src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('dashboard')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
@Get('kpis')
|
||||
@Roles('manager')
|
||||
async getKpis(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.dashboardService.getKpis(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('revenue-chart')
|
||||
@Roles('manager')
|
||||
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.dashboardService.getRevenueChart(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
@Roles('manager')
|
||||
async getActivity(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.dashboardService.getRecentActivity(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('financial-summary')
|
||||
@Roles('manager')
|
||||
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.dashboardService.getFinancialSummary(user.tenantId);
|
||||
}
|
||||
}
|
||||
9
src/dashboard/dashboard.module.ts
Normal file
9
src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user