Apply configurable-demo-persona: per-instance demo identity + voice

Adds DEMO_BOT_PERSONA env var (inline JSON or file:<path>) so
self-hosted instances can replace Bruno-in-Berlin with their own
demo account identity and content pools. No-op for trails.cool — the
built-in Bruno persona remains the default.

- DemoPersona type + Zod schema validation
- loadPersona() cached at boot; falls back to default on any failure
- ensureDemoUser throws DemoPersonaUsernameClashError when the
  persona username is already a real user; server declines to
  schedule demo jobs for that process
- isDemoUser flag moved from hardcoded "bruno" check to loader-supplied
  boolean computed from the running persona
- docs/demo-persona.md explains the schema + operator flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 10:30:32 +02:00
parent f447852d70
commit fc4485f6ef
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
15 changed files with 877 additions and 113 deletions

View file

@ -3,11 +3,14 @@ import { sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, users } from "@trails-cool/db/schema/journal";
import {
DEMO_USERNAME,
DEFAULT_PERSONA,
DemoPersonaUsernameClashError,
__resetPersonaCacheForTests,
ensureDemoUser,
generateOneWalk,
pruneSynthetic,
} from "./demo-bot.server.ts";
import { randomUUID } from "node:crypto";
// 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
@ -41,6 +44,10 @@ describe.skipIf(!runIntegration)("demo-bot integration", () => {
afterEach(async () => {
await wipeSynthetic();
// Scrub any test-inserted Bruno so the next test starts clean.
const db = getDb();
await db.execute(sql`DELETE FROM journal.users WHERE username = ${DEFAULT_PERSONA.username}`);
__resetPersonaCacheForTests();
vi.restoreAllMocks();
});
@ -53,10 +60,27 @@ describe.skipIf(!runIntegration)("demo-bot integration", () => {
const rows = await db
.select()
.from(users)
.where(sql`${users.username} = ${DEMO_USERNAME}`);
.where(sql`${users.username} = ${DEFAULT_PERSONA.username}`);
expect(rows.length).toBe(1);
});
it("ensureDemoUser throws username-clash when a real user owns the persona name", async () => {
const db = getDb();
const domain = process.env.DOMAIN ?? "localhost";
const realUserId = randomUUID();
// Create a real user at the persona username with a NON-sentinel email.
await db.execute(sql`
INSERT INTO journal.users (id, email, username, domain, terms_accepted_at, terms_version)
VALUES (${realUserId}, ${`real-${Date.now()}@${domain}`}, ${DEFAULT_PERSONA.username}, ${domain}, now(), '2026-04-19')
`);
try {
await expect(ensureDemoUser()).rejects.toBeInstanceOf(DemoPersonaUsernameClashError);
} finally {
await db.execute(sql`DELETE FROM journal.users WHERE id = ${realUserId}`);
}
});
it("generateOneWalk inserts a public synthetic route + activity", async () => {
vi.stubGlobal(
"fetch",

View file

@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { z } from "zod";
import { and, eq, lt, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, users } from "@trails-cool/db/schema/journal";
@ -11,10 +13,198 @@ import {
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.";
// --- Persona configuration -----------------------------------------------
export type DemoLocale = "en" | "de";
export interface DemoPersonaContent {
names: { en?: string[]; de?: string[] };
descriptions: { en?: string[]; de?: string[] };
}
export interface DemoPersona {
username: string;
displayName: string;
bio: string;
locales: DemoLocale[];
content: DemoPersonaContent;
}
const LocaleSchema = z.enum(["en", "de"]);
const PoolSchema = z.array(z.string().min(1)).min(3).max(50);
const PersonaSchema = z
.object({
username: z.string().regex(/^[a-z0-9][a-z0-9_-]{1,30}$/),
displayName: z.string().min(1).max(200),
bio: z.string().max(200),
locales: z.array(LocaleSchema).min(1),
content: z.object({
names: z.object({ en: PoolSchema.optional(), de: PoolSchema.optional() }),
descriptions: z.object({
en: PoolSchema.optional(),
de: PoolSchema.optional(),
}),
}),
})
.superRefine((p, ctx) => {
for (const loc of p.locales) {
if (!p.content.names[loc]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content", "names", loc],
message: `missing name pool for declared locale '${loc}'`,
});
}
if (!p.content.descriptions[loc]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content", "descriptions", loc],
message: `missing description pool for declared locale '${loc}'`,
});
}
}
});
/**
* Built-in Bruno persona. Used verbatim when no `DEMO_BOT_PERSONA` is
* supplied or the supplied override fails validation. Moving these
* strings behind the persona abstraction means every reference to the
* bot's identity flows through one place.
*/
export const DEFAULT_PERSONA: DemoPersona = Object.freeze<DemoPersona>({
username: "bruno",
displayName: "Bruno",
bio: "Professional park inspector. Currently accepting tennis balls.",
locales: ["en", "de"],
content: {
names: {
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",
],
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",
],
},
descriptions: {
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.",
],
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.",
],
},
},
});
// Sanity: the default persona itself must parse under the schema. Catch
// accidental drift at module load rather than first invocation.
PersonaSchema.parse(DEFAULT_PERSONA);
let _cachedPersona: DemoPersona | null = null;
/**
* Read the persona from `DEMO_BOT_PERSONA`. Accepts either inline JSON
* or `file:<absolute-path>`. Falls back to `DEFAULT_PERSONA` on any
* failure (unset, bad JSON, schema violation, unreadable file) with a
* single warn-level log line per process.
*/
export function loadPersona(): DemoPersona {
if (_cachedPersona) return _cachedPersona;
_cachedPersona = Object.freeze(loadPersonaUncached());
return _cachedPersona;
}
function loadPersonaUncached(): DemoPersona {
const raw = process.env.DEMO_BOT_PERSONA;
if (!raw) return DEFAULT_PERSONA;
let json: string;
if (raw.startsWith("file:")) {
const path = raw.slice("file:".length);
try {
json = readFileSync(path, "utf8");
} catch (err) {
logger.warn(
{ path, err: (err as Error).message },
"DEMO_BOT_PERSONA file unreadable, using default",
);
return DEFAULT_PERSONA;
}
} else {
json = raw;
}
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (err) {
logger.warn(
{ err: (err as Error).message },
"DEMO_BOT_PERSONA not valid JSON, using default",
);
return DEFAULT_PERSONA;
}
const result = PersonaSchema.safeParse(parsed);
if (!result.success) {
const first = result.error.issues[0];
logger.warn(
{ path: first?.path.join("."), message: first?.message },
"DEMO_BOT_PERSONA schema violation, using default",
);
return DEFAULT_PERSONA;
}
return result.data;
}
/**
* Test-only hook to reset the cached persona between tests. Never call
* from application code.
*/
export function __resetPersonaCacheForTests() {
_cachedPersona = null;
}
/**
* Inner Berlin (Tiergarten Grunewald corridor). Keeps Bruno's walks
@ -35,28 +225,59 @@ export function demoRetentionDays(): number {
}
/**
* Idempotent insert of the Bruno demo user. Safe to call every worker
* boot; relies on `users.username` uniqueness to short-circuit duplicates.
* Surfaced when the persona username already belongs to a real user.
* Callers (worker boot) treat this as a signal to skip scheduling the
* bot for this process instead of silently attaching to a human's
* account.
*/
export async function ensureDemoUser(): Promise<string> {
export class DemoPersonaUsernameClashError extends Error {
readonly kind = "username-clash";
readonly username: string;
constructor(username: string) {
super(`demo persona username '${username}' is already taken by a non-demo user`);
this.username = username;
}
}
function expectedSentinelEmail(persona: DemoPersona, domain: string): string {
return `${persona.username}@${domain}`;
}
/**
* Idempotent insert of the demo user described by `persona`. Safe to
* call every worker boot; relies on `users.username` uniqueness to
* short-circuit duplicates. Throws `DemoPersonaUsernameClashError` if
* the row exists but looks like a real account (email doesn't match
* the sentinel pattern) callers should catch this and keep the bot
* disabled for the process.
*/
export async function ensureDemoUser(
persona: DemoPersona = loadPersona(),
): Promise<string> {
const db = getDb();
const domain = process.env.DOMAIN ?? "localhost";
const sentinelEmail = expectedSentinelEmail(persona, domain);
const [existing] = await db
.select({ id: users.id })
.select({ id: users.id, email: users.email })
.from(users)
.where(eq(users.username, DEMO_USERNAME));
if (existing) return existing.id;
.where(eq(users.username, persona.username));
if (existing) {
if (existing.email !== sentinelEmail) {
throw new DemoPersonaUsernameClashError(persona.username);
}
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,
username: persona.username,
email: sentinelEmail,
displayName: persona.displayName,
bio: persona.bio,
domain,
termsAcceptedAt: new Date(),
termsVersion: TERMS_VERSION,
@ -65,10 +286,13 @@ export async function ensureDemoUser(): Promise<string> {
// Re-read in case a concurrent insert won the race
const [row] = await db
.select({ id: users.id })
.select({ id: users.id, email: users.email })
.from(users)
.where(eq(users.username, DEMO_USERNAME));
.where(eq(users.username, persona.username));
if (!row) throw new Error("ensureDemoUser: insert succeeded but row not found");
if (row.email !== sentinelEmail) {
throw new DemoPersonaUsernameClashError(persona.username);
}
return row.id;
}
@ -141,63 +365,7 @@ export function pickEndpoints(bbox: [number, number, number, number]): Endpoints
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.",
];
// --- Persona-driven copy -------------------------------------------------
function pickFrom<T>(pool: readonly T[], seed: number): T {
const idx = Math.abs(Math.floor(seed)) % pool.length;
@ -205,26 +373,46 @@ function pickFrom<T>(pool: readonly T[], seed: number): T {
}
/**
* 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.
* Pull a route name from the persona's name pool for the given locale.
* Seeded by the start-time so same-day walks usually don't collide.
* Falls back to the first persona locale if the caller asked for a
* locale the persona doesn't support (defensive the caller is
* expected to pick from `persona.locales`).
*/
export function templateName(startedAt: Date, locale: "en" | "de" = "en"): string {
const pool = locale === "de" ? NAME_POOL_DE : NAME_POOL_EN;
export function templateName(
startedAt: Date,
locale: DemoLocale,
persona: DemoPersona = loadPersona(),
): string {
const pool = persona.content.names[locale] ?? persona.content.names[persona.locales[0]!]!;
const seed = startedAt.getUTCDate() * 60 + startedAt.getUTCMinutes();
return pickFrom(pool, seed);
}
export function templateDescription(
startedAt: Date,
locale: "en" | "de" = "en",
locale: DemoLocale,
persona: DemoPersona = loadPersona(),
): string {
const pool = locale === "de" ? DESCRIPTION_POOL_DE : DESCRIPTION_POOL_EN;
const pool =
persona.content.descriptions[locale] ??
persona.content.descriptions[persona.locales[0]!]!;
const seed = startedAt.getUTCDate() * 137 + startedAt.getUTCSeconds() * 7;
return pickFrom(pool, seed);
}
/**
* Random locale from the persona's supported set. Used by the
* generation job so instances with `locales: ["en"]` never emit
* German-voiced walks.
*/
export function pickLocale(
persona: DemoPersona = loadPersona(),
rand: () => number = Math.random,
): DemoLocale {
return persona.locales[Math.floor(rand() * persona.locales.length)]!;
}
// --- BRouter --------------------------------------------------------------
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
@ -263,12 +451,14 @@ export async function requestBrouterGpx(
export interface GenerateOptions {
now?: Date;
/** Localise the walk name; defaults to random-ish. */
locale?: "en" | "de";
/** Override the locale; defaults to a random one from the persona. */
locale?: DemoLocale;
/** Test-only: inject a persona instead of reading the env. */
persona?: DemoPersona;
}
/**
* Core generate step: insert one Bruno route + activity pair. Callers
* Core generate step: insert one demo route + activity pair. Callers
* (the recurring job and the backfill loop) decide whether to invoke it.
*
* Returns the inserted route id on success, or `null` on BRouter failure
@ -276,7 +466,11 @@ export interface GenerateOptions {
*/
export async function generateOneWalk(
ownerId: string,
{ now = new Date(), locale = Math.random() < 0.5 ? "en" : "de" }: GenerateOptions = {},
{
now = new Date(),
persona = loadPersona(),
locale = pickLocale(persona),
}: GenerateOptions = {},
): Promise<string | null> {
const region = loadRegion();
const endpoints = pickEndpoints(region.bbox);
@ -287,8 +481,8 @@ export async function generateOneWalk(
return null;
}
const name = templateName(now, locale);
const description = templateDescription(now, locale);
const name = templateName(now, locale, persona);
const description = templateDescription(now, locale, persona);
let distance: number;
let elevationGain: number;

View file

@ -1,11 +1,19 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEFAULT_PERSONA,
__resetPersonaCacheForTests,
berlinHour,
loadPersona,
loadRegion,
pickEndpoints,
pickLocale,
shouldWalkNow,
templateDescription,
templateName,
type DemoPersona,
} from "./demo-bot.server.ts";
describe("pickEndpoints", () => {
@ -117,3 +125,156 @@ describe("berlinHour", () => {
expect(h).toBeLessThanOrEqual(23);
});
});
// --- Persona configuration -------------------------------------------------
function minimalPersona(overrides: Partial<DemoPersona> = {}): DemoPersona {
return {
username: "test-demo",
displayName: "Test",
bio: "test bio",
locales: ["en"],
content: {
names: {
en: ["alpha walk", "bravo walk", "charlie walk"],
},
descriptions: {
en: ["alpha desc", "bravo desc", "charlie desc"],
},
},
...overrides,
};
}
describe("loadPersona", () => {
const envKey = "DEMO_BOT_PERSONA";
let savedEnv: string | undefined;
beforeEach(() => {
savedEnv = process.env[envKey];
delete process.env[envKey];
__resetPersonaCacheForTests();
});
afterEach(() => {
if (savedEnv === undefined) delete process.env[envKey];
else process.env[envKey] = savedEnv;
__resetPersonaCacheForTests();
});
it("returns the default persona when unset", () => {
const p = loadPersona();
expect(p.username).toBe(DEFAULT_PERSONA.username);
});
it("caches the result across calls", () => {
const a = loadPersona();
const b = loadPersona();
expect(a).toBe(b);
});
it("parses a valid inline JSON override", () => {
process.env[envKey] = JSON.stringify(minimalPersona({ username: "hamish" }));
const p = loadPersona();
expect(p.username).toBe("hamish");
expect(p.locales).toEqual(["en"]);
});
it("falls back to the default on invalid JSON", () => {
process.env[envKey] = "{not json";
const p = loadPersona();
expect(p.username).toBe(DEFAULT_PERSONA.username);
});
it("falls back when the username violates the pattern", () => {
process.env[envKey] = JSON.stringify(minimalPersona({ username: "Has Spaces" }));
const p = loadPersona();
expect(p.username).toBe(DEFAULT_PERSONA.username);
});
it("falls back when a declared locale has no content pool", () => {
process.env[envKey] = JSON.stringify(
minimalPersona({
locales: ["en", "de"],
// intentionally omit de pools
}),
);
const p = loadPersona();
expect(p.username).toBe(DEFAULT_PERSONA.username);
});
it("falls back when a pool has fewer than 3 entries", () => {
process.env[envKey] = JSON.stringify(
minimalPersona({
content: {
names: { en: ["only", "two"] },
descriptions: { en: ["d1", "d2", "d3"] },
},
}),
);
const p = loadPersona();
expect(p.username).toBe(DEFAULT_PERSONA.username);
});
it("reads a persona from a file: path", () => {
const dir = mkdtempSync(join(tmpdir(), "demo-persona-"));
const path = join(dir, "persona.json");
writeFileSync(path, JSON.stringify(minimalPersona({ username: "from-file" })));
try {
process.env[envKey] = `file:${path}`;
const p = loadPersona();
expect(p.username).toBe("from-file");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("falls back when a file: path is unreadable", () => {
process.env[envKey] = "file:/nonexistent/does-not-exist.json";
const p = loadPersona();
expect(p.username).toBe(DEFAULT_PERSONA.username);
});
});
describe("templateName / templateDescription with custom persona", () => {
it("draws from the supplied persona's pool and never leaks entries from the default", () => {
const persona = minimalPersona();
const bannedSubstring = "Grunewald"; // present in DEFAULT_PERSONA but not in `persona`
for (let i = 0; i < 30; i++) {
const d = new Date(2026, 3, 1 + (i % 28), i % 24, i % 60, i);
const name = templateName(d, "en", persona);
const desc = templateDescription(d, "en", persona);
expect(persona.content.names.en).toContain(name);
expect(persona.content.descriptions.en).toContain(desc);
expect(name).not.toContain(bannedSubstring);
expect(desc).not.toContain(bannedSubstring);
}
});
});
describe("pickLocale", () => {
it("only returns locales declared by the persona", () => {
const persona = minimalPersona({ locales: ["en"] });
for (let i = 0; i < 10; i++) {
expect(pickLocale(persona)).toBe("en");
}
});
it("distributes across declared locales", () => {
const persona = minimalPersona({
locales: ["en", "de"],
content: {
names: {
en: ["en1", "en2", "en3"],
de: ["de1", "de2", "de3"],
},
descriptions: {
en: ["d1", "d2", "d3"],
de: ["ds1", "ds2", "ds3"],
},
},
});
expect(pickLocale(persona, () => 0)).toBe("en");
expect(pickLocale(persona, () => 0.99)).toBe("de");
});
});

View file

@ -7,6 +7,7 @@ import { eq } from "drizzle-orm";
import { getSessionUser } from "~/lib/auth.server";
import { listPublicRoutesForOwner } from "~/lib/routes.server";
import { listPublicActivitiesForOwner } from "~/lib/activities.server";
import { loadPersona } from "~/lib/demo-bot.server";
import { ClientDate } from "~/components/ClientDate";
export async function loader({ params, request }: Route.LoaderArgs) {
@ -31,6 +32,11 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw data({ error: "User not found" }, { status: 404 });
}
// Demo-account badge: true when this profile matches the instance's
// configured demo persona username. Computed server-side so we don't
// ship the persona config through client HTML.
const isDemoUser = user.username === loadPersona().username;
return data({
user: {
username: user.username,
@ -57,6 +63,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
createdAt: a.createdAt.toISOString(),
})),
isOwn,
isDemoUser,
});
}
@ -82,11 +89,9 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
}
export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
const { user, routes, activities, isOwn } = loaderData;
const { user, routes, activities, isOwn, isDemoUser } = loaderData;
const { t } = useTranslation("journal");
const isDemo = user.username === "bruno";
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="flex items-start gap-4">
@ -98,7 +103,7 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
<h1 className="text-2xl font-bold text-gray-900">
{user.displayName ?? user.username}
</h1>
{isDemo && (
{isDemoUser && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
{t("demo.badge")}
</span>

View file

@ -35,7 +35,8 @@
"prom-client": "^15.1.3",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:"
"react-router": "catalog:",
"zod": "^3.25.0"
},
"devDependencies": {
"@react-router/dev": "catalog:",

View file

@ -108,21 +108,38 @@ 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
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, [demoBotGenerateJob, demoBotPruneJob]);
logger.info("Background job worker started");
// Bootstrap the demo user when the bot is enabled; cheap idempotent insert.
// Pre-flight the demo user so a persona username that clashes with a
// real account blocks job scheduling rather than silently attaching
// synthetic rows to a human.
let enableDemoJobs = false;
if (process.env.DEMO_BOT_ENABLED === "true") {
const { ensureDemoUser } = await import("./app/lib/demo-bot.server.ts");
const { ensureDemoUser, DemoPersonaUsernameClashError } = await import(
"./app/lib/demo-bot.server.ts"
);
try {
const id = await ensureDemoUser();
logger.info({ id }, "demo-bot user ensured");
enableDemoJobs = true;
} catch (err) {
logger.error({ err }, "demo-bot ensureDemoUser failed");
if (err instanceof DemoPersonaUsernameClashError) {
logger.error(
{ username: err.username },
"demo persona username clash — demo jobs disabled for this process",
);
} else {
logger.error({ err }, "demo-bot ensureDemoUser failed");
}
}
}
// Start background job worker
const demoJobs: Parameters<typeof startWorker>[1] = [];
if (enableDemoJobs) {
const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts");
const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts");
demoJobs.push(demoBotGenerateJob, demoBotPruneJob);
}
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
await startWorker(boss, demoJobs);
logger.info("Background job worker started");
});