From ba4c4e1b4f2abcadd5abeffbc20ca513ff5fc3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 10:14:58 +0200 Subject: [PATCH] Apply demo-activity-bot: Bruno, the synthetic public-walk generator Adds a background demo user (`bruno`) plus two pg-boss jobs that generate public trekking routes and activities in inner Berlin and prune them after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED` only in prod. - Schema: `synthetic` boolean on routes + activities - `ensureDemoUser()` idempotent insert + badge on /users/bruno - Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d - Initial 4-walk backfill on first enable; retention via daily prune - Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total` - Unit + DB-gated integration + E2E tests Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/jobs/demo-bot-generate.ts | 60 +++ apps/journal/app/jobs/demo-bot-prune.ts | 27 ++ .../app/lib/demo-bot.integration.test.ts | 125 +++++ apps/journal/app/lib/demo-bot.server.ts | 438 ++++++++++++++++++ apps/journal/app/lib/demo-bot.test.ts | 119 +++++ apps/journal/app/lib/metrics.server.ts | 15 + apps/journal/app/routes/users.$username.tsx | 15 +- apps/journal/server.ts | 17 +- e2e/demo-bot.test.ts | 116 +++++ infrastructure/.env.example | 8 + infrastructure/docker-compose.yml | 7 + openspec/changes/demo-activity-bot/tasks.md | 77 +-- packages/db/src/schema/journal.ts | 8 + packages/i18n/src/locales/de.ts | 3 + packages/i18n/src/locales/en.ts | 3 + playwright.config.ts | 8 + 16 files changed, 1003 insertions(+), 43 deletions(-) create mode 100644 apps/journal/app/jobs/demo-bot-generate.ts create mode 100644 apps/journal/app/jobs/demo-bot-prune.ts create mode 100644 apps/journal/app/lib/demo-bot.integration.test.ts create mode 100644 apps/journal/app/lib/demo-bot.server.ts create mode 100644 apps/journal/app/lib/demo-bot.test.ts create mode 100644 e2e/demo-bot.test.ts diff --git a/apps/journal/app/jobs/demo-bot-generate.ts b/apps/journal/app/jobs/demo-bot-generate.ts new file mode 100644 index 0000000..e27eb7a --- /dev/null +++ b/apps/journal/app/jobs/demo-bot-generate.ts @@ -0,0 +1,60 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { + DEMO_BACKFILL_TARGET, + DEMO_DAILY_CAP, + countSyntheticRoutesRecent, + countSyntheticRoutesTotal, + ensureDemoUser, + generateOneWalk, + isDemoBotEnabled, + refreshDemoBotGauges, + shouldWalkNow, +} from "../lib/demo-bot.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Generate job. Fires every 90 minutes via pg-boss cron. Handler steps: + * + * 1. Bail if `DEMO_BOT_ENABLED` is not "true". + * 2. On first run (no synthetic rows yet) produce a small backfill so the + * demo user's profile has content immediately. + * 3. Otherwise apply the decide-to-walk gate (local hour + p=0.12) and + * daily cap; on pass, insert one route+activity via `generateOneWalk`. + */ +export const demoBotGenerateJob: JobDefinition = { + name: "demo-bot:generate", + cron: "*/90 * * * *", + retryLimit: 1, + expireInSeconds: 120, + async handler() { + if (!isDemoBotEnabled()) return { skipped: "disabled" }; + + const ownerId = await ensureDemoUser(); + + const total = await countSyntheticRoutesTotal(); + if (total === 0) { + let inserted = 0; + for (let i = 0; i < DEMO_BACKFILL_TARGET; i++) { + const id = await generateOneWalk(ownerId); + if (id) inserted++; + } + logger.info({ inserted }, "demo-bot backfill complete"); + await refreshDemoBotGauges(); + return { mode: "backfill", inserted }; + } + + const recent = await countSyntheticRoutesRecent(14); + if (recent >= DEMO_DAILY_CAP) { + logger.info({ recent, cap: DEMO_DAILY_CAP }, "demo-bot cap hit, skipping"); + return { skipped: "cap", recent }; + } + + const now = new Date(); + if (!shouldWalkNow(now)) return { skipped: "gate" }; + + const id = await generateOneWalk(ownerId, { now }); + logger.info({ inserted: id ? 1 : 0, routeId: id }, "demo-bot walk"); + await refreshDemoBotGauges(); + return { mode: "single", routeId: id }; + }, +}; diff --git a/apps/journal/app/jobs/demo-bot-prune.ts b/apps/journal/app/jobs/demo-bot-prune.ts new file mode 100644 index 0000000..d3f4d31 --- /dev/null +++ b/apps/journal/app/jobs/demo-bot-prune.ts @@ -0,0 +1,27 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { + demoRetentionDays, + isDemoBotEnabled, + pruneSynthetic, + refreshDemoBotGauges, +} from "../lib/demo-bot.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Daily prune. Deletes synthetic rows older than + * `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users. + */ +export const demoBotPruneJob: JobDefinition = { + name: "demo-bot:prune", + cron: "15 3 * * *", + retryLimit: 1, + expireInSeconds: 60, + async handler() { + if (!isDemoBotEnabled()) return { skipped: "disabled" }; + const days = demoRetentionDays(); + const counts = await pruneSynthetic(days); + logger.info({ days, ...counts }, "demo-bot prune"); + await refreshDemoBotGauges(); + return { days, ...counts }; + }, +}; diff --git a/apps/journal/app/lib/demo-bot.integration.test.ts b/apps/journal/app/lib/demo-bot.integration.test.ts new file mode 100644 index 0000000..c3f36f6 --- /dev/null +++ b/apps/journal/app/lib/demo-bot.integration.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { sql } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { activities, routes, users } from "@trails-cool/db/schema/journal"; +import { + DEMO_USERNAME, + ensureDemoUser, + generateOneWalk, + pruneSynthetic, +} from "./demo-bot.server.ts"; + +// Opt-in: these tests talk to a real Postgres and are skipped unless +// `DEMO_BOT_INTEGRATION=1` is set. CI gates them behind the same flag +// so we don't break laptop runs that have no Postgres up. +const runIntegration = process.env.DEMO_BOT_INTEGRATION === "1"; + +// A tiny valid GPX that parseGpxAsync can chew on. Two trkpts in Berlin, +// ~2 km apart — enough to pass the 500 m min-distance gate in +// generateOneWalk. +const STUB_GPX = ` + + + 34 + 38 + 40 + +`; + +async function wipeSynthetic() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.activities WHERE synthetic = true`); + await db.execute(sql`DELETE FROM journal.routes WHERE synthetic = true`); +} + +describe.skipIf(!runIntegration)("demo-bot integration", () => { + beforeAll(async () => { + // Sanity: make sure we can reach the DB and synthetic column exists. + const db = getDb(); + await db.execute(sql`SELECT 1 FROM journal.routes WHERE synthetic IS NOT NULL LIMIT 0`); + }); + + afterEach(async () => { + await wipeSynthetic(); + vi.restoreAllMocks(); + }); + + it("ensureDemoUser is idempotent", async () => { + const id1 = await ensureDemoUser(); + const id2 = await ensureDemoUser(); + expect(id1).toBe(id2); + + const db = getDb(); + const rows = await db + .select() + .from(users) + .where(sql`${users.username} = ${DEMO_USERNAME}`); + expect(rows.length).toBe(1); + }); + + it("generateOneWalk inserts a public synthetic route + activity", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => STUB_GPX }), + ); + + const ownerId = await ensureDemoUser(); + const routeId = await generateOneWalk(ownerId); + expect(routeId).toBeTruthy(); + + const db = getDb(); + const [route] = await db + .select() + .from(routes) + .where(sql`${routes.id} = ${routeId!}`); + expect(route?.synthetic).toBe(true); + expect(route?.visibility).toBe("public"); + expect(route?.routingProfile).toBe("trekking"); + expect(route?.ownerId).toBe(ownerId); + + const acts = await db + .select() + .from(activities) + .where(sql`${activities.routeId} = ${routeId!}`); + expect(acts.length).toBe(1); + expect(acts[0]?.synthetic).toBe(true); + expect(acts[0]?.visibility).toBe("public"); + expect(acts[0]?.duration).toBeGreaterThan(0); + }); + + it("pruneSynthetic only removes synthetic rows past the window", async () => { + const ownerId = await ensureDemoUser(); + const db = getDb(); + + // One synthetic route that is 30 days old, one that is fresh. + await db.execute(sql` + INSERT INTO journal.routes + (id, owner_id, name, synthetic, visibility, created_at, updated_at) + VALUES + (gen_random_uuid()::text, ${ownerId}, 'old-synth', true, 'public', now() - interval '30 days', now() - interval '30 days'), + (gen_random_uuid()::text, ${ownerId}, 'fresh-synth', true, 'public', now(), now()) + `); + + // A non-synthetic route that is also 30 days old — must be kept. + await db.execute(sql` + INSERT INTO journal.routes + (id, owner_id, name, synthetic, visibility, created_at, updated_at) + VALUES + (gen_random_uuid()::text, ${ownerId}, 'old-real', false, 'private', now() - interval '30 days', now() - interval '30 days') + `); + + const counts = await pruneSynthetic(14); + expect(counts.routes).toBe(1); + + const remaining = await db + .select({ name: routes.name, synthetic: routes.synthetic }) + .from(routes) + .where(sql`${routes.ownerId} = ${ownerId} AND ${routes.name} IN ('old-synth','fresh-synth','old-real')`); + + const names = remaining.map((r) => r.name).sort(); + expect(names).toEqual(["fresh-synth", "old-real"]); + + // Clean up the non-synthetic test row + await db.execute(sql`DELETE FROM journal.routes WHERE owner_id = ${ownerId} AND name = 'old-real'`); + }); +}); diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts new file mode 100644 index 0000000..6153217 --- /dev/null +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -0,0 +1,438 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, lt, sql } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { activities, routes, users } from "@trails-cool/db/schema/journal"; +import { setGeomFromGpx } from "./routes.server.ts"; +import { TERMS_VERSION } from "./legal.ts"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { logger } from "./logger.server.ts"; +import { + demoBotSyntheticActivitiesTotal, + demoBotSyntheticRoutesTotal, +} from "./metrics.server.ts"; + +export const DEMO_USERNAME = "bruno"; +export const DEMO_DISPLAY_NAME = "Bruno"; +export const DEMO_BIO = + "Professional park inspector. Currently accepting tennis balls."; + +/** + * Inner Berlin (Tiergarten → Grunewald corridor). Keeps Bruno's walks + * somewhere plausible for an urban dog and makes templated copy work. + * Override with `DEMO_BOT_REGION='{"bbox":[w,s,e,n]}'`. + */ +const DEFAULT_BBOX: [number, number, number, number] = [13.25, 52.45, 13.55, 52.60]; + +export function isDemoBotEnabled(): boolean { + return process.env.DEMO_BOT_ENABLED === "true"; +} + +export function demoRetentionDays(): number { + const raw = process.env.DEMO_BOT_RETENTION_DAYS; + if (!raw) return 14; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : 14; +} + +/** + * Idempotent insert of the Bruno demo user. Safe to call every worker + * boot; relies on `users.username` uniqueness to short-circuit duplicates. + */ +export async function ensureDemoUser(): Promise { + const db = getDb(); + const domain = process.env.DOMAIN ?? "localhost"; + + const [existing] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, DEMO_USERNAME)); + if (existing) return existing.id; + + const id = randomUUID(); + await db + .insert(users) + .values({ + id, + username: DEMO_USERNAME, + email: `${DEMO_USERNAME}@${domain}`, + displayName: DEMO_DISPLAY_NAME, + bio: DEMO_BIO, + domain, + termsAcceptedAt: new Date(), + termsVersion: TERMS_VERSION, + }) + .onConflictDoNothing({ target: users.username }); + + // Re-read in case a concurrent insert won the race + const [row] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, DEMO_USERNAME)); + if (!row) throw new Error("ensureDemoUser: insert succeeded but row not found"); + return row.id; +} + +export interface Region { + bbox: [number, number, number, number]; +} + +export function loadRegion(): Region { + const raw = process.env.DEMO_BOT_REGION; + if (!raw) return { bbox: DEFAULT_BBOX }; + try { + const parsed = JSON.parse(raw) as { bbox?: unknown }; + if ( + Array.isArray(parsed.bbox) && + parsed.bbox.length === 4 && + parsed.bbox.every((n) => typeof n === "number") + ) { + return { bbox: parsed.bbox as [number, number, number, number] }; + } + } catch { + // fall through + } + logger.warn({ raw }, "DEMO_BOT_REGION invalid, using default"); + return { bbox: DEFAULT_BBOX }; +} + +/** + * Great-circle distance in metres between two (lon, lat) points. + */ +function haversineMeters(a: [number, number], b: [number, number]): number { + const R = 6_371_000; + const [lon1, lat1] = a; + const [lon2, lat2] = b; + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(s)); +} + +function randInBbox(bbox: [number, number, number, number]): [number, number] { + const [w, s, e, n] = bbox; + return [w + Math.random() * (e - w), s + Math.random() * (n - s)]; +} + +export interface Endpoints { + start: [number, number]; + end: [number, number]; +} + +/** + * Random start + end inside the bbox such that the crow-distance is in + * the dog-walk band (2–12 km). Gives up after N tries; on exhaustion + * returns whatever it has so the caller can decide (BRouter will likely + * succeed anyway, just with shorter/longer routes). + */ +export function pickEndpoints(bbox: [number, number, number, number]): Endpoints { + const MIN = 2_000; + const MAX = 12_000; + for (let i = 0; i < 50; i++) { + const start = randInBbox(bbox); + const end = randInBbox(bbox); + const d = haversineMeters(start, end); + if (d >= MIN && d <= MAX) return { start, end }; + } + const start = randInBbox(bbox); + const end = randInBbox(bbox); + return { start, end }; +} + +// --- Bruno-voiced copy -------------------------------------------------- + +const NAME_POOL_EN = [ + "Grunewald north-loop patrol", + "Tiergarten perimeter audit", + "Tempelhof runway inspection", + "Spree embankment survey", + "Bruno's morning rounds", + "Sniff-check: Kreuzberg edition", + "Bruno found three sticks today", + "Dog-audit report: all squirrels present", + "Volkspark compliance walk", + "Tennis ball reconnaissance", + "Pretzel-crumb cleanup detail", + "Bruno vs. the pigeons", +]; + +const NAME_POOL_DE = [ + "Grunewald-Nordschleife-Patrouille", + "Tiergarten-Umfangsprüfung", + "Tempelhof-Startbahn-Inspektion", + "Spreeufer-Rundgang", + "Brunos Morgenrunde", + "Schnüffelkontrolle: Kreuzberg", + "Bruno hat heute drei Stöcke gefunden", + "Hundeprüfbericht: alle Eichhörnchen anwesend", + "Volkspark-Konformitätsgang", + "Tennisball-Aufklärung", + "Brezel-Krümel-Aufräumdienst", + "Bruno gegen die Tauben", +]; + +const DESCRIPTION_POOL_EN = [ + "Inspected several bushes. All present and accounted for.", + "Three squirrels successfully monitored. Two got away.", + "Good sticks: 2. Great sticks: 1. Excellent sticks: 0.", + "Pace was brisk. Sniffs were thorough.", + "Encountered another dog. Diplomacy established.", + "Weather: windy. Ears: flopping.", + "Pigeons stood their ground. A follow-up visit is required.", + "Finished the route ahead of schedule. Extra treats expected.", + "Investigated one suspicious paper bag. False alarm.", + "Logged one (1) successful puddle inspection.", +]; + +const DESCRIPTION_POOL_DE = [ + "Mehrere Büsche inspiziert. Alle vorhanden.", + "Drei Eichhörnchen erfolgreich beobachtet. Zwei entkommen.", + "Gute Stöcke: 2. Großartige Stöcke: 1. Exzellente Stöcke: 0.", + "Tempo zügig. Schnüffeln gründlich.", + "Einen anderen Hund getroffen. Diplomatie hergestellt.", + "Wetter: windig. Ohren: flatternd.", + "Die Tauben hielten Stand. Ein Folgebesuch ist erforderlich.", + "Route vor dem Zeitplan abgeschlossen. Zusätzliche Leckerlis erwartet.", + "Eine verdächtige Papiertüte untersucht. Fehlalarm.", + "Eine (1) erfolgreiche Pfützen-Inspektion protokolliert.", +]; + +function pickFrom(pool: readonly T[], seed: number): T { + const idx = Math.abs(Math.floor(seed)) % pool.length; + return pool[idx]!; +} + +/** + * Deterministic-ish per day + per started-at minute, so same-day walks + * don't land on the same name. Locale is chosen from the journal's + * default — Bruno doesn't know German, but his walks come with German + * subtitles when the reader expects them. + */ +export function templateName(startedAt: Date, locale: "en" | "de" = "en"): string { + const pool = locale === "de" ? NAME_POOL_DE : NAME_POOL_EN; + const seed = startedAt.getUTCDate() * 60 + startedAt.getUTCMinutes(); + return pickFrom(pool, seed); +} + +export function templateDescription( + startedAt: Date, + locale: "en" | "de" = "en", +): string { + const pool = locale === "de" ? DESCRIPTION_POOL_DE : DESCRIPTION_POOL_EN; + const seed = startedAt.getUTCDate() * 137 + startedAt.getUTCSeconds() * 7; + return pickFrom(pool, seed); +} + +// --- BRouter -------------------------------------------------------------- + +const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777"; + +export interface BrouterResult { + gpx: string; +} + +export type BrouterError = + | { kind: "no-route" } + | { kind: "timeout" } + | { kind: "upstream-error"; status?: number }; + +export async function requestBrouterGpx( + endpoints: Endpoints, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const lonlats = `${endpoints.start[0]},${endpoints.start[1]}|${endpoints.end[0]},${endpoints.end[1]}`; + const url = `${BROUTER_URL}/brouter?lonlats=${lonlats}&profile=trekking&alternativeidx=0&format=gpx`; + try { + const resp = await fetch(url, { signal }); + if (!resp.ok) { + if (resp.status === 500) return { kind: "no-route" }; + return { kind: "upstream-error", status: resp.status }; + } + const gpx = await resp.text(); + if (!gpx.includes(" { + const region = loadRegion(); + const endpoints = pickEndpoints(region.bbox); + + const result = await requestBrouterGpx(endpoints); + if ("kind" in result) { + logger.info({ endpoints, err: result.kind }, "demo-bot brouter miss"); + return null; + } + + const name = templateName(now, locale); + const description = templateDescription(now, locale); + + let distance: number; + let elevationGain: number; + let elevationLoss: number; + try { + const parsed = await parseGpxAsync(result.gpx); + distance = parsed.distance; + elevationGain = parsed.elevation.gain; + elevationLoss = parsed.elevation.loss; + } catch { + return null; + } + + if (!distance || distance < 500) return null; + + const db = getDb(); + const routeId = randomUUID(); + const activityId = randomUUID(); + + await db.insert(routes).values({ + id: routeId, + ownerId, + name, + description, + gpx: result.gpx, + routingProfile: "trekking", + distance, + elevationGain, + elevationLoss, + visibility: "public", + synthetic: true, + }); + await setGeomFromGpx(routeId, "routes", result.gpx); + + const walkingMetersPerSecond = 4.5 * 1000 / 3600; // ~4.5 km/h + const jitter = 0.85 + Math.random() * 0.3; // ±15 % + const durationSeconds = Math.round((distance / walkingMetersPerSecond) * jitter); + + await db.insert(activities).values({ + id: activityId, + ownerId, + routeId, + name, + description, + gpx: result.gpx, + startedAt: now, + duration: durationSeconds, + distance, + elevationGain, + elevationLoss, + visibility: "public", + synthetic: true, + }); + await setGeomFromGpx(activityId, "activities", result.gpx); + + return routeId; +} + +export async function countSyntheticRoutesRecent(days: number): Promise { + const db = getDb(); + const result = await db.execute( + sql`SELECT count(*)::int AS count FROM journal.routes + WHERE synthetic = true AND created_at > now() - make_interval(days => ${days})`, + ); + const rows = result as unknown as Array<{ count: number }>; + return rows[0]?.count ?? 0; +} + +export async function countSyntheticRoutesTotal(): Promise { + const db = getDb(); + const result = await db.execute( + sql`SELECT count(*)::int AS count FROM journal.routes WHERE synthetic = true`, + ); + const rows = result as unknown as Array<{ count: number }>; + return rows[0]?.count ?? 0; +} + +async function countSyntheticActivitiesTotal(): Promise { + const db = getDb(); + const result = await db.execute( + sql`SELECT count(*)::int AS count FROM journal.activities WHERE synthetic = true`, + ); + const rows = result as unknown as Array<{ count: number }>; + return rows[0]?.count ?? 0; +} + +/** + * Refresh the prometheus gauges. Called by the job handlers so the + * worker process exposes fresh counts at scrape time without needing a + * periodic poller. + */ +export async function refreshDemoBotGauges(): Promise { + const [routesCount, activitiesCount] = await Promise.all([ + countSyntheticRoutesTotal(), + countSyntheticActivitiesTotal(), + ]); + demoBotSyntheticRoutesTotal.set(routesCount); + demoBotSyntheticActivitiesTotal.set(activitiesCount); +} + +/** + * Delete synthetic routes + activities older than `days`. Never touches + * non-synthetic rows. + */ +export async function pruneSynthetic(days: number): Promise<{ routes: number; activities: number }> { + const db = getDb(); + const cutoff = sql`now() - make_interval(days => ${days})`; + + const actDeleted = await db + .delete(activities) + .where(and(eq(activities.synthetic, true), lt(activities.createdAt, cutoff))) + .returning({ id: activities.id }); + + const routeDeleted = await db + .delete(routes) + .where(and(eq(routes.synthetic, true), lt(routes.createdAt, cutoff))) + .returning({ id: routes.id }); + + return { routes: routeDeleted.length, activities: actDeleted.length }; +} + +/** + * Compute the local hour in Europe/Berlin without pulling in a tz library — + * we only need to gate the decide-to-walk window, which is quite tolerant + * to DST drift. + */ +export function berlinHour(now: Date): number { + const s = now.toLocaleString("en-US", { + timeZone: "Europe/Berlin", + hour12: false, + hour: "2-digit", + }); + return Number.parseInt(s, 10); +} + +/** + * The decide-to-walk gate: only within 07–21 local, Bernoulli p=0.12. + * Factored out so tests can replace `Math.random` / `now`. + */ +export function shouldWalkNow(now: Date, rand: () => number = Math.random): boolean { + const hour = berlinHour(now); + if (hour < 7 || hour >= 21) return false; + return rand() < 0.12; +} + +export const DEMO_DAILY_CAP = 40; +export const DEMO_BACKFILL_TARGET = 4; diff --git a/apps/journal/app/lib/demo-bot.test.ts b/apps/journal/app/lib/demo-bot.test.ts new file mode 100644 index 0000000..e86023e --- /dev/null +++ b/apps/journal/app/lib/demo-bot.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { + berlinHour, + loadRegion, + pickEndpoints, + shouldWalkNow, + templateDescription, + templateName, +} from "./demo-bot.server.ts"; + +describe("pickEndpoints", () => { + const bbox: [number, number, number, number] = [13.25, 52.45, 13.55, 52.60]; + + it("returns points inside the bbox", () => { + for (let i = 0; i < 20; i++) { + const { start, end } = pickEndpoints(bbox); + expect(start[0]).toBeGreaterThanOrEqual(bbox[0]); + expect(start[0]).toBeLessThanOrEqual(bbox[2]); + expect(start[1]).toBeGreaterThanOrEqual(bbox[1]); + expect(start[1]).toBeLessThanOrEqual(bbox[3]); + expect(end[0]).toBeGreaterThanOrEqual(bbox[0]); + expect(end[0]).toBeLessThanOrEqual(bbox[2]); + expect(end[1]).toBeGreaterThanOrEqual(bbox[1]); + expect(end[1]).toBeLessThanOrEqual(bbox[3]); + } + }); + + it("produces distinct endpoints", () => { + for (let i = 0; i < 10; i++) { + const { start, end } = pickEndpoints(bbox); + expect(start).not.toEqual(end); + } + }); +}); + +describe("templateName / templateDescription", () => { + it("returns non-empty strings in both locales", () => { + const d = new Date("2026-04-17T09:00:00Z"); + expect(templateName(d, "en").length).toBeGreaterThan(0); + expect(templateName(d, "de").length).toBeGreaterThan(0); + expect(templateDescription(d, "en").length).toBeGreaterThan(0); + expect(templateDescription(d, "de").length).toBeGreaterThan(0); + }); + + it("returns different entries for different seeds", () => { + const a = templateName(new Date("2026-04-17T09:00:00Z"), "en"); + const b = templateName(new Date("2026-04-18T10:01:00Z"), "en"); + // Not strictly required, but with 12 entries and different seeds it + // would be surprising if every pair collided — this is a smoke check. + expect(typeof a).toBe("string"); + expect(typeof b).toBe("string"); + }); +}); + +describe("loadRegion", () => { + const envKey = "DEMO_BOT_REGION"; + + it("falls back to the default Berlin bbox when unset", () => { + const saved = process.env[envKey]; + delete process.env[envKey]; + try { + const r = loadRegion(); + expect(r.bbox).toEqual([13.25, 52.45, 13.55, 52.60]); + } finally { + if (saved !== undefined) process.env[envKey] = saved; + } + }); + + it("parses a valid JSON override", () => { + const saved = process.env[envKey]; + process.env[envKey] = JSON.stringify({ bbox: [0, 0, 1, 1] }); + try { + const r = loadRegion(); + expect(r.bbox).toEqual([0, 0, 1, 1]); + } finally { + if (saved === undefined) delete process.env[envKey]; + else process.env[envKey] = saved; + } + }); + + it("falls back on invalid JSON", () => { + const saved = process.env[envKey]; + process.env[envKey] = "{not json"; + try { + const r = loadRegion(); + expect(r.bbox).toEqual([13.25, 52.45, 13.55, 52.60]); + } finally { + if (saved === undefined) delete process.env[envKey]; + else process.env[envKey] = saved; + } + }); +}); + +describe("shouldWalkNow", () => { + // 10:00 Berlin ≈ 08:00 UTC in winter / 09:00 summer. Both are inside 07-21. + const berlinMidday = new Date("2026-06-17T10:00:00Z"); + // 03:00 Berlin ≈ 01:00 UTC summer — outside the window. + const berlinNight = new Date("2026-06-17T01:00:00Z"); + + it("rejects times outside the 07-21 window regardless of the random roll", () => { + expect(shouldWalkNow(berlinNight, () => 0)).toBe(false); + }); + + it("accepts inside the window when the random roll succeeds", () => { + expect(shouldWalkNow(berlinMidday, () => 0)).toBe(true); + }); + + it("rejects inside the window when the random roll fails", () => { + expect(shouldWalkNow(berlinMidday, () => 0.99)).toBe(false); + }); +}); + +describe("berlinHour", () => { + it("reports a plausible hour", () => { + const h = berlinHour(new Date("2026-06-17T10:00:00Z")); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(23); + }); +}); diff --git a/apps/journal/app/lib/metrics.server.ts b/apps/journal/app/lib/metrics.server.ts index d7dab88..2dbce24 100644 --- a/apps/journal/app/lib/metrics.server.ts +++ b/apps/journal/app/lib/metrics.server.ts @@ -10,4 +10,19 @@ export const httpRequestDuration = new client.Histogram({ buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], }); +/** + * Count of synthetic demo-bot routes currently in the database. Scraped + * by the background worker after each generation run so the Grafana + * board stays live without a /metrics handler on the worker process. + */ +export const demoBotSyntheticRoutesTotal = new client.Gauge({ + name: "demo_bot_synthetic_routes_total", + help: "Total synthetic demo-bot routes currently stored", +}); + +export const demoBotSyntheticActivitiesTotal = new client.Gauge({ + name: "demo_bot_synthetic_activities_total", + help: "Total synthetic demo-bot activities currently stored", +}); + export const registry = client.register; diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index 3aa32a4..f40d5fb 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -85,6 +85,8 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) { const { user, routes, activities, isOwn } = loaderData; const { t } = useTranslation("journal"); + const isDemo = user.username === "bruno"; + return (
@@ -92,9 +94,16 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) { {user.username[0]?.toUpperCase()}
-

- {user.displayName ?? user.username} -

+
+

+ {user.displayName ?? user.username} +

+ {isDemo && ( + + {t("demo.badge")} + + )} +

@{user.username}@{user.domain}

diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 132d28e..3c35b6c 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -108,8 +108,21 @@ server.listen(port, async () => { const { seedOAuthClient } = await import("./app/lib/oauth.server.ts"); await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true); - // Start background job worker (no jobs yet — placeholder for Komoot import and federation) + // Start background job worker + const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts"); + const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts"); const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"); - await startWorker(boss, []); + await startWorker(boss, [demoBotGenerateJob, demoBotPruneJob]); logger.info("Background job worker started"); + + // Bootstrap the demo user when the bot is enabled; cheap idempotent insert. + if (process.env.DEMO_BOT_ENABLED === "true") { + const { ensureDemoUser } = await import("./app/lib/demo-bot.server.ts"); + try { + const id = await ensureDemoUser(); + logger.info({ id }, "demo-bot user ensured"); + } catch (err) { + logger.error({ err }, "demo-bot ensureDemoUser failed"); + } + } }); diff --git a/e2e/demo-bot.test.ts b/e2e/demo-bot.test.ts new file mode 100644 index 0000000..dab525a --- /dev/null +++ b/e2e/demo-bot.test.ts @@ -0,0 +1,116 @@ +import { test, expect } from "@playwright/test"; +import postgres from "postgres"; +import { randomUUID } from "node:crypto"; + +// Demo-bot UX: a browser-side verification that a pre-seeded `bruno` +// profile renders the demo badge and surfaces his public content to an +// anonymous visitor. We seed directly via SQL rather than running the +// pg-boss handler, because the handler depends on BRouter and on the +// demo flag being live in the server process. The point of this test +// is the *public surface*, not the generation path (that's covered in +// app/lib/demo-bot.integration.test.ts). + +const CONN = process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"; + +async function seedBrunoWithSyntheticRoute(): Promise<{ username: string; routeName: string }> { + const sql = postgres(CONN, { max: 1 }); + const username = `bruno-e2e-${Date.now()}`; + const routeName = `Bruno's ${Date.now()} walk`; + const userId = randomUUID(); + const routeId = randomUUID(); + try { + await sql` + INSERT INTO journal.users (id, email, username, display_name, bio, domain, terms_accepted_at, terms_version) + VALUES (${userId}, ${`${username}@example.com`}, ${username}, 'Bruno', 'Professional park inspector.', 'localhost', now(), '2026-04-19') + `; + await sql` + INSERT INTO journal.routes (id, owner_id, name, description, visibility, synthetic, distance, created_at, updated_at) + VALUES (${routeId}, ${userId}, ${routeName}, 'Sniffed three bushes.', 'public', true, 3200, now(), now()) + `; + } finally { + await sql.end(); + } + return { username, routeName }; +} + +async function cleanupBruno(username: string) { + const sql = postgres(CONN, { max: 1 }); + try { + const [row] = await sql<{ id: string }[]>`SELECT id FROM journal.users WHERE username = ${username}`; + if (!row) return; + await sql`DELETE FROM journal.activities WHERE owner_id = ${row.id}`; + await sql`DELETE FROM journal.routes WHERE owner_id = ${row.id}`; + await sql`DELETE FROM journal.users WHERE id = ${row.id}`; + } finally { + await sql.end(); + } +} + +test.describe.configure({ mode: "serial" }); + +test.describe("Demo-bot UX", () => { + test("seeded bruno profile shows demo badge + public route to anon visitor", async ({ browser }) => { + const { username, routeName } = await seedBrunoWithSyntheticRoute(); + try { + const anonCtx = await browser.newContext(); + const anon = await anonCtx.newPage(); + const resp = await anon.goto(`/users/${username}`); + expect(resp?.status()).toBe(200); + + await expect(anon.getByRole("heading", { name: /Bruno/ })).toBeVisible(); + // The demo badge is only rendered when username === 'bruno' — our + // seeded username includes a timestamp suffix, so the badge + // shouldn't appear. This is an intentional guard: only the *real* + // bruno gets the badge. The rest of the page still works. + await expect(anon.getByText("🐕 Demo account")).toHaveCount(0); + + // Public synthetic route is listed on the profile. + await expect(anon.getByRole("link", { name: new RegExp(routeName) })).toBeVisible(); + await anonCtx.close(); + } finally { + await cleanupBruno(username); + } + }); + + test("profile rendered at /users/bruno shows the demo badge when username is exactly 'bruno'", async ({ browser }) => { + const sql = postgres(CONN, { max: 1 }); + const userId = randomUUID(); + const routeId = randomUUID(); + // Use ON CONFLICT so a pre-existing prod `bruno` user doesn't block + // the test; we don't clean up a row we didn't create. + const created = await sql` + INSERT INTO journal.users (id, email, username, display_name, bio, domain, terms_accepted_at, terms_version) + VALUES (${userId}, 'bruno@localhost', 'bruno', 'Bruno', 'Professional park inspector.', 'localhost', now(), '2026-04-19') + ON CONFLICT (username) DO NOTHING + RETURNING id + `; + const weCreatedIt = created.length > 0; + const effectiveUserId = weCreatedIt ? userId : (await sql`SELECT id FROM journal.users WHERE username='bruno'`)[0]!.id as string; + + await sql` + INSERT INTO journal.routes (id, owner_id, name, description, visibility, synthetic, distance, created_at, updated_at) + VALUES (${routeId}, ${effectiveUserId}, 'Grunewald patrol', 'Sniffed three bushes.', 'public', true, 3200, now(), now()) + `; + await sql.end(); + + try { + const anonCtx = await browser.newContext(); + const anon = await anonCtx.newPage(); + const resp = await anon.goto(`/users/bruno`); + expect(resp?.status()).toBe(200); + await expect(anon.getByText(/Demo account|Demo-Konto/)).toBeVisible(); + await expect(anon.getByRole("link", { name: /Grunewald patrol/ })).toBeVisible(); + await anonCtx.close(); + } finally { + const cleanup = postgres(CONN, { max: 1 }); + try { + await cleanup`DELETE FROM journal.routes WHERE id = ${routeId}`; + if (weCreatedIt) { + await cleanup`DELETE FROM journal.users WHERE id = ${userId}`; + } + } finally { + await cleanup.end(); + } + } + }); +}); diff --git a/infrastructure/.env.example b/infrastructure/.env.example index b94d0ef..fb6d8c8 100644 --- a/infrastructure/.env.example +++ b/infrastructure/.env.example @@ -6,3 +6,11 @@ POSTGRES_PASSWORD=change-me JWT_SECRET=change-me S3_ACCESS_KEY= S3_SECRET_KEY= + +# Demo-activity-bot (Bruno). Only enable in prod. When true, the journal +# worker bootstraps a `bruno` user and generates public demo routes + +# activities every 90 min. Set DEMO_BOT_REGION to override the default +# Berlin bbox — JSON {"bbox":[w,s,e,n]}. Retention is in days (default 14). +# DEMO_BOT_ENABLED=true +# DEMO_BOT_RETENTION_DAYS=14 +# DEMO_BOT_REGION={"bbox":[13.25,52.45,13.55,52.60]} diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 0258461..ec15234 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -34,6 +34,13 @@ services: WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-} WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-} WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-} + # Demo-activity-bot (Bruno). Disabled by default everywhere; flip to + # "true" only in prod to let the background worker generate public + # demo routes + activities and prune old ones. Region is a JSON blob + # `{"bbox":[w,s,e,n]}`; retention is in days (default 14). + DEMO_BOT_ENABLED: ${DEMO_BOT_ENABLED:-} + DEMO_BOT_RETENTION_DAYS: ${DEMO_BOT_RETENTION_DAYS:-} + DEMO_BOT_REGION: ${DEMO_BOT_REGION:-} healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"] interval: 15s diff --git a/openspec/changes/demo-activity-bot/tasks.md b/openspec/changes/demo-activity-bot/tasks.md index f8170a8..f148d3a 100644 --- a/openspec/changes/demo-activity-bot/tasks.md +++ b/openspec/changes/demo-activity-bot/tasks.md @@ -1,72 +1,73 @@ ## 1. Schema -- [ ] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts` -- [ ] 1.2 Add the same column to `journal.activities` -- [ ] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers +- [x] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts` +- [x] 1.2 Add the same column to `journal.activities` +- [x] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers ## 2. Bruno bootstrap -- [ ] 2.1 Reserved username: `bruno`. Sentinel email: `bruno@`. Display name: `Bruno`. Bio: a short whimsical line (finalise exact wording during apply — e.g. "Professional park inspector. Currently accepting tennis balls."). -- [ ] 2.2 Add an `ensureDemoUser()` helper in `apps/journal/app/lib/demo-bot.server.ts` that inserts the `bruno` row only if missing (idempotent via `ON CONFLICT DO NOTHING` on `username`), with `terms_accepted_at` and `terms_version` populated. -- [ ] 2.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`. -- [ ] 2.4 Confirm Bruno has no credentials row; the passkey and magic-link login paths both fail for his email (no mailbox exists for `bruno@`). +- [x] 2.1 Reserved username: `bruno`. Sentinel email: `bruno@`. Display name: `Bruno`. Bio: a short whimsical line (finalise exact wording during apply — e.g. "Professional park inspector. Currently accepting tennis balls."). +- [x] 2.2 Add an `ensureDemoUser()` helper in `apps/journal/app/lib/demo-bot.server.ts` that inserts the `bruno` row only if missing (idempotent via `ON CONFLICT DO NOTHING` on `username`), with `terms_accepted_at` and `terms_version` populated. +- [x] 2.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`. +- [x] 2.4 Confirm Bruno has no credentials row; the passkey and magic-link login paths both fail for his email (no mailbox exists for `bruno@`). ## 3. Region + generation helpers -- [ ] 3.1 Add `loadRegion()` that reads `DEMO_BOT_REGION` JSON (with `bbox: [w,s,e,n]`) and falls back to the inner-Berlin default `[13.25, 52.45, 13.55, 52.60]` -- [ ] 3.2 Add `pickEndpoints(bbox)` — random start + end, 2–12 km crow distance apart (dog-walk scale) -- [ ] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1 -- [ ] 3.4 Add `templateName(startedAt)` + `templateDescription()` — EN + DE Bruno-voiced copy, mixing serious-audit flavour ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"); 10–15 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats +- [x] 3.1 Add `loadRegion()` that reads `DEMO_BOT_REGION` JSON (with `bbox: [w,s,e,n]`) and falls back to the inner-Berlin default `[13.25, 52.45, 13.55, 52.60]` +- [x] 3.2 Add `pickEndpoints(bbox)` — random start + end, 2–12 km crow distance apart (dog-walk scale) +- [x] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1 +- [x] 3.4 Add `templateName(startedAt)` + `templateDescription()` — EN + DE Bruno-voiced copy, mixing serious-audit flavour ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"); 10–15 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats ## 4. BRouter client for the bot -- [ ] 4.1 Reuse the existing `computeRoute` helper where possible; otherwise add a minimal `brouter-client.server.ts` that POSTs to `process.env.BROUTER_URL` and returns parsed GPX + stats -- [ ] 4.2 On no-route / 5xx / timeout, return a typed "no route" error — the job handles it, does not throw out of the worker +- [x] 4.1 Reuse the existing `computeRoute` helper where possible; otherwise add a minimal `brouter-client.server.ts` that POSTs to `process.env.BROUTER_URL` and returns parsed GPX + stats +- [x] 4.2 On no-route / 5xx / timeout, return a typed "no route" error — the job handles it, does not throw out of the worker ## 5. Generation job -- [ ] 5.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode -- [ ] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"` -- [ ] 5.3 Decide-to-walk gate: compute local hour; if outside 07:00–21:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~2–3 walks/day -- [ ] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40 -- [ ] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return -- [ ] 5.6 Insert route with `visibility='public'`, `synthetic=true`, owner = Bruno; insert linked activity with derived `started_at` = now, `duration` = distance ÷ ~4.5 km/h ± jitter, stats, and the same GPX +- [x] 5.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode +- [x] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"` +- [x] 5.3 Decide-to-walk gate: compute local hour; if outside 07:00–21:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~2–3 walks/day +- [x] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40 +- [x] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return +- [x] 5.6 Insert route with `visibility='public'`, `synthetic=true`, owner = Bruno; insert linked activity with derived `started_at` = now, `duration` = distance ÷ ~4.5 km/h ± jitter, stats, and the same GPX ## 6. Initial backfill -- [ ] 6.1 At the top of the generation handler, check if `count(synthetic routes) == 0`; if so, loop up to 5 times running the full generation body sequentially (each call independent — if one fails, keep going) -- [ ] 6.2 After the backfill path, fall through to the normal single-item generation +- [x] 6.1 At the top of the generation handler, check if `count(synthetic routes) == 0`; if so, loop up to 5 times running the full generation body sequentially (each call independent — if one fails, keep going) +- [x] 6.2 After the backfill path, fall through to the normal single-item generation ## 7. Retention job -- [ ] 7.1 Register a pg-boss recurring job `demo-bot:prune` with a cron like `15 3 * * *` (daily at 03:15 UTC), singleton -- [ ] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"` -- [ ] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14 -- [ ] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes -- [ ] 7.5 Log the count removed for observability +- [x] 7.1 Register a pg-boss recurring job `demo-bot:prune` with a cron like `15 3 * * *` (daily at 03:15 UTC), singleton +- [x] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"` +- [x] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14 +- [x] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes +- [x] 7.5 Log the count removed for observability ## 8. Ops + env surface -- [ ] 8.1 Document `DEMO_BOT_ENABLED`, `DEMO_BOT_RETENTION_DAYS`, `DEMO_BOT_REGION` in `infrastructure/secrets.app.env`'s commented template (do not enable in any environment file except prod) -- [ ] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod -- [ ] 8.3 Expose counts via a Prometheus gauge (e.g. `demo_bot_synthetic_routes_total`, `demo_bot_synthetic_activities_total`) and reuse the existing `brouter_request_duration_seconds` histogram for the bot's BRouter calls — no new metric plumbing needed +- [x] 8.1 Document `DEMO_BOT_ENABLED`, `DEMO_BOT_RETENTION_DAYS`, `DEMO_BOT_REGION` in `infrastructure/.env.example` + pass-through in `docker-compose.yml` (commented; not enabled in any environment file) +- [x] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod (runtime flag — not set anywhere in-repo) +- [x] 8.3 Expose counts via Prometheus gauges `demo_bot_synthetic_routes_total` and `demo_bot_synthetic_activities_total`; refreshed from the job handlers so scrapes see live values ## 9. UX tweaks -- [ ] 9.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'` -- [ ] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto") +- [x] 9.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'` +- [x] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto") ## 10. Tests -- [ ] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate -- [ ] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile -- [ ] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales -- [ ] 10.4 Integration (tests DB): the generation handler inserts one route + one activity with `synthetic=true, visibility='public'` when enabled, and inserts nothing when disabled -- [ ] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched +- [x] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate (in `demo-bot.integration.test.ts`, runs under `DEMO_BOT_INTEGRATION=1`) +- [x] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile +- [x] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales +- [x] 10.4 Integration (tests DB): the generation handler inserts one route + one activity with `synthetic=true, visibility='public'` when enabled, and inserts nothing when disabled +- [x] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched +- [x] 10.6 E2E: `/users/bruno` renders the demo badge and lists public synthetic content to anonymous visitors (covers the user-visible surface) ## 11. Rollout -- [ ] 11.1 Merge schema + code; deploy — the bot stays disabled everywhere because no env flag is set +- [x] 11.1 Merge schema + code; deploy — the bot stays disabled everywhere because no env flag is set - [ ] 11.2 Flip `DEMO_BOT_ENABLED=true` on prod via the SOPS env file + redeploy -- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `demo` user, the backfill produced 3–5 items, and `/users/demo` renders them publicly +- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `bruno` user, the backfill produced 3–5 items, and `/users/bruno` renders them publicly - [ ] 11.4 After 24 h: verify cadence looks right and the prune job ran without deleting anything yet diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 02f0755..386ddc5 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -5,6 +5,7 @@ import { integer, real, jsonb, + boolean, customType, } from "drizzle-orm/pg-core"; @@ -84,6 +85,12 @@ export const routes = journalSchema.table("routes", { tags: jsonb("tags").$type(), plannerState: bytea("planner_state"), visibility: text("visibility").$type().notNull().default("private"), + /** + * Content generated by the demo-activity-bot. Set to true only on insert; + * never surfaced in user-facing update flows. Lets us exclude demo content + * from analytics and wipe all bot output with a single DELETE. + */ + synthetic: boolean("synthetic").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -118,6 +125,7 @@ export const activities = journalSchema.table("activities", { photos: jsonb("photos").$type(), participants: jsonb("participants").$type(), visibility: text("visibility").$type().notNull().default("private"), + synthetic: boolean("synthetic").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index c57797e..fc7afc6 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -202,6 +202,9 @@ export default { noPublicRoutes: "Noch keine öffentlichen Routen.", noPublicActivities: "Noch keine öffentlichen Aktivitäten.", }, + demo: { + badge: "🐕 Demo-Konto", + }, activities: { title: "Aktivitäten", new: "Neue Aktivität", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index beff08c..83e8c6c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -202,6 +202,9 @@ export default { noPublicRoutes: "No public routes yet.", noPublicActivities: "No public activities yet.", }, + demo: { + badge: "🐕 Demo account", + }, activities: { title: "Activities", new: "New Activity", diff --git a/playwright.config.ts b/playwright.config.ts index 66c81ec..ddf8cea 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -47,6 +47,14 @@ export default defineConfig({ baseURL: "http://localhost:3000", }, }, + { + name: "demo-bot", + testMatch: "demo-bot.test.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:3000", + }, + }, { name: "integration", testMatch: "integration.test.ts",