diff --git a/src/households/dto/update-household-member.dto.ts b/src/households/dto/update-household-member.dto.ts index dd7ff64..9a93506 100644 --- a/src/households/dto/update-household-member.dto.ts +++ b/src/households/dto/update-household-member.dto.ts @@ -1,6 +1,6 @@ import { IsIn, IsOptional } from "class-validator"; -export const HOUSEHOLD_ROLES = ["owner", "admin", "member", "viewer"] as const; +export const HOUSEHOLD_ROLES = ["owner", "admin", "member", "viewer", "accountant", "advisor"] as const; export const HOUSEHOLD_MEMBER_STATUSES = ["active", "inactive", "removed"] as const; export type HouseholdRole = typeof HOUSEHOLD_ROLES[number]; diff --git a/src/households/households.service.ts b/src/households/households.service.ts index 5237992..584fc91 100644 --- a/src/households/households.service.ts +++ b/src/households/households.service.ts @@ -212,6 +212,7 @@ export class HouseholdsService { id: member.id, userId: member.userId, role: member.role, + status: member.status, joinedAt: member.joinedAt, user: member.user, })), @@ -464,7 +465,7 @@ export class HouseholdsService { const email = payload.email.trim().toLowerCase(); const role = payload.role ?? "member"; if (role === "owner") { - throw new BadRequestException("Invite partners as admin, member, or viewer. Promote owners after acceptance."); + throw new BadRequestException("Invite collaborators as admin, member, viewer, accountant, or advisor. Promote owners after acceptance."); } const token = crypto.randomBytes(32).toString("base64url"); diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts index d1ec622..74bab82 100644 --- a/test/households.service.spec.ts +++ b/test/households.service.spec.ts @@ -239,6 +239,29 @@ describe("HouseholdsService", () => { }); }); + it("allows managers to assign accountant and advisor collaborator roles", async () => { + const { prisma, service } = createService(); + prisma.householdMember.findFirst + .mockResolvedValueOnce({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }) + .mockResolvedValueOnce({ id: "member_2", userId: "user_2", role: "viewer", status: "active" }); + prisma.householdMember.update.mockResolvedValue({ + id: "member_2", + userId: "user_2", + role: "advisor", + status: "active", + }); + prisma.auditLog.create.mockResolvedValue({}); + + const result = await service.updateMember("user_1", "household_1", "member_2", { role: "advisor" }); + + expect(result.role).toBe("advisor"); + expect(prisma.householdMember.update).toHaveBeenCalledWith({ + where: { id: "member_2" }, + data: { role: "advisor" }, + include: expect.any(Object), + }); + }); + 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" }); @@ -444,6 +467,41 @@ describe("HouseholdsService", () => { }); }); + it("creates accountant collaborator invites for household managers", async () => { + const { prisma, email, service } = createService(); + prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }); + prisma.household.findFirst.mockResolvedValue({ id: "household_1", name: "Mohan Household" }); + prisma.user.findUnique.mockResolvedValue({ email: "owner@example.com", fullName: "Owner User" }); + prisma.householdInvite.create.mockResolvedValue({ + id: "invite_2", + email: "cpa@example.com", + role: "accountant", + status: "pending", + expiresAt: new Date("2026-07-23T00:00:00.000Z"), + createdAt: new Date("2026-07-16T00:00:00.000Z"), + }); + prisma.auditLog.create.mockResolvedValue({}); + + const result = await service.invite("user_1", "household_1", { + email: " CPA@Example.com ", + role: "accountant", + }); + + expect(result.role).toBe("accountant"); + expect(prisma.householdInvite.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + email: "cpa@example.com", + role: "accountant", + }), + })); + expect(email.sendHouseholdInviteEmail).toHaveBeenCalledWith( + "cpa@example.com", + "Mohan Household", + "Owner User", + expect.any(String), + ); + }); + it("accepts a pending invite for the matching email", async () => { const { prisma, service } = createService(); const expiresAt = new Date(Date.now() + 60_000);