Compare commits
5 Commits
09d7f4b922
...
4e69e7738b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e69e7738b | ||
|
|
8f46de9a28 | ||
|
|
d1ff021007 | ||
|
|
a03d803963 | ||
|
|
5382f3b4e5 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_ADMIN_URL=postgresql://postgres:postgres@localhost:5433/fiberops_admin
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fiberops
|
||||||
|
JWT_SECRET=your-admin-secret-key
|
||||||
|
PORT=3004
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
generated/
|
||||||
|
packages/admin-db/generated/
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
COPY packages/db/package.json ./packages/db/
|
||||||
|
COPY packages/admin-db/package.json ./packages/admin-db/
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY packages/shared/ ./packages/shared/
|
||||||
|
COPY packages/db/ ./packages/db/
|
||||||
|
COPY packages/admin-db/ ./packages/admin-db/
|
||||||
|
COPY nest-cli.json ./
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
RUN cd packages/shared && npx tsc --outDir dist --declaration
|
||||||
|
RUN cd packages/admin-db && DATABASE_ADMIN_URL="postgresql://placeholder" npx prisma generate
|
||||||
|
RUN cd packages/db && DATABASE_URL="postgresql://placeholder" npx prisma generate
|
||||||
|
RUN npx -p @nestjs/cli nest build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/packages/admin-db/generated ./packages/admin-db/generated
|
||||||
|
COPY --from=builder /app/packages/admin-db/prisma ./packages/admin-db/prisma
|
||||||
|
COPY --from=builder /app/packages/admin-db/package.json ./packages/admin-db/
|
||||||
|
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
|
||||||
|
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
||||||
|
RUN node -e "const p=require('./packages/shared/package.json');p.main='./dist/index.js';require('fs').writeFileSync('./packages/shared/package.json',JSON.stringify(p,null,2))"
|
||||||
|
COPY --from=builder /app/packages/db/prisma ./packages/db/prisma
|
||||||
|
COPY --from=builder /app/packages/db/package.json ./packages/db/
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3004
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["sh", "-c", "cd packages/admin-db && npx prisma migrate deploy && cd /app && node dist/main.js"]
|
||||||
8
nest-cli.json
Normal file
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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
package.json
Normal file
47
package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "fiberops-api-admin",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": ["packages/*"],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "node dist/main",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"db:generate": "cd packages/admin-db && npx prisma generate",
|
||||||
|
"db:migrate": "cd packages/admin-db && npx prisma migrate dev",
|
||||||
|
"db:migrate:deploy": "cd packages/admin-db && npx prisma migrate deploy",
|
||||||
|
"db:seed": "cd packages/admin-db && npx prisma db seed",
|
||||||
|
"db:studio": "cd packages/admin-db && npx prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fiberops/admin-db": "workspace:*",
|
||||||
|
"@fiberops/db": "workspace:*",
|
||||||
|
"@fiberops/shared": "workspace:*",
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/config": "^4.0.0",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.0",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.0",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/admin-db/package.json
Normal file
28
packages/admin-db/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@fiberops/admin-db",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "./generated/index.js",
|
||||||
|
"types": "./generated/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"generate": "prisma generate",
|
||||||
|
"migrate:dev": "prisma migrate dev",
|
||||||
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
|
"seed": "tsx prisma/seed.ts",
|
||||||
|
"studio": "prisma studio",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prisma": "^6.5.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "super_admins" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "super_admins_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "admin_refresh_tokens" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"adminId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "admin_refresh_tokens_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "platform_audit_logs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"adminId" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"target" TEXT,
|
||||||
|
"details" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "platform_audit_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "support_tickets" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"tenantName" TEXT NOT NULL,
|
||||||
|
"tenantSlug" TEXT NOT NULL,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdByName" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL DEFAULT 'general',
|
||||||
|
"priority" TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"assignedToId" TEXT,
|
||||||
|
"resolvedAt" TIMESTAMP(3),
|
||||||
|
"closedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "support_tickets_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "support_ticket_comments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ticketId" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"authorName" TEXT NOT NULL,
|
||||||
|
"authorType" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "support_ticket_comments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "super_admins_email_key" ON "super_admins"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "admin_refresh_tokens_token_key" ON "admin_refresh_tokens"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "admin_refresh_tokens_adminId_idx" ON "admin_refresh_tokens"("adminId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "admin_refresh_tokens_expiresAt_idx" ON "admin_refresh_tokens"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "platform_audit_logs_adminId_idx" ON "platform_audit_logs"("adminId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "platform_audit_logs_action_idx" ON "platform_audit_logs"("action");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "platform_audit_logs_target_idx" ON "platform_audit_logs"("target");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "platform_audit_logs_createdAt_idx" ON "platform_audit_logs"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "support_tickets_tenantId_idx" ON "support_tickets"("tenantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "support_tickets_status_idx" ON "support_tickets"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "support_tickets_category_idx" ON "support_tickets"("category");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "support_tickets_priority_idx" ON "support_tickets"("priority");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "support_tickets_assignedToId_idx" ON "support_tickets"("assignedToId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "support_tickets_createdAt_idx" ON "support_tickets"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "support_ticket_comments_ticketId_idx" ON "support_ticket_comments"("ticketId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "support_ticket_comments_authorId_idx" ON "support_ticket_comments"("authorId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "admin_refresh_tokens" ADD CONSTRAINT "admin_refresh_tokens_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "super_admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "platform_audit_logs" ADD CONSTRAINT "platform_audit_logs_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "super_admins"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "support_tickets" ADD CONSTRAINT "support_tickets_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "super_admins"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "support_ticket_comments" ADD CONSTRAINT "support_ticket_comments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "support_tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "support_ticket_comments" ADD CONSTRAINT "support_ticket_comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "super_admins"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
3
packages/admin-db/prisma/migrations/migration_lock.toml
Normal file
3
packages/admin-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"
|
||||||
133
packages/admin-db/prisma/schema.prisma
Normal file
133
packages/admin-db/prisma/schema.prisma
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_ADMIN_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Platform SuperAdmins ────────────────────────────
|
||||||
|
|
||||||
|
model SuperAdmin {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
refreshTokens AdminRefreshToken[]
|
||||||
|
auditLogs PlatformAuditLog[]
|
||||||
|
assignedTickets SupportTicket[] @relation("TicketAssignee")
|
||||||
|
ticketComments SupportTicketComment[]
|
||||||
|
|
||||||
|
@@map("super_admins")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AdminRefreshToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
token String @unique
|
||||||
|
adminId String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
admin SuperAdmin @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([adminId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("admin_refresh_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Platform Audit Log ──────────────────────────────
|
||||||
|
|
||||||
|
model PlatformAuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
adminId String
|
||||||
|
action String
|
||||||
|
target String?
|
||||||
|
details Json @default("{}")
|
||||||
|
ipAddress String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
admin SuperAdmin @relation(fields: [adminId], references: [id])
|
||||||
|
|
||||||
|
@@index([adminId])
|
||||||
|
@@index([action])
|
||||||
|
@@index([target])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("platform_audit_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Support Tickets (Tenant → Platform) ─────────────
|
||||||
|
|
||||||
|
model SupportTicket {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
tenantName String
|
||||||
|
tenantSlug String
|
||||||
|
createdById String
|
||||||
|
createdByName String
|
||||||
|
subject String
|
||||||
|
description String
|
||||||
|
category String @default("general") // billing, technical, account, general, feature_request
|
||||||
|
priority String @default("normal") // low, normal, high, urgent
|
||||||
|
status String @default("open") // open, in_progress, waiting_tenant, resolved, closed
|
||||||
|
assignedToId String?
|
||||||
|
resolvedAt DateTime?
|
||||||
|
closedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
assignee SuperAdmin? @relation("TicketAssignee", fields: [assignedToId], references: [id])
|
||||||
|
comments SupportTicketComment[]
|
||||||
|
attachments SupportTicketAttachment[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([category])
|
||||||
|
@@index([priority])
|
||||||
|
@@index([assignedToId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("support_tickets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupportTicketComment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
ticketId String
|
||||||
|
authorId String
|
||||||
|
authorName String
|
||||||
|
authorType String // "super_admin" or "tenant_user"
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
author SuperAdmin? @relation(fields: [authorId], references: [id])
|
||||||
|
attachments SupportTicketAttachment[]
|
||||||
|
|
||||||
|
@@index([ticketId])
|
||||||
|
@@index([authorId])
|
||||||
|
@@map("support_ticket_comments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupportTicketAttachment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
ticketId String
|
||||||
|
commentId String?
|
||||||
|
fileName String
|
||||||
|
originalName String
|
||||||
|
mimeType String
|
||||||
|
sizeBytes Int
|
||||||
|
uploadedBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
comment SupportTicketComment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([ticketId])
|
||||||
|
@@index([commentId])
|
||||||
|
@@map("support_ticket_attachments")
|
||||||
|
}
|
||||||
29
packages/admin-db/prisma/seed.ts
Normal file
29
packages/admin-db/prisma/seed.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { PrismaClient } from '../generated';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const password = await bcrypt.hash('SuperAdmin@123', 12);
|
||||||
|
|
||||||
|
const admin = await prisma.superAdmin.upsert({
|
||||||
|
where: { email: 'superadmin@fiberops.dev' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'superadmin@fiberops.dev',
|
||||||
|
password,
|
||||||
|
firstName: 'Super',
|
||||||
|
lastName: 'Admin',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Seeded superadmin: ${admin.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
1
packages/admin-db/src/index.ts
Normal file
1
packages/admin-db/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PrismaClient, Prisma } from '../generated';
|
||||||
8
packages/admin-db/tsconfig.json
Normal file
8
packages/admin-db/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src", "prisma"]
|
||||||
|
}
|
||||||
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;
|
||||||
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"
|
||||||
682
packages/db/prisma/schema.prisma
Normal file
682
packages/db/prisma/schema.prisma
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Multi-Tenant Foundation ────────────────────────────────────────
|
||||||
|
|
||||||
|
model Tenant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
settings Json @default("{}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
tenantRoles TenantRole[]
|
||||||
|
areas Area[]
|
||||||
|
plans Plan[]
|
||||||
|
clients Client[]
|
||||||
|
subscriptions Subscription[]
|
||||||
|
invoices Invoice[]
|
||||||
|
payments Payment[]
|
||||||
|
tickets Ticket[]
|
||||||
|
notifications Notification[]
|
||||||
|
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("tenants")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth & Users ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String? // null for super_admin (platform-level user)
|
||||||
|
email String
|
||||||
|
password String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
mustChangePassword Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
roles UserRole[]
|
||||||
|
tenantRoles UserTenantRole[]
|
||||||
|
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||||
|
createdTickets Ticket[] @relation("TicketCreator")
|
||||||
|
payments Payment[]
|
||||||
|
remittances Remittance[] @relation("RemittanceCollector")
|
||||||
|
confirmedRemittances Remittance[] @relation("RemittanceConfirmer")
|
||||||
|
|
||||||
|
@@unique([tenantId, email])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserRole {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
role String // super_admin (platform-level only, legacy compat)
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, role])
|
||||||
|
@@map("user_roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tenant-Scoped Role Management ─────────────────────────────────
|
||||||
|
|
||||||
|
model TenantRole {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
slug String
|
||||||
|
description String?
|
||||||
|
isSystem Boolean @default(false) // system roles can't be deleted
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
permissions RolePermission[]
|
||||||
|
users UserTenantRole[]
|
||||||
|
|
||||||
|
@@unique([tenantId, slug])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("tenant_roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RolePermission {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantRoleId String
|
||||||
|
module String // clients, subscriptions, invoices, payments, tickets, employees, expenses, assets, payroll, accounts, accounting, reports, settings, users, areas, plans, dashboard, fund_transfers
|
||||||
|
canView Boolean @default(false)
|
||||||
|
canCreate Boolean @default(false)
|
||||||
|
canUpdate Boolean @default(false)
|
||||||
|
canArchive Boolean @default(false)
|
||||||
|
canApprove Boolean @default(false)
|
||||||
|
canExport Boolean @default(false)
|
||||||
|
|
||||||
|
tenantRole TenantRole @relation(fields: [tenantRoleId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([tenantRoleId, module])
|
||||||
|
@@index([tenantRoleId])
|
||||||
|
@@map("role_permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserTenantRole {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
tenantRoleId String
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tenantRole TenantRole @relation(fields: [tenantRoleId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, tenantRoleId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([tenantRoleId])
|
||||||
|
@@map("user_tenant_roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
token String @unique
|
||||||
|
userId String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("refresh_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Area & Zone Management ─────────────────────────────────────────
|
||||||
|
|
||||||
|
model Area {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
clients Client[]
|
||||||
|
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("areas")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plan / Package Management ──────────────────────────────────────
|
||||||
|
|
||||||
|
model Plan {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
speedDown Int // Mbps download
|
||||||
|
speedUp Int // Mbps upload
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
billingCycle Int @default(30) // days
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
subscriptions Subscription[]
|
||||||
|
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("plans")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Client Profiling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Client {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
accountNumber String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
email String?
|
||||||
|
phone String?
|
||||||
|
address String
|
||||||
|
areaId String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
status String @default("active") // active, inactive, suspended
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
area Area? @relation(fields: [areaId], references: [id])
|
||||||
|
subscriptions Subscription[]
|
||||||
|
invoices Invoice[]
|
||||||
|
payments Payment[]
|
||||||
|
tickets Ticket[]
|
||||||
|
|
||||||
|
@@unique([tenantId, accountNumber])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("clients")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Subscription Management ────────────────────────────────────────
|
||||||
|
|
||||||
|
model Subscription {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
clientId String
|
||||||
|
planId String
|
||||||
|
type String // prepaid, postpaid
|
||||||
|
status String @default("pending") // pending, active, suspended, cancelled, expired
|
||||||
|
startDate DateTime?
|
||||||
|
endDate DateTime?
|
||||||
|
installedAt DateTime?
|
||||||
|
activatedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||||
|
plan Plan @relation(fields: [planId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([clientId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Billing & Invoicing ────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Invoice {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
clientId String
|
||||||
|
number String
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
balance Decimal @db.Decimal(10, 2) // remaining unpaid
|
||||||
|
status String @default("draft") // draft, sent, partial, paid, overdue, void
|
||||||
|
dueDate DateTime
|
||||||
|
paidAt DateTime?
|
||||||
|
periodStart DateTime?
|
||||||
|
periodEnd DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||||
|
payments Payment[]
|
||||||
|
|
||||||
|
@@unique([tenantId, number])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([clientId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("invoices")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Payment & Collection ───────────────────────────────────────────
|
||||||
|
|
||||||
|
model Payment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
clientId String
|
||||||
|
invoiceId String?
|
||||||
|
collectedById String?
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
method String // gcash, maya, cash, bank_transfer
|
||||||
|
referenceNo String?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||||
|
invoice Invoice? @relation(fields: [invoiceId], references: [id])
|
||||||
|
collectedBy User? @relation(fields: [collectedById], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([clientId])
|
||||||
|
@@index([invoiceId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Remittance {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
collectorId String
|
||||||
|
confirmedById String?
|
||||||
|
totalAmount Decimal @db.Decimal(10, 2)
|
||||||
|
status String @default("pending") // pending, confirmed, rejected
|
||||||
|
submittedAt DateTime @default(now())
|
||||||
|
confirmedAt DateTime?
|
||||||
|
notes String?
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
collector User @relation("RemittanceCollector", fields: [collectorId], references: [id])
|
||||||
|
confirmedBy User? @relation("RemittanceConfirmer", fields: [confirmedById], references: [id])
|
||||||
|
payments RemittancePayment[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([collectorId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("remittances")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RemittancePayment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
remittanceId String
|
||||||
|
paymentId String
|
||||||
|
|
||||||
|
remittance Remittance @relation(fields: [remittanceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([remittanceId, paymentId])
|
||||||
|
@@map("remittance_payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tickets (Work Orders) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
model Ticket {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
clientId String?
|
||||||
|
createdById String
|
||||||
|
assigneeId String?
|
||||||
|
type String // installation, activation, support, maintenance
|
||||||
|
status String @default("open") // open, in_progress, resolved, cancelled
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
priority String @default("normal") // low, normal, high, urgent
|
||||||
|
resolvedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
client Client? @relation(fields: [clientId], references: [id])
|
||||||
|
createdBy User @relation("TicketCreator", fields: [createdById], references: [id])
|
||||||
|
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, type, status])
|
||||||
|
@@index([clientId])
|
||||||
|
@@index([assigneeId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("tickets")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notifications ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
userId String?
|
||||||
|
clientId String?
|
||||||
|
type String // sms, in_app
|
||||||
|
channel String // billing_reminder, payment_confirmation, ticket_update
|
||||||
|
title String
|
||||||
|
message String
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
sentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([userId, isRead])
|
||||||
|
@@map("notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Audit Log (append-only, no soft delete) ────────────────────────
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
userId String
|
||||||
|
action String
|
||||||
|
entity String
|
||||||
|
entityId String
|
||||||
|
details Json @default("{}")
|
||||||
|
ipAddress String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, entity])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Employee Management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Employee {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
userId String? @unique // Linked user account (optional, 1:1)
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
email String?
|
||||||
|
phone String?
|
||||||
|
position String
|
||||||
|
department String?
|
||||||
|
employeeNo String
|
||||||
|
status String @default("active") // active, on_leave, terminated
|
||||||
|
hireDate DateTime @default(now())
|
||||||
|
terminatedAt DateTime?
|
||||||
|
salary Decimal? @db.Decimal(10, 2)
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
assets Asset[] @relation("AssetAssignee")
|
||||||
|
payslips Payslip[]
|
||||||
|
|
||||||
|
@@unique([tenantId, employeeNo])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("employees")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Payroll ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model PayrollRun {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
period String
|
||||||
|
status String @default("draft") // draft, processing, completed
|
||||||
|
totalAmount Decimal @default(0) @db.Decimal(12, 2)
|
||||||
|
processedBy String?
|
||||||
|
processedAt DateTime?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
payslips Payslip[]
|
||||||
|
|
||||||
|
@@unique([tenantId, period])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("payroll_runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Payslip {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
payrollRunId String
|
||||||
|
employeeId String
|
||||||
|
baseSalary Decimal @db.Decimal(10, 2)
|
||||||
|
deductions Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
bonuses Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
netPay Decimal @db.Decimal(10, 2)
|
||||||
|
status String @default("pending") // pending, paid
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id], onDelete: Cascade)
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
|
|
||||||
|
@@unique([payrollRunId, employeeId])
|
||||||
|
@@index([payrollRunId])
|
||||||
|
@@index([employeeId])
|
||||||
|
@@map("payslips")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recurring Expenses ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
model RecurringExpense {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
category String
|
||||||
|
description String
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
frequency String @default("monthly") // monthly, quarterly, yearly
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
nextRunDate DateTime
|
||||||
|
lastRunDate DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("recurring_expenses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Expense Management ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Expense {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
createdById String
|
||||||
|
approvedById String?
|
||||||
|
category String
|
||||||
|
description String
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
receiptUrl String?
|
||||||
|
status String @default("pending") // pending, approved, rejected
|
||||||
|
expenseDate DateTime @default(now())
|
||||||
|
approvedAt DateTime?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([tenantId, category])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("expenses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Company Accounts & Fund Transfers ──────────────────────────────
|
||||||
|
|
||||||
|
model CompanyAccount {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
type String // bank, e_wallet, cash
|
||||||
|
accountNo String?
|
||||||
|
balance Decimal @default(0) @db.Decimal(12, 2)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
chartOfAccountId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
outgoing FundTransfer[] @relation("TransferFrom")
|
||||||
|
incoming FundTransfer[] @relation("TransferTo")
|
||||||
|
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("company_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model FundTransfer {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
fromAccountId String
|
||||||
|
toAccountId String
|
||||||
|
amount Decimal @db.Decimal(12, 2)
|
||||||
|
description String?
|
||||||
|
transferredBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
fromAccount CompanyAccount @relation("TransferFrom", fields: [fromAccountId], references: [id])
|
||||||
|
toAccount CompanyAccount @relation("TransferTo", fields: [toAccountId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("fund_transfers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Asset Management ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Asset {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
category String // router, olt, cable, tool, vehicle, computer, other
|
||||||
|
serialNumber String?
|
||||||
|
purchaseDate DateTime?
|
||||||
|
purchasePrice Decimal? @db.Decimal(10, 2)
|
||||||
|
assignedToId String?
|
||||||
|
status String @default("available") // available, in_use, maintenance, retired
|
||||||
|
location String?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
assignedTo Employee? @relation("AssetAssignee", fields: [assignedToId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([tenantId, category])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Billing Settings ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
model BillingSetting {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique
|
||||||
|
autoGenerate Boolean @default(true)
|
||||||
|
gracePeriodDays Int @default(7)
|
||||||
|
dueDateOffsetDays Int @default(15)
|
||||||
|
lateFeePercent Decimal @default(0) @db.Decimal(5, 2)
|
||||||
|
invoicePrefix String @default("INV")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("billing_settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chart of Accounts ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
model ChartOfAccount {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
code String
|
||||||
|
name String
|
||||||
|
type String // asset, liability, equity, revenue, expense
|
||||||
|
parentId String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
balance Decimal @default(0) @db.Decimal(14, 2)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
parent ChartOfAccount? @relation("AccountTree", fields: [parentId], references: [id])
|
||||||
|
children ChartOfAccount[] @relation("AccountTree")
|
||||||
|
journalLines JournalLine[]
|
||||||
|
|
||||||
|
@@unique([tenantId, code])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, type])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@map("chart_of_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Journal Entries (append-only, no soft delete) ──────────────────
|
||||||
|
|
||||||
|
model JournalEntry {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
entryDate DateTime @default(now())
|
||||||
|
description String
|
||||||
|
reference String?
|
||||||
|
sourceType String?
|
||||||
|
sourceId String?
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
lines JournalLine[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, sourceType, sourceId])
|
||||||
|
@@map("journal_entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
model JournalLine {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
journalEntryId String
|
||||||
|
accountId String
|
||||||
|
debit Decimal @default(0) @db.Decimal(14, 2)
|
||||||
|
credit Decimal @default(0) @db.Decimal(14, 2)
|
||||||
|
|
||||||
|
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade)
|
||||||
|
account ChartOfAccount @relation(fields: [accountId], references: [id])
|
||||||
|
|
||||||
|
@@index([journalEntryId])
|
||||||
|
@@index([accountId])
|
||||||
|
@@map("journal_lines")
|
||||||
|
}
|
||||||
603
packages/db/prisma/seed.ts
Normal file
603
packages/db/prisma/seed.ts
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
// Default permission matrices for system roles
|
||||||
|
const MODULES = [
|
||||||
|
'dashboard', 'clients', 'subscriptions', 'invoices', 'payments', 'tickets',
|
||||||
|
'employees', 'payroll', 'expenses', 'assets', 'accounts', 'fund_transfers',
|
||||||
|
'accounting', 'reports', 'areas', 'plans', 'settings', 'users',
|
||||||
|
] as const;
|
||||||
|
type Module = (typeof MODULES)[number];
|
||||||
|
|
||||||
|
interface PermRow { module: Module; canView: boolean; canCreate: boolean; canUpdate: boolean; canArchive: boolean; canApprove: boolean; canExport: boolean; }
|
||||||
|
|
||||||
|
const DEFAULT_ROLES: { name: string; slug: string; description: string; perms: PermRow[] }[] = [
|
||||||
|
{
|
||||||
|
name: 'Tenant Admin', slug: 'tenant_admin', description: 'Full access to all modules',
|
||||||
|
perms: MODULES.map((m) => ({ module: m, canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: true, canExport: true })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Manager', slug: 'manager', description: 'Operational management with approval rights',
|
||||||
|
perms: MODULES.map((m) => {
|
||||||
|
const noAccess: Module[] = ['users'];
|
||||||
|
const viewOnly: Module[] = ['dashboard', 'accounting', 'settings'];
|
||||||
|
if (noAccess.includes(m)) return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||||
|
if (viewOnly.includes(m)) return { module: m, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: m === 'accounting' };
|
||||||
|
return { module: m, canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: ['invoices', 'payments', 'expenses', 'payroll', 'fund_transfers'].includes(m), canExport: true };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Technician', slug: 'technician', description: 'Field operations: tickets, payments, client/invoice viewing',
|
||||||
|
perms: MODULES.map((m) => {
|
||||||
|
const viewOnly: Module[] = ['clients', 'subscriptions', 'invoices', 'dashboard'];
|
||||||
|
const fullAccess: Module[] = ['tickets', 'payments'];
|
||||||
|
if (viewOnly.includes(m)) return { module: m, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||||
|
if (fullAccess.includes(m)) return { module: m, canView: true, canCreate: true, canUpdate: true, canArchive: false, canApprove: false, canExport: false };
|
||||||
|
return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Collector', slug: 'collector', description: 'Payment collection and client viewing',
|
||||||
|
perms: MODULES.map((m) => {
|
||||||
|
const canWrite: Module[] = ['payments'];
|
||||||
|
const canViewMods: Module[] = ['dashboard', 'clients', 'invoices', 'payments'];
|
||||||
|
if (!canViewMods.includes(m)) return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||||
|
return { module: m, canView: true, canCreate: canWrite.includes(m), canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding database...');
|
||||||
|
|
||||||
|
// Clean existing data (order matters for FK constraints)
|
||||||
|
await prisma.journalLine.deleteMany();
|
||||||
|
await prisma.journalEntry.deleteMany();
|
||||||
|
await prisma.chartOfAccount.deleteMany();
|
||||||
|
await prisma.billingSetting.deleteMany();
|
||||||
|
await prisma.fundTransfer.deleteMany();
|
||||||
|
await prisma.companyAccount.deleteMany();
|
||||||
|
await prisma.asset.deleteMany();
|
||||||
|
await prisma.expense.deleteMany();
|
||||||
|
await prisma.payslip.deleteMany();
|
||||||
|
await prisma.payrollRun.deleteMany();
|
||||||
|
await prisma.recurringExpense.deleteMany();
|
||||||
|
await prisma.employee.deleteMany();
|
||||||
|
await prisma.notification.deleteMany();
|
||||||
|
await prisma.remittancePayment.deleteMany();
|
||||||
|
await prisma.remittance.deleteMany();
|
||||||
|
await prisma.auditLog.deleteMany();
|
||||||
|
await prisma.payment.deleteMany();
|
||||||
|
await prisma.invoice.deleteMany();
|
||||||
|
await prisma.ticket.deleteMany();
|
||||||
|
await prisma.subscription.deleteMany();
|
||||||
|
await prisma.client.deleteMany();
|
||||||
|
await prisma.plan.deleteMany();
|
||||||
|
await prisma.area.deleteMany();
|
||||||
|
await prisma.refreshToken.deleteMany();
|
||||||
|
await prisma.userTenantRole.deleteMany();
|
||||||
|
await prisma.rolePermission.deleteMany();
|
||||||
|
await prisma.tenantRole.deleteMany();
|
||||||
|
await prisma.userRole.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.tenant.deleteMany();
|
||||||
|
|
||||||
|
// ─── Tenant ────────────────────────────────────────────
|
||||||
|
const tenant = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: 'FiberNet Philippines',
|
||||||
|
slug: 'fibernet-ph',
|
||||||
|
settings: { companyName: 'FiberNet Philippines Inc.', currency: 'PHP', timezone: 'Asia/Manila' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Tenant: ${tenant.name}`);
|
||||||
|
|
||||||
|
// ─── Super Admin (platform-level, no tenant) ───────────
|
||||||
|
const superAdmin = await prisma.user.create({
|
||||||
|
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
|
||||||
|
});
|
||||||
|
await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } });
|
||||||
|
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
|
||||||
|
|
||||||
|
// ─── Tenant Users ─────────────────────────────────────
|
||||||
|
const users: Record<string, any> = {};
|
||||||
|
const usersByEmail: Record<string, any> = {};
|
||||||
|
const userDefs = [
|
||||||
|
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
|
||||||
|
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
|
||||||
|
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'technician' },
|
||||||
|
{ email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' },
|
||||||
|
{ email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' },
|
||||||
|
{ email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const u of userDefs) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { tenantId: tenant.id, email: u.email, password: await hashPassword('admin123!'), firstName: u.first, lastName: u.last },
|
||||||
|
});
|
||||||
|
await prisma.userRole.create({ data: { userId: user.id, role: u.role } });
|
||||||
|
users[u.role] = user;
|
||||||
|
usersByEmail[u.email] = user;
|
||||||
|
console.log(`User: ${u.email} (${u.role})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Default Tenant Roles ──────────────────────────────
|
||||||
|
const tenantRoles: Record<string, any> = {};
|
||||||
|
for (const def of DEFAULT_ROLES) {
|
||||||
|
const role = await prisma.tenantRole.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
name: def.name,
|
||||||
|
slug: def.slug,
|
||||||
|
description: def.description,
|
||||||
|
isSystem: true,
|
||||||
|
permissions: {
|
||||||
|
create: def.perms.map((p) => ({
|
||||||
|
module: p.module,
|
||||||
|
canView: p.canView,
|
||||||
|
canCreate: p.canCreate,
|
||||||
|
canUpdate: p.canUpdate,
|
||||||
|
canArchive: p.canArchive,
|
||||||
|
canApprove: p.canApprove,
|
||||||
|
canExport: p.canExport,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tenantRoles[def.slug] = role;
|
||||||
|
}
|
||||||
|
console.log(`Tenant Roles: ${DEFAULT_ROLES.length} (${DEFAULT_ROLES.map((r) => r.slug).join(', ')})`);
|
||||||
|
|
||||||
|
// ─── Assign Tenant Roles to Users ─────────────────────
|
||||||
|
// Map of old role names to new tenant role slugs
|
||||||
|
const userRoleMap: Record<string, string> = {
|
||||||
|
tenant_admin: 'tenant_admin',
|
||||||
|
manager: 'manager',
|
||||||
|
technician: 'technician',
|
||||||
|
viewer: 'collector', // viewer user gets collector role for demo
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign roles from the map
|
||||||
|
for (const [oldRole, newRoleSlug] of Object.entries(userRoleMap)) {
|
||||||
|
if (users[oldRole] && tenantRoles[newRoleSlug]) {
|
||||||
|
await prisma.userTenantRole.create({
|
||||||
|
data: { userId: users[oldRole].id, tenantRoleId: tenantRoles[newRoleSlug].id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional assignments:
|
||||||
|
// - Assign technician users (collector, tech, tech2) to technician role
|
||||||
|
// - Assign viewer user to collector role
|
||||||
|
const technicianUsers = ['collector', 'tech', 'tech2'];
|
||||||
|
for (const email of technicianUsers) {
|
||||||
|
const userEmail = `${email}@demo-isp.com`;
|
||||||
|
const user = usersByEmail[userEmail];
|
||||||
|
if (user) {
|
||||||
|
const existing = await prisma.userTenantRole.findFirst({ where: { userId: user.id } });
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.userTenantRole.create({
|
||||||
|
data: { userId: user.id, tenantRoleId: tenantRoles['technician'].id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerUserEmail = 'viewer@demo-isp.com';
|
||||||
|
const viewerUser = usersByEmail[viewerUserEmail];
|
||||||
|
if (viewerUser) {
|
||||||
|
const existing = await prisma.userTenantRole.findFirst({ where: { userId: viewerUser.id } });
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.userTenantRole.create({
|
||||||
|
data: { userId: viewerUser.id, tenantRoleId: tenantRoles['collector'].id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('User-TenantRole assignments complete');
|
||||||
|
|
||||||
|
// ─── Areas ─────────────────────────────────────────────
|
||||||
|
const areas = await Promise.all([
|
||||||
|
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 1 - Centro', description: 'Town center, commercial area' } }),
|
||||||
|
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 2 - Poblacion', description: 'Residential zone near market' } }),
|
||||||
|
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 3 - San Isidro', description: 'Agricultural and residential' } }),
|
||||||
|
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 4 - Riverside', description: 'River-side residential' } }),
|
||||||
|
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 5 - Hilltop', description: 'Elevated residential subdivision' } }),
|
||||||
|
]);
|
||||||
|
console.log(`Areas: ${areas.length}`);
|
||||||
|
|
||||||
|
// ─── Plans ─────────────────────────────────────────────
|
||||||
|
const plans = await Promise.all([
|
||||||
|
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Lite 15', description: 'Entry-level 15 Mbps', speedDown: 15, speedUp: 15, price: 699, billingCycle: 30 } }),
|
||||||
|
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Basic 25', description: '25 Mbps residential', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 } }),
|
||||||
|
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Standard 50', description: '50 Mbps residential', speedDown: 50, speedUp: 50, price: 1499, billingCycle: 30 } }),
|
||||||
|
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Premium 100', description: '100 Mbps business', speedDown: 100, speedUp: 100, price: 2499, billingCycle: 30 } }),
|
||||||
|
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Enterprise 200', description: '200 Mbps dedicated', speedDown: 200, speedUp: 200, price: 4999, billingCycle: 30 } }),
|
||||||
|
]);
|
||||||
|
console.log(`Plans: ${plans.length}`);
|
||||||
|
|
||||||
|
// ─── Clients (20 clients across various areas/plans) ──
|
||||||
|
// Area center coordinates (Lipa City, Batangas area)
|
||||||
|
const areaCoords: [number, number][] = [
|
||||||
|
[14.0785, 121.1760], // Barangay 1 - Centro
|
||||||
|
[14.0820, 121.1800], // Barangay 2 - Poblacion
|
||||||
|
[14.0850, 121.1700], // Barangay 3 - San Isidro
|
||||||
|
[14.0750, 121.1720], // Barangay 4 - Riverside
|
||||||
|
[14.0900, 121.1780], // Barangay 5 - Hilltop
|
||||||
|
];
|
||||||
|
|
||||||
|
const clientDefs = [
|
||||||
|
{ first: 'Juan', last: 'Dela Cruz', phone: '09171234567', email: 'juan@email.com', address: '123 Rizal St, Centro', area: 0, plan: 1, type: 'postpaid', latOff: 0.001, lngOff: 0.002 },
|
||||||
|
{ first: 'Maria', last: 'Santos', phone: '09181234567', email: 'maria@email.com', address: '456 Mabini St, Centro', area: 0, plan: 2, type: 'postpaid', latOff: -0.002, lngOff: 0.001 },
|
||||||
|
{ first: 'Jose', last: 'Garcia', phone: '09191234567', email: 'jose@email.com', address: '789 Bonifacio St, Poblacion', area: 1, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.001 },
|
||||||
|
{ first: 'Ana', last: 'Reyes', phone: '09201234567', email: 'ana@email.com', address: '12 Luna St, Poblacion', area: 1, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
|
||||||
|
{ first: 'Pedro', last: 'Aquino', phone: '09211234567', email: null, address: '34 Del Pilar St, San Isidro', area: 2, plan: 0, type: 'prepaid', latOff: 0.002, lngOff: -0.002 },
|
||||||
|
{ first: 'Rosa', last: 'Mendoza', phone: '09221234567', email: 'rosa@email.com', address: '56 Quezon Ave, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: -0.003, lngOff: 0.001 },
|
||||||
|
{ first: 'Carlos', last: 'Bautista', phone: '09231234567', email: null, address: '78 Magsaysay Blvd, Riverside', area: 3, plan: 1, type: 'postpaid', latOff: 0.001, lngOff: 0.002 },
|
||||||
|
{ first: 'Elena', last: 'Villanueva', phone: '09241234567', email: 'elena@email.com', address: '90 Roxas St, Riverside', area: 3, plan: 2, type: 'postpaid', latOff: -0.002, lngOff: -0.003 },
|
||||||
|
{ first: 'Roberto', last: 'Tan', phone: '09251234567', email: 'roberto@email.com', address: '11 Laurel St, Hilltop', area: 4, plan: 4, type: 'postpaid', latOff: 0.002, lngOff: 0.001 },
|
||||||
|
{ first: 'Carmen', last: 'Lim', phone: '09261234567', email: 'carmen@email.com', address: '22 Osmena Ave, Hilltop', area: 4, plan: 3, type: 'postpaid', latOff: -0.001, lngOff: 0.002 },
|
||||||
|
{ first: 'Miguel', last: 'Ramos', phone: '09271234567', email: null, address: '33 Aguinaldo St, Centro', area: 0, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.001 },
|
||||||
|
{ first: 'Isabel', last: 'Torres', phone: '09281234567', email: 'isabel@email.com', address: '44 Andres Blvd, Poblacion', area: 1, plan: 0, type: 'postpaid', latOff: -0.002, lngOff: 0.003 },
|
||||||
|
{ first: 'Ricardo', last: 'Flores', phone: '09291234567', email: null, address: '55 Katipunan Rd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
|
||||||
|
{ first: 'Teresa', last: 'Navarro', phone: '09301234567', email: 'teresa@email.com', address: '66 Makabayan St, Riverside', area: 3, plan: 1, type: 'prepaid', latOff: -0.003, lngOff: 0.001 },
|
||||||
|
{ first: 'Fernando', last: 'Castillo', phone: '09311234567', email: null, address: '77 Silang Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const clients: any[] = [];
|
||||||
|
let invoiceCount = 0;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (let i = 0; i < clientDefs.length; i++) {
|
||||||
|
const c = clientDefs[i];
|
||||||
|
const accountNumber = `C-${String(i + 1).padStart(6, '0')}`;
|
||||||
|
const plan = plans[c.plan];
|
||||||
|
|
||||||
|
const client = await prisma.client.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
accountNumber,
|
||||||
|
firstName: c.first,
|
||||||
|
lastName: c.last,
|
||||||
|
phone: c.phone,
|
||||||
|
email: c.email,
|
||||||
|
address: c.address,
|
||||||
|
areaId: areas[c.area].id,
|
||||||
|
latitude: areaCoords[c.area][0] + c.latOff,
|
||||||
|
longitude: areaCoords[c.area][1] + c.lngOff,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
const installedAt = new Date(now);
|
||||||
|
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
|
||||||
|
|
||||||
|
const activatedAt = new Date(installedAt);
|
||||||
|
activatedAt.setDate(activatedAt.getDate() + 2);
|
||||||
|
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
planId: plan.id,
|
||||||
|
type: c.type,
|
||||||
|
status: 'active',
|
||||||
|
installedAt,
|
||||||
|
activatedAt,
|
||||||
|
startDate: activatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create resolved installation + activation tickets
|
||||||
|
await prisma.ticket.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
createdById: users.tenant_admin.id,
|
||||||
|
assigneeId: users.technician.id,
|
||||||
|
type: 'installation',
|
||||||
|
title: `Installation for ${c.first} ${c.last}`,
|
||||||
|
description: `Installation at ${c.address}`,
|
||||||
|
status: 'resolved',
|
||||||
|
priority: 'high',
|
||||||
|
resolvedAt: new Date(installedAt.getTime() + 86400000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.ticket.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
createdById: users.tenant_admin.id,
|
||||||
|
assigneeId: users.technician.id,
|
||||||
|
type: 'activation',
|
||||||
|
title: `Activation for ${c.first} ${c.last}`,
|
||||||
|
status: 'resolved',
|
||||||
|
priority: 'high',
|
||||||
|
resolvedAt: activatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 2 invoices per client
|
||||||
|
for (let m = 0; m < 2; m++) {
|
||||||
|
invoiceCount++;
|
||||||
|
const periodStart = new Date(activatedAt);
|
||||||
|
periodStart.setMonth(periodStart.getMonth() + m);
|
||||||
|
const periodEnd = new Date(periodStart);
|
||||||
|
periodEnd.setDate(periodEnd.getDate() + 30);
|
||||||
|
const dueDate = new Date(periodStart);
|
||||||
|
dueDate.setDate(dueDate.getDate() + 15);
|
||||||
|
|
||||||
|
const isPaid = m === 0 || Math.random() > 0.4; // First invoice always paid, second 60% chance
|
||||||
|
const balance = isPaid ? 0 : Number(plan.price);
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
|
||||||
|
amount: plan.price,
|
||||||
|
balance,
|
||||||
|
status: isPaid ? 'paid' : (dueDate < now ? 'overdue' : 'sent'),
|
||||||
|
dueDate,
|
||||||
|
paidAt: isPaid ? new Date(dueDate.getTime() - 86400000 * 3) : null,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create payment for paid invoices
|
||||||
|
if (isPaid) {
|
||||||
|
const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
|
||||||
|
const method = methods[Math.floor(Math.random() * methods.length)];
|
||||||
|
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
|
||||||
|
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
collectedById: users.technician.id,
|
||||||
|
amount: plan.price,
|
||||||
|
method,
|
||||||
|
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
|
||||||
|
createdAt: paidDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`);
|
||||||
|
|
||||||
|
// ─── Open support tickets ──────────────────────────────
|
||||||
|
const supportTickets = [
|
||||||
|
{ clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high' },
|
||||||
|
{ clientIdx: 5, title: 'Slow speed during peak hours', desc: 'Speed drops to 5 Mbps from 8-10 PM', priority: 'normal' },
|
||||||
|
{ clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent' },
|
||||||
|
{ clientIdx: 11, title: 'Request for plan upgrade', desc: 'Would like to upgrade from Basic to Standard', priority: 'low' },
|
||||||
|
{ clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const t of supportTickets) {
|
||||||
|
await prisma.ticket.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: clients[t.clientIdx].id,
|
||||||
|
createdById: users.tenant_admin.id,
|
||||||
|
type: 'support',
|
||||||
|
title: t.title,
|
||||||
|
description: t.desc,
|
||||||
|
priority: t.priority,
|
||||||
|
status: 'open',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Support tickets: ${supportTickets.length}`);
|
||||||
|
|
||||||
|
// ─── Employees ─────────────────────────────────────────
|
||||||
|
const empDefs = [
|
||||||
|
{ first: 'Pedro', last: 'Cruz', position: 'Senior Technician', dept: 'Operations', salary: 18000 },
|
||||||
|
{ first: 'Jose', last: 'Garcia', position: 'Field Technician', dept: 'Operations', salary: 15000 },
|
||||||
|
{ first: 'Maria', last: 'Reyes', position: 'Operations Manager', dept: 'Management', salary: 30000 },
|
||||||
|
{ first: 'Juan', last: 'Santos', position: 'Collection Officer', dept: 'Finance', salary: 16000 },
|
||||||
|
{ first: 'Ana', last: 'De Leon', position: 'Billing Clerk', dept: 'Finance', salary: 14000 },
|
||||||
|
{ first: 'Luis', last: 'Mercado', position: 'Network Engineer', dept: 'Technical', salary: 25000 },
|
||||||
|
{ first: 'Sofia', last: 'Pascual', position: 'Customer Service', dept: 'Support', salary: 14000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const employees: any[] = [];
|
||||||
|
for (let i = 0; i < empDefs.length; i++) {
|
||||||
|
const e = empDefs[i];
|
||||||
|
const emp = await prisma.employee.create({
|
||||||
|
data: { tenantId: tenant.id, employeeNo: `E-${String(i + 1).padStart(4, '0')}`, firstName: e.first, lastName: e.last, position: e.position, department: e.dept, salary: e.salary },
|
||||||
|
});
|
||||||
|
employees.push(emp);
|
||||||
|
}
|
||||||
|
console.log(`Employees: ${employees.length}`);
|
||||||
|
|
||||||
|
// ─── Expenses ──────────────────────────────────────────
|
||||||
|
const expDefs = [
|
||||||
|
{ cat: 'utilities', desc: 'Electricity bill - March 2026', amount: 12500, status: 'approved', days: -15 },
|
||||||
|
{ cat: 'utilities', desc: 'Internet backbone ISP bill', amount: 35000, status: 'approved', days: -10 },
|
||||||
|
{ cat: 'supplies', desc: 'Fiber optic cables (500m)', amount: 8500, status: 'approved', days: -8 },
|
||||||
|
{ cat: 'maintenance', desc: 'OLT maintenance and cleaning', amount: 3500, status: 'approved', days: -5 },
|
||||||
|
{ cat: 'transport', desc: 'Fuel for service vehicles', amount: 4200, status: 'approved', days: -3 },
|
||||||
|
{ cat: 'equipment', desc: '10x Mikrotik hEX S routers', amount: 28000, status: 'approved', days: -2 },
|
||||||
|
{ cat: 'supplies', desc: 'Office supplies and printer ink', amount: 2100, status: 'pending', days: -1 },
|
||||||
|
{ cat: 'maintenance', desc: 'Generator repair', amount: 7800, status: 'pending', days: 0 },
|
||||||
|
{ cat: 'transport', desc: 'Technician transport allowance - April', amount: 6000, status: 'pending', days: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const e of expDefs) {
|
||||||
|
const expDate = new Date();
|
||||||
|
expDate.setDate(expDate.getDate() + e.days);
|
||||||
|
await prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
createdById: users.manager.id,
|
||||||
|
approvedById: e.status === 'approved' ? users.tenant_admin.id : null,
|
||||||
|
category: e.cat,
|
||||||
|
description: e.desc,
|
||||||
|
amount: e.amount,
|
||||||
|
status: e.status,
|
||||||
|
expenseDate: expDate,
|
||||||
|
approvedAt: e.status === 'approved' ? expDate : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Expenses: ${expDefs.length}`);
|
||||||
|
|
||||||
|
// ─── Company Accounts ──────────────────────────────────
|
||||||
|
// Company accounts will be created after CoA so we can link them
|
||||||
|
console.log('Company accounts: deferred to after CoA');
|
||||||
|
|
||||||
|
// ─── Fund Transfers ────────────────────────────────────
|
||||||
|
// Fund transfers deferred to after company accounts
|
||||||
|
console.log('Fund transfers: deferred');
|
||||||
|
|
||||||
|
// ─── Assets ────────────────────────────────────────────
|
||||||
|
const assetDefs = [
|
||||||
|
{ name: 'Huawei MA5608T OLT', cat: 'olt', serial: 'HW-OLT-001', price: 85000, status: 'in_use', loc: 'Main Office' },
|
||||||
|
{ name: 'Mikrotik CCR1009', cat: 'router', serial: 'MK-CCR-001', price: 32000, status: 'in_use', loc: 'Main Office' },
|
||||||
|
{ name: 'Mikrotik hEX S #1', cat: 'router', serial: 'MK-HEX-001', price: 2800, status: 'in_use', empIdx: 0 },
|
||||||
|
{ name: 'Mikrotik hEX S #2', cat: 'router', serial: 'MK-HEX-002', price: 2800, status: 'in_use', empIdx: 1 },
|
||||||
|
{ name: 'Mikrotik hEX S #3', cat: 'router', serial: 'MK-HEX-003', price: 2800, status: 'available', loc: 'Warehouse' },
|
||||||
|
{ name: 'OTDR Tester', cat: 'tool', serial: 'OTDR-001', price: 45000, status: 'in_use', empIdx: 0 },
|
||||||
|
{ name: 'Fiber Splicer', cat: 'tool', serial: 'FS-001', price: 65000, status: 'in_use', empIdx: 5 },
|
||||||
|
{ name: 'Honda XRM 125 (Field)', cat: 'vehicle', serial: 'MV-2024-001', price: 68000, status: 'in_use', empIdx: 0 },
|
||||||
|
{ name: 'Honda Wave 110 (Field)', cat: 'vehicle', serial: 'MV-2024-002', price: 55000, status: 'in_use', empIdx: 1 },
|
||||||
|
{ name: 'Dell Latitude 5540', cat: 'computer', serial: 'DELL-LAP-001', price: 48000, status: 'in_use', empIdx: 2 },
|
||||||
|
{ name: 'Fiber Cable Spool 1km', cat: 'cable', price: 12000, status: 'available', loc: 'Warehouse' },
|
||||||
|
{ name: 'Fiber Cable Spool 500m', cat: 'cable', price: 6500, status: 'available', loc: 'Warehouse' },
|
||||||
|
{ name: 'UPS 1500VA', cat: 'other', serial: 'UPS-001', price: 8500, status: 'in_use', loc: 'Main Office' },
|
||||||
|
{ name: 'Old Mikrotik RB750', cat: 'router', serial: 'MK-OLD-001', price: 1500, status: 'retired' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const a of assetDefs) {
|
||||||
|
await prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
name: a.name,
|
||||||
|
category: a.cat,
|
||||||
|
serialNumber: a.serial || null,
|
||||||
|
purchasePrice: a.price,
|
||||||
|
status: a.status,
|
||||||
|
location: a.loc || null,
|
||||||
|
assignedToId: a.empIdx !== undefined ? employees[a.empIdx].id : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Assets: ${assetDefs.length}`);
|
||||||
|
|
||||||
|
// ─── Billing Settings ──────────────────────────────────
|
||||||
|
await prisma.billingSetting.create({
|
||||||
|
data: { tenantId: tenant.id, autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, invoicePrefix: 'INV' },
|
||||||
|
});
|
||||||
|
console.log('Billing settings created');
|
||||||
|
|
||||||
|
// ─── Chart of Accounts (auto-seeded by API, but seed defaults) ──
|
||||||
|
const coaDefs = [
|
||||||
|
{ code: '1000', name: 'Assets', type: 'asset', sys: true },
|
||||||
|
{ code: '1010', name: 'Cash on Hand', type: 'asset', sys: true },
|
||||||
|
{ code: '1020', name: 'GCash Business', type: 'asset', sys: true },
|
||||||
|
{ code: '1030', name: 'Maya Business', type: 'asset', sys: true },
|
||||||
|
{ code: '1040', name: 'Bank Account', type: 'asset', sys: true },
|
||||||
|
{ code: '1100', name: 'Accounts Receivable', type: 'asset', sys: true },
|
||||||
|
{ code: '1200', name: 'Equipment', type: 'asset', sys: true },
|
||||||
|
{ code: '2000', name: 'Liabilities', type: 'liability', sys: true },
|
||||||
|
{ code: '2010', name: 'Accounts Payable', type: 'liability', sys: true },
|
||||||
|
{ code: '3000', name: 'Equity', type: 'equity', sys: true },
|
||||||
|
{ code: '3010', name: "Owner's Equity", type: 'equity', sys: true },
|
||||||
|
{ code: '3020', name: 'Retained Earnings', type: 'equity', sys: true },
|
||||||
|
{ code: '4000', name: 'Revenue', type: 'revenue', sys: true },
|
||||||
|
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', sys: true },
|
||||||
|
{ code: '4020', name: 'Installation Fees', type: 'revenue', sys: true },
|
||||||
|
{ code: '5000', name: 'Expenses', type: 'expense', sys: true },
|
||||||
|
{ code: '5010', name: 'Utilities Expense', type: 'expense', sys: true },
|
||||||
|
{ code: '5020', name: 'Salaries Expense', type: 'expense', sys: true },
|
||||||
|
{ code: '5030', name: 'Maintenance Expense', type: 'expense', sys: true },
|
||||||
|
{ code: '5040', name: 'Transport Expense', type: 'expense', sys: true },
|
||||||
|
{ code: '5050', name: 'Supplies Expense', type: 'expense', sys: true },
|
||||||
|
{ code: '5060', name: 'Equipment Expense', type: 'expense', sys: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const a of coaDefs) {
|
||||||
|
await prisma.chartOfAccount.create({
|
||||||
|
data: { tenantId: tenant.id, code: a.code, name: a.name, type: a.type, isSystem: a.sys },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Chart of Accounts: ${coaDefs.length}`);
|
||||||
|
|
||||||
|
// ─── Custodial CoA per user ────────────────────────────
|
||||||
|
const methods = ['Cash', 'GCash', 'Maya', 'Bank'];
|
||||||
|
let custodialCode = 1500;
|
||||||
|
for (const u of userDefs) {
|
||||||
|
const user = users[u.role];
|
||||||
|
for (const m of methods) {
|
||||||
|
await prisma.chartOfAccount.create({
|
||||||
|
data: { tenantId: tenant.id, code: String(custodialCode++), name: `${u.first} ${u.last} - ${m}`, type: 'asset', isSystem: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Custodial CoA accounts: ${userDefs.length * 4}`);
|
||||||
|
|
||||||
|
// ─── Company Accounts (linked to CoA) ──────────────────
|
||||||
|
const coa1010 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1010' } });
|
||||||
|
const coa1020 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1020' } });
|
||||||
|
const coa1030 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1030' } });
|
||||||
|
const coa1040 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1040' } });
|
||||||
|
|
||||||
|
const accts = await Promise.all([
|
||||||
|
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'Cash on Hand', type: 'cash', balance: 15000, isSystem: true, chartOfAccountId: coa1010?.id } }),
|
||||||
|
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'GCash Business', type: 'e_wallet', accountNo: '09171234567', balance: 42500, chartOfAccountId: coa1020?.id } }),
|
||||||
|
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'Maya Business', type: 'e_wallet', accountNo: '09181234567', balance: 18200, chartOfAccountId: coa1030?.id } }),
|
||||||
|
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'BDO Savings', type: 'bank', accountNo: '0012-3456-7890', balance: 285000, chartOfAccountId: coa1040?.id } }),
|
||||||
|
]);
|
||||||
|
console.log(`Company accounts: ${accts.length} (linked to CoA)`);
|
||||||
|
|
||||||
|
// ─── Fund Transfers ────────────────────────────────────
|
||||||
|
await prisma.fundTransfer.create({
|
||||||
|
data: { tenantId: tenant.id, fromAccountId: accts[1].id, toAccountId: accts[3].id, amount: 20000, description: 'GCash to BDO weekly transfer', transferredBy: users.tenant_admin.id },
|
||||||
|
});
|
||||||
|
await prisma.fundTransfer.create({
|
||||||
|
data: { tenantId: tenant.id, fromAccountId: accts[3].id, toAccountId: accts[0].id, amount: 5000, description: 'Petty cash replenishment', transferredBy: users.tenant_admin.id },
|
||||||
|
});
|
||||||
|
console.log('Fund transfers: 2');
|
||||||
|
|
||||||
|
// ─── Remittances ───────────────────────────────────────
|
||||||
|
await prisma.remittance.create({
|
||||||
|
data: { tenantId: tenant.id, collectorId: users.technician.id, confirmedById: users.tenant_admin.id, totalAmount: 8995, status: 'confirmed', confirmedAt: new Date() },
|
||||||
|
});
|
||||||
|
await prisma.remittance.create({
|
||||||
|
data: { tenantId: tenant.id, collectorId: users.technician.id, totalAmount: 5497, status: 'pending' },
|
||||||
|
});
|
||||||
|
console.log('Remittances: 2');
|
||||||
|
|
||||||
|
console.log('\n✅ Seed completed successfully!');
|
||||||
|
console.log(`\n📊 Summary:`);
|
||||||
|
console.log(` Tenant: ${tenant.name}`);
|
||||||
|
console.log(` Users: ${userDefs.length}`);
|
||||||
|
console.log(` Areas: ${areas.length}`);
|
||||||
|
console.log(` Plans: ${plans.length}`);
|
||||||
|
console.log(` Clients: ${clients.length} (with active subscriptions)`);
|
||||||
|
console.log(` Invoices: ${invoiceCount * 2} (paid + unpaid)`);
|
||||||
|
console.log(` Employees: ${empDefs.length}`);
|
||||||
|
console.log(` Expenses: ${expDefs.length} (approved + pending)`);
|
||||||
|
console.log(` Assets: ${assetDefs.length}`);
|
||||||
|
console.log(` Company Accounts: ${accts.length}`);
|
||||||
|
console.log(`\n🔑 Login: admin@demo-isp.com / admin123!`);
|
||||||
|
console.log(`🌐 Portal: C-000001 / 09171234567`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => { console.error('Seed failed:', e); process.exit(1); })
|
||||||
|
.finally(async () => { await prisma.$disconnect(); });
|
||||||
19
packages/db/src/index.ts
Normal file
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": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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 [];
|
||||||
|
}
|
||||||
36
packages/shared/src/constants/roles.ts
Normal file
36
packages/shared/src/constants/roles.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export const Role = {
|
||||||
|
SUPER_ADMIN: 'super_admin',
|
||||||
|
TENANT_ADMIN: 'tenant_admin',
|
||||||
|
MANAGER: 'manager',
|
||||||
|
TECHNICIAN: 'technician',
|
||||||
|
VIEWER: 'viewer',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Role = (typeof Role)[keyof typeof Role];
|
||||||
|
|
||||||
|
export const ALL_ROLES: readonly Role[] = Object.values(Role);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numeric hierarchy level per role.
|
||||||
|
* Higher number = more powerful role.
|
||||||
|
* Used for hierarchy-aware authorization checks.
|
||||||
|
*/
|
||||||
|
const ROLE_LEVEL: Record<string, number> = {
|
||||||
|
[Role.SUPER_ADMIN]: 100,
|
||||||
|
[Role.TENANT_ADMIN]: 80,
|
||||||
|
[Role.MANAGER]: 60,
|
||||||
|
[Role.TECHNICIAN]: 40,
|
||||||
|
[Role.VIEWER]: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any of the user's roles satisfies the required role level.
|
||||||
|
* A higher-level role always satisfies a lower-level requirement.
|
||||||
|
*
|
||||||
|
* Example: user with ['manager'] satisfies 'technician' because manager(60) >= technician(40).
|
||||||
|
*/
|
||||||
|
export function satisfiesRole(userRoles: string[], requiredRole: string): boolean {
|
||||||
|
const requiredLevel = ROLE_LEVEL[requiredRole];
|
||||||
|
if (requiredLevel === undefined) return false;
|
||||||
|
return userRoles.some((r) => (ROLE_LEVEL[r] ?? 0) >= requiredLevel);
|
||||||
|
}
|
||||||
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';
|
||||||
20
packages/shared/tsconfig.json
Normal file
20
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
43
src/app.module.ts
Normal file
43
src/app.module.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { AdminPrismaModule } from './prisma/admin-prisma.module';
|
||||||
|
import { TenantPrismaModule } from './prisma/tenant-prisma.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { SupportModule } from './support/support.module';
|
||||||
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { ImpersonateModule } from './impersonate/impersonate.module';
|
||||||
|
import { PublicSupportModule } from './public-support/public-support.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
|
import { AdminAuthGuard } from './common/guards/admin-auth.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: '../../.env',
|
||||||
|
}),
|
||||||
|
AdminPrismaModule,
|
||||||
|
TenantPrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
DashboardModule,
|
||||||
|
TenantsModule,
|
||||||
|
UsersModule,
|
||||||
|
SupportModule,
|
||||||
|
AuditModule,
|
||||||
|
ImpersonateModule,
|
||||||
|
PublicSupportModule,
|
||||||
|
HealthModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
|
{ provide: APP_GUARD, useClass: AdminAuthGuard },
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
29
src/audit/audit.controller.ts
Normal file
29
src/audit/audit.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Controller('audit-logs')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly service: AuditService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getTenantAuditLogs(
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
@Query('userId') userId?: string,
|
||||||
|
@Query('entity') entity?: string,
|
||||||
|
@Query('action') action?: string,
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
) {
|
||||||
|
return this.service.getTenantAuditLogs({ tenantId, userId, entity, action, page, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('platform')
|
||||||
|
getPlatformAuditLogs(
|
||||||
|
@Query('adminId') adminId?: string,
|
||||||
|
@Query('action') action?: string,
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
) {
|
||||||
|
return this.service.getPlatformAuditLogs({ adminId, action, page, limit });
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/audit/audit.module.ts
Normal file
11
src/audit/audit.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [AuditController],
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
80
src/audit/audit.service.ts
Normal file
80
src/audit/audit.service.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||||
|
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantDb: TenantPrismaService,
|
||||||
|
private readonly adminDb: AdminPrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getTenantAuditLogs(query: {
|
||||||
|
tenantId?: string;
|
||||||
|
userId?: string;
|
||||||
|
entity?: string;
|
||||||
|
action?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const { tenantId, userId, entity, action, page = 1, limit = 20 } = query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (tenantId) where.tenantId = tenantId;
|
||||||
|
if (userId) where.userId = userId;
|
||||||
|
if (entity) where.entity = entity;
|
||||||
|
if (action) where.action = action;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.tenantDb.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.tenantDb.auditLog.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlatformAuditLogs(query: {
|
||||||
|
adminId?: string;
|
||||||
|
action?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const { adminId, action, page = 1, limit = 20 } = query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (adminId) where.adminId = adminId;
|
||||||
|
if (action) where.action = action;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.adminDb.platformAuditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
include: { admin: { select: { id: true, firstName: true, lastName: true, email: true } } },
|
||||||
|
}),
|
||||||
|
this.adminDb.platformAuditLog.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async log(adminId: string, action: string, target?: string, details?: any, ipAddress?: string) {
|
||||||
|
return this.adminDb.platformAuditLog.create({
|
||||||
|
data: {
|
||||||
|
adminId,
|
||||||
|
action,
|
||||||
|
target: target || null,
|
||||||
|
details: details || {},
|
||||||
|
ipAddress: ipAddress || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
SetMetadata,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
||||||
|
class LoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefreshDto {
|
||||||
|
@IsString()
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto.email, dto.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async refresh(@Body() dto: RefreshDto) {
|
||||||
|
return this.authService.refreshToken(dto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async logout(@Req() req: any) {
|
||||||
|
await this.authService.logout(req.admin.sub);
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('profile')
|
||||||
|
async profile(@Req() req: any) {
|
||||||
|
return this.authService.getProfile(req.admin.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/auth/auth.module.ts
Normal file
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 { AdminJwtStrategy } from './strategies/admin-jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'admin-jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>('ADMIN_JWT_SECRET', 'admin-jwt-secret'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: config.get<string>('ADMIN_JWT_EXPIRES_IN', '15m') as any,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, AdminJwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
155
src/auth/auth.service.ts
Normal file
155
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly adminDb: AdminPrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
const admin = await this.adminDb.superAdmin.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin || !admin.isActive) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, admin.password);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.generateTokens(admin.id, admin.email);
|
||||||
|
|
||||||
|
await this.adminDb.platformAuditLog.create({
|
||||||
|
data: {
|
||||||
|
adminId: admin.id,
|
||||||
|
action: 'auth.login',
|
||||||
|
details: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...tokens,
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
firstName: admin.firstName,
|
||||||
|
lastName: admin.lastName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(refreshToken: string) {
|
||||||
|
const stored = await this.adminDb.adminRefreshToken.findUnique({
|
||||||
|
where: { token: refreshToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stored || stored.expiresAt < new Date()) {
|
||||||
|
if (stored) {
|
||||||
|
await this.adminDb.adminRefreshToken.delete({
|
||||||
|
where: { id: stored.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.adminDb.adminRefreshToken.delete({
|
||||||
|
where: { id: stored.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = await this.adminDb.superAdmin.findUnique({
|
||||||
|
where: { id: stored.adminId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin || !admin.isActive) {
|
||||||
|
throw new UnauthorizedException('Account is inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateTokens(admin.id, admin.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(adminId: string) {
|
||||||
|
const admin = await this.adminDb.superAdmin.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
throw new UnauthorizedException('Admin not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(adminId: string) {
|
||||||
|
await this.adminDb.adminRefreshToken.deleteMany({
|
||||||
|
where: { adminId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.adminDb.platformAuditLog.create({
|
||||||
|
data: {
|
||||||
|
adminId,
|
||||||
|
action: 'auth.logout',
|
||||||
|
details: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTokens(adminId: string, email: string) {
|
||||||
|
const accessToken = this.jwt.sign({
|
||||||
|
sub: adminId,
|
||||||
|
email,
|
||||||
|
type: 'super_admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshSecret = this.config.get<string>(
|
||||||
|
'ADMIN_JWT_REFRESH_SECRET',
|
||||||
|
'admin-refresh-secret',
|
||||||
|
);
|
||||||
|
const refreshExpiresIn = this.config.get<string>(
|
||||||
|
'ADMIN_JWT_REFRESH_EXPIRES_IN',
|
||||||
|
'7d',
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshToken = this.jwt.sign(
|
||||||
|
{ sub: adminId, email, type: 'super_admin' },
|
||||||
|
{
|
||||||
|
secret: refreshSecret,
|
||||||
|
expiresIn: refreshExpiresIn as any,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const days = parseInt(refreshExpiresIn) || 7;
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + days);
|
||||||
|
|
||||||
|
await this.adminDb.adminRefreshToken.create({
|
||||||
|
data: {
|
||||||
|
token: refreshToken,
|
||||||
|
adminId,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/auth/dto/login.dto.ts
Normal file
9
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsEmail, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
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;
|
||||||
|
}
|
||||||
28
src/auth/strategies/admin-jwt.strategy.ts
Normal file
28
src/auth/strategies/admin-jwt.strategy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminJwtStrategy extends PassportStrategy(
|
||||||
|
Strategy,
|
||||||
|
'admin-jwt',
|
||||||
|
) {
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey:
|
||||||
|
config.get<string>('ADMIN_JWT_SECRET', 'admin-jwt-secret'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(payload: any) {
|
||||||
|
if (payload.type !== 'super_admin') return null;
|
||||||
|
return {
|
||||||
|
sub: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
type: payload.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/common/decorators/current-admin.decorator.ts
Normal file
9
src/common/decorators/current-admin.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentAdmin = createParamDecorator(
|
||||||
|
(data: string | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const admin = request['admin'];
|
||||||
|
return data ? admin?.[data] : admin;
|
||||||
|
},
|
||||||
|
);
|
||||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
36
src/common/filters/http-exception.filter.ts
Normal file
36
src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
|
||||||
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
let message = 'Internal server error';
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
message =
|
||||||
|
typeof exceptionResponse === 'string'
|
||||||
|
? exceptionResponse
|
||||||
|
: (exceptionResponse as { message?: string }).message || message;
|
||||||
|
} else {
|
||||||
|
console.error('[GlobalExceptionFilter] Unhandled exception:', exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: Array.isArray(message) ? message.join(', ') : message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/common/guards/admin-auth.guard.ts
Normal file
60
src/common/guards/admin-auth.guard.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminAuthGuard implements CanActivate {
|
||||||
|
private jwtSecret: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.jwtSecret = this.config.get<string>('ADMIN_JWT_SECRET', 'admin-jwt-secret')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const url = request.url || '';
|
||||||
|
|
||||||
|
// Skip auth for public support endpoints
|
||||||
|
if (url.includes('/public/support')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
|
const token = this.extractToken(request);
|
||||||
|
if (!token) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
|
secret: this.jwtSecret,
|
||||||
|
});
|
||||||
|
(request as any)['admin'] = payload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/common/interceptors/response.interceptor.ts
Normal file
20
src/common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseInterceptor<T> implements NestInterceptor<T> {
|
||||||
|
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => ({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/dashboard/dashboard.controller.ts
Normal file
17
src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
@Controller('dashboard')
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(private readonly service: DashboardService) {}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
getStats() {
|
||||||
|
return this.service.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('recent-activity')
|
||||||
|
getRecentActivity() {
|
||||||
|
return this.service.getRecentActivity();
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
94
src/dashboard/dashboard.service.ts
Normal file
94
src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||||
|
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantDb: TenantPrismaService,
|
||||||
|
private readonly adminDb: AdminPrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getStats() {
|
||||||
|
const [
|
||||||
|
totalTenants,
|
||||||
|
activeTenants,
|
||||||
|
totalUsers,
|
||||||
|
totalClients,
|
||||||
|
totalSubscriptions,
|
||||||
|
activeSubscriptions,
|
||||||
|
totalInvoices,
|
||||||
|
totalPayments,
|
||||||
|
paymentAgg,
|
||||||
|
openTickets,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.tenantDb.tenant.count({ where: { deletedAt: null } }),
|
||||||
|
this.tenantDb.tenant.count({ where: { deletedAt: null, isActive: true } }),
|
||||||
|
this.tenantDb.user.count({ where: { deletedAt: null } }),
|
||||||
|
this.tenantDb.client.count({ where: { deletedAt: null } }),
|
||||||
|
this.tenantDb.subscription.count({ where: { deletedAt: null } }),
|
||||||
|
this.tenantDb.subscription.count({ where: { deletedAt: null, status: 'active' } }),
|
||||||
|
this.tenantDb.invoice.count({ where: { deletedAt: null } }),
|
||||||
|
this.tenantDb.payment.count({ where: { deletedAt: null } }),
|
||||||
|
this.tenantDb.payment.aggregate({
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: true,
|
||||||
|
where: { deletedAt: null },
|
||||||
|
}),
|
||||||
|
this.adminDb.supportTicket.count({ where: { status: { in: ['open', 'in_progress'] } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTenants,
|
||||||
|
activeTenants,
|
||||||
|
inactiveTenants: totalTenants - activeTenants,
|
||||||
|
totalUsers,
|
||||||
|
totalClients,
|
||||||
|
totalSubscriptions,
|
||||||
|
activeSubscriptions,
|
||||||
|
totalInvoices,
|
||||||
|
totalPayments,
|
||||||
|
totalRevenue: paymentAgg._sum.amount || 0,
|
||||||
|
openSupportTickets: openTickets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentActivity() {
|
||||||
|
const [recentTenants, recentAudit, recentTickets] = await Promise.all([
|
||||||
|
this.tenantDb.tenant.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
select: { id: true, name: true, slug: true, isActive: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.tenantDb.auditLog.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
tenantId: true,
|
||||||
|
userId: true,
|
||||||
|
action: true,
|
||||||
|
entity: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.adminDb.supportTicket.findMany({
|
||||||
|
where: { status: { in: ['open', 'in_progress'] } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
subject: true,
|
||||||
|
tenantName: true,
|
||||||
|
category: true,
|
||||||
|
priority: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { recentTenants, recentAudit, recentTickets };
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/health/health.controller.ts
Normal file
15
src/health/health.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
@Public()
|
||||||
|
check() {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'admin-api',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/health/health.module.ts
Normal file
7
src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
23
src/impersonate/impersonate.controller.ts
Normal file
23
src/impersonate/impersonate.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Controller, Post, Delete, Param, Req } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ImpersonateService } from './impersonate.service';
|
||||||
|
|
||||||
|
@Controller('impersonate')
|
||||||
|
export class ImpersonateController {
|
||||||
|
constructor(private readonly service: ImpersonateService) {}
|
||||||
|
|
||||||
|
@Post(':tenantId')
|
||||||
|
start(@Param('tenantId') tenantId: string, @Req() req: Request & { admin: any }) {
|
||||||
|
const admin = req.admin;
|
||||||
|
return this.service.startImpersonation(
|
||||||
|
tenantId,
|
||||||
|
admin.sub,
|
||||||
|
`${admin.firstName || ''} ${admin.lastName || ''}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
end(@Req() req: Request & { admin: any }) {
|
||||||
|
return this.service.endImpersonation(req.admin.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/impersonate/impersonate.module.ts
Normal file
12
src/impersonate/impersonate.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ImpersonateController } from './impersonate.controller';
|
||||||
|
import { ImpersonateService } from './impersonate.service';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuditModule, AuthModule],
|
||||||
|
controllers: [ImpersonateController],
|
||||||
|
providers: [ImpersonateService],
|
||||||
|
})
|
||||||
|
export class ImpersonateModule {}
|
||||||
80
src/impersonate/impersonate.service.ts
Normal file
80
src/impersonate/impersonate.service.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImpersonateService {
|
||||||
|
private readonly mainJwtSecret: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tenantDb: TenantPrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {
|
||||||
|
// Use the MAIN API's JWT secret so the token works with the tenant API
|
||||||
|
this.mainJwtSecret = this.config.get<string>('JWT_SECRET', 'dev-jwt-secret-not-for-production');
|
||||||
|
}
|
||||||
|
|
||||||
|
async startImpersonation(tenantId: string, adminId: string, adminName: string) {
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({
|
||||||
|
where: { id: tenantId, deletedAt: null, isActive: true },
|
||||||
|
});
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found or inactive');
|
||||||
|
|
||||||
|
// Find the tenant's first admin user
|
||||||
|
const adminUser = await this.tenantDb.user.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
deletedAt: null,
|
||||||
|
isActive: true,
|
||||||
|
roles: { some: { role: 'tenant_admin' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!adminUser) throw new NotFoundException('No tenant admin found for this tenant');
|
||||||
|
|
||||||
|
// Generate a JWT compatible with the main API
|
||||||
|
const token = this.jwt.sign(
|
||||||
|
{
|
||||||
|
sub: adminUser.id,
|
||||||
|
tenantId,
|
||||||
|
roles: ['tenant_admin'],
|
||||||
|
permissions: 'all',
|
||||||
|
impersonatedBy: adminId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
secret: this.mainJwtSecret,
|
||||||
|
expiresIn: '1h',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Audit the impersonation
|
||||||
|
await this.audit.log(adminId, 'impersonate.start', tenantId, {
|
||||||
|
tenantName: tenant.name,
|
||||||
|
impersonatedUserId: adminUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: token,
|
||||||
|
impersonatedUser: {
|
||||||
|
id: adminUser.id,
|
||||||
|
email: adminUser.email,
|
||||||
|
firstName: adminUser.firstName,
|
||||||
|
lastName: adminUser.lastName,
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
},
|
||||||
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async endImpersonation(adminId: string) {
|
||||||
|
await this.audit.log(adminId, 'impersonate.end');
|
||||||
|
return { message: 'Impersonation ended' };
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/main.ts
Normal file
45
src/main.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowedOrigins = (
|
||||||
|
process.env.CORS_ORIGIN ||
|
||||||
|
'http://localhost:3000,http://localhost:3002,http://localhost:3003'
|
||||||
|
).split(',');
|
||||||
|
app.enableCors({
|
||||||
|
origin: allowedOrigins,
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'x-tenant-id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const port = process.env.ADMIN_API_PORT || 3004;
|
||||||
|
await app.listen(port, '0.0.0.0');
|
||||||
|
console.log(`Admin API running on http://localhost:${port}/api`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
9
src/prisma/admin-prisma.module.ts
Normal file
9
src/prisma/admin-prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AdminPrismaService } from './admin-prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AdminPrismaService],
|
||||||
|
exports: [AdminPrismaService],
|
||||||
|
})
|
||||||
|
export class AdminPrismaModule {}
|
||||||
16
src/prisma/admin-prisma.service.ts
Normal file
16
src/prisma/admin-prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@fiberops/admin-db';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminPrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/prisma/tenant-prisma.module.ts
Normal file
9
src/prisma/tenant-prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TenantPrismaService } from './tenant-prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [TenantPrismaService],
|
||||||
|
exports: [TenantPrismaService],
|
||||||
|
})
|
||||||
|
export class TenantPrismaModule {}
|
||||||
16
src/prisma/tenant-prisma.service.ts
Normal file
16
src/prisma/tenant-prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantPrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/public-support/public-support.controller.ts
Normal file
159
src/public-support/public-support.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
Headers,
|
||||||
|
Res,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFiles,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { SupportService } from '../support/support.service';
|
||||||
|
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||||
|
import { CreateTicketDto } from '../support/dto/create-ticket.dto';
|
||||||
|
import { CommentDto } from '../support/dto/comment.dto';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { supportUploadOptions } from '../support/multer-options';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public support endpoints for tenant users.
|
||||||
|
* These require a valid tenant JWT from the main API.
|
||||||
|
*/
|
||||||
|
@Controller('public/support')
|
||||||
|
export class PublicSupportController {
|
||||||
|
private readonly mainJwtSecret: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly support: SupportService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly tenantDb: TenantPrismaService,
|
||||||
|
) {
|
||||||
|
this.mainJwtSecret = this.config.get<string>('JWT_SECRET', 'dev-jwt-secret-not-for-production');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveTenantUser(authorization?: string) {
|
||||||
|
if (!authorization) throw new UnauthorizedException();
|
||||||
|
const token = authorization.replace('Bearer ', '');
|
||||||
|
try {
|
||||||
|
const payload = await this.jwt.verifyAsync(token, { secret: this.mainJwtSecret });
|
||||||
|
if (!payload.tenantId || !payload.sub) throw new UnauthorizedException();
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
tenantId: payload.tenantId,
|
||||||
|
name: `${payload.firstName || ''} ${payload.lastName || ''}`.trim(),
|
||||||
|
email: payload.email,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('tickets')
|
||||||
|
async listTickets(
|
||||||
|
@Headers('authorization') authorization?: string,
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
) {
|
||||||
|
const user = await this.resolveTenantUser(authorization);
|
||||||
|
return this.support.getTenantTickets(user.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('tickets/:id')
|
||||||
|
async getTicket(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('authorization') authorization?: string,
|
||||||
|
) {
|
||||||
|
const user = await this.resolveTenantUser(authorization);
|
||||||
|
return this.support.getTenantTicketDetail(user.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('tickets')
|
||||||
|
async createTicket(
|
||||||
|
@Body() dto: CreateTicketDto,
|
||||||
|
@Headers('authorization') authorization?: string,
|
||||||
|
) {
|
||||||
|
const user = await this.resolveTenantUser(authorization);
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({
|
||||||
|
where: { id: user.tenantId },
|
||||||
|
select: { name: true, slug: true },
|
||||||
|
});
|
||||||
|
return this.support.createFromTenant(
|
||||||
|
user.tenantId,
|
||||||
|
tenant?.name || '',
|
||||||
|
tenant?.slug || '',
|
||||||
|
user.userId,
|
||||||
|
user.name,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('tickets/:id/comments')
|
||||||
|
async addComment(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: CommentDto,
|
||||||
|
@Headers('authorization') authorization?: string,
|
||||||
|
) {
|
||||||
|
const user = await this.resolveTenantUser(authorization);
|
||||||
|
return this.support.addComment(id, user.userId, user.name, 'tenant_user', dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attachments (Tenant-facing) ────────────────────
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('tickets/:id/attachments')
|
||||||
|
@UseInterceptors(FilesInterceptor('files', 5, supportUploadOptions))
|
||||||
|
async uploadAttachments(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@Body() body: { commentId?: string },
|
||||||
|
@Headers('authorization') authorization?: string,
|
||||||
|
) {
|
||||||
|
const user = await this.resolveTenantUser(authorization);
|
||||||
|
await this.support.getTenantTicketDetail(user.tenantId, id);
|
||||||
|
const results = await Promise.all(
|
||||||
|
files.map((f) => this.support.addAttachment(id, f, user.userId, body.commentId)),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('tickets/:id/attachments')
|
||||||
|
async getAttachments(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('authorization') authorization?: string,
|
||||||
|
) {
|
||||||
|
const user = await this.resolveTenantUser(authorization);
|
||||||
|
await this.support.getTenantTicketDetail(user.tenantId, id);
|
||||||
|
return this.support.getAttachments(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('uploads/:fileName')
|
||||||
|
async downloadFile(
|
||||||
|
@Param('fileName') fileName: string,
|
||||||
|
@Headers('authorization') authorization?: string,
|
||||||
|
@Res() res?: Response,
|
||||||
|
) {
|
||||||
|
await this.resolveTenantUser(authorization);
|
||||||
|
const { filePath, attachment } = await this.support.getAttachment(fileName);
|
||||||
|
res!.setHeader('Content-Type', attachment.mimeType);
|
||||||
|
res!.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename="${attachment.originalName}"`,
|
||||||
|
);
|
||||||
|
createReadStream(filePath).pipe(res!);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/public-support/public-support.module.ts
Normal file
13
src/public-support/public-support.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PublicSupportController } from './public-support.controller';
|
||||||
|
import { SupportService } from '../support/support.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { AdminPrismaModule } from '../prisma/admin-prisma.module';
|
||||||
|
import { TenantPrismaModule } from '../prisma/tenant-prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, AdminPrismaModule, TenantPrismaModule],
|
||||||
|
controllers: [PublicSupportController],
|
||||||
|
providers: [SupportService],
|
||||||
|
})
|
||||||
|
export class PublicSupportModule {}
|
||||||
6
src/support/dto/comment.dto.ts
Normal file
6
src/support/dto/comment.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CommentDto {
|
||||||
|
@IsString()
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
17
src/support/dto/create-ticket.dto.ts
Normal file
17
src/support/dto/create-ticket.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsString, IsOptional, IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTicketDto {
|
||||||
|
@IsString()
|
||||||
|
subject!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['billing', 'technical', 'account', 'general', 'feature_request'])
|
||||||
|
category?: string = 'general';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['low', 'normal', 'high', 'urgent'])
|
||||||
|
priority?: string = 'normal';
|
||||||
|
}
|
||||||
40
src/support/dto/list-query.dto.ts
Normal file
40
src/support/dto/list-query.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { IsOptional, IsString, IsInt, Min, IsIn } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class TicketListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['open', 'in_progress', 'waiting_tenant', 'resolved', 'closed'])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['billing', 'technical', 'account', 'general', 'feature_request'])
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['low', 'normal', 'high', 'urgent'])
|
||||||
|
priority?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
tenantId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assignedToId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number = 20;
|
||||||
|
}
|
||||||
19
src/support/dto/update-ticket.dto.ts
Normal file
19
src/support/dto/update-ticket.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsOptional, IsIn, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateTicketDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['low', 'normal', 'high', 'urgent'])
|
||||||
|
priority?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['open', 'in_progress', 'waiting_tenant', 'resolved', 'closed'])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assignedToId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['billing', 'technical', 'account', 'general', 'feature_request'])
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
40
src/support/multer-options.ts
Normal file
40
src/support/multer-options.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||||
|
import { diskStorage } from 'multer';
|
||||||
|
import { extname } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads/support';
|
||||||
|
|
||||||
|
export const supportUploadOptions: MulterOptions = {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
const fs = require('fs');
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||||
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, UPLOAD_DIR);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const uniqueName = `${randomUUID()}${extname(file.originalname)}`;
|
||||||
|
cb(null, uniqueName);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10 MB per file
|
||||||
|
files: 5, // max 5 files per request
|
||||||
|
},
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const allowed = [
|
||||||
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||||
|
'application/pdf',
|
||||||
|
'text/plain',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
];
|
||||||
|
if (allowed.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`Unsupported file type: ${file.mimetype}`), false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
119
src/support/support.controller.ts
Normal file
119
src/support/support.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFiles,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { SupportService } from './support.service';
|
||||||
|
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
||||||
|
import { CommentDto } from './dto/comment.dto';
|
||||||
|
import { TicketListQueryDto } from './dto/list-query.dto';
|
||||||
|
import { supportUploadOptions } from './multer-options';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
|
||||||
|
@Controller('support/tickets')
|
||||||
|
export class SupportController {
|
||||||
|
constructor(private readonly service: SupportService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query() query: TicketListQueryDto) {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('uploads/:fileName')
|
||||||
|
async downloadFile(
|
||||||
|
@Param('fileName') fileName: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filePath, attachment } = await this.service.getAttachment(fileName);
|
||||||
|
res.setHeader('Content-Type', attachment.mimeType);
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename="${attachment.originalName}"`,
|
||||||
|
);
|
||||||
|
createReadStream(filePath).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.service.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateTicketDto) {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/assign')
|
||||||
|
assign(@Param('id') id: string, @Body() body: { adminId: string }) {
|
||||||
|
return this.service.assign(id, body.adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/resolve')
|
||||||
|
resolve(@Param('id') id: string) {
|
||||||
|
return this.service.resolve(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/close')
|
||||||
|
close(@Param('id') id: string) {
|
||||||
|
return this.service.close(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/comments')
|
||||||
|
addComment(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: CommentDto,
|
||||||
|
@Req() req: Request & { admin: any },
|
||||||
|
) {
|
||||||
|
const admin = req.admin;
|
||||||
|
return this.service.addComment(
|
||||||
|
id,
|
||||||
|
admin.sub,
|
||||||
|
`${admin.firstName || ''} ${admin.lastName || ''}`.trim(),
|
||||||
|
'super_admin',
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attachments ────────────────────────────────
|
||||||
|
|
||||||
|
@Post(':id/attachments')
|
||||||
|
@UseInterceptors(FilesInterceptor('files', 5, supportUploadOptions))
|
||||||
|
async uploadAttachments(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@Body() body: { commentId?: string },
|
||||||
|
@Req() req: Request & { admin: any },
|
||||||
|
) {
|
||||||
|
const admin = req.admin;
|
||||||
|
const results = await Promise.all(
|
||||||
|
files.map((f) =>
|
||||||
|
this.service.addAttachment(id, f, admin.sub, body.commentId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/attachments')
|
||||||
|
getAttachments(@Param('id') id: string) {
|
||||||
|
return this.service.getAttachments(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/attachments/:attachmentId')
|
||||||
|
deleteAttachment(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('attachmentId') attachmentId: string,
|
||||||
|
) {
|
||||||
|
return this.service.deleteAttachment(id, attachmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/support/support.module.ts
Normal file
12
src/support/support.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SupportController } from './support.controller';
|
||||||
|
import { SupportService } from './support.service';
|
||||||
|
import { AdminPrismaModule } from '../prisma/admin-prisma.module';
|
||||||
|
import { TenantPrismaModule } from '../prisma/tenant-prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AdminPrismaModule, TenantPrismaModule],
|
||||||
|
controllers: [SupportController],
|
||||||
|
providers: [SupportService],
|
||||||
|
})
|
||||||
|
export class SupportModule {}
|
||||||
240
src/support/support.service.ts
Normal file
240
src/support/support.service.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||||
|
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||||
|
import { CreateTicketDto } from './dto/create-ticket.dto';
|
||||||
|
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
||||||
|
import { CommentDto } from './dto/comment.dto';
|
||||||
|
import { TicketListQueryDto } from './dto/list-query.dto';
|
||||||
|
import { UPLOAD_DIR } from './multer-options';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupportService {
|
||||||
|
constructor(
|
||||||
|
private readonly db: AdminPrismaService,
|
||||||
|
private readonly tenantDb: TenantPrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(query: TicketListQueryDto) {
|
||||||
|
const { search, status, category, priority, tenantId, assignedToId, page = 1, limit = 20 } = query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ subject: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ tenantName: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (priority) where.priority = priority;
|
||||||
|
if (tenantId) where.tenantId = tenantId;
|
||||||
|
if (assignedToId) where.assignedToId = assignedToId;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.db.supportTicket.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.db.supportTicket.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const ticket = await this.db.supportTicket.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
comments: { orderBy: { createdAt: 'asc' }, include: { attachments: true } },
|
||||||
|
attachments: { where: { commentId: null }, orderBy: { createdAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAsAdmin(tenantId: string, adminId: string, adminName: string, data: { subject: string; description: string; category?: string; priority?: string }) {
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({ where: { id: tenantId }, select: { id: true, name: true, slug: true } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
|
||||||
|
return this.db.supportTicket.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
createdById: adminId,
|
||||||
|
createdByName: adminName,
|
||||||
|
subject: data.subject,
|
||||||
|
description: data.description,
|
||||||
|
category: data.category || 'general',
|
||||||
|
priority: data.priority || 'normal',
|
||||||
|
status: 'open',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFromTenant(tenantId: string, tenantName: string, tenantSlug: string, userId: string, userName: string, dto: CreateTicketDto) {
|
||||||
|
return this.db.supportTicket.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
tenantName,
|
||||||
|
tenantSlug,
|
||||||
|
createdById: userId,
|
||||||
|
createdByName: userName,
|
||||||
|
subject: dto.subject,
|
||||||
|
description: dto.description,
|
||||||
|
category: dto.category || 'general',
|
||||||
|
priority: dto.priority || 'normal',
|
||||||
|
status: 'open',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTicketDto) {
|
||||||
|
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (dto.priority) data.priority = dto.priority;
|
||||||
|
if (dto.status) data.status = dto.status;
|
||||||
|
if (dto.assignedToId !== undefined) data.assignedToId = dto.assignedToId || null;
|
||||||
|
if (dto.category) data.category = dto.category;
|
||||||
|
|
||||||
|
if (dto.status === 'resolved') data.resolvedAt = new Date();
|
||||||
|
if (dto.status === 'closed') data.closedAt = new Date();
|
||||||
|
|
||||||
|
return this.db.supportTicket.update({ where: { id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async assign(id: string, adminId: string) {
|
||||||
|
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
|
||||||
|
return this.db.supportTicket.update({
|
||||||
|
where: { id },
|
||||||
|
data: { assignedToId: adminId, status: 'in_progress' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(id: string) {
|
||||||
|
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
|
||||||
|
return this.db.supportTicket.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'resolved', resolvedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(id: string) {
|
||||||
|
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
|
||||||
|
return this.db.supportTicket.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'closed', closedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addComment(id: string, authorId: string, authorName: string, authorType: string, dto: CommentDto) {
|
||||||
|
const ticket = await this.db.supportTicket.findUnique({ where: { id } });
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
|
||||||
|
return this.db.supportTicketComment.create({
|
||||||
|
data: {
|
||||||
|
ticketId: id,
|
||||||
|
authorId,
|
||||||
|
authorName,
|
||||||
|
authorType,
|
||||||
|
content: dto.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attachments ──────────────────────────────────────
|
||||||
|
|
||||||
|
async addAttachment(ticketId: string, file: Express.Multer.File, uploadedBy: string, commentId?: string) {
|
||||||
|
const ticket = await this.db.supportTicket.findUnique({ where: { id: ticketId } });
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
|
||||||
|
if (commentId) {
|
||||||
|
const comment = await this.db.supportTicketComment.findFirst({ where: { id: commentId, ticketId } });
|
||||||
|
if (!comment) throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db.supportTicketAttachment.create({
|
||||||
|
data: {
|
||||||
|
ticketId,
|
||||||
|
commentId: commentId || null,
|
||||||
|
fileName: file.filename,
|
||||||
|
originalName: file.originalname,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
uploadedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachment(fileName: string) {
|
||||||
|
const attachment = await this.db.supportTicketAttachment.findFirst({ where: { fileName } });
|
||||||
|
if (!attachment) throw new NotFoundException('Attachment not found');
|
||||||
|
|
||||||
|
const filePath = path.join(UPLOAD_DIR, fileName);
|
||||||
|
if (!fs.existsSync(filePath)) throw new NotFoundException('File not found on disk');
|
||||||
|
|
||||||
|
return { filePath, attachment };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachments(ticketId: string) {
|
||||||
|
return this.db.supportTicketAttachment.findMany({
|
||||||
|
where: { ticketId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAttachment(ticketId: string, attachmentId: string) {
|
||||||
|
const attachment = await this.db.supportTicketAttachment.findFirst({
|
||||||
|
where: { id: attachmentId, ticketId },
|
||||||
|
});
|
||||||
|
if (!attachment) throw new NotFoundException('Attachment not found');
|
||||||
|
|
||||||
|
const filePath = path.join(UPLOAD_DIR, attachment.fileName);
|
||||||
|
try { fs.unlinkSync(filePath); } catch { /* already gone */ }
|
||||||
|
|
||||||
|
return this.db.supportTicketAttachment.delete({ where: { id: attachmentId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTenantTickets(tenantId: string) {
|
||||||
|
return this.db.supportTicket.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTenantTicketDetail(tenantId: string, ticketId: string) {
|
||||||
|
const ticket = await this.db.supportTicket.findFirst({
|
||||||
|
where: { id: ticketId, tenantId },
|
||||||
|
include: {
|
||||||
|
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
comments: { orderBy: { createdAt: 'asc' }, include: { attachments: true } },
|
||||||
|
attachments: { where: { commentId: null }, orderBy: { createdAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/tenants/dto/create-tenant.dto.ts
Normal file
25
src/tenants/dto/create-tenant.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsString, IsOptional, IsEmail, IsObject } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTenantDto {
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
adminEmail!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
adminPassword!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
adminFirstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
adminLastName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
24
src/tenants/dto/list-query.dto.ts
Normal file
24
src/tenants/dto/list-query.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class TenantListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: 'active' | 'inactive' | 'all';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number = 20;
|
||||||
|
}
|
||||||
19
src/tenants/dto/update-tenant.dto.ts
Normal file
19
src/tenants/dto/update-tenant.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsBoolean, IsObject } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateTenantDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
59
src/tenants/tenants.controller.ts
Normal file
59
src/tenants/tenants.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { TenantsService } from './tenants.service';
|
||||||
|
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||||
|
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||||
|
import { TenantListQueryDto } from './dto/list-query.dto';
|
||||||
|
|
||||||
|
@Controller('tenants')
|
||||||
|
export class TenantsController {
|
||||||
|
constructor(private readonly service: TenantsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query() query: TenantListQueryDto) {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.service.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateTenantDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateTenantDto) {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/activate')
|
||||||
|
activate(@Param('id') id: string) {
|
||||||
|
return this.service.activate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/deactivate')
|
||||||
|
deactivate(@Param('id') id: string) {
|
||||||
|
return this.service.deactivate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/users')
|
||||||
|
getTenantUsers(@Param('id') tenantId: string) {
|
||||||
|
return this.service.getTenantUsers(tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/tenants/tenants.module.ts
Normal file
9
src/tenants/tenants.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TenantsController } from './tenants.controller';
|
||||||
|
import { TenantsService } from './tenants.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TenantsController],
|
||||||
|
providers: [TenantsService],
|
||||||
|
})
|
||||||
|
export class TenantsModule {}
|
||||||
323
src/tenants/tenants.service.ts
Normal file
323
src/tenants/tenants.service.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||||
|
import { AdminPrismaService } from '../prisma/admin-prisma.service';
|
||||||
|
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||||
|
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||||
|
import { TenantListQueryDto } from './dto/list-query.dto';
|
||||||
|
import { DEFAULT_ROLE_PERMISSIONS } from '@fiberops/shared';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantsService {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantDb: TenantPrismaService,
|
||||||
|
private readonly adminDb: AdminPrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(query: TenantListQueryDto) {
|
||||||
|
const { search, status, page = 1, limit = 20 } = query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = { deletedAt: null };
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ slug: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (status === 'active') where.isActive = true;
|
||||||
|
if (status === 'inactive') where.isActive = false;
|
||||||
|
|
||||||
|
const [tenants, total] = await Promise.all([
|
||||||
|
this.tenantDb.tenant.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
_count: { select: { users: { where: { deletedAt: null } }, clients: { where: { deletedAt: null } }, subscriptions: { where: { deletedAt: null } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.tenantDb.tenant.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: tenants.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
slug: t.slug,
|
||||||
|
isActive: t.isActive,
|
||||||
|
settings: t.settings,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
updatedAt: t.updatedAt,
|
||||||
|
_count: t._count,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
users: {
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
isActive: true,
|
||||||
|
roles: { select: { role: true } },
|
||||||
|
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
clients: { where: { deletedAt: null } },
|
||||||
|
subscriptions: { where: { deletedAt: null } },
|
||||||
|
invoices: { where: { deletedAt: null } },
|
||||||
|
payments: { where: { deletedAt: null } },
|
||||||
|
tickets: { where: { deletedAt: null } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
|
||||||
|
// Revenue aggregation
|
||||||
|
const revenueAgg = await this.tenantDb.payment.aggregate({
|
||||||
|
_sum: { amount: true },
|
||||||
|
where: { tenantId: id, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...tenant,
|
||||||
|
totalRevenue: revenueAgg._sum.amount || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateTenantDto) {
|
||||||
|
const existing = await this.tenantDb.tenant.findUnique({
|
||||||
|
where: { slug: dto.slug },
|
||||||
|
});
|
||||||
|
if (existing) throw new ConflictException('Tenant slug already taken');
|
||||||
|
|
||||||
|
const existingEmail = await this.tenantDb.user.findFirst({
|
||||||
|
where: { email: dto.adminEmail },
|
||||||
|
});
|
||||||
|
if (existingEmail) throw new ConflictException('Admin email already in use');
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
|
||||||
|
|
||||||
|
const tenant = await this.tenantDb.tenant.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
slug: dto.slug,
|
||||||
|
settings: dto.settings || {
|
||||||
|
companyName: dto.name,
|
||||||
|
currency: 'PHP',
|
||||||
|
timezone: 'Asia/Manila',
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
create: {
|
||||||
|
email: dto.adminEmail,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: dto.adminFirstName,
|
||||||
|
lastName: dto.adminLastName,
|
||||||
|
mustChangePassword: true,
|
||||||
|
roles: { create: { role: 'tenant_admin' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
users: { select: { id: true, email: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminUser = tenant.users[0];
|
||||||
|
await this.seedTenantDefaults(tenant.id, adminUser.id);
|
||||||
|
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTenantDto) {
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
|
||||||
|
if (dto.slug && dto.slug !== tenant.slug) {
|
||||||
|
const existing = await this.tenantDb.tenant.findUnique({ where: { slug: dto.slug } });
|
||||||
|
if (existing) throw new ConflictException('Slug already taken');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tenantDb.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name && { name: dto.name }),
|
||||||
|
...(dto.slug && { slug: dto.slug }),
|
||||||
|
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||||
|
...(dto.settings && { settings: dto.settings }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
return this.tenantDb.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(id: string) {
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
return this.tenantDb.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(id: string) {
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
return this.tenantDb.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTenantUsers(tenantId: string) {
|
||||||
|
const tenant = await this.tenantDb.tenant.findUnique({ where: { id: tenantId, deletedAt: null } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
|
||||||
|
return this.tenantDb.user.findMany({
|
||||||
|
where: { tenantId, deletedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
roles: { select: { role: true } },
|
||||||
|
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedTenantDefaults(tenantId: string, adminUserId: string) {
|
||||||
|
const ROLE_DEFS: { name: string; slug: string; description: string }[] = [
|
||||||
|
{ name: 'Tenant Admin', slug: 'tenant_admin', description: 'Full access to all modules' },
|
||||||
|
{ name: 'Manager', slug: 'manager', description: 'Operational management with approval rights' },
|
||||||
|
{ name: 'Technician', slug: 'technician', description: 'Field operations: clients, tickets, payments' },
|
||||||
|
{ name: 'Collector', slug: 'collector', description: 'Payment collection and client viewing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create roles with permissions
|
||||||
|
const roleMap: Record<string, string> = {};
|
||||||
|
for (const def of ROLE_DEFS) {
|
||||||
|
const perms = DEFAULT_ROLE_PERMISSIONS[def.slug] || [];
|
||||||
|
const role = await this.tenantDb.tenantRole.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: def.name,
|
||||||
|
slug: def.slug,
|
||||||
|
description: def.description,
|
||||||
|
isSystem: true,
|
||||||
|
permissions: {
|
||||||
|
create: perms.map((p) => ({
|
||||||
|
module: p.module,
|
||||||
|
canView: p.canView,
|
||||||
|
canCreate: p.canCreate,
|
||||||
|
canUpdate: p.canUpdate,
|
||||||
|
canArchive: p.canArchive,
|
||||||
|
canApprove: p.canApprove,
|
||||||
|
canExport: p.canExport,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
roleMap[def.slug] = role.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign admin user to tenant_admin role
|
||||||
|
await this.tenantDb.userTenantRole.create({
|
||||||
|
data: { userId: adminUserId, tenantRoleId: roleMap['tenant_admin'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Areas
|
||||||
|
await Promise.all([
|
||||||
|
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 1 - Centro', description: 'Town center, commercial area' } }),
|
||||||
|
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 2 - Poblacion', description: 'Residential zone near market' } }),
|
||||||
|
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 3 - San Isidro', description: 'Agricultural and residential' } }),
|
||||||
|
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 4 - Riverside', description: 'River-side residential' } }),
|
||||||
|
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 5 - Hilltop', description: 'Elevated residential subdivision' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Plans
|
||||||
|
await Promise.all([
|
||||||
|
this.tenantDb.plan.create({ data: { tenantId, name: 'Lite 15', description: 'Entry-level 15 Mbps', speedDown: 15, speedUp: 15, price: 699, billingCycle: 30 } }),
|
||||||
|
this.tenantDb.plan.create({ data: { tenantId, name: 'Basic 25', description: '25 Mbps residential', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 } }),
|
||||||
|
this.tenantDb.plan.create({ data: { tenantId, name: 'Standard 50', description: '50 Mbps residential', speedDown: 50, speedUp: 50, price: 1499, billingCycle: 30 } }),
|
||||||
|
this.tenantDb.plan.create({ data: { tenantId, name: 'Premium 100', description: '100 Mbps business', speedDown: 100, speedUp: 100, price: 2499, billingCycle: 30 } }),
|
||||||
|
this.tenantDb.plan.create({ data: { tenantId, name: 'Enterprise 200', description: '200 Mbps dedicated', speedDown: 200, speedUp: 200, price: 4999, billingCycle: 30 } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Chart of Accounts
|
||||||
|
const coaDefs = [
|
||||||
|
{ code: '1000', name: 'Assets', type: 'asset' },
|
||||||
|
{ code: '1010', name: 'Cash on Hand', type: 'asset' },
|
||||||
|
{ code: '1020', name: 'GCash Business', type: 'asset' },
|
||||||
|
{ code: '1030', name: 'Maya Business', type: 'asset' },
|
||||||
|
{ code: '1040', name: 'Bank Account', type: 'asset' },
|
||||||
|
{ code: '1100', name: 'Accounts Receivable', type: 'asset' },
|
||||||
|
{ code: '1200', name: 'Equipment', type: 'asset' },
|
||||||
|
{ code: '2000', name: 'Liabilities', type: 'liability' },
|
||||||
|
{ code: '2010', name: 'Accounts Payable', type: 'liability' },
|
||||||
|
{ code: '3000', name: 'Equity', type: 'equity' },
|
||||||
|
{ code: '3010', name: "Owner's Equity", type: 'equity' },
|
||||||
|
{ code: '3020', name: 'Retained Earnings', type: 'equity' },
|
||||||
|
{ code: '4000', name: 'Revenue', type: 'revenue' },
|
||||||
|
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue' },
|
||||||
|
{ code: '4020', name: 'Installation Fees', type: 'revenue' },
|
||||||
|
{ code: '5000', name: 'Expenses', type: 'expense' },
|
||||||
|
{ code: '5010', name: 'Utilities Expense', type: 'expense' },
|
||||||
|
{ code: '5020', name: 'Salaries Expense', type: 'expense' },
|
||||||
|
{ code: '5030', name: 'Maintenance Expense', type: 'expense' },
|
||||||
|
{ code: '5040', name: 'Transport Expense', type: 'expense' },
|
||||||
|
{ code: '5050', name: 'Supplies Expense', type: 'expense' },
|
||||||
|
{ code: '5060', name: 'Equipment Expense', type: 'expense' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const a of coaDefs) {
|
||||||
|
await this.tenantDb.chartOfAccount.create({
|
||||||
|
data: { tenantId, code: a.code, name: a.name, type: a.type as any, isSystem: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company Accounts (linked to CoA)
|
||||||
|
const coa1010 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1010' } });
|
||||||
|
const coa1020 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1020' } });
|
||||||
|
const coa1030 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1030' } });
|
||||||
|
const coa1040 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1040' } });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'Cash on Hand', type: 'cash', balance: 0, isSystem: true, chartOfAccountId: coa1010?.id } }),
|
||||||
|
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'GCash Business', type: 'e_wallet', balance: 0, chartOfAccountId: coa1020?.id } }),
|
||||||
|
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'Maya Business', type: 'e_wallet', balance: 0, chartOfAccountId: coa1030?.id } }),
|
||||||
|
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'BDO Savings', type: 'bank', balance: 0, chartOfAccountId: coa1040?.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Billing Settings
|
||||||
|
await this.tenantDb.billingSetting.create({
|
||||||
|
data: { tenantId, autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, invoicePrefix: 'INV' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/users/users.controller.ts
Normal file
35
src/users/users.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Get, Patch, Param, Body, Query } from '@nestjs/common';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly service: UsersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
) {
|
||||||
|
return this.service.findAll({ search, tenantId, page, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.service.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: { isActive?: boolean; firstName?: string; lastName?: string },
|
||||||
|
) {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/reset-password')
|
||||||
|
resetPassword(@Param('id') id: string, @Body() dto: { password: string }) {
|
||||||
|
return this.service.resetPassword(id, dto.password);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/users/users.module.ts
Normal file
9
src/users/users.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
91
src/users/users.service.ts
Normal file
91
src/users/users.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly tenantDb: TenantPrismaService) {}
|
||||||
|
|
||||||
|
async findAll(query: { search?: string; tenantId?: string; page?: number; limit?: number }) {
|
||||||
|
const { search, tenantId, page = 1, limit = 20 } = query;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = { deletedAt: null };
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ email: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (tenantId) where.tenantId = tenantId;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.tenantDb.user.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
roles: { select: { role: true } },
|
||||||
|
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.tenantDb.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const user = await this.tenantDb.user.findUnique({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
roles: { select: { role: true } },
|
||||||
|
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: { isActive?: boolean; firstName?: string; lastName?: string }) {
|
||||||
|
const user = await this.tenantDb.user.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|
||||||
|
return this.tenantDb.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||||
|
...(dto.firstName && { firstName: dto.firstName }),
|
||||||
|
...(dto.lastName && { lastName: dto.lastName }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(id: string, newPassword: string) {
|
||||||
|
const user = await this.tenantDb.user.findUnique({ where: { id, deletedAt: null } });
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(newPassword, 12);
|
||||||
|
return this.tenantDb.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { password: hashed },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"strictPropertyInitialization": false
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user