Add household privacy mode
This commit is contained in:
parent
a33d9b8f00
commit
c0118b6986
5
src/households/dto/update-household-privacy.dto.ts
Normal file
5
src/households/dto/update-household-privacy.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export class UpdateHouseholdPrivacyDto {
|
||||
enabled!: boolean;
|
||||
hideIndividualBalances?: boolean;
|
||||
hideIndividualTransactions?: boolean;
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user