feat: add household shared goals

This commit is contained in:
MOHAN 2026-07-16 15:33:52 +05:30
parent ade95e6fdf
commit 5794203c80
8 changed files with 463 additions and 0 deletions

View File

@ -0,0 +1,25 @@
CREATE TABLE "HouseholdGoal" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"targetAmount" DECIMAL(18, 2) NOT NULL,
"currentAmount" DECIMAL(18, 2) NOT NULL DEFAULT 0,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"targetDate" TIMESTAMP(3),
"priority" TEXT NOT NULL DEFAULT 'medium',
"status" TEXT NOT NULL DEFAULT 'active',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HouseholdGoal_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "HouseholdGoal_householdId_status_idx" ON "HouseholdGoal"("householdId", "status");
CREATE INDEX "HouseholdGoal_createdByUserId_createdAt_idx" ON "HouseholdGoal"("createdByUserId", "createdAt");
CREATE INDEX "HouseholdGoal_targetDate_idx" ON "HouseholdGoal"("targetDate");
ALTER TABLE "HouseholdGoal" ADD CONSTRAINT "HouseholdGoal_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HouseholdGoal" ADD CONSTRAINT "HouseholdGoal_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -48,6 +48,7 @@ model User {
sentHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteInviter")
acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter")
ownedAccounts Account[] @relation("AccountOwnerUser")
createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator")
}
model Household {
@ -62,10 +63,35 @@ model Household {
members HouseholdMember[]
invites HouseholdInvite[]
accounts Account[]
goals HouseholdGoal[]
@@index([createdByUserId, createdAt])
}
model HouseholdGoal {
id String @id @default(uuid())
householdId String
createdByUserId String
name String
description String?
targetAmount Decimal
currentAmount Decimal @default(0)
isoCurrencyCode String @default("USD")
targetDate DateTime?
priority String @default("medium")
status String @default("active")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
createdBy User @relation("HouseholdGoalCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
@@index([householdId, status])
@@index([createdByUserId, createdAt])
@@index([targetDate])
}
model HouseholdMember {
id String @id @default(uuid())
householdId String

View File

@ -0,0 +1,38 @@
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
export class CreateHouseholdGoalDto {
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsNumber()
@Min(0.01)
targetAmount!: number;
@IsOptional()
@IsNumber()
@Min(0)
currentAmount?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
targetDate?: string;
@IsOptional()
@IsIn(["low", "medium", "high"])
priority?: "low" | "medium" | "high";
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@ -0,0 +1,44 @@
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
export class UpdateHouseholdGoalDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@IsOptional()
@IsNumber()
@Min(0.01)
targetAmount?: number;
@IsOptional()
@IsNumber()
@Min(0)
currentAmount?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
targetDate?: string | null;
@IsOptional()
@IsIn(["low", "medium", "high"])
priority?: "low" | "medium" | "high";
@IsOptional()
@IsIn(["active", "paused", "completed", "archived"])
status?: "active" | "paused" | "completed" | "archived";
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@ -2,8 +2,10 @@ import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
import { ok } from "../common/response";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
import { CreateHouseholdDto } from "./dto/create-household.dto";
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
import { HouseholdsService } from "./households.service";
@ -36,6 +38,30 @@ export class HouseholdsController {
return ok(await this.householdsService.listMembers(userId, id));
}
@Get(":id/goals")
async goals(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listGoals(userId, id));
}
@Post(":id/goals")
async createGoal(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: CreateHouseholdGoalDto,
) {
return ok(await this.householdsService.createGoal(userId, id, payload));
}
@Patch(":id/goals/:goalId")
async updateGoal(
@CurrentUser() userId: string,
@Param("id") id: string,
@Param("goalId") goalId: string,
@Body() payload: UpdateHouseholdGoalDto,
) {
return ok(await this.householdsService.updateGoal(userId, id, goalId, payload));
}
@Patch(":id/members/:memberId")
async updateMember(
@CurrentUser() userId: string,

View File

@ -4,8 +4,10 @@ import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { EmailService } from "../email/email.service";
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
import { CreateHouseholdDto } from "./dto/create-household.dto";
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
@Injectable()
@ -247,6 +249,121 @@ export class HouseholdsService {
});
}
async listGoals(userId: string, householdId: string) {
await this.requireActiveMember(userId, householdId);
const goals = await this.prisma.householdGoal.findMany({
where: { householdId },
include: {
createdBy: {
select: { id: true, email: true, fullName: true },
},
},
orderBy: [{ status: "asc" }, { priority: "desc" }, { targetDate: "asc" }, { createdAt: "desc" }],
});
return goals.map((goal: any) => this.serializeGoal(goal));
}
async createGoal(userId: string, householdId: string, payload: CreateHouseholdGoalDto) {
await this.requireManager(userId, householdId);
const targetAmount = this.positiveMoney(payload.targetAmount, "Target amount");
const currentAmount = this.nonNegativeMoney(payload.currentAmount ?? 0, "Current amount");
if (currentAmount > targetAmount) {
throw new BadRequestException("Current amount cannot exceed target amount.");
}
const goal = await this.prisma.householdGoal.create({
data: {
householdId,
createdByUserId: userId,
name: this.requiredTrim(payload.name, "Goal name"),
description: payload.description?.trim() || null,
targetAmount,
currentAmount,
isoCurrencyCode: (payload.isoCurrencyCode ?? "USD").toUpperCase(),
targetDate: payload.targetDate ? new Date(payload.targetDate) : null,
priority: payload.priority ?? "medium",
status: "active",
metadata: (payload.metadata ?? {}) as Prisma.InputJsonValue,
},
include: {
createdBy: {
select: { id: true, email: true, fullName: true },
},
},
});
await this.prisma.auditLog.create({
data: {
userId,
action: "household.goal.create",
metadata: {
householdId,
goalId: goal.id,
targetAmount,
},
},
});
return this.serializeGoal(goal);
}
async updateGoal(userId: string, householdId: string, goalId: string, payload: UpdateHouseholdGoalDto) {
if (!Object.keys(payload).length) {
throw new BadRequestException("At least one goal field is required.");
}
await this.requireManager(userId, householdId);
const existing = await this.prisma.householdGoal.findFirst({
where: { id: goalId, householdId },
});
if (!existing) throw new BadRequestException("Household goal not found.");
const targetAmount = payload.targetAmount !== undefined
? this.positiveMoney(payload.targetAmount, "Target amount")
: this.toNumber((existing as any).targetAmount);
const currentAmount = payload.currentAmount !== undefined
? this.nonNegativeMoney(payload.currentAmount, "Current amount")
: this.toNumber((existing as any).currentAmount);
if (currentAmount > targetAmount) {
throw new BadRequestException("Current amount cannot exceed target amount.");
}
const updated = await this.prisma.householdGoal.update({
where: { id: goalId },
data: {
...(payload.name !== undefined && { name: this.requiredTrim(payload.name, "Goal name") }),
...(payload.description !== undefined && { description: payload.description?.trim() || null }),
...(payload.targetAmount !== undefined && { targetAmount }),
...(payload.currentAmount !== undefined && { currentAmount }),
...(payload.isoCurrencyCode !== undefined && { isoCurrencyCode: payload.isoCurrencyCode.toUpperCase() }),
...(payload.targetDate !== undefined && { targetDate: payload.targetDate ? new Date(payload.targetDate) : null }),
...(payload.priority !== undefined && { priority: payload.priority }),
...(payload.status !== undefined && { status: payload.status }),
...(payload.metadata !== undefined && { metadata: payload.metadata as Prisma.InputJsonValue }),
},
include: {
createdBy: {
select: { id: true, email: true, fullName: true },
},
},
});
await this.prisma.auditLog.create({
data: {
userId,
action: "household.goal.update",
metadata: {
householdId,
goalId,
status: updated.status,
currentAmount: this.toNumber((updated as any).currentAmount),
targetAmount: this.toNumber((updated as any).targetAmount),
},
},
});
return this.serializeGoal(updated);
}
async updateMember(userId: string, householdId: string, memberId: string, payload: UpdateHouseholdMemberDto) {
if (payload.role === undefined && payload.status === undefined) {
throw new BadRequestException("Role or status is required.");
@ -532,4 +649,49 @@ export class HouseholdsService {
yoursAmount: this.roundCurrency(amount * (yoursPercent / 100)),
};
}
private serializeGoal(goal: any) {
const targetAmount = this.toNumber(goal.targetAmount);
const currentAmount = this.toNumber(goal.currentAmount);
return {
id: goal.id,
householdId: goal.householdId,
name: goal.name,
description: goal.description,
targetAmount: this.roundCurrency(targetAmount),
currentAmount: this.roundCurrency(currentAmount),
remainingAmount: this.roundCurrency(Math.max(targetAmount - currentAmount, 0)),
progressPercent: targetAmount > 0 ? this.roundCurrency(Math.min((currentAmount / targetAmount) * 100, 100)) : 0,
isoCurrencyCode: goal.isoCurrencyCode,
targetDate: goal.targetDate,
priority: goal.priority,
status: goal.status,
metadata: goal.metadata ?? {},
createdAt: goal.createdAt,
updatedAt: goal.updatedAt,
createdBy: goal.createdBy,
};
}
private requiredTrim(value: string, label: string) {
const trimmed = value.trim();
if (!trimmed) throw new BadRequestException(`${label} is required.`);
return trimmed;
}
private positiveMoney(value: number, label: string) {
const amount = Number(value);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException(`${label} must be greater than zero.`);
}
return this.roundCurrency(amount);
}
private nonNegativeMoney(value: number, label: string) {
const amount = Number(value);
if (!Number.isFinite(amount) || amount < 0) {
throw new BadRequestException(`${label} must be zero or greater.`);
}
return this.roundCurrency(amount);
}
}

View File

@ -207,6 +207,142 @@ describe("HouseholdsService", () => {
});
});
it("lists shared household goals for active members", async () => {
const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "member", status: "active" });
prisma.householdGoal.findMany.mockResolvedValue([
{
id: "goal_1",
householdId: "household_1",
name: "Emergency Fund",
description: "Six months of expenses",
targetAmount: "10000",
currentAmount: "2500",
isoCurrencyCode: "USD",
targetDate: new Date("2026-12-31T00:00:00.000Z"),
priority: "high",
status: "active",
metadata: {},
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
createdBy: { id: "user_1", email: "owner@example.com", fullName: "Owner User" },
},
]);
const result = await service.listGoals("user_1", "household_1");
expect(result[0]).toEqual(expect.objectContaining({
id: "goal_1",
targetAmount: 10000,
currentAmount: 2500,
remainingAmount: 7500,
progressPercent: 25,
}));
expect(prisma.householdGoal.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { householdId: "household_1" },
}));
});
it("creates shared goals for household managers", async () => {
const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" });
prisma.householdGoal.create.mockResolvedValue({
id: "goal_1",
householdId: "household_1",
name: "Vacation",
description: "Summer trip",
targetAmount: "5000",
currentAmount: "1000",
isoCurrencyCode: "USD",
targetDate: new Date("2026-10-01T00:00:00.000Z"),
priority: "medium",
status: "active",
metadata: { color: "teal" },
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
createdBy: { id: "user_1", email: "owner@example.com", fullName: "Owner User" },
});
prisma.auditLog.create.mockResolvedValue({});
const result = await service.createGoal("user_1", "household_1", {
name: " Vacation ",
description: "Summer trip",
targetAmount: 5000,
currentAmount: 1000,
targetDate: "2026-10-01",
metadata: { color: "teal" },
});
expect(result.progressPercent).toBe(20);
expect(prisma.householdGoal.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
householdId: "household_1",
createdByUserId: "user_1",
name: "Vacation",
targetAmount: 5000,
currentAmount: 1000,
status: "active",
}),
}));
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "household.goal.create",
metadata: expect.objectContaining({ householdId: "household_1", goalId: "goal_1" }),
}),
});
});
it("updates shared goals for household managers", async () => {
const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "admin", status: "active" });
prisma.householdGoal.findFirst.mockResolvedValue({
id: "goal_1",
householdId: "household_1",
targetAmount: "5000",
currentAmount: "1000",
});
prisma.householdGoal.update.mockResolvedValue({
id: "goal_1",
householdId: "household_1",
name: "Vacation",
description: null,
targetAmount: "5000",
currentAmount: "2500",
isoCurrencyCode: "USD",
targetDate: null,
priority: "high",
status: "active",
metadata: {},
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
createdBy: { id: "user_1", email: "owner@example.com", fullName: "Owner User" },
});
prisma.auditLog.create.mockResolvedValue({});
const result = await service.updateGoal("user_1", "household_1", "goal_1", {
currentAmount: 2500,
priority: "high",
});
expect(result.progressPercent).toBe(50);
expect(prisma.householdGoal.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: "goal_1" },
data: expect.objectContaining({ currentAmount: 2500, priority: "high" }),
}));
});
it("rejects household goals with current amount above target", async () => {
const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" });
await expect(service.createGoal("user_1", "household_1", {
name: "Bad goal",
targetAmount: 100,
currentAmount: 150,
})).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.householdGoal.create).not.toHaveBeenCalled();
});
it("blocks non-managers from updating member roles", async () => {
const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue({

View File

@ -23,6 +23,12 @@ export const createPrismaMock = () => ({
findUnique: jest.fn(),
update: jest.fn()
},
householdGoal: {
create: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn()
},
account: {
count: jest.fn(),
findFirst: jest.fn(),