Add accountant task workflows
This commit is contained in:
parent
49acee6b6a
commit
fa7b4b81be
@ -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;
|
||||
@ -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
|
||||
|
||||
20
src/households/dto/accountant-task.dto.ts
Normal file
20
src/households/dto/accountant-task.dto.ts
Normal 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>;
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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.`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user