Fas 1a: product analytics schema + GDPR controls
This commit is contained in:
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user