Files
Cibello-app/apps/api/src/routes/memory.ts
T
2026-08-11 02:41:39 +07:00

189 lines
6.6 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 { 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;
}