Files
Cibello-app/apps/api/src/routes/households.ts
T
2026-08-14 20:00:49 +07:00

237 lines
8.4 KiB
TypeScript

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;
});
}