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 { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.dto";
import { HouseholdsService } from "./households.service";
@Controller("households")
@ -34,6 +35,15 @@ export class HouseholdsController {
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")
async members(@CurrentUser() userId: string, @Param("id") id: string) {
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 { UpdateHouseholdGoalDto } from "./dto/update-household-goal.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()
export class HouseholdsService {
@ -117,6 +124,7 @@ export class HouseholdsService {
},
});
if (!household) throw new BadRequestException("Household not found.");
const privacyMode = this.getPrivacyMode(household.metadata);
const accounts = await this.prisma.account.findMany({
where: { householdId, isActive: true },
@ -182,17 +190,20 @@ export class HouseholdsService {
}),
]);
const activeTransactions = cashflowRows.filter((row: any) => !row.derived?.isHidden);
const balanceByOwnership = this.buildOwnershipBreakdown(accounts);
const visibleAccounts = this.applyAccountPrivacy(accounts, privacyMode);
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 monthlyIncome = cashflow.currentMonth.income;
const monthlyExpenses = cashflow.currentMonth.expenses;
const monthlyNet = cashflow.currentMonth.net;
const totalBalance = this.roundCurrency(accounts.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 totalBalance = this.roundCurrency(visibleAccounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0));
const availableBalance = this.roundCurrency(visibleAccounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0));
const healthScore = this.buildCouplesHealthScore({
memberCount: household.members.length,
accounts,
accounts: visibleAccounts,
activeTransactions,
goals: goalRows,
totalBalance,
@ -226,9 +237,10 @@ export class HouseholdsService {
monthlyExpenses,
monthlyNet,
},
privacyMode,
healthScore,
ownershipBreakdown: balanceByOwnership,
accounts: accounts.map((account: any, index: number) => ({
accounts: visibleAccounts.map((account: any, index: number) => ({
displayId: `household_account_${index + 1}`,
institutionName: account.institutionName,
accountType: account.accountType,
@ -242,7 +254,7 @@ export class HouseholdsService {
syncStatus: account.syncStatus,
})),
cashflow: cashflow.months,
recentTransactions: recentRows
recentTransactions: visibleRecentRows
.filter((row: any) => !row.derived?.isHidden)
.slice(0, 10)
.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) {
await this.requireActiveMember(userId, householdId);
return this.prisma.householdMember.findMany({
@ -653,6 +703,31 @@ export class HouseholdsService {
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 {
if (!mode) return "income_weighted";
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();
});
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 () => {
const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "owner", status: "active" });

View File

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