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[]
|
householdMemberships HouseholdMember[]
|
||||||
sentHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteInviter")
|
sentHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteInviter")
|
||||||
acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter")
|
acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter")
|
||||||
|
createdAccountantTasks AccountantTask[] @relation("AccountantTaskCreator")
|
||||||
|
assignedAccountantTasks AccountantTask[] @relation("AccountantTaskAssignee")
|
||||||
ownedAccounts Account[] @relation("AccountOwnerUser")
|
ownedAccounts Account[] @relation("AccountOwnerUser")
|
||||||
createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator")
|
createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator")
|
||||||
personalGoals PersonalGoal[]
|
personalGoals PersonalGoal[]
|
||||||
@ -209,10 +211,36 @@ model Household {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
goals HouseholdGoal[]
|
goals HouseholdGoal[]
|
||||||
budgets HouseholdBudget[]
|
budgets HouseholdBudget[]
|
||||||
|
accountantTasks AccountantTask[]
|
||||||
|
|
||||||
@@index([createdByUserId, createdAt])
|
@@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 {
|
model HouseholdBudget {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
householdId String
|
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 { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||||
import { ok } from "../common/response";
|
import { ok } from "../common/response";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
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 { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
||||||
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
||||||
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
||||||
@ -83,6 +84,30 @@ export class HouseholdsController {
|
|||||||
return ok(await this.householdsService.calculateFutureScenarios(userId, id, payload));
|
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")
|
@Get(":id/goals")
|
||||||
async goals(@CurrentUser() userId: string, @Param("id") id: string) {
|
async goals(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||||
return ok(await this.householdsService.listGoals(userId, id));
|
return ok(await this.householdsService.listGoals(userId, id));
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import * as crypto from "crypto";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { EmailService } from "../email/email.service";
|
import { EmailService } from "../email/email.service";
|
||||||
|
import { CreateAccountantTaskDto, UpdateAccountantTaskDto } from "./dto/accountant-task.dto";
|
||||||
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
||||||
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
||||||
import { CreateHouseholdDto } from "./dto/create-household.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) {
|
async listGoals(userId: string, householdId: string) {
|
||||||
await this.requireActiveMember(userId, householdId);
|
await this.requireActiveMember(userId, householdId);
|
||||||
const goals = await this.prisma.householdGoal.findMany({
|
const goals = await this.prisma.householdGoal.findMany({
|
||||||
@ -864,6 +969,22 @@ export class HouseholdsService {
|
|||||||
return membership;
|
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) {
|
private hashInviteToken(token: string) {
|
||||||
return crypto.createHash("sha256").update(token).digest("hex");
|
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) {
|
private requiredTrim(value: string, label: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) throw new BadRequestException(`${label} is required.`);
|
if (!trimmed) throw new BadRequestException(`${label} is required.`);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user