Add accountant task workflows

This commit is contained in:
MOHAN 2026-07-17 23:28:59 +05:30
parent 49acee6b6a
commit fa7b4b81be
5 changed files with 248 additions and 0 deletions

View File

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

View File

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

View File

@ -0,0 +1,20 @@
export class CreateAccountantTaskDto {
title!: string;
description?: string;
taskType?: string;
assignedToUserId?: string;
priority?: string;
dueDate?: string;
metadata?: Record<string, unknown>;
}
export class UpdateAccountantTaskDto {
title?: string;
description?: string | null;
taskType?: string;
assignedToUserId?: string | null;
priority?: string;
dueDate?: string | null;
status?: string;
metadata?: Record<string, unknown>;
}

View File

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

View File

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