Files
Cibello-app/apps/api/src/routes/me.ts
T
2026-08-11 04:43:43 +07:00

362 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { buildErasurePlan, eraseUser, schema } from "@app/database";
import {
consentInputSchema,
onboardingInputSchema,
updateHealthProfileInputSchema,
updateMeInputSchema,
updatePreferencesInputSchema,
} from "@app/validation";
import { computeDailyTargets, DEFAULT_TARGETS } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { audit, generateInviteCode, getActiveHouseholdId } from "../lib/helpers.js";
import { loadEntitlementsWithToken } from "../lib/entitlements.js";
import { syncOnboardingMemory } from "../lib/onboardingMemory.js";
/** Profil, preferenser, samtycken, dagsmål, GDPR-export/-radering (spec §6, §56). */
export async function meRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/me", auth, async (req) => {
const [user] = await app.db
.select()
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
if (!user) throw errors.notFound();
const householdId = await getActiveHouseholdId(app.db, req.userId);
return {
id: user.id,
email: user.email,
emailVerified: user.emailVerifiedAt != null,
displayName: user.displayName,
role: user.role,
locale: user.locale,
precisionMode: user.precisionMode,
onboardingCompleted: user.onboardingCompleted,
activeHouseholdId: householdId,
};
});
app.patch("/v1/me", auth, async (req) => {
const input = parse(updateMeInputSchema, req.body);
const [user] = await app.db
.update(schema.users)
.set({ ...input, updatedAt: new Date() })
.where(eq(schema.users.id, req.userId))
.returning();
return {
id: user!.id,
displayName: user!.displayName,
locale: user!.locale,
precisionMode: user!.precisionMode,
};
});
// --- Hälsoprofil (separerad domän, spec §56) ---
app.get("/v1/me/health-profile", auth, async (req) => {
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
return profile ?? null;
});
app.patch("/v1/me/health-profile", auth, async (req) => {
const input = parse(updateHealthProfileInputSchema, req.body);
const [row] = await app.db
.insert(schema.userHealthProfiles)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userHealthProfiles.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- Preferenser ---
app.get("/v1/me/preferences", auth, async (req) => {
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
return prefs ?? null;
});
app.patch("/v1/me/preferences", auth, async (req) => {
const input = parse(updatePreferencesInputSchema, req.body);
const [row] = await app.db
.insert(schema.userPreferences)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userPreferences.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- Onboarding i ett svep (spec §6) ---
app.post("/v1/me/onboarding", auth, async (req) => {
const input = parse(onboardingInputSchema, req.body);
if (input.healthProfile) {
await app.db
.insert(schema.userHealthProfiles)
.values({ userId: req.userId, ...input.healthProfile })
.onConflictDoUpdate({
target: schema.userHealthProfiles.userId,
set: { ...input.healthProfile, updatedAt: new Date() },
});
}
if (input.preferences) {
await app.db
.insert(schema.userPreferences)
.values({ userId: req.userId, ...input.preferences })
.onConflictDoUpdate({
target: schema.userPreferences.userId,
set: { ...input.preferences, updatedAt: new Date() },
});
// S5: spegla uttryckligen angivna preferenser i minne + smaksignaler.
await syncOnboardingMemory(app.db, req.userId, input.preferences);
}
let householdId: string | null = await getActiveHouseholdId(app.db, req.userId);
if (input.householdChoice.kind === "create" && !householdId) {
const [household] = await app.db
.insert(schema.households)
.values({ name: input.householdChoice.name, inviteCode: generateInviteCode() })
.returning();
await app.db.insert(schema.householdMembers).values({
householdId: household!.id,
userId: req.userId,
role: "owner",
});
// Standardplatser: kyl, frys, skafferi (spec §8)
await app.db.insert(schema.storageLocations).values([
{ householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 },
{ householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 },
{ householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 },
]);
householdId = household!.id;
} else if (input.householdChoice.kind === "join") {
const [household] = await app.db
.select()
.from(schema.households)
.where(eq(schema.households.inviteCode, input.householdChoice.inviteCode.toUpperCase()))
.limit(1);
if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden.");
await app.db
.insert(schema.householdMembers)
.values({ householdId: household.id, userId: req.userId, role: "adult" })
.onConflictDoNothing();
householdId = household.id;
}
await app.db
.update(schema.users)
.set({ precisionMode: input.precisionMode, onboardingCompleted: true, updatedAt: new Date() })
.where(eq(schema.users.id, req.userId));
return { ok: true, householdId };
});
// --- Samtycken (spec §33: separata) ---
app.get("/v1/me/consents", auth, async (req) => {
return app.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, req.userId));
});
app.put("/v1/me/consents", auth, async (req) => {
const input = parse(consentInputSchema, req.body);
const now = new Date();
const [row] = await app.db
.insert(schema.userConsents)
.values({
userId: req.userId,
kind: input.kind,
status: input.granted ? "granted" : "denied",
grantedAt: input.granted ? now : null,
revokedAt: input.granted ? null : now,
})
.onConflictDoUpdate({
target: [schema.userConsents.userId, schema.userConsents.kind],
set: {
status: input.granted ? "granted" : "revoked",
...(input.granted ? { grantedAt: now, revokedAt: null } : { revokedAt: now }),
updatedAt: now,
},
})
.returning();
await audit(app.db, {
actorUserId: req.userId,
action: `consent.${input.granted ? "granted" : "revoked"}`,
targetType: "consent",
targetId: input.kind,
});
return row;
});
// --- Dagsmål: beräknas deterministiskt, med transparent grund (spec §21) ---
app.get("/v1/me/daily-targets", auth, async (req) => {
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
if (!profile?.weightKg || !profile.heightCm || !profile.birthYear) {
return {
targets: DEFAULT_TARGETS,
basis: null,
note: "Schablonmål fyll i längd, vikt och födelseår för personliga mål.",
};
}
const result = computeDailyTargets({
sex: profile.sex ?? "unspecified",
age: new Date().getUTCFullYear() - profile.birthYear,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
activityLevel: profile.activityLevel,
primaryGoal: prefs?.primaryGoal ?? undefined,
});
return {
...result,
note: "Uppskattning enligt MifflinSt Jeor. Appen är inte medicinsk rådgivning.",
};
});
// --- Entitlements (spec §47) ---
app.get("/v1/me/entitlements", auth, async (req) => {
return loadEntitlementsWithToken(app, req.userId);
});
// --- Locale-preferenser (i18n-spec §6): språk ≠ region ≠ enheter ---
app.get("/v1/me/locale-preferences", auth, async (req) => {
const { loadLocalePreferences } = await import("../lib/localeContext.js");
return loadLocalePreferences(app.db, req.userId);
});
app.patch("/v1/me/locale-preferences", auth, async (req) => {
const { updateLocalePreferencesInputSchema } = await import("@app/validation");
const input = parse(updateLocalePreferencesInputSchema, req.body);
const [row] = await app.db
.insert(schema.userLocalePreferences)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userLocalePreferences.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- GDPR: export (spec §56) ---
app.get("/v1/me/export", auth, async (req) => {
const userId = req.userId;
const [user] = await app.db
.select()
.from(schema.users)
.where(eq(schema.users.id, userId))
.limit(1);
const [health] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, userId))
.limit(1);
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
const consents = await app.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, userId));
const meals = await app.db.select().from(schema.meals).where(eq(schema.meals.userId, userId));
const memory = await app.db
.select()
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
const ratings = await app.db
.select()
.from(schema.recipeRatings)
.where(eq(schema.recipeRatings.userId, userId));
await audit(app.db, { actorUserId: userId, action: "gdpr.export", ip: req.ip });
return {
exportedAt: new Date().toISOString(),
user,
healthProfile: health ?? null,
preferences: prefs ?? null,
consents,
meals,
memory,
ratings,
};
});
// --- GDPR: radera konto (spec §56, §32, docs/29) ---
app.delete("/v1/me", auth, async (req) => {
const userId = req.userId;
// Planera först (read-only): vilka bilder/hushåll ska tas bort.
const plan = await buildErasurePlan(app.db, userId);
// Kör all DB-mutation i en transaktion.
const log = await app.db.transaction(async (tx) => {
const l = await eraseUser(tx, userId);
await tx
.update(schema.users)
.set({
email: `deleted-${userId}@anonymized.invalid`,
displayName: "Raderad användare",
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(schema.users.id, userId));
return l;
});
// Radera objektlagring efter att DB-transaktionen commitat.
const imageKeys = new Set<string>();
for (const ref of plan.imageReferences) {
const key = app.storage.extractKeyFromUrl(ref) ?? ref;
if (key) imageKeys.add(key);
}
for (const key of imageKeys) {
try {
await app.storage.deleteObject(key);
} catch (err) {
app.log.warn({ err, key, userId }, "Kunde inte radera bild vid GDPR-radering");
}
}
// Logga att en radering skett, men utan att länka tillbaka till den raderade användaren.
await audit(app.db, {
action: "gdpr.delete_account",
targetType: "user",
metadata: { erasedTables: log.length, householdsDeleted: plan.householdsToDelete.length },
ip: req.ip,
});
return {
ok: true,
message: "Kontot är raderat. Kvarvarande backupper roteras ut enligt retentionspolicyn.",
erasedTables: log,
};
});
}