import type { FastifyInstance } from "fastify"; import { and, eq } from "drizzle-orm"; import { schema } from "@app/database"; import { createHouseholdInputSchema, createStorageLocationInputSchema, idParamSchema, joinHouseholdInputSchema, memberParamSchema, updateHouseholdInputSchema, updateMemberInputSchema, updateStorageLocationInputSchema, } from "@app/validation"; import { loadEntitlements } from "../lib/entitlements.js"; import { errors, parse } from "../lib/errors.js"; import { audit, createHouseholdWithDefaults, discardEmptySoloHouseholds, emitEvent, generateInviteCode, requireMembership, } from "../lib/helpers.js"; /** Hushåll (spec §7): delat lager/plan/lista, individuella mål och roller. */ export async function householdRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.get("/v1/households", auth, async (req) => { const rows = await app.db .select({ household: schema.households, role: schema.householdMembers.role, portionFactor: schema.householdMembers.portionFactor, }) .from(schema.householdMembers) .innerJoin(schema.households, eq(schema.householdMembers.householdId, schema.households.id)) .where(eq(schema.householdMembers.userId, req.userId)); return rows.map((r) => ({ ...r.household, myRole: r.role, myPortionFactor: r.portionFactor })); }); app.post("/v1/households", auth, async (req, reply) => { const input = parse(createHouseholdInputSchema, req.body); const household = await createHouseholdWithDefaults(app.db, { userId: req.userId, name: input.name, }); if (input.weeklyBudgetMinor != null || input.currencyCode) { await app.db .update(schema.households) .set({ weeklyBudgetMinor: input.weeklyBudgetMinor ?? null, ...(input.currencyCode ? { currencyCode: input.currencyCode } : {}), updatedAt: new Date(), }) .where(eq(schema.households.id, household.id)); } return reply.status(201).send(household); }); app.get("/v1/households/:id", auth, async (req) => { const { id } = parse(idParamSchema, req.params); await requireMembership(app.db, id, req.userId); const [household] = await app.db .select() .from(schema.households) .where(eq(schema.households.id, id)) .limit(1); if (!household) throw errors.notFound(); const members = await app.db .select({ userId: schema.householdMembers.userId, role: schema.householdMembers.role, portionFactor: schema.householdMembers.portionFactor, joinedAt: schema.householdMembers.joinedAt, displayName: schema.users.displayName, }) .from(schema.householdMembers) .innerJoin(schema.users, eq(schema.householdMembers.userId, schema.users.id)) .where(eq(schema.householdMembers.householdId, id)); const locations = await app.db .select() .from(schema.storageLocations) .where(eq(schema.storageLocations.householdId, id)) .orderBy(schema.storageLocations.sortOrder); // OBS: individuella hälsomål/allergier exponeras INTE här (spec §7, §56). return { ...household, members, storageLocations: locations }; }); app.patch("/v1/households/:id", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const membership = await requireMembership(app.db, id, req.userId); if (membership.role !== "owner" && membership.role !== "adult") { throw errors.forbidden("Endast vuxna medlemmar kan ändra hushållet."); } const input = parse(updateHouseholdInputSchema, req.body); const [row] = await app.db .update(schema.households) .set({ ...input, updatedAt: new Date() }) .where(eq(schema.households.id, id)) .returning(); return row; }); app.post("/v1/households/join", auth, async (req) => { const input = parse(joinHouseholdInputSchema, req.body); const [household] = await app.db .select() .from(schema.households) .where(eq(schema.households.inviteCode, input.inviteCode.toUpperCase())) .limit(1); if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden."); // Kontrollera plangräns: max medlemmar styrs av ägarens plan (spec §45). const [owner] = await app.db .select({ userId: schema.householdMembers.userId }) .from(schema.householdMembers) .where( and( eq(schema.householdMembers.householdId, household.id), eq(schema.householdMembers.role, "owner"), ), ) .limit(1); const members = await app.db .select({ userId: schema.householdMembers.userId }) .from(schema.householdMembers) .where(eq(schema.householdMembers.householdId, household.id)); if (owner) { const ent = await loadEntitlements(app.db, owner.userId); if (members.length >= ent.maxHouseholdMembers) { throw errors.paymentRequired( `Hushållet har nått maxantalet medlemmar (${ent.maxHouseholdMembers}) för sin plan.`, ); } } await app.db .insert(schema.householdMembers) .values({ householdId: household.id, userId: req.userId, role: "adult" }) .onConflictDoNothing(); await discardEmptySoloHouseholds(app.db, req.userId, household.id); await emitEvent(app.db, { type: "HOUSEHOLD_MEMBER_ADDED", payload: { householdId: household.id, newUserId: req.userId, role: "adult" }, userId: req.userId, householdId: household.id, correlationId: req.correlationId, }); return { ok: true, household: { id: household.id, name: household.name } }; }); app.patch("/v1/households/:id/members/:userId", auth, async (req) => { const { id, userId } = parse(memberParamSchema, req.params); const membership = await requireMembership(app.db, id, req.userId); const isSelf = userId === req.userId; if (!isSelf && membership.role !== "owner" && membership.role !== "adult") { throw errors.forbidden("Endast vuxna kan ändra andra medlemmar."); } const input = parse(updateMemberInputSchema, req.body); if (input.role && membership.role !== "owner") { throw errors.forbidden("Endast ägaren kan ändra roller."); } const [row] = await app.db .update(schema.householdMembers) .set(input) .where( and( eq(schema.householdMembers.householdId, id), eq(schema.householdMembers.userId, userId), ), ) .returning(); if (!row) throw errors.notFound("Medlemmen finns inte."); return row; }); app.delete("/v1/households/:id/members/:userId", auth, async (req) => { const { id, userId } = parse(memberParamSchema, req.params); const membership = await requireMembership(app.db, id, req.userId); const isSelf = userId === req.userId; if (!isSelf && membership.role !== "owner") { throw errors.forbidden("Endast ägaren kan ta bort andra medlemmar."); } await app.db .delete(schema.householdMembers) .where( and( eq(schema.householdMembers.householdId, id), eq(schema.householdMembers.userId, userId), ), ); await audit(app.db, { actorUserId: req.userId, action: "household.member_removed", targetType: "user", targetId: userId, }); return { ok: true }; }); // --- Förvaringsplatser (spec §8) --- app.post("/v1/households/:id/storage-locations", auth, async (req, reply) => { const { id } = parse(idParamSchema, req.params); await requireMembership(app.db, id, req.userId); const input = parse(createStorageLocationInputSchema, req.body); const [row] = await app.db .insert(schema.storageLocations) .values({ householdId: id, ...input }) .returning(); return reply.status(201).send(row); }); app.patch("/v1/households/:id/storage-locations/:locationId", auth, async (req) => { const params = req.params as { id: string; locationId: string }; await requireMembership(app.db, params.id, req.userId); const input = parse(updateStorageLocationInputSchema, req.body); const [row] = await app.db .update(schema.storageLocations) .set(input) .where( and( eq(schema.storageLocations.id, params.locationId), eq(schema.storageLocations.householdId, params.id), ), ) .returning(); if (!row) throw errors.notFound(); return row; }); }