Files
Cibello-app/apps/api/src/routes/me.ts
T
Sven (AAMOS AI) 28908d5257 fix(gdpr): explicit radering av ai_corrections/scan_jobs + S3-bilder vid DELETE /v1/me
- DELETE /v1/me soft-deletar users, så users-cascade fyrar aldrig.
- Samlar distinkta bildnycklar från ai_corrections, ai_training_bank och
  scan_jobs och raderar dem via storage.deleteObject innan DB-radering.
- S3-fel loggas och avbryter inte raderingen.
- Raderar explicit ai_corrections (ai_training_bank cascadar) och scan_jobs.
- Lägger till deleteObject i StorageService (mock + AWS/DeleteObjectCommand).
- Uppdaterar docs/28-lärande-loop.md med faktisk mekanism och retention.
- Tester: verifierar noll rader kvar och storage.deleteObject-anrop.

Relaterat: skiva-1-fixrunda, blockerande GDPR-hål.
2026-08-08 04:20:03 +07:00

397 lines
14 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, inArray } from "drizzle-orm";
import { deleteUserAnalyticsEvents, 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";
/** 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() },
});
}
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) ---
app.delete("/v1/me", auth, async (req) => {
const userId = req.userId;
// Eftersom users-raden soft-deletas (anonymiseras) fyras INTE users.onDelete
// cascade på ai_corrections / scan_jobs. Vi måste explicit radera träningsdata
// och skanningar, plus tillhörande bilder i objektlagring.
const correctionRows = await app.db
.select({ id: schema.aiCorrections.id, imageS3Key: schema.aiCorrections.imageS3Key })
.from(schema.aiCorrections)
.where(eq(schema.aiCorrections.userId, userId));
const bankRows = await app.db
.select({ imageS3Key: schema.aiTrainingBank.imageS3Key })
.from(schema.aiTrainingBank)
.innerJoin(
schema.aiCorrections,
eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id),
)
.where(eq(schema.aiCorrections.userId, userId));
const scanRows = await app.db
.select({ s3Keys: schema.scanJobs.s3Keys })
.from(schema.scanJobs)
.where(eq(schema.scanJobs.userId, userId));
const imageKeys = new Set<string>();
for (const row of correctionRows) {
if (row.imageS3Key) imageKeys.add(row.imageS3Key);
}
for (const row of bankRows) {
if (row.imageS3Key) imageKeys.add(row.imageS3Key);
}
for (const row of scanRows) {
for (const key of row.s3Keys ?? []) {
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");
}
}
// ai_training_bank försvinner via correction_id ON DELETE CASCADE.
if (correctionRows.length > 0) {
await app.db
.delete(schema.aiCorrections)
.where(
inArray(
schema.aiCorrections.id,
correctionRows.map((r) => r.id),
),
);
}
// scan_jobs har också ON DELETE CASCADE på users.id, men eftersom vi
// soft-deletar användaren måste vi radera explicit.
await app.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, userId));
// Hård radering av övrig persondata.
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, userId));
await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, userId));
await app.db.delete(schema.meals).where(eq(schema.meals.userId, userId));
await app.db
.delete(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, userId));
await app.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId));
await deleteUserAnalyticsEvents(app.db, userId);
await app.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, userId));
await app.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, userId));
await app.db
.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));
await audit(app.db, { actorUserId: userId, action: "gdpr.delete_account", ip: req.ip });
return {
ok: true,
message: "Kontot är raderat. Kvarvarande backupper roteras ut enligt retentionspolicyn.",
};
});
}