From 0af6a01b69e2355f0c81904833c064d3c7400977 Mon Sep 17 00:00:00 2001 From: claude-code Date: Wed, 26 Aug 2026 13:07:07 +0000 Subject: [PATCH] Add Metabase dashboard setup and rework the teaser workflow The teaser workflow now takes its Postiz routing and API key from a config node instead of hard-coded placeholders inside the Code node, so activating it is a matter of filling two fields rather than editing JavaScript. scripts/setup-metabase-dashboard.mjs registers the hidden11 database in Metabase and builds the funnel dashboard from the SQL views. It is idempotent, so it can be re-run after adding a view. Refs #4 #9 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn --- README.md | 9 +- deploy/n8n-daily-teaser.json | 109 +++++++++++--- scripts/setup-metabase-dashboard.mjs | 205 +++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 18 deletions(-) create mode 100644 scripts/setup-metabase-dashboard.mjs diff --git a/README.md b/README.md index f5ee8a0..b29f232 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,13 @@ Player data provenance and clue rules are documented in `docs/data-sources.md`. `deploy/sql/hidden11-events.sql`. - **Daily teaser**: `GET /api/teaser` returns a spoiler-free localized teaser for today's puzzle (first Easy clue). `deploy/n8n-daily-teaser.json` is an importable n8n workflow that posts it - daily through the Postiz public API (set `POSTIZ_API_KEY` in n8n and fill in the integration - ids inside the Code node). + daily through the Postiz public API. Fill in the "Postiz config" node: `routing` maps a locale + to the integration ids that should post it, e.g. `{"en":[""],"es":[""]}` + (list ids with `GET {POSTIZ_URL}/api/public/v1/integrations`), and `postizApiKey` comes from + `pass show homelab/postiz-api-key`. +- **Funnel dashboard**: `node scripts/setup-metabase-dashboard.mjs` registers the hidden11 + Postgres database in Metabase and builds the funnel dashboard (funnel by day, completion and + share rate, D1 retention, share channels, reminder opt-in). Re-running is safe. - **IndexNow**: `node scripts/submit-indexnow.mjs [--today]` submits the sitemap URLs to Bing, Yandex and Seznam. The key file lives in `public/.txt`; the key is in `pass show hidden11/indexnow-key`. Google has no ping endpoint any more — its sitemap is diff --git a/deploy/n8n-daily-teaser.json b/deploy/n8n-daily-teaser.json index a3b522d..accfc9e 100644 --- a/deploy/n8n-daily-teaser.json +++ b/deploy/n8n-daily-teaser.json @@ -1,5 +1,5 @@ { - "name": "hidden11 — daily teaser to Postiz", + "name": "hidden11 — daily teaser", "nodes": [ { "parameters": { @@ -12,32 +12,71 @@ ] } }, - "id": "schedule", + "id": "teaser-cron", "name": "Every day 09:00", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, - "position": [0, 0] + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "routing", + "name": "routing", + "value": "{}", + "type": "string" + }, + { + "id": "apikey", + "name": "postizApiKey", + "value": "", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "teaser-config", + "name": "Postiz config", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 220, + 0 + ], + "notes": "routing: JSON like {\"en\":[\"\"],\"es\":[\"\"]}. API key: pass show homelab/postiz-api-key. Never commit these." }, { "parameters": { "url": "https://hidden11.app/api/teaser", "options": {} }, - "id": "teaser", + "id": "teaser-fetch", "name": "Fetch teaser", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [220, 0] + "position": [ + 440, + 0 + ] }, { "parameters": { - "jsCode": "// Pick the languages you want to post and map them to Postiz integration ids.\n// Get the ids with: GET {POSTIZ_URL}/api/public/v1/integrations (same API key).\nconst teaser = $input.first().json;\nconst INTEGRATIONS = {\n // \"\": teaser.texts.en,\n // \"\": teaser.texts.es,\n};\n\nreturn Object.entries(INTEGRATIONS).map(([id, content]) => ({\n json: {\n type: \"now\",\n posts: [\n {\n integration: { id },\n value: [{ content }],\n },\n ],\n },\n}));" + "jsCode": "\n// Maps each locale's teaser text to the Postiz integrations that should post it.\n// Configure the ids in the \"Postiz config\" node; get them with\n// GET {POSTIZ_URL}/api/public/v1/integrations\n// using the same API key.\n\nconst teaser = $input.first().json;\nconst config = $('Postiz config').first().json;\n\n// \"\": [\"\", ...]\nconst routing = JSON.parse(config.routing || '{}');\n\nconst posts = [];\nfor (const [locale, integrationIds] of Object.entries(routing)) {\n const content = teaser.texts?.[locale];\n if (!content) continue;\n for (const id of integrationIds) {\n posts.push({ json: { type: 'now', posts: [{ integration: { id }, value: [{ content }] }] } });\n }\n}\n\nif (posts.length === 0) {\n throw new Error('No routing configured — fill in the \"Postiz config\" node');\n}\n\nreturn posts;\n" }, - "id": "buildPayload", + "id": "teaser-build", "name": "Build Postiz payload", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [440, 0] + "position": [ + 660, + 0 + ] }, { "parameters": { @@ -48,7 +87,7 @@ "parameters": [ { "name": "Authorization", - "value": "={{ $env.POSTIZ_API_KEY }}" + "value": "={{ $('Postiz config').first().json.postizApiKey }}" }, { "name": "Content-Type", @@ -61,25 +100,63 @@ "jsonBody": "={{ JSON.stringify($json) }}", "options": {} }, - "id": "postiz", + "id": "teaser-post", "name": "Post via Postiz", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [660, 0] + "position": [ + 880, + 0 + ] } ], "connections": { "Every day 09:00": { - "main": [[{ "node": "Fetch teaser", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Postiz config", + "type": "main", + "index": 0 + } + ] + ] + }, + "Postiz config": { + "main": [ + [ + { + "node": "Fetch teaser", + "type": "main", + "index": 0 + } + ] + ] }, "Fetch teaser": { - "main": [[{ "node": "Build Postiz payload", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Build Postiz payload", + "type": "main", + "index": 0 + } + ] + ] }, "Build Postiz payload": { - "main": [[{ "node": "Post via Postiz", "type": "main", "index": 0 }]] + "main": [ + [ + { + "node": "Post via Postiz", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": { - "timezone": "Europe/Madrid" + "executionOrder": "v1" } -} +} \ No newline at end of file diff --git a/scripts/setup-metabase-dashboard.mjs b/scripts/setup-metabase-dashboard.mjs new file mode 100644 index 0000000..d7e5c41 --- /dev/null +++ b/scripts/setup-metabase-dashboard.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +// Creates the hidden11 funnel dashboard in Metabase. +// +// Registers the hidden11 Postgres database (if it is not there yet), creates one +// native question per view in deploy/sql/hidden11-events.sql, and puts them on a +// dashboard. Re-running is safe: existing items are reused, not duplicated. +// +// Usage: +// MB_URL=https://metabase.alexandre-vazquez.cloud \ +// MB_USER=you@example.com MB_PASSWORD="$(pass show ...)" \ +// PG_PASSWORD="$(pass show hidden11/postgres-password)" \ +// node scripts/setup-metabase-dashboard.mjs + +const MB_URL = (process.env.MB_URL ?? "https://metabase.alexandre-vazquez.cloud").replace(/\/$/, ""); +const MB_USER = process.env.MB_USER; +const MB_PASSWORD = process.env.MB_PASSWORD; +const PG_HOST = process.env.PG_HOST ?? "192.168.1.29"; +const PG_PORT = Number(process.env.PG_PORT ?? 5432); +const PG_DB = process.env.PG_DB ?? "hidden11"; +const PG_USER = process.env.PG_USER ?? "hidden11"; +const PG_PASSWORD = process.env.PG_PASSWORD; + +const DB_NAME = "hidden11"; +const DASHBOARD_NAME = "hidden11 — growth funnel"; + +if (!MB_USER || !MB_PASSWORD) { + console.error("MB_USER and MB_PASSWORD are required"); + process.exit(1); +} + +let sessionToken = ""; + +async function api(path, { method = "GET", body } = {}) { + const response = await fetch(`${MB_URL}/api${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(sessionToken ? { "X-Metabase-Session": sessionToken } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`${method} ${path} -> ${response.status}: ${text.slice(0, 400)}`); + } + return text ? JSON.parse(text) : null; +} + +const CARDS = [ + { + name: "Funnel by day", + description: "Visitors → started → guessed → completed → shared → challenge accepted.", + sql: "SELECT * FROM hidden11_funnel_daily ORDER BY day DESC LIMIT 60", + display: "table", + size: { col: 0, row: 0, size_x: 24, size_y: 7 }, + }, + { + name: "Completion and share rate", + description: "Share of players who finish the day, and of finishers who share.", + sql: "SELECT day, completion_rate, share_rate FROM hidden11_funnel_daily ORDER BY day DESC LIMIT 60", + display: "line", + size: { col: 0, row: 7, size_x: 12, size_y: 6 }, + }, + { + name: "D1 retention", + description: "Of the visitors seen on a day, how many came back the next day.", + sql: "SELECT day, visitors, returned_next_day, d1_retention FROM hidden11_retention_d1 ORDER BY day DESC LIMIT 60", + display: "line", + size: { col: 12, row: 7, size_x: 12, size_y: 6 }, + }, + { + name: "Share channels", + description: "Which channel players actually use to share.", + sql: "SELECT channel, sum(shares) AS shares FROM hidden11_share_channels GROUP BY channel ORDER BY shares DESC", + display: "bar", + size: { col: 0, row: 13, size_x: 12, size_y: 6 }, + }, + { + name: "Reminders and installs", + description: "Opt-in rate for the daily push reminder.", + sql: "SELECT day, enabled_reminder, visitors FROM hidden11_funnel_daily ORDER BY day DESC LIMIT 60", + display: "line", + size: { col: 12, row: 13, size_x: 12, size_y: 6 }, + }, +]; + +async function ensureDatabase() { + const { data: databases } = await api("/database"); + const existing = databases.find((database) => database.name === DB_NAME); + if (existing) { + console.log(`database "${DB_NAME}" already registered (id ${existing.id})`); + return existing.id; + } + + if (!PG_PASSWORD) { + throw new Error("PG_PASSWORD is required to register the database for the first time"); + } + + const created = await api("/database", { + method: "POST", + body: { + name: DB_NAME, + engine: "postgres", + details: { + host: PG_HOST, + port: PG_PORT, + dbname: PG_DB, + user: PG_USER, + password: PG_PASSWORD, + ssl: false, + }, + }, + }); + console.log(`registered database "${DB_NAME}" (id ${created.id})`); + return created.id; +} + +async function ensureCard(databaseId, card) { + const existing = await api(`/search?q=${encodeURIComponent(card.name)}&models=card`) + .then((result) => result.data?.find((item) => item.name === card.name)) + .catch(() => null); + + if (existing) { + console.log(`card "${card.name}" already exists (id ${existing.id})`); + return existing.id; + } + + const created = await api("/card", { + method: "POST", + body: { + name: card.name, + description: card.description, + display: card.display, + visualization_settings: {}, + dataset_query: { + type: "native", + database: databaseId, + native: { query: card.sql }, + }, + }, + }); + console.log(`created card "${card.name}" (id ${created.id})`); + return created.id; +} + +async function ensureDashboard() { + const found = await api(`/search?q=${encodeURIComponent(DASHBOARD_NAME)}&models=dashboard`) + .then((result) => result.data?.find((item) => item.name === DASHBOARD_NAME)) + .catch(() => null); + + if (found) { + console.log(`dashboard already exists (id ${found.id})`); + return found.id; + } + + const created = await api("/dashboard", { + method: "POST", + body: { + name: DASHBOARD_NAME, + description: "Visits → game started → completed → shared → challenge accepted, from first-party events.", + }, + }); + console.log(`created dashboard (id ${created.id})`); + return created.id; +} + +async function main() { + const session = await api("/session", { + method: "POST", + body: { username: MB_USER, password: MB_PASSWORD }, + }); + sessionToken = session.id; + console.log("authenticated"); + + const databaseId = await ensureDatabase(); + + const cardIds = []; + for (const card of CARDS) { + cardIds.push({ id: await ensureCard(databaseId, card), card }); + } + + const dashboardId = await ensureDashboard(); + const dashboard = await api(`/dashboard/${dashboardId}`); + const placed = new Set((dashboard.dashcards ?? []).map((dashcard) => dashcard.card_id)); + + const dashcards = [ + ...(dashboard.dashcards ?? []), + ...cardIds + .filter(({ id }) => !placed.has(id)) + .map(({ id, card }, index) => ({ + id: -(index + 1), // negative ids mark newly added cards + card_id: id, + ...card.size, + })), + ]; + + await api(`/dashboard/${dashboardId}`, { method: "PUT", body: { dashcards } }); + console.log(`\ndone: ${MB_URL}/dashboard/${dashboardId}`); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +});