Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions prisma/migrations/20260625010000_add_multi_family_isolation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
-- Migration: Add familyId to all financial models for multi-family isolation
-- Run this against the Neon production database BEFORE merging the PR.
--
-- This migration:
-- 1. Adds familyId column to 9 tables (nullable first, for backfill)
-- 2. Backfills all existing data to the founder's familyId
-- 3. Makes familyId NOT NULL
-- 4. Drops the old FamilySettings singleton row, creates per-family rows
-- 5. Adds indexes
--
-- How to run:
-- 1. Open Neon console → SQL Editor
-- 2. Paste this entire file
-- 3. Run
--
-- IMPORTANT: Run this BEFORE merging the PR. The app will work with the old
-- schema until you merge, but the new code expects familyId to exist.

-- Step 1: Add nullable familyId columns
ALTER TABLE "Child" ADD COLUMN "familyId" TEXT;
ALTER TABLE "ParentProfile" ADD COLUMN "familyId" TEXT;
ALTER TABLE "Account" ADD COLUMN "familyId" TEXT;
ALTER TABLE "Transaction" ADD COLUMN "familyId" TEXT;
ALTER TABLE "SpendingCategory" ADD COLUMN "familyId" TEXT;
ALTER TABLE "SpendingEntry" ADD COLUMN "familyId" TEXT;
ALTER TABLE "Investment" ADD COLUMN "familyId" TEXT;
ALTER TABLE "TokenLedgerEntry" ADD COLUMN "familyId" TEXT;
ALTER TABLE "Goal" ADD COLUMN "familyId" TEXT;

-- Step 2: Get the founder's userId from SystemSettings
-- We'll use this as the familyId for all existing data.
-- The founder's User row already has a familyId column (nullable).
-- We'll generate a new familyId for the founder and assign all existing data to it.

-- First, set the founder's familyId if not set
UPDATE "User"
SET "familyId" = 'fam_' || "id"
WHERE "platformRole" = 'FOUNDER' AND "familyId" IS NULL;

-- Now backfill all existing data with the founder's familyId
-- (If there are multiple founders, this assigns to the first one — acceptable for migration)
UPDATE "Child" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;
UPDATE "ParentProfile" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;
UPDATE "Account" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;
UPDATE "Transaction" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;
UPDATE "SpendingCategory" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;
UPDATE "SpendingEntry" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;
UPDATE "Investment" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;
UPDATE "TokenLedgerEntry" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;
UPDATE "Goal" SET "familyId" = (SELECT "familyId" FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1) WHERE "familyId" IS NULL;

-- Step 3: Make familyId NOT NULL
ALTER TABLE "Child" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "ParentProfile" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "Account" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "Transaction" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "SpendingCategory" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "SpendingEntry" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "Investment" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "TokenLedgerEntry" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "Goal" ALTER COLUMN "familyId" SET NOT NULL;

-- Step 4: Drop old FamilySettings singleton, create per-family row
-- First, get the founder's familyId and create a FamilySettings row for it
INSERT INTO "FamilySettings" ("id", "familyId", "annualTheme", "monthlyQuote", "currency", "createdAt", "updatedAt")
SELECT
'fam_settings_' || "familyId",
"familyId",
COALESCE((SELECT "annualTheme" FROM "FamilySettings" WHERE "id" = 'singleton'), ''),
COALESCE((SELECT "monthlyQuote" FROM "FamilySettings" WHERE "id" = 'singleton'), ''),
COALESCE((SELECT "currency" FROM "FamilySettings" WHERE "id" = 'singleton'), 'UGX'),
NOW(),
NOW()
FROM "User" WHERE "platformRole" = 'FOUNDER' AND "familyId" IS NOT NULL;

-- Delete the old singleton row
DELETE FROM "FamilySettings" WHERE "id" = 'singleton';

-- Step 5: Add indexes
CREATE INDEX "Child_familyId_idx" ON "Child"("familyId");
CREATE INDEX "ParentProfile_familyId_idx" ON "ParentProfile"("familyId");
CREATE INDEX "Account_familyId_idx" ON "Account"("familyId");
CREATE INDEX "Transaction_familyId_idx" ON "Transaction"("familyId");
CREATE INDEX "SpendingCategory_familyId_idx" ON "SpendingCategory"("familyId");
CREATE INDEX "SpendingEntry_familyId_idx" ON "SpendingEntry"("familyId");
CREATE INDEX "Investment_familyId_idx" ON "Investment"("familyId");
CREATE INDEX "TokenLedgerEntry_familyId_idx" ON "TokenLedgerEntry"("familyId");
CREATE INDEX "Goal_familyId_idx" ON "Goal"("familyId");
29 changes: 26 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ datasource db {

model Child {
id String @id
familyId String
name String
age Int
avatarColor String
Expand All @@ -29,6 +30,8 @@ model Child {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([familyId])

accounts Account[]
transactions Transaction[]
spending SpendingEntry[]
Expand All @@ -39,13 +42,16 @@ model Child {

model ParentProfile {
id String @id
familyId String
name String
role String // "Mother" | "Father" | "Guardian"
avatarColor String
avatarPhoto String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([familyId])

spending SpendingEntry[]
goals Goal[] @relation("ParentGoals")
}
Expand All @@ -54,17 +60,21 @@ model ParentProfile {

model Account {
id String @id @default(cuid())
familyId String
childId String
name String
balance Int @default(0) // UGX external balance
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([familyId])

child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
}

model Transaction {
id String @id @default(cuid())
familyId String
childId String
type String // save | withdraw | invest | redeem | parent_give
amount Int @default(0) // UGX, always positive
Expand All @@ -77,18 +87,23 @@ model Transaction {

child Child @relation(fields: [childId], references: [id], onDelete: Cascade)

@@index([familyId])
@@index([childId])
@@index([timestamp])
}

model SpendingCategory {
id String @id
name String
budget Int @default(0) // UGX monthly budget
id String @id
familyId String
name String
budget Int @default(0) // UGX monthly budget

@@index([familyId])
}

model SpendingEntry {
id String @id @default(cuid())
familyId String
ownerId String // child id OR parent id
ownerKind String // "parent" | "child"
ownerName String // denormalized for convenience
Expand All @@ -98,6 +113,8 @@ model SpendingEntry {
timestamp BigInt @default(0)
createdAt DateTime @default(now())

@@index([familyId])

child Child? @relation(fields: [childId], references: [id], onDelete: Cascade)
parent ParentProfile? @relation(fields: [parentId], references: [id], onDelete: Cascade)
childId String?
Expand All @@ -109,6 +126,7 @@ model SpendingEntry {

model Investment {
id String @id @default(cuid())
familyId String
childId String
name String
type String // Equity | Bond | Savings Bond | Treasury Bill | Unit Trust
Expand All @@ -119,11 +137,14 @@ model Investment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([familyId])

child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
}

model TokenLedgerEntry {
id String @id @default(cuid())
familyId String
childId String
type String // parent_give | redeem
tokens Int @default(0)
Expand All @@ -133,11 +154,13 @@ model TokenLedgerEntry {

child Child @relation(fields: [childId], references: [id], onDelete: Cascade)

@@index([familyId])
@@index([childId])
}

model Goal {
id String @id @default(cuid())
familyId String
ownerId String // child id OR parent id
ownerKind String // "parent" | "child"
ownerName String
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/child/state/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const runtime = "nodejs";

export async function GET() {
const user = await getAuthUser();
if (!user) return NextResponse.json({ ok: false, error: "Not authenticated" }, { status: 401 });
if (!user || !user.familyId) return NextResponse.json({ ok: false, error: "Not authenticated" }, { status: 401 });
if (user.familyRole !== "CHILD") return NextResponse.json({ ok: false, error: "Not a child account" }, { status: 403 });
const childProfile = await db.childProfile.findFirst({ where: { userId: user.id } });
if (!childProfile) return NextResponse.json({ ok: false, error: "Child profile not found" }, { status: 404 });
Expand Down
73 changes: 56 additions & 17 deletions src/app/api/mutations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,64 +23,103 @@ import {
setChildName,
createChild,
createParent,
deleteTransaction,
deleteSpendingEntry,
closeInvestment,
deleteInvestment,
deleteChild,
deleteParent,
resetFamilyData,
deleteSpendingCategory,
getFullState,
} from "@/lib/db-queries";
import { getAuthUser } from "@/lib/auth";

export const dynamic = "force-dynamic";

export async function POST(req: NextRequest) {
const user = await getAuthUser();
if (!user || !user.familyId) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const familyId = user.familyId;

try {
const body = await req.json();
const { action, payload } = body as { action: string; payload: any };

switch (action) {
case "addTransaction":
await addTransaction(payload);
await addTransaction({ ...payload, familyId });
break;
case "addSpendingEntry":
await addSpendingEntry(payload);
await addSpendingEntry({ ...payload, familyId });
break;
case "giveTokens":
await giveTokens(payload.childId, payload.tokens, payload.note);
await giveTokens(familyId, payload.childId, payload.tokens, payload.note);
break;
case "redeemTokens":
await redeemTokens(payload.childId, payload.tokens, payload.cashValue);
await redeemTokens(familyId, payload.childId, payload.tokens, payload.cashValue);
break;
case "investNow":
await investNow(payload.childId, payload.amount, payload.name, payload.type);
await investNow(familyId, payload.childId, payload.amount, payload.name, payload.type);
break;
case "createGoal":
await createGoal(payload);
await createGoal({ ...payload, familyId });
break;
case "updateGoal":
await updateGoal(payload.id, payload.patch);
await updateGoal(familyId, payload.id, payload.patch);
break;
case "deleteGoal":
await deleteGoal(payload.id);
await deleteGoal(familyId, payload.id);
break;
case "contributeToGoal":
await contributeToGoal(payload.id, payload.amount);
await contributeToGoal(familyId, payload.id, payload.amount);
break;
case "setFamilySettings":
await setFamilySettings(payload);
await setFamilySettings(familyId, payload);
break;
case "setParentPhoto":
await setParentPhoto(payload.parentId, payload.photo);
await setParentPhoto(familyId, payload.parentId, payload.photo);
break;
case "setParentName":
await setParentName(payload.parentId, payload.name);
await setParentName(familyId, payload.parentId, payload.name);
break;
case "setChildPhoto":
await setChildPhoto(payload.childId, payload.photo);
await setChildPhoto(familyId, payload.childId, payload.photo);
break;
case "setChildName":
await setChildName(payload.childId, payload.name);
await setChildName(familyId, payload.childId, payload.name);
break;
case "createChild":
await createChild(payload);
await createChild({ ...payload, familyId });
break;
case "createParent":
await createParent(payload);
await createParent({ ...payload, familyId });
break;
case "deleteTransaction":
await deleteTransaction(familyId, payload.id);
break;
case "deleteSpendingEntry":
await deleteSpendingEntry(familyId, payload.id);
break;
case "closeInvestment":
await closeInvestment(familyId, payload.id);
break;
case "deleteInvestment":
await deleteInvestment(familyId, payload.id);
break;
case "deleteChild":
await deleteChild(familyId, payload.childId);
break;
case "deleteParent":
await deleteParent(familyId, payload.parentId);
break;
case "deleteSpendingCategory":
await deleteSpendingCategory(familyId, payload.id);
break;
case "resetFamilyData":
await resetFamilyData(familyId);
break;
default:
return NextResponse.json(
Expand All @@ -90,7 +129,7 @@ export async function POST(req: NextRequest) {
}

// Return fresh state so client hydrates in one round-trip.
const state = await getFullState();
const state = await getFullState(familyId);
return NextResponse.json({ ok: true, state });
} catch (err) {
console.error("POST /api/mutations failed:", err);
Expand Down
7 changes: 6 additions & 1 deletion src/app/api/state/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@

import { NextResponse } from "next/server";
import { getFullState } from "@/lib/db-queries";
import { getAuthUser } from "@/lib/auth";

export const dynamic = "force-dynamic";

export async function GET() {
const user = await getAuthUser();
if (!user || !user.familyId) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
try {
const state = await getFullState();
const state = await getFullState(user.familyId);
return NextResponse.json(state);
} catch (err) {
console.error("GET /api/state failed:", err);
Expand Down
Loading