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 { 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));
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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" });
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user