Add household privacy mode

This commit is contained in:
MOHAN 2026-07-16 23:22:30 +05:30
parent a33d9b8f00
commit c0118b6986
5 changed files with 134 additions and 8 deletions

View File

@ -0,0 +1,5 @@
export class UpdateHouseholdPrivacyDto {
enabled!: boolean;
hideIndividualBalances?: boolean;
hideIndividualTransactions?: boolean;
}

View File

@ -8,6 +8,7 @@ import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
import { FairSplitCalculatorDto } from "./dto/fair-split-calculator.dto"; import { FairSplitCalculatorDto } from "./dto/fair-split-calculator.dto";
import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto"; import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.dto";
import { HouseholdsService } from "./households.service"; import { HouseholdsService } from "./households.service";
@Controller("households") @Controller("households")
@ -34,6 +35,15 @@ export class HouseholdsController {
return ok(await this.householdsService.getForUser(userId, id)); return ok(await this.householdsService.getForUser(userId, id));
} }
@Patch(":id/privacy")
async updatePrivacy(
@CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: UpdateHouseholdPrivacyDto,
) {
return ok(await this.householdsService.updatePrivacyMode(userId, id, payload));
}
@Get(":id/members") @Get(":id/members")
async members(@CurrentUser() userId: string, @Param("id") id: string) { async members(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listMembers(userId, id)); return ok(await this.householdsService.listMembers(userId, id));

View File

@ -10,6 +10,13 @@ import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
import { FairSplitCalculatorDto, FairSplitMode } from "./dto/fair-split-calculator.dto"; import { FairSplitCalculatorDto, FairSplitMode } from "./dto/fair-split-calculator.dto";
import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto"; import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.dto";
type HouseholdPrivacyMode = {
enabled: boolean;
hideIndividualBalances: boolean;
hideIndividualTransactions: boolean;
};
@Injectable() @Injectable()
export class HouseholdsService { export class HouseholdsService {
@ -117,6 +124,7 @@ export class HouseholdsService {
}, },
}); });
if (!household) throw new BadRequestException("Household not found."); if (!household) throw new BadRequestException("Household not found.");
const privacyMode = this.getPrivacyMode(household.metadata);
const accounts = await this.prisma.account.findMany({ const accounts = await this.prisma.account.findMany({
where: { householdId, isActive: true }, where: { householdId, isActive: true },
@ -182,17 +190,20 @@ export class HouseholdsService {
}), }),
]); ]);
const activeTransactions = cashflowRows.filter((row: any) => !row.derived?.isHidden); const visibleAccounts = this.applyAccountPrivacy(accounts, privacyMode);
const balanceByOwnership = this.buildOwnershipBreakdown(accounts); const visibleCashflowRows = this.applyTransactionPrivacy(cashflowRows, privacyMode);
const visibleRecentRows = this.applyTransactionPrivacy(recentRows, privacyMode);
const activeTransactions = visibleCashflowRows.filter((row: any) => !row.derived?.isHidden);
const balanceByOwnership = this.buildOwnershipBreakdown(visibleAccounts);
const cashflow = this.buildCashflow(activeTransactions, cashflowStart, now); const cashflow = this.buildCashflow(activeTransactions, cashflowStart, now);
const monthlyIncome = cashflow.currentMonth.income; const monthlyIncome = cashflow.currentMonth.income;
const monthlyExpenses = cashflow.currentMonth.expenses; const monthlyExpenses = cashflow.currentMonth.expenses;
const monthlyNet = cashflow.currentMonth.net; const monthlyNet = cashflow.currentMonth.net;
const totalBalance = this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0)); const totalBalance = this.roundCurrency(visibleAccounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0));
const availableBalance = this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0)); const availableBalance = this.roundCurrency(visibleAccounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0));
const healthScore = this.buildCouplesHealthScore({ const healthScore = this.buildCouplesHealthScore({
memberCount: household.members.length, memberCount: household.members.length,
accounts, accounts: visibleAccounts,
activeTransactions, activeTransactions,
goals: goalRows, goals: goalRows,
totalBalance, totalBalance,
@ -226,9 +237,10 @@ export class HouseholdsService {
monthlyExpenses, monthlyExpenses,
monthlyNet, monthlyNet,
}, },
privacyMode,
healthScore, healthScore,
ownershipBreakdown: balanceByOwnership, ownershipBreakdown: balanceByOwnership,
accounts: accounts.map((account: any, index: number) => ({ accounts: visibleAccounts.map((account: any, index: number) => ({
displayId: `household_account_${index + 1}`, displayId: `household_account_${index + 1}`,
institutionName: account.institutionName, institutionName: account.institutionName,
accountType: account.accountType, accountType: account.accountType,
@ -242,7 +254,7 @@ export class HouseholdsService {
syncStatus: account.syncStatus, syncStatus: account.syncStatus,
})), })),
cashflow: cashflow.months, cashflow: cashflow.months,
recentTransactions: recentRows recentTransactions: visibleRecentRows
.filter((row: any) => !row.derived?.isHidden) .filter((row: any) => !row.derived?.isHidden)
.slice(0, 10) .slice(0, 10)
.map((row: any) => ({ .map((row: any) => ({
@ -263,6 +275,44 @@ export class HouseholdsService {
}; };
} }
async updatePrivacyMode(userId: string, householdId: string, payload: UpdateHouseholdPrivacyDto) {
await this.requireManager(userId, householdId);
const household = await this.prisma.household.findFirst({ where: { id: householdId } });
if (!household) throw new BadRequestException("Household not found.");
const existingMetadata = this.asRecord(household.metadata);
const privacyMode: HouseholdPrivacyMode = {
enabled: Boolean(payload.enabled),
hideIndividualBalances: payload.hideIndividualBalances ?? true,
hideIndividualTransactions: payload.hideIndividualTransactions ?? true,
};
const updated = await this.prisma.household.update({
where: { id: householdId },
data: {
metadata: {
...existingMetadata,
privacyMode,
} as Prisma.InputJsonValue,
},
});
await this.prisma.auditLog.create({
data: {
userId,
action: "household.privacy.update",
metadata: {
householdId,
privacyMode,
},
},
});
return {
householdId: updated.id,
privacyMode,
};
}
async listMembers(userId: string, householdId: string) { async listMembers(userId: string, householdId: string) {
await this.requireActiveMember(userId, householdId); await this.requireActiveMember(userId, householdId);
return this.prisma.householdMember.findMany({ return this.prisma.householdMember.findMany({
@ -653,6 +703,31 @@ export class HouseholdsService {
return crypto.createHash("sha256").update(token).digest("hex"); return crypto.createHash("sha256").update(token).digest("hex");
} }
private getPrivacyMode(metadata: unknown): HouseholdPrivacyMode {
const value = this.asRecord(metadata).privacyMode;
const mode = this.asRecord(value);
return {
enabled: Boolean(mode.enabled),
hideIndividualBalances: mode.hideIndividualBalances !== false,
hideIndividualTransactions: mode.hideIndividualTransactions !== false,
};
}
private applyAccountPrivacy(accounts: any[], privacyMode: HouseholdPrivacyMode) {
if (!privacyMode.enabled || !privacyMode.hideIndividualBalances) return accounts;
return accounts.filter((account) => account.ownershipType === "joint");
}
private applyTransactionPrivacy(rows: any[], privacyMode: HouseholdPrivacyMode) {
if (!privacyMode.enabled || !privacyMode.hideIndividualTransactions) return rows;
return rows.filter((row) => row.account?.ownershipType === "joint");
}
private asRecord(value: unknown): Record<string, any> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, any>;
}
private normalizeFairSplitMode(mode?: string): FairSplitMode { private normalizeFairSplitMode(mode?: string): FairSplitMode {
if (!mode) return "income_weighted"; if (!mode) return "income_weighted";
if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode; if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode;

View File

@ -113,6 +113,41 @@ describe("HouseholdsService", () => {
expect(prisma.household.findFirst).not.toHaveBeenCalled(); expect(prisma.household.findFirst).not.toHaveBeenCalled();
}); });
it("updates household privacy mode for managers", async () => {
const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" });
prisma.household.findFirst.mockResolvedValue({ id: "household_1", metadata: { type: "couple" } });
prisma.household.update.mockResolvedValue({ id: "household_1" });
prisma.auditLog.create.mockResolvedValue({});
const result = await service.updatePrivacyMode("user_1", "household_1", {
enabled: true,
hideIndividualBalances: true,
hideIndividualTransactions: true,
});
expect(result.privacyMode.enabled).toBe(true);
expect(prisma.household.update).toHaveBeenCalledWith({
where: { id: "household_1" },
data: {
metadata: {
type: "couple",
privacyMode: {
enabled: true,
hideIndividualBalances: true,
hideIndividualTransactions: true,
},
},
},
});
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "household.privacy.update",
metadata: expect.objectContaining({ householdId: "household_1" }),
}),
});
});
it("builds a shared household financial dashboard for active members", async () => { it("builds a shared household financial dashboard for active members", async () => {
const { prisma, service } = createService(); const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "owner", status: "active" }); prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "owner", status: "active" });

View File

@ -8,7 +8,8 @@ export const createPrismaMock = () => ({
household: { household: {
create: jest.fn(), create: jest.fn(),
findFirst: jest.fn(), findFirst: jest.fn(),
findMany: jest.fn() findMany: jest.fn(),
update: jest.fn()
}, },
householdMember: { householdMember: {
count: jest.fn(), count: jest.fn(),