Fas 1a: product analytics schema + GDPR controls

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:32 +07:00
parent ac5340195a
commit d627014425
26 changed files with 9039 additions and 14 deletions
+50
View File
@@ -0,0 +1,50 @@
/**
* GDPR helpers for product analytics.
* Hard deletion SET NULL is not enough because anonymous_id can link rows.
*
* Consent model: product_analytics is managed via user_consents
* (CONSENT_KINDS in @app/shared-types). It is registered as granted by default
* on account creation (legitimate interest with opt-out). Users revoke it via
* PUT /v1/me/consents { kind: "product_analytics", granted: false }.
*
* Semantics for existing accounts created before this feature:
* - Missing user_consents row for product_analytics → treated as granted.
* - Row with status = "granted" → granted.
* - Row with status = "revoked" or "denied" → not granted.
*/
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { and, eq } from "drizzle-orm";
import * as schema from "./schema/index.js";
export type DbClient = NodePgDatabase<typeof schema>;
/**
* Delete all product analytics events for a user.
* Called during account deletion (spec §56).
*/
export async function deleteUserAnalyticsEvents(db: DbClient, userId: string): Promise<number> {
const result = await db
.delete(schema.productAnalyticsEvents)
.where(eq(schema.productAnalyticsEvents.userId, userId));
return result.rowCount ?? 0;
}
/**
* Check if a user has analytics collection enabled.
* Missing row → granted (legitimate interest, opt-out).
* Only an explicit revoked/denied row disables collection.
*/
export async function isAnalyticsOptedIn(db: DbClient, userId: string): Promise<boolean> {
const [row] = await db
.select({ status: schema.userConsents.status })
.from(schema.userConsents)
.where(
and(
eq(schema.userConsents.userId, userId),
eq(schema.userConsents.kind, "product_analytics"),
),
)
.limit(1);
if (!row) return true; // default granted for existing accounts
return row.status === "granted";
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./client.js";
export * as schema from "./schema/index.js";
export * from "./schema/index.js";
export * from "./analytics-gdpr.js";
+67
View File
@@ -0,0 +1,67 @@
/**
* Product analytics schema (spec §8).
* Pseudonymous events stored in Postgres, separated from AAMOS memory.
*/
import { index, integer, jsonb, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import { createdAt } from "./_shared.js";
import { households } from "./households.js";
import { users } from "./users.js";
/** Event name stored as text so the taxonomy can grow without ALTER TYPE. */
const ANALYTICS_EVENT_NAME_LEN = 64;
/**
* Raw product analytics events.
* No PII, no raw ingredient names, no free text, no images.
*/
export const productAnalyticsEvents = pgTable(
"product_analytics_events",
{
id: uuid("id").primaryKey().defaultRandom(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull().defaultNow(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
eventName: varchar("event_name", { length: ANALYTICS_EVENT_NAME_LEN }).notNull(),
/** Pseudonymous device/session identifiers. */
anonymousId: varchar("anonymous_id", { length: 64 }),
sessionId: varchar("session_id", { length: 64 }),
/** Foreign keys are nullable because events may arrive before login. */
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
/** App version, platform, locale. */
appVersion: varchar("app_version", { length: 32 }),
platform: varchar("platform", { length: 16 }),
locale: varchar("locale", { length: 16 }),
/** Experiment / feature flag context. */
experimentVariant: varchar("experiment_variant", { length: 128 }),
/** Structured, safe properties only. */
properties: jsonb("properties").notNull().default({}),
},
(t) => [
index("pa_events_name_occurred_idx").on(t.eventName, t.occurredAt),
index("pa_events_household_occurred_idx").on(t.householdId, t.occurredAt),
index("pa_events_user_occurred_idx").on(t.userId, t.occurredAt),
index("pa_events_session_idx").on(t.sessionId),
index("pa_events_received_idx").on(t.receivedAt),
],
);
/**
* Materialized-like daily aggregates per household.
* Kept simple; dashboards can also query product_analytics_events directly.
*/
export const analyticsDailySnapshots = pgTable(
"analytics_daily_snapshots",
{
id: uuid("id").primaryKey().defaultRandom(),
date: varchar("date", { length: 10 }).notNull(), // YYYY-MM-DD UTC
householdId: uuid("household_id").references(() => households.id, { onDelete: "cascade" }),
eventName: varchar("event_name", { length: ANALYTICS_EVENT_NAME_LEN }).notNull(),
count: integer("count").notNull().default(0),
uniqueSessions: integer("unique_sessions").notNull().default(0),
propertiesFingerprint: varchar("properties_fingerprint", { length: 64 }),
},
(t) => [
index("pa_daily_household_date_idx").on(t.householdId, t.date),
index("pa_daily_date_event_idx").on(t.date, t.eventName),
],
);
+1
View File
@@ -15,4 +15,5 @@ export * from "./scans.js";
export * from "./memory.js";
export * from "./seasons.js";
export * from "./subscriptions.js";
export * from "./analytics.js";
export * from "./platform.js";
@@ -0,0 +1,105 @@
/**
* GDPR helpers for product analytics integration tests.
* Assumes DATABASE_URL points to a writable database.
*/
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq } from "drizzle-orm";
import { createDatabase, closeDatabase, schema, isAnalyticsOptedIn, deleteUserAnalyticsEvents } from "@app/database";
const TEST_USER_EMAIL = "analytics-gdpr-test@example.invalid";
describe("analytics GDPR helpers", () => {
const { db, pool } = createDatabase();
let userId: string;
beforeAll(async () => {
// Clean up any stale test user.
await db.delete(schema.users).where(eq(schema.users.email, TEST_USER_EMAIL));
const [user] = await db
.insert(schema.users)
.values({
email: TEST_USER_EMAIL,
displayName: "Analytics Test",
locale: "sv-SE",
})
.returning({ id: schema.users.id });
userId = user!.id;
});
afterAll(async () => {
await db.delete(schema.userConsents).where(eq(schema.userConsents.userId, userId));
await db.delete(schema.users).where(eq(schema.users.id, userId));
await closeDatabase();
});
it("missing consent row → opted in (legitimate interest, opt-out)", async () => {
await db.delete(schema.userConsents).where(eq(schema.userConsents.userId, userId));
expect(await isAnalyticsOptedIn(db, userId)).toBe(true);
});
it("explicit granted → opted in", async () => {
await db
.insert(schema.userConsents)
.values({
userId,
kind: "product_analytics",
status: "granted",
grantedAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [schema.userConsents.userId, schema.userConsents.kind],
set: { status: "granted", revokedAt: null, updatedAt: new Date() },
});
expect(await isAnalyticsOptedIn(db, userId)).toBe(true);
});
it("explicit revoked → opted out", async () => {
const now = new Date();
await db
.insert(schema.userConsents)
.values({
userId,
kind: "product_analytics",
status: "revoked",
revokedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [schema.userConsents.userId, schema.userConsents.kind],
set: { status: "revoked", revokedAt: now, updatedAt: now },
});
expect(await isAnalyticsOptedIn(db, userId)).toBe(false);
});
it("deleteUserAnalyticsEvents removes only the target user's events", async () => {
// Insert a dummy event for the test user and another user.
const [otherUser] = await db
.insert(schema.users)
.values({ email: "other-analytics-test@example.invalid", displayName: "Other", locale: "sv-SE" })
.returning({ id: schema.users.id });
await db.insert(schema.productAnalyticsEvents).values({
userId,
eventName: "app_first_open",
anonymousId: "anon-test",
});
await db.insert(schema.productAnalyticsEvents).values({
userId: otherUser!.id,
eventName: "app_first_open",
anonymousId: "anon-other",
});
const deleted = await deleteUserAnalyticsEvents(db, userId);
expect(deleted).toBe(1);
const remaining = await db
.select({ id: schema.productAnalyticsEvents.id })
.from(schema.productAnalyticsEvents)
.where(eq(schema.productAnalyticsEvents.userId, otherUser!.id));
expect(remaining).toHaveLength(1);
await db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, otherUser!.id));
await db.delete(schema.users).where(eq(schema.users.id, otherUser!.id));
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Product analytics taxonomy (spec §8.2).
* Pseudonymous events separated from AAMOS memory and domain events.
* All event names are snake_case and stable.
*/
// ---------------------------------------------------------------------------
// Acquisition & onboarding
// ---------------------------------------------------------------------------
export const ANALYTICS_EVENT_NAMES = [
"app_installed",
"app_first_open",
"onboarding_started",
"onboarding_step_completed",
"onboarding_skipped",
"account_created",
"household_created",
"household_joined",
"allergy_setup_completed",
"camera_permission_result",
"notification_permission_result",
] as const;
// ---------------------------------------------------------------------------
// Inventory
// ---------------------------------------------------------------------------
export const ANALYTICS_INVENTORY_EVENT_NAMES = [
"scan_started",
"scan_uploaded",
"scan_processing_completed",
"scan_processing_failed",
"scan_review_opened",
"scan_item_confirmed",
"scan_item_corrected",
"scan_item_removed",
"scan_completed",
"inventory_item_added",
"inventory_item_consumed",
"inventory_item_discarded",
"inventory_reconciliation_started",
"inventory_reconciliation_completed",
"inventory_conflict_created",
"inventory_conflict_resolved",
] as const;
// ---------------------------------------------------------------------------
// Recipes & cooking
// ---------------------------------------------------------------------------
export const ANALYTICS_COOKING_EVENT_NAMES = [
"recommendations_viewed",
"recommendation_opened",
"recipe_saved",
"cooking_session_started",
"cooking_session_completed",
"cooking_session_cancelled",
"leftovers_created",
"substitution_confirmed",
] as const;
// ---------------------------------------------------------------------------
// Shopping
// ---------------------------------------------------------------------------
export const ANALYTICS_SHOPPING_EVENT_NAMES = [
"shopping_item_added",
"shopping_item_checked",
"shopping_list_shared",
"receipt_scanned",
"purchase_imported",
] as const;
// ---------------------------------------------------------------------------
// Household
// ---------------------------------------------------------------------------
export const ANALYTICS_HOUSEHOLD_EVENT_NAMES = [
"household_invite_sent",
"household_invite_accepted",
"second_member_first_action",
] as const;
// ---------------------------------------------------------------------------
// Subscription
// ---------------------------------------------------------------------------
export const ANALYTICS_SUBSCRIPTION_EVENT_NAMES = [
"paywall_viewed",
"trial_started",
"trial_cancelled",
"subscription_started",
"subscription_renewed",
"subscription_grace_period",
"subscription_cancelled",
"subscription_expired",
"entitlement_restored",
] as const;
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
export const ANALYTICS_FEEDBACK_EVENT_NAMES = [
"feedback_submitted",
"incorrect_inventory_reported",
"bad_recommendation_reported",
"support_contact_started",
] as const;
export const PRODUCT_ANALYTICS_EVENT_NAMES = [
...ANALYTICS_EVENT_NAMES,
...ANALYTICS_INVENTORY_EVENT_NAMES,
...ANALYTICS_COOKING_EVENT_NAMES,
...ANALYTICS_SHOPPING_EVENT_NAMES,
...ANALYTICS_HOUSEHOLD_EVENT_NAMES,
...ANALYTICS_SUBSCRIPTION_EVENT_NAMES,
...ANALYTICS_FEEDBACK_EVENT_NAMES,
] as const;
export type ProductAnalyticsEventName = (typeof PRODUCT_ANALYTICS_EVENT_NAMES)[number];
/**
* Built-in funnels. Each funnel is an ordered list of event names.
*/
export const FUNNELS = {
install_to_account: ["app_installed", "app_first_open", "account_created"],
account_to_first_scan: ["account_created", "scan_started", "scan_completed"],
scan_to_inventory: ["scan_completed", "inventory_item_added"],
inventory_to_recipe: ["inventory_item_added", "recommendations_viewed"],
recipe_to_cooking: ["recommendation_opened", "cooking_session_started"],
cooking_to_updated_inventory: ["cooking_session_started", "cooking_session_completed"],
activated_to_trial: ["cooking_session_completed", "trial_started"],
trial_to_paid: ["trial_started", "subscription_started"],
single_to_two_members: ["household_invite_sent", "household_invite_accepted", "second_member_first_action"],
} as const;
export type FunnelName = keyof typeof FUNNELS;
+2
View File
@@ -485,6 +485,8 @@ export const CONSENT_KINDS = [
"health_integration",
"location_weather",
"push_notifications",
/** Product analytics legitimate interest with opt-out (spec §8, §33). */
"product_analytics",
] as const;
export type ConsentKind = (typeof CONSENT_KINDS)[number];
+1
View File
@@ -6,3 +6,4 @@ export * from "./brand.js";
export * from "./locale.js";
export * from "./money.js";
export * from "./measurement.js";
export * from "./analytics.js";