Files
QR-master/docker-compose.yml
Timo Knuth eb932ebdaa Publish milestones per channel and add Instagram
Consent is bound to the channel it was given for: approving a post on X says
nothing about Instagram. Publishing state moves from the SocialMilestone row
into SocialMilestonePost, one row per channel, where a missing row means no
consent. The dialog asks per channel, shows the text each one will publish and
keeps a separate handle for each; Instagram captions end in hashtags because a
link there is not clickable.

Also fixes three problems in the existing X path:

- A QR code already past several thresholds produced one prompt per threshold,
  and since the post quotes the current scan count, every one of them would
  have published the same number. Only the highest threshold is announced now.
- Detection ran after every unique scan and re-read the QR code's full scan
  history just to hit skipDuplicates. Known milestones are filtered first.
- A failed post stayed failed forever because the consent dialog only opens
  once. The queue now retries three times on its own, spaces first attempts by
  SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per
  channel with restart and revoke.

The worker no longer renders the card itself; it downloads the image the app
renders at /s/m/<token>/og, which also serves the new square and portrait
formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and
SOCIAL_WORKER_CHANNELS both name it.

Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before
deploying this version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:46:13 +02:00

182 lines
6.9 KiB
YAML

services:
# PostgreSQL Database
db:
image: postgres:16-alpine
container_name: qrmaster-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8"
ports:
- "5435:5432"
volumes:
- dbdata:/var/lib/postgresql/data
- ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d qrmaster" ]
interval: 5s
timeout: 5s
retries: 10
networks:
- qrmaster-network
# Redis Cache
redis:
image: redis:7-alpine
container_name: qrmaster-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5
networks:
- qrmaster-network
# Next.js Application
web:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
container_name: qrmaster-web
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
REDIS_URL: redis://redis:6379
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:-http://localhost:3050}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
CRON_SECRET: ${CRON_SECRET:-}
SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-}
SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-}
SOCIAL_MILESTONE_MIN_GAP_HOURS: ${SOCIAL_MILESTONE_MIN_GAP_HOURS:-}
# Channels the consent dialog may ask for. Only extend this once the
# worker actually publishes that channel.
SOCIAL_MILESTONE_CHANNELS: ${SOCIAL_MILESTONE_CHANNELS:-x}
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-}
# Guards the asset upload route that Instagram (and TikTok) pull media
# from. Falls back to the TikTok key so existing setups keep working.
SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-}
TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-}
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
ENABLE_DEMO: ${ENABLE_DEMO:-false}
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}
# Google OAuth
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
# Stripe
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}
STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-}
STRIPE_PRICE_ID_PRO_YEARLY: ${STRIPE_PRICE_ID_PRO_YEARLY:-}
STRIPE_PRICE_ID_BUSINESS_MONTHLY: ${STRIPE_PRICE_ID_BUSINESS_MONTHLY:-}
STRIPE_PRICE_ID_BUSINESS_YEARLY: ${STRIPE_PRICE_ID_BUSINESS_YEARLY:-}
# Email & Analytics
RESEND_API_KEY: ${RESEND_API_KEY:-}
SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USER: ${SMTP_USER:-info@qrmaster.net}
SMTP_PASS: ${SMTP_PASS:-}
NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-}
NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-}
NEWSLETTER_TEST_EMAIL: ${NEWSLETTER_TEST_EMAIL:-}
EMAIL_UNSUBSCRIBE_SECRET: ${EMAIL_UNSUBSCRIBE_SECRET:-}
NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-}
NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}
# Cloudflare R2 Storage
R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-}
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-}
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
R2_BUCKET_NAME: ${R2_BUCKET_NAME:-qrmaster-menus}
R2_PUBLIC_URL: ${R2_PUBLIC_URL:-}
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: [ "CMD", "node", "-e", "require('http').get('http://localhost:3000',()=>process.exit(0)).on('error',()=>process.exit(1))" ]
interval: 10s
timeout: 3s
retries: 10
networks:
- qrmaster-network
social-worker:
build:
context: ./scripts/social-worker
restart: unless-stopped
environment:
QRMASTER_API_BASE: http://web:3000
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false}
SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10}
X_API_KEY: ${X_API_KEY:-}
X_API_SECRET: ${X_API_SECRET:-}
X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-}
X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-}
# Channels this worker publishes. Must stay a subset of the app's
# SOCIAL_MILESTONE_CHANNELS - a channel the dialog offers but nobody
# publishes would leave approvals sitting in the queue.
SOCIAL_WORKER_CHANNELS: ${SOCIAL_WORKER_CHANNELS:-x}
INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-}
INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-}
GRAPH_API_VERSION: ${GRAPH_API_VERSION:-v22.0}
# Instagram downloads the image itself, so the worker hosts it through
# /api/social-assets on the verified domain. Same key as the web service.
SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-${TIKTOK_ADMIN_KEY:-}}
depends_on:
web:
condition: service_started
networks:
- qrmaster-network
# Adminer - Database Management UI (Optional)
adminer:
image: adminer:latest
container_name: qrmaster-adminer
restart: unless-stopped
ports:
- "8080:8080"
environment:
ADMINER_DEFAULT_SERVER: db
depends_on:
- db
networks:
- qrmaster-network
profiles:
- dev
volumes:
dbdata:
driver: local
redisdata:
driver: local
networks:
qrmaster-network:
external: true