feat(consent): user_terms_consents + /v1/me/consent

This commit is contained in:
Sven (AAMOS AI)
2026-08-13 02:06:44 +07:00
parent 1df3ca1709
commit 61c3193f17
8 changed files with 10534 additions and 1 deletions
+35 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { buildErasurePlan, eraseUser, schema } from "@app/database";
import {
consentInputSchema,
@@ -8,6 +8,7 @@ import {
updateMeInputSchema,
updatePreferencesInputSchema,
} from "@app/validation";
import { TERMS_VERSION } from "@app/shared-types";
import { computeDailyTargets, DEFAULT_TARGETS } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { audit, generateInviteCode, getActiveHouseholdId } from "../lib/helpers.js";
@@ -166,6 +167,39 @@ export async function meRoutes(app: FastifyInstance) {
return { ok: true, householdId };
});
// --- Villkorssamtycke (terms) ---
app.post("/v1/me/consent", auth, async (req) => {
const now = new Date();
const [row] = await app.db
.insert(schema.userTermsConsents)
.values({ userId: req.userId, termsVersion: TERMS_VERSION, acceptedAt: now })
.onConflictDoNothing({ target: [schema.userTermsConsents.userId, schema.userTermsConsents.termsVersion] })
.returning();
return {
accepted: true,
version: TERMS_VERSION,
acceptedAt: row?.acceptedAt ?? now,
};
});
app.get("/v1/me/consent", auth, async (req) => {
const [row] = await app.db
.select()
.from(schema.userTermsConsents)
.where(
and(
eq(schema.userTermsConsents.userId, req.userId),
eq(schema.userTermsConsents.termsVersion, TERMS_VERSION),
),
)
.limit(1);
return {
accepted: row != null,
version: TERMS_VERSION,
acceptedAt: row?.acceptedAt ?? null,
};
});
// --- Samtycken (spec §33: separata) ---
app.get("/v1/me/consents", auth, async (req) => {
return app.db
@@ -28,6 +28,7 @@ STATIC_CONFIG = {
"LOG_LEVEL": "info",
"AAMOS_MODE": "gemini",
"AAMOS_API_URL": "https://amos.aamos.systems",
"EOC_URL": "https://eoc.aamos.systems",
"AAMOS_TIMEOUT_MS": "60000",
# GEMINI_MODEL hämtas från SSM (String-typ)
"GEMINI_TIMEOUT_MS": "60000",
@@ -0,0 +1,20 @@
CREATE TABLE "gemini_usage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"task_type" text NOT NULL,
"prompt_tokens" integer,
"output_tokens" integer,
"total_tokens" integer,
"cost_microcents" bigint
);
--> statement-breakpoint
CREATE TABLE "user_terms_consents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"terms_version" text NOT NULL,
"accepted_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_terms_consents" ADD CONSTRAINT "user_terms_consents_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "user_terms_consents_user_version_unique" ON "user_terms_consents" USING btree ("user_id","terms_version");
File diff suppressed because it is too large Load Diff
@@ -183,6 +183,13 @@
"when": 1786397281333,
"tag": "0026_s5_onboarding_memory",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1786561534897,
"tag": "0026_light_infant_terrible",
"breakpoints": true
}
]
}
+1
View File
@@ -23,3 +23,4 @@ export * from "./ops.js";
export * from "./platform.js";
export * from "./releaseGates.js";
export * from "./gemini-usage.js";
export * from "./terms.js";
+22
View File
@@ -0,0 +1,22 @@
import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { createdAt } from "./_shared.js";
import { users } from "./users.js";
/**
* Godkännande av allmänna villkor. En rad per (userId, termsVersion) gör
* bevislagringen otvetydig: vilken version som gällde när användaren
* accepterade och exakt när det skedde.
*/
export const userTermsConsents = pgTable(
"user_terms_consents",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
termsVersion: text("terms_version").notNull(),
acceptedAt: timestamp("accepted_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: createdAt(),
},
(t) => [uniqueIndex("user_terms_consents_user_version_unique").on(t.userId, t.termsVersion)],
);
+3
View File
@@ -180,3 +180,6 @@ export const STORE_SECTION_LABELS_SV: Record<StoreSection, string> = {
export const AI_CONTRACT_VERSION = "1.0.0";
export const API_VERSION = "v1";
/** Gällande version av användarvillkoren. Skrivs till user_terms_consents. */
export const TERMS_VERSION = "1.0";