From fa7b4b81be6c0a7737568c31a37d5e3a2941caf9 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Fri, 17 Jul 2026 23:28:59 +0530 Subject: [PATCH] Add accountant task workflows --- .../migration.sql | 27 ++++ prisma/schema.prisma | 28 ++++ src/households/dto/accountant-task.dto.ts | 20 +++ src/households/households.controller.ts | 25 +++ src/households/households.service.ts | 148 ++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 prisma/migrations/20260717173000_accountant_tasks/migration.sql create mode 100644 src/households/dto/accountant-task.dto.ts diff --git a/prisma/migrations/20260717173000_accountant_tasks/migration.sql b/prisma/migrations/20260717173000_accountant_tasks/migration.sql new file mode 100644 index 0000000..1059c4f --- /dev/null +++ b/prisma/migrations/20260717173000_accountant_tasks/migration.sql @@ -0,0 +1,27 @@ +-- Add household-scoped accountant/advisor workflow tasks. +CREATE TABLE "AccountantTask" ( + "id" TEXT NOT NULL, + "householdId" TEXT NOT NULL, + "createdByUserId" TEXT NOT NULL, + "assignedToUserId" TEXT, + "title" TEXT NOT NULL, + "description" TEXT, + "taskType" TEXT NOT NULL DEFAULT 'review', + "status" TEXT NOT NULL DEFAULT 'open', + "priority" TEXT NOT NULL DEFAULT 'medium', + "dueDate" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AccountantTask_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "AccountantTask_householdId_status_idx" ON "AccountantTask"("householdId", "status"); +CREATE INDEX "AccountantTask_assignedToUserId_status_idx" ON "AccountantTask"("assignedToUserId", "status"); +CREATE INDEX "AccountantTask_dueDate_idx" ON "AccountantTask"("dueDate"); + +ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AccountantTask" ADD CONSTRAINT "AccountantTask_assignedToUserId_fkey" FOREIGN KEY ("assignedToUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14d8a54..3234165 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,8 @@ model User { householdMemberships HouseholdMember[] sentHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteInviter") acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter") + createdAccountantTasks AccountantTask[] @relation("AccountantTaskCreator") + assignedAccountantTasks AccountantTask[] @relation("AccountantTaskAssignee") ownedAccounts Account[] @relation("AccountOwnerUser") createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator") personalGoals PersonalGoal[] @@ -209,10 +211,36 @@ model Household { accounts Account[] goals HouseholdGoal[] budgets HouseholdBudget[] + accountantTasks AccountantTask[] @@index([createdByUserId, createdAt]) } +model AccountantTask { + id String @id @default(uuid()) + householdId String + createdByUserId String + assignedToUserId String? + title String + description String? + taskType String @default("review") + status String @default("open") + priority String @default("medium") + dueDate DateTime? + completedAt DateTime? + metadata Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) + createdBy User @relation("AccountantTaskCreator", fields: [createdByUserId], references: [id], onDelete: Cascade) + assignedTo User? @relation("AccountantTaskAssignee", fields: [assignedToUserId], references: [id], onDelete: SetNull) + + @@index([householdId, status]) + @@index([assignedToUserId, status]) + @@index([dueDate]) +} + model HouseholdBudget { id String @id @default(uuid()) householdId String diff --git a/src/households/dto/accountant-task.dto.ts b/src/households/dto/accountant-task.dto.ts new file mode 100644 index 0000000..c3ad4bb --- /dev/null +++ b/src/households/dto/accountant-task.dto.ts @@ -0,0 +1,20 @@ +export class CreateAccountantTaskDto { + title!: string; + description?: string; + taskType?: string; + assignedToUserId?: string; + priority?: string; + dueDate?: string; + metadata?: Record; +} + +export class UpdateAccountantTaskDto { + title?: string; + description?: string | null; + taskType?: string; + assignedToUserId?: string | null; + priority?: string; + dueDate?: string | null; + status?: string; + metadata?: Record; +} diff --git a/src/households/households.controller.ts b/src/households/households.controller.ts index 00e80e3..0e4f354 100644 --- a/src/households/households.controller.ts +++ b/src/households/households.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common"; import { ok } from "../common/response"; import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { CreateAccountantTaskDto, UpdateAccountantTaskDto } from "./dto/accountant-task.dto"; import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto"; import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto"; import { CreateHouseholdDto } from "./dto/create-household.dto"; @@ -83,6 +84,30 @@ export class HouseholdsController { return ok(await this.householdsService.calculateFutureScenarios(userId, id, payload)); } + @Get(":id/accountant-tasks") + async accountantTasks(@CurrentUser() userId: string, @Param("id") id: string) { + return ok(await this.householdsService.listAccountantTasks(userId, id)); + } + + @Post(":id/accountant-tasks") + async createAccountantTask( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: CreateAccountantTaskDto, + ) { + return ok(await this.householdsService.createAccountantTask(userId, id, payload)); + } + + @Patch(":id/accountant-tasks/:taskId") + async updateAccountantTask( + @CurrentUser() userId: string, + @Param("id") id: string, + @Param("taskId") taskId: string, + @Body() payload: UpdateAccountantTaskDto, + ) { + return ok(await this.householdsService.updateAccountantTask(userId, id, taskId, payload)); + } + @Get(":id/goals") async goals(@CurrentUser() userId: string, @Param("id") id: string) { return ok(await this.householdsService.listGoals(userId, id)); diff --git a/src/households/households.service.ts b/src/households/households.service.ts index 9d31d9d..65e35f4 100644 --- a/src/households/households.service.ts +++ b/src/households/households.service.ts @@ -3,6 +3,7 @@ import * as crypto from "crypto"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { EmailService } from "../email/email.service"; +import { CreateAccountantTaskDto, UpdateAccountantTaskDto } from "./dto/accountant-task.dto"; import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto"; import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto"; import { CreateHouseholdDto } from "./dto/create-household.dto"; @@ -548,6 +549,110 @@ export class HouseholdsService { }; } + async listAccountantTasks(userId: string, householdId: string) { + await this.requireAccountantWorkflowAccess(userId, householdId); + const tasks = await (this.prisma as any).accountantTask.findMany({ + where: { householdId }, + orderBy: [{ status: "asc" }, { dueDate: "asc" }, { createdAt: "desc" }], + include: { + createdBy: { select: { id: true, email: true, fullName: true } }, + assignedTo: { select: { id: true, email: true, fullName: true } }, + }, + }); + return tasks.map((task: any) => this.serializeAccountantTask(task)); + } + + async createAccountantTask(userId: string, householdId: string, payload: CreateAccountantTaskDto) { + await this.requireAccountantWorkflowAccess(userId, householdId); + const assignedToUserId = payload.assignedToUserId?.trim() || null; + if (assignedToUserId) { + await this.requireAssignableHouseholdMember(householdId, assignedToUserId); + } + + const task = await (this.prisma as any).accountantTask.create({ + data: { + householdId, + createdByUserId: userId, + assignedToUserId, + title: this.requiredTrim(payload.title, "Task title"), + description: payload.description?.trim() || null, + taskType: payload.taskType?.trim() || "review", + priority: payload.priority?.trim() || "medium", + dueDate: payload.dueDate ? this.parseDate(payload.dueDate, "Due date") : null, + metadata: (payload.metadata ?? {}) as Prisma.InputJsonValue, + }, + include: { + createdBy: { select: { id: true, email: true, fullName: true } }, + assignedTo: { select: { id: true, email: true, fullName: true } }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + userId, + action: "household.accountant_task.create", + metadata: { + householdId, + taskId: task.id, + taskType: task.taskType, + assignedToUserId, + }, + }, + }); + + return this.serializeAccountantTask(task); + } + + async updateAccountantTask(userId: string, householdId: string, taskId: string, payload: UpdateAccountantTaskDto) { + if (!Object.keys(payload).length) { + throw new BadRequestException("At least one task field is required."); + } + await this.requireAccountantWorkflowAccess(userId, householdId); + const existing = await (this.prisma as any).accountantTask.findFirst({ where: { id: taskId, householdId } }); + if (!existing) throw new BadRequestException("Accountant task not found."); + + const data: Record = {}; + if (payload.title !== undefined) data.title = this.requiredTrim(payload.title, "Task title"); + if (payload.description !== undefined) data.description = payload.description?.trim() || null; + if (payload.taskType !== undefined) data.taskType = payload.taskType.trim() || "review"; + if (payload.priority !== undefined) data.priority = payload.priority.trim() || "medium"; + if (payload.dueDate !== undefined) data.dueDate = payload.dueDate ? this.parseDate(payload.dueDate, "Due date") : null; + if (payload.metadata !== undefined) data.metadata = payload.metadata as Prisma.InputJsonValue; + if (payload.assignedToUserId !== undefined) { + const assignedToUserId = payload.assignedToUserId?.trim() || null; + if (assignedToUserId) await this.requireAssignableHouseholdMember(householdId, assignedToUserId); + data.assignedToUserId = assignedToUserId; + } + if (payload.status !== undefined) { + const status = payload.status.trim() || "open"; + data.status = status; + data.completedAt = status === "completed" ? new Date() : null; + } + + const updated = await (this.prisma as any).accountantTask.update({ + where: { id: taskId }, + data, + include: { + createdBy: { select: { id: true, email: true, fullName: true } }, + assignedTo: { select: { id: true, email: true, fullName: true } }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + userId, + action: "household.accountant_task.update", + metadata: { + householdId, + taskId, + status: updated.status, + }, + }, + }); + + return this.serializeAccountantTask(updated); + } + async listGoals(userId: string, householdId: string) { await this.requireActiveMember(userId, householdId); const goals = await this.prisma.householdGoal.findMany({ @@ -864,6 +969,22 @@ export class HouseholdsService { return membership; } + private async requireAccountantWorkflowAccess(userId: string, householdId: string) { + const membership = await this.requireActiveMember(userId, householdId); + if (!["owner", "admin", "accountant", "advisor"].includes(membership.role)) { + throw new ForbiddenException("Only owners, admins, accountants, and advisors can manage accountant workflows."); + } + return membership; + } + + private async requireAssignableHouseholdMember(householdId: string, userId: string) { + const membership = await this.prisma.householdMember.findFirst({ + where: { householdId, userId, status: "active" }, + }); + if (!membership) throw new BadRequestException("Assigned user must be an active household member."); + return membership; + } + private hashInviteToken(token: string) { return crypto.createHash("sha256").update(token).digest("hex"); } @@ -1557,6 +1678,33 @@ export class HouseholdsService { }; } + private serializeAccountantTask(task: any) { + return { + id: task.id, + householdId: task.householdId, + title: task.title, + description: task.description, + taskType: task.taskType, + status: task.status, + priority: task.priority, + dueDate: task.dueDate, + completedAt: task.completedAt, + metadata: task.metadata ?? {}, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + createdBy: task.createdBy, + assignedTo: task.assignedTo, + }; + } + + private parseDate(value: string, label: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`${label} must be a valid date.`); + } + return date; + } + private requiredTrim(value: string, label: string) { const trimmed = value.trim(); if (!trimmed) throw new BadRequestException(`${label} is required.`);