Fas 1a: product analytics schema + GDPR controls
This commit is contained in:
@@ -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,3 +1,4 @@
|
||||
export * from "./client.js";
|
||||
export * as schema from "./schema/index.js";
|
||||
export * from "./schema/index.js";
|
||||
export * from "./analytics-gdpr.js";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user