Add Metabase dashboard setup and rework the teaser workflow
Build and push image / build (push) Successful in 7m50s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn
This commit is contained in:
2026-08-26 13:07:07 +00:00
parent dd9497193a
commit 0af6a01b69
3 changed files with 305 additions and 18 deletions
+7 -2
View File
@@ -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":["<x-id>"],"es":["<bluesky-id>"]}`
(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/<key>.txt`; the key is in
`pass show hidden11/indexnow-key`. Google has no ping endpoint any more — its sitemap is
+93 -16
View File
@@ -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\":[\"<x-id>\"],\"es\":[\"<bluesky-id>\"]}. 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 // \"<postiz-integration-id-x-en>\": teaser.texts.en,\n // \"<postiz-integration-id-x-es>\": 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// \"<locale>\": [\"<integration id>\", ...]\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"
}
}
}
+205
View File
@@ -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);
});