189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
||
import { and, desc, eq, or } from "drizzle-orm";
|
||
import { schema } from "@app/database";
|
||
import { buildMemoryOverview } from "@app/memory-client";
|
||
import { userLanguageTag } from "../lib/contentLanguage.js";
|
||
import { idParamSchema, memoryQuerySchema, updateMemoryItemInputSchema } from "@app/validation";
|
||
import { errors, parse } from "../lib/errors.js";
|
||
import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js";
|
||
import { computeMemoryImpact } from "../lib/memoryImpact.js";
|
||
|
||
/**
|
||
* "Vad plattformen vet om mig" (spec §32): full transparens.
|
||
* Användaren kan korrigera, pausa, radera varje post – och radera allt.
|
||
*/
|
||
export async function memoryRoutes(app: FastifyInstance) {
|
||
const auth = { preHandler: [app.authenticate] };
|
||
|
||
app.get("/v1/me/memory", auth, async (req) => {
|
||
const q = parse(memoryQuerySchema, req.query);
|
||
const householdId = await getActiveHouseholdId(app.db, req.userId);
|
||
|
||
const scope = householdId
|
||
? or(
|
||
eq(schema.memoryItems.userId, req.userId),
|
||
eq(schema.memoryItems.householdId, householdId),
|
||
)!
|
||
: eq(schema.memoryItems.userId, req.userId);
|
||
const conditions = [scope];
|
||
if (q.kind) conditions.push(eq(schema.memoryItems.kind, q.kind));
|
||
if (!q.includePaused) conditions.push(eq(schema.memoryItems.paused, false));
|
||
|
||
const items = await app.db
|
||
.select()
|
||
.from(schema.memoryItems)
|
||
.where(and(...conditions))
|
||
.orderBy(desc(schema.memoryItems.updatedAt))
|
||
.limit(q.limit)
|
||
.offset(q.offset);
|
||
|
||
const overview = buildMemoryOverview(
|
||
items.map((i) => ({
|
||
id: i.id,
|
||
userId: i.userId ?? undefined,
|
||
householdId: i.householdId ?? undefined,
|
||
kind: i.kind,
|
||
key: i.key,
|
||
summarySv: i.summarySv,
|
||
value: i.value,
|
||
origin: i.origin,
|
||
confidence: i.confidence,
|
||
verifiedByUser: i.verifiedByUser,
|
||
paused: i.paused,
|
||
createdAt: i.createdAt.toISOString(),
|
||
updatedAt: i.updatedAt.toISOString(),
|
||
lastUsedAt: i.lastUsedAt?.toISOString(),
|
||
expiresAt: i.expiresAt?.toISOString(),
|
||
})),
|
||
await userLanguageTag(app.db, req.userId),
|
||
);
|
||
return overview;
|
||
});
|
||
|
||
/**
|
||
* Visa vilka rekommendationer ett specifikt minne påverkar.
|
||
* Kräver personalization-samtycke; isolerat till den anropande användaren.
|
||
*/
|
||
app.get("/v1/me/memory/:id/impact", auth, async (req, reply) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
const item = await getOwnedMemory(app, id, req.userId);
|
||
|
||
const memoryItem: import("@app/shared-types").MemoryItem = {
|
||
id: item.id,
|
||
userId: item.userId ?? undefined,
|
||
householdId: item.householdId ?? undefined,
|
||
kind: item.kind,
|
||
key: item.key,
|
||
summarySv: item.summarySv,
|
||
value: item.value,
|
||
origin: item.origin,
|
||
confidence: item.confidence,
|
||
verifiedByUser: item.verifiedByUser,
|
||
paused: item.paused,
|
||
createdAt: item.createdAt.toISOString(),
|
||
updatedAt: item.updatedAt.toISOString(),
|
||
lastUsedAt: item.lastUsedAt?.toISOString(),
|
||
expiresAt: item.expiresAt?.toISOString(),
|
||
};
|
||
|
||
const result = await computeMemoryImpact({
|
||
db: app.db,
|
||
userId: req.userId,
|
||
memoryItem,
|
||
});
|
||
|
||
if (!result.personalizationEnabled) {
|
||
return reply.status(403).send({
|
||
error: {
|
||
code: "PERSONALIZATION_CONSENT_REQUIRED",
|
||
message: "Impact kräver personalization-samtycke.",
|
||
},
|
||
});
|
||
}
|
||
|
||
return result;
|
||
});
|
||
|
||
app.patch("/v1/me/memory/:id", auth, async (req) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
const input = parse(updateMemoryItemInputSchema, req.body);
|
||
const item = await getOwnedMemory(app, id, req.userId);
|
||
|
||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||
if (input.summarySv != null) updates.summarySv = input.summarySv;
|
||
if (input.value !== undefined) updates.value = input.value;
|
||
if (input.paused != null) updates.paused = input.paused;
|
||
if (input.verified != null || input.summarySv != null || input.value !== undefined) {
|
||
// Användarkorrigering gör posten verifierad och användarägd (spec §30).
|
||
updates.verifiedByUser = true;
|
||
updates.origin = "user_stated";
|
||
updates.confidence = 1;
|
||
}
|
||
|
||
const [row] = await app.db
|
||
.update(schema.memoryItems)
|
||
.set(updates)
|
||
.where(eq(schema.memoryItems.id, item.id))
|
||
.returning();
|
||
|
||
await emitEvent(app.db, {
|
||
type: "MEMORY_UPDATED",
|
||
payload: { memoryItemId: id, kind: item.kind, origin: "user_stated" },
|
||
userId: req.userId,
|
||
correlationId: req.correlationId,
|
||
});
|
||
return row;
|
||
});
|
||
|
||
app.delete("/v1/me/memory/:id", auth, async (req) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
const item = await getOwnedMemory(app, id, req.userId);
|
||
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.id, item.id));
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
action: "memory.deleted",
|
||
targetType: "memory_item",
|
||
targetId: id,
|
||
});
|
||
return { ok: true };
|
||
});
|
||
|
||
/** Radera ALLT personligt minne (spec §32: "radera"). */
|
||
app.delete("/v1/me/memory", auth, async (req) => {
|
||
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, req.userId));
|
||
await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, req.userId));
|
||
await audit(app.db, { actorUserId: req.userId, action: "memory.deleted_all" });
|
||
return { ok: true, message: "Allt personligt minne är raderat." };
|
||
});
|
||
|
||
/** Pausa allt minne (spec §32: "pausa"). */
|
||
app.post("/v1/me/memory/pause-all", auth, async (req) => {
|
||
const body = (req.body ?? {}) as { paused?: boolean };
|
||
const paused = body.paused ?? true;
|
||
await app.db
|
||
.update(schema.memoryItems)
|
||
.set({ paused, updatedAt: new Date() })
|
||
.where(eq(schema.memoryItems.userId, req.userId));
|
||
await audit(app.db, {
|
||
actorUserId: req.userId,
|
||
action: paused ? "memory.paused_all" : "memory.resumed_all",
|
||
});
|
||
return { ok: true, paused };
|
||
});
|
||
}
|
||
|
||
async function getOwnedMemory(app: FastifyInstance, id: string, userId: string) {
|
||
const [item] = await app.db
|
||
.select()
|
||
.from(schema.memoryItems)
|
||
.where(eq(schema.memoryItems.id, id))
|
||
.limit(1);
|
||
if (!item) throw errors.notFound("Minnesposten finns inte.");
|
||
if (item.userId && item.userId !== userId) throw errors.forbidden();
|
||
if (!item.userId && item.householdId) {
|
||
const { requireMembership } = await import("../lib/helpers.js");
|
||
await requireMembership(app.db, item.householdId, userId);
|
||
}
|
||
return item;
|
||
}
|