diff --git a/prisma/migrations/20260716001200_household_goals/migration.sql b/prisma/migrations/20260716001200_household_goals/migration.sql new file mode 100644 index 0000000..a6f504e --- /dev/null +++ b/prisma/migrations/20260716001200_household_goals/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb851d8..dbb4517 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/households/dto/create-household-goal.dto.ts b/src/households/dto/create-household-goal.dto.ts new file mode 100644 index 0000000..0fccf96 --- /dev/null +++ b/src/households/dto/create-household-goal.dto.ts @@ -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; +} diff --git a/src/households/dto/update-household-goal.dto.ts b/src/households/dto/update-household-goal.dto.ts new file mode 100644 index 0000000..8c9cac7 --- /dev/null +++ b/src/households/dto/update-household-goal.dto.ts @@ -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; +} diff --git a/src/households/households.controller.ts b/src/households/households.controller.ts index 0c504ca..4613b67 100644 --- a/src/households/households.controller.ts +++ b/src/households/households.controller.ts @@ -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, diff --git a/src/households/households.service.ts b/src/households/households.service.ts index 355a811..ad219fa 100644 --- a/src/households/households.service.ts +++ b/src/households/households.service.ts @@ -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); + } } diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts index 1d07e3d..cc14703 100644 --- a/test/households.service.spec.ts +++ b/test/households.service.spec.ts @@ -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({ diff --git a/test/utils/mock-prisma.ts b/test/utils/mock-prisma.ts index fab244d..2b228ab 100644 --- a/test/utils/mock-prisma.ts +++ b/test/utils/mock-prisma.ts @@ -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(),