Merge dev to main #1

Open
kibin wants to merge 33 commits from dev into main
4 changed files with 784 additions and 744 deletions
Showing only changes of commit 83ad0cb8d5 - Show all commits

View File

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

View File

@@ -1,15 +1,15 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// ─── Multi-Tenant Foundation ──────────────────────────────────────── // ─── Multi-Tenant Foundation ────────────────────────────────
model Tenant { model Tenant {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
slug String @unique slug String @unique
@@ -28,16 +28,16 @@ model Tenant {
invoices Invoice[] invoices Invoice[]
payments Payment[] payments Payment[]
tickets Ticket[] tickets Ticket[]
comments TicketComment[]
notifications Notification[] notifications Notification[]
ticketComments TicketComment[]
@@index([deletedAt]) @@index([deletedAt])
@@map("tenants") @@map("tenants")
} }
// ─── Auth & Users ─────────────────────────────────────────────────── // ─── Auth & Users ───────────────────────────────────────────
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String? // null for super_admin (platform-level user) tenantId String? // null for super_admin (platform-level user)
email String email String
@@ -45,7 +45,6 @@ model User {
firstName String firstName String
lastName String lastName String
isActive Boolean @default(true) isActive Boolean @default(true)
mustChangePassword Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@ -64,22 +63,22 @@ model User {
@@index([tenantId]) @@index([tenantId])
@@index([deletedAt]) @@index([deletedAt])
@@map("users") @@map("users")
} }
model UserRole { model UserRole {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
role String // super_admin (platform-level only, legacy compat) role String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role]) @@unique([userId, role])
@@map("user_roles") @@map("user_roles")
} }
// ─── Tenant-Scoped Role Management ───────────────────────────────── // ─── Tenant-Scoped Role Management ─────────────────────────
model TenantRole { model TenantRole {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
name String name String
@@ -99,12 +98,12 @@ model TenantRole {
@@index([tenantId]) @@index([tenantId])
@@index([deletedAt]) @@index([deletedAt])
@@map("tenant_roles") @@map("tenant_roles")
} }
model RolePermission { model RolePermission {
id String @id @default(uuid()) id String @id @default(uuid())
tenantRoleId String tenantRoleId String
module String // clients, subscriptions, invoices, payments, tickets, employees, expenses, assets, payroll, accounts, accounting, reports, settings, users, areas, plans, dashboard, fund_transfers module String
canView Boolean @default(false) canView Boolean @default(false)
canCreate Boolean @default(false) canCreate Boolean @default(false)
canUpdate Boolean @default(false) canUpdate Boolean @default(false)
@@ -117,9 +116,9 @@ model RolePermission {
@@unique([tenantRoleId, module]) @@unique([tenantRoleId, module])
@@index([tenantRoleId]) @@index([tenantRoleId])
@@map("role_permissions") @@map("role_permissions")
} }
model UserTenantRole { model UserTenantRole {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
tenantRoleId String tenantRoleId String
@@ -131,9 +130,11 @@ model UserTenantRole {
@@index([userId]) @@index([userId])
@@index([tenantRoleId]) @@index([tenantRoleId])
@@map("user_tenant_roles") @@map("user_tenant_roles")
} }
model RefreshToken { // ─── Refresh Token ───────────────────────────────────
model RefreshToken {
id String @id @default(uuid()) id String @id @default(uuid())
token String @unique token String @unique
userId String userId String
@@ -143,11 +144,11 @@ model RefreshToken {
@@index([userId]) @@index([userId])
@@index([expiresAt]) @@index([expiresAt])
@@map("refresh_tokens") @@map("refresh_tokens")
} }
// ─── Area & Zone Management ───────────────────────────────────────── // ─── Area & Zone Management ─────────────────────────────────
model Area { model Area {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
name String name String
@@ -164,11 +165,11 @@ model Area {
@@index([tenantId]) @@index([tenantId])
@@index([deletedAt]) @@index([deletedAt])
@@map("areas") @@map("areas")
} }
// ─── Plan / Package Management ────────────────────────────────────── // ─── Plan / Package Management ─────────────────────────
model Plan { model Plan {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
name String name String
@@ -189,11 +190,11 @@ model Plan {
@@index([tenantId]) @@index([tenantId])
@@index([deletedAt]) @@index([deletedAt])
@@map("plans") @@map("plans")
} }
// ─── Client Profiling ─────────────────────────────────────────────── // ─── Client Profiling ─────────────────────────────────
model Client { model Client {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
accountNumber String accountNumber String
@@ -202,10 +203,10 @@ model Client {
email String? email String?
phone String? phone String?
address String address String
areaId String?
status String @default("active") // active, inactive, suspended
latitude Float? latitude Float?
longitude Float? longitude Float?
areaId String?
status String @default("active") // active, inactive, suspended
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@ -222,11 +223,11 @@ model Client {
@@index([tenantId, status]) @@index([tenantId, status])
@@index([deletedAt]) @@index([deletedAt])
@@map("clients") @@map("clients")
} }
// ─── Subscription Management ──────────────────────────────────────── // ─── Subscription Management ─────────────────────────
model Subscription { model Subscription {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
clientId String clientId String
@@ -250,11 +251,11 @@ model Subscription {
@@index([clientId]) @@index([clientId])
@@index([deletedAt]) @@index([deletedAt])
@@map("subscriptions") @@map("subscriptions")
} }
// ─── Billing & Invoicing ──────────────────────────────────────────── // ─── Billing & Invoicing ───────────────────────────────────
model Invoice { model Invoice {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
clientId String clientId String
@@ -280,11 +281,11 @@ model Invoice {
@@index([clientId]) @@index([clientId])
@@index([deletedAt]) @@index([deletedAt])
@@map("invoices") @@map("invoices")
} }
// ─── Payment & Collection ─────────────────────────────────────────── // ─── Payment & Collection ─────────────────────────────────
model Payment { model Payment {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
clientId String clientId String
@@ -307,9 +308,11 @@ model Payment {
@@index([invoiceId]) @@index([invoiceId])
@@index([deletedAt]) @@index([deletedAt])
@@map("payments") @@map("payments")
} }
model Remittance { // ─── Remittance ─────────────────────────────────────────
model Remittance {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
collectorId String collectorId String
@@ -329,9 +332,9 @@ model Remittance {
@@index([collectorId]) @@index([collectorId])
@@index([deletedAt]) @@index([deletedAt])
@@map("remittances") @@map("remittances")
} }
model RemittancePayment { model RemittancePayment {
id String @id @default(uuid()) id String @id @default(uuid())
remittanceId String remittanceId String
paymentId String paymentId String
@@ -340,11 +343,11 @@ model RemittancePayment {
@@unique([remittanceId, paymentId]) @@unique([remittanceId, paymentId])
@@map("remittance_payments") @@map("remittance_payments")
} }
// ─── Tickets (Work Orders) ────────────────────────────────────────── // ─── Tickets (Work Orders) ─────────────────────────────────
model Ticket { model Ticket {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
clientId String? clientId String?
@@ -374,11 +377,11 @@ model Ticket {
@@index([assigneeId]) @@index([assigneeId])
@@index([deletedAt]) @@index([deletedAt])
@@map("tickets") @@map("tickets")
} }
// ─── Notifications ────────────────────────────────────────────────── // ─── Notifications ─────────────────────────────────────────
model Notification { model Notification {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
userId String? userId String?
@@ -397,11 +400,11 @@ model Notification {
@@index([tenantId]) @@index([tenantId])
@@index([userId, isRead]) @@index([userId, isRead])
@@map("notifications") @@map("notifications")
} }
// ─── Ticket Comments ────────────────────────────────────────────────── // ─── Ticket Comments ─────────────────────────────────
model TicketComment { model TicketComment {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
ticketId String ticketId String
@@ -410,17 +413,18 @@ model TicketComment {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation("CommentAuthor", fields: [userId], references: [id]) author User @relation("CommentAuthor", fields: [userId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
attachments TicketAttachment[] attachments TicketAttachment[]
@@index([ticketId]) @@index([ticketId])
@@index([tenantId])
@@map("ticket_comments") @@map("ticket_comments")
} }
model TicketAttachment { // ─── Ticket Attachments ─────────────────────────────────
model TicketAttachment {
id String @id @default(uuid()) id String @id @default(uuid())
commentId String commentId String
fileName String fileName String
@@ -432,11 +436,11 @@ model TicketAttachment {
comment TicketComment @relation(fields: [commentId], references: [id], onDelete: Cascade) comment TicketComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@map("ticket_attachments") @@map("ticket_attachments")
} }
// ─── Audit Log (append-only, no soft delete) ──────────────────────── // ─── Audit Log (append-only, no soft delete) ─────────────────
model AuditLog { model AuditLog {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
userId String userId String
@@ -451,11 +455,11 @@ model AuditLog {
@@index([tenantId, entity]) @@index([tenantId, entity])
@@index([userId]) @@index([userId])
@@map("audit_logs") @@map("audit_logs")
} }
// ─── Employee Management ──────────────────────────────────────────── // ─── Employee Management ─────────────────────────
model Employee { model Employee {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
userId String? @unique // Linked user account (optional, 1:1) userId String? @unique // Linked user account (optional, 1:1)
@@ -483,11 +487,11 @@ model Employee {
@@index([tenantId, status]) @@index([tenantId, status])
@@index([deletedAt]) @@index([deletedAt])
@@map("employees") @@map("employees")
} }
// ─── Payroll ──────────────────────────────────────────────────────── // ─── Payroll ─────────────────────────────────────────────
model PayrollRun { model PayrollRun {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
period String period String
@@ -506,9 +510,9 @@ model PayrollRun {
@@index([tenantId]) @@index([tenantId])
@@index([deletedAt]) @@index([deletedAt])
@@map("payroll_runs") @@map("payroll_runs")
} }
model Payslip { model Payslip {
id String @id @default(uuid()) id String @id @default(uuid())
payrollRunId String payrollRunId String
employeeId String employeeId String
@@ -519,18 +523,17 @@ model Payslip {
status String @default("pending") // pending, paid status String @default("pending") // pending, paid
notes String? notes String?
payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id], onDelete: Cascade) payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id])
employee Employee @relation(fields: [employeeId], references: [id]) employee Employee @relation(fields: [employeeId], references: [id])
@@unique([payrollRunId, employeeId]) @@unique([payrollRunId, employeeId])
@@index([payrollRunId]) @@index([payrollRunId])
@@index([employeeId])
@@map("payslips") @@map("payslips")
} }
// ─── Recurring Expenses ───────────────────────────────────────────── // ─── Recurring Expenses ─────────────────────────
model RecurringExpense { model RecurringExpense {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
category String category String
@@ -547,11 +550,11 @@ model RecurringExpense {
@@index([tenantId]) @@index([tenantId])
@@index([deletedAt]) @@index([deletedAt])
@@map("recurring_expenses") @@map("recurring_expenses")
} }
// ─── Expense Management ───────────────────────────────────────────── // ─── Expense Management ─────────────────────────
model Expense { model Expense {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
createdById String createdById String
@@ -573,11 +576,11 @@ model Expense {
@@index([tenantId, category]) @@index([tenantId, category])
@@index([deletedAt]) @@index([deletedAt])
@@map("expenses") @@map("expenses")
} }
// ─── Company Accounts & Fund Transfers ────────────────────────────── // ─── Company Accounts & Fund Transfers ─────────────────
model CompanyAccount { model CompanyAccount {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
name String name String
@@ -596,11 +599,10 @@ model CompanyAccount {
@@unique([tenantId, name]) @@unique([tenantId, name])
@@index([tenantId]) @@index([tenantId])
@@index([deletedAt])
@@map("company_accounts") @@map("company_accounts")
} }
model FundTransfer { model FundTransfer {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
fromAccountId String fromAccountId String
@@ -615,11 +617,11 @@ model FundTransfer {
@@index([tenantId]) @@index([tenantId])
@@map("fund_transfers") @@map("fund_transfers")
} }
// ─── Asset Management ─────────────────────────────────────────────── // ─── Asset Management ─────────────────────────────────
model Asset { model Asset {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
name String name String
@@ -642,11 +644,11 @@ model Asset {
@@index([tenantId, category]) @@index([tenantId, category])
@@index([deletedAt]) @@index([deletedAt])
@@map("assets") @@map("assets")
} }
// ─── Billing Settings ─────────────────────────────────────────────── // ─── Billing Settings ─────────────────────────
model BillingSetting { model BillingSetting {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String @unique tenantId String @unique
autoGenerate Boolean @default(true) autoGenerate Boolean @default(true)
@@ -658,11 +660,11 @@ model BillingSetting {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("billing_settings") @@map("billing_settings")
} }
// ─── Chart of Accounts ────────────────────────────────────────────── // ─── Chart of Accounts ─────────────────────────
model ChartOfAccount { model ChartOfAccount {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
code String code String
@@ -685,11 +687,11 @@ model ChartOfAccount {
@@index([tenantId, type]) @@index([tenantId, type])
@@index([deletedAt]) @@index([deletedAt])
@@map("chart_of_accounts") @@map("chart_of_accounts")
} }
// ─── Journal Entries (append-only, no soft delete) ────────────────── // ─── Journal Entries (append-only, no soft delete) ─────────
model JournalEntry { model JournalEntry {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
entryDate DateTime @default(now()) entryDate DateTime @default(now())
@@ -705,19 +707,19 @@ model JournalEntry {
@@index([tenantId]) @@index([tenantId])
@@index([tenantId, sourceType, sourceId]) @@index([tenantId, sourceType, sourceId])
@@map("journal_entries") @@map("journal_entries")
} }
model JournalLine { model JournalLine {
id String @id @default(uuid()) id String @id @default(uuid())
journalEntryId String journalEntryId String
accountId String accountId String
debit Decimal @default(0) @db.Decimal(14, 2) debit Decimal @default(0) @db.Decimal(14, 2)
credit Decimal @default(0) @db.Decimal(14, 2) credit Decimal @default(0) @db.Decimal(14, 2)
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade) journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id])
account ChartOfAccount @relation(fields: [accountId], references: [id]) account ChartOfAccount @relation(fields: [accountId], references: [id])
@@index([journalEntryId]) @@index([journalEntryId])
@@index([accountId]) @@index([accountId])
@@map("journal_lines") @@map("journal_lines")
} }

View File

@@ -14,24 +14,28 @@ export class DashboardController {
@Get('kpis') @Get('kpis')
@Roles('manager') @Roles('manager')
async getKpis(@CurrentUser() user: CurrentUserPayload) { async getKpis(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getKpis(user.tenantId); const data = await this.dashboardService.getKpis(user.tenantId);
return { data };
} }
@Get('revenue-chart') @Get('revenue-chart')
@Roles('manager') @Roles('manager')
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) { async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getRevenueChart(user.tenantId); const data = await this.dashboardService.getRevenueChart(user.tenantId);
return { data };
} }
@Get('activity') @Get('activity')
@Roles('manager') @Roles('manager')
async getActivity(@CurrentUser() user: CurrentUserPayload) { async getActivity(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getRecentActivity(user.tenantId); const data = await this.dashboardService.getRecentActivity(user.tenantId);
return { data };
} }
@Get('financial-summary') @Get('financial-summary')
@Roles('manager') @Roles('manager')
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) { async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getFinancialSummary(user.tenantId); const data = await this.dashboardService.getFinancialSummary(user.tenantId);
return { data };
} }
} }

View File

@@ -4,9 +4,9 @@ import {
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
import { CreateTicketDto } from './dto/create-ticket.dto'; import { CreateTicketDto } from './dto/create-ticket.dto';
import { UpdateTicketDto } from './dto/update-ticket.dto'; import { UpdateTicketDto } from './dto/update-ticket.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
export interface TicketResolvedEvent { export interface TicketResolvedEvent {
ticketId: string; ticketId: string;
@@ -21,10 +21,7 @@ export class TicketService {
onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null = onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null =
null; null;
constructor( constructor(private readonly prisma: PrismaService) {}
private readonly prisma: PrismaService,
private readonly notificationService: NotificationService,
) {}
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) { async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) {
const db = this.prisma.forTenant(tenantId); const db = this.prisma.forTenant(tenantId);
@@ -115,18 +112,6 @@ export class TicketService {
}); });
} }
// Notify newly assigned user
if (dto.assigneeId && dto.assigneeId !== existing.assigneeId) {
this.notificationService.create(tenantId, {
userId: dto.assigneeId,
type: 'in_app',
channel: 'ticket_assigned',
title: 'Ticket assigned to you',
message: `You were assigned to "${existing.title}"`,
ticketId: id,
}).catch(() => {}); // non-blocking
}
return this.prisma.ticket.update({ return this.prisma.ticket.update({
where: { id }, where: { id },
data: { data: {
@@ -180,4 +165,50 @@ export class TicketService {
return resolved; return resolved;
} }
// Comments
async getComments(tenantId: string, ticketId: string) {
const db = this.prisma.forTenant(tenantId);
const comments = await db.ticketComment.findMany({
where: { ticketId },
include: {
author: {
select: { id: true, firstName: true, lastName: true },
},
},
orderBy: { createdAt: 'asc' },
});
return comments.map((c: any) => ({
...c,
createdByName: c.author
? `${c.author.firstName} ${c.author.lastName}`
: 'Unknown',
}));
}
async addComment(
tenantId: string,
ticketId: string,
userId: string,
dto: CreateCommentDto,
) {
const db = this.prisma.forTenant(tenantId);
const ticket = await db.ticket.findFirst({
where: { id: ticketId },
});
if (!ticket) {
throw new NotFoundException('Ticket not found');
}
return db.ticketComment.create({
data: {
tenantId,
ticketId,
userId,
content: dto.content,
},
});
}
} }