237 lines
8.4 KiB
TypeScript
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;
|
|
});
|
|
}
|