diff --git a/.env.example b/.env.example index 728f3b1..95aab1e 100644 --- a/.env.example +++ b/.env.example @@ -16,22 +16,50 @@ REDIS_URL=redis://redis:6379 IP_SALT=CHANGE_ME_SALT ENABLE_DEMO=true -# SMTP (for welcome + retention emails via nodemailer) +# SMTP & Email Senders (for welcome + retention emails via nodemailer / resend) SMTP_HOST=smtp.qrmaster.net SMTP_PORT=465 SMTP_USER=timo@qrmaster.net SMTP_PASS= +EMAIL_FROM="Timo from QR Master " +EMAIL_FROM_SECURITY="QR Master Security " +EMAIL_REPLY_TO="support@qrmaster.net" # Cron job protection — generate with: openssl rand -base64 32 CRON_SECRET= +# Leave empty in production for 1,000 / 10,000 unique scans. Test only, e.g. 1,2. +SOCIAL_MILESTONE_THRESHOLDS= +# Leave empty for immediate publishing after consent. Set 24 to enable a revocation window. +SOCIAL_MILESTONE_POST_DELAY_HOURS= +# Hours between two brand posts (default 24). Set 0 on test to publish back to back. +SOCIAL_MILESTONE_MIN_GAP_HOURS= +SOCIAL_MILESTONE_POSTING_ENABLED=false +SOCIAL_WORKER_INTERVAL_SECONDS=10 +X_API_KEY= +X_API_SECRET= +X_ACCESS_TOKEN= +X_ACCESS_TOKEN_SECRET= + +# Channels the consent dialog offers (app) and the worker publishes (worker). +# Keep both in sync: x / x,instagram +SOCIAL_MILESTONE_CHANNELS=x +SOCIAL_WORKER_CHANNELS=x +# Instagram Business account for QRMaster.net, see docs/automations/social-accounts-and-jobs.md +INSTAGRAM_USER_ID= +INSTAGRAM_ACCESS_TOKEN= +GRAPH_API_VERSION=v22.0 +# Guards POST/DELETE on /api/social-assets, the public image host Instagram +# pulls from. Unrelated to TikTok posting; falls back to TIKTOK_ADMIN_KEY. +SOCIAL_ASSET_ADMIN_KEY= + # TikTok OAuth / posting (server-side only) # Source of truth for cron posting: QRMaster server .env # Production example: https://qrmaster.net/api/tiktok/callback # Local dev example: http://localhost:3000/api/tiktok/callback # Tokens are saved in the DB after the OAuth callback; do not store access tokens here. -TIKTOK_CLIENT_KEY= -TIKTOK_CLIENT_SECRET= +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback -TIKTOK_ADMIN_KEY= -TIKTOK_EXPECTED_OPEN_ID= +TIKTOK_ADMIN_KEY= +TIKTOK_EXPECTED_OPEN_ID= diff --git a/.gitignore b/.gitignore index a24cd47..0d6fa9d 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ src/lib/blog-data.snapshot-*.ts /public/Real Estate/ /public/restaurant/ /.qr-master-api-health-state + +# Python worker bytecode +__pycache__/ diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1c02db2..05b6143 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -38,6 +38,17 @@ services: # April 2026 and the schema has moved on through manual SQL since, so running them # against a fresh database would build a stale schema the app cannot work with. # Bring the schema in with `pg_dump --schema-only` from production instead. + environment: + # Docker sets HOSTNAME=, and the Next.js standalone server binds to + # that single interface. With two networks Caddy then cannot reach the container. + HOSTNAME: "0.0.0.0" + # `db` and `redis` are taken in BOTH networks - by this stack in test-internal and + # by production in qrmaster-network. Production wins the lookup every time, so the + # base file's hostnames point the staging app at the production instances. Container + # names are unique per daemon and cannot be shadowed. + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public + DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public + REDIS_URL: redis://qrmaster-test-redis:6379 entrypoint: ["node", "server.js"] build: args: @@ -54,6 +65,15 @@ services: - test-internal - qrmaster-network + social-worker: + container_name: qrmaster-test-social-worker + environment: + # Never resolve the ambiguous `web` alias on the shared production + # network. The test container name is unique on this Docker daemon. + QRMASTER_API_BASE: http://qrmaster-test-web:3000 + networks: !override + - test-internal + adminer: container_name: qrmaster-test-adminer ports: !reset [] diff --git a/docker-compose.yml b/docker-compose.yml index 2b4235e..7206a71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,10 +62,20 @@ services: 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} @@ -113,6 +123,35 @@ services: 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) diff --git a/docker/init-db.sh b/docker/init-db.sh old mode 100644 new mode 100755 index 57f56a8..c3e8d25 --- a/docker/init-db.sh +++ b/docker/init-db.sh @@ -1,26 +1,28 @@ -#!/bin/bash -set -e - -# This script runs when the PostgreSQL container is first created -# It ensures the database is properly initialized - -echo "🚀 Initializing QR Master database..." - -# Create the database if it doesn't exist (already created by POSTGRES_DB) -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - -- Enable required extensions - CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - CREATE EXTENSION IF NOT EXISTS "pg_trgm"; - - -- Grant privileges - GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres; - - -- Set timezone - ALTER DATABASE qrmaster SET timezone TO 'UTC'; -EOSQL - -echo "✅ Database initialization complete!" -echo "📊 Database: $POSTGRES_DB" -echo "👤 User: $POSTGRES_USER" -echo "🌐 Ready to accept connections on port 5432" - +#!/bin/bash +set -e + +# This script runs when the PostgreSQL container is first created +# It ensures the database is properly initialized +# +# Keep this database-name agnostic: the staging stack (docker-compose.test.yml) +# runs the same script with POSTGRES_DB=qrmaster_test. A hardcoded name aborts +# the init, and the container never becomes healthy. +# Must stay LF-only and executable - Postgres sources non-executable init +# scripts, and CRLF breaks them on the first line. + +echo "🚀 Initializing QR Master database..." + +# The database itself is already created by POSTGRES_DB +psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + -- Enable required extensions + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + + -- Set timezone + ALTER DATABASE :"dbname" SET timezone TO 'UTC'; +EOSQL + +echo "✅ Database initialization complete!" +echo "📊 Database: $POSTGRES_DB" +echo "👤 User: $POSTGRES_USER" +echo "🌐 Ready to accept connections on port 5432" diff --git a/docs/automations/social-accounts-and-jobs.md b/docs/automations/social-accounts-and-jobs.md index 22736dc..1352394 100644 --- a/docs/automations/social-accounts-and-jobs.md +++ b/docs/automations/social-accounts-and-jobs.md @@ -29,7 +29,7 @@ | `C:\Users\timo\Documents\meta_instagram_tokens.env` | Meta Facebook + Instagram long-lived/page tokens | | `C:\Users\timo\Documents\r2_social_media.env` | R2 upload credentials for IG assets | | `C:\Users\timo\Documents\instagram_r2_carousel_post.py` | uses both env files above | -| `C:\Users\timo\Documents\XApiAutopost\.env` | X/Twitter QRMaster creds | +| `C:\Users\timo\x-api-autopost\.env` | X/Twitter QRMaster creds (`X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET`) | | `C:\Users\timo\Documents\greenlens-x-autopost\.env` | X/Twitter GreenLens creds | | `C:\Users\timo\Documents\greenlens\Greenlens\.env` | GreenLens TikTok + plant import admin creds | diff --git a/docs/automations/social-milestone-worker.md b/docs/automations/social-milestone-worker.md new file mode 100644 index 0000000..67f8361 --- /dev/null +++ b/docs/automations/social-milestone-worker.md @@ -0,0 +1,113 @@ +# Social milestone worker + +The app detects QR-code scan milestones and stores customer consent. It does not +hold X or Meta credentials. The external worker publishes what was approved — +per channel, never more. + +## Consent is bound to a channel + +Approving a post on X says nothing about Instagram: different audience, +different disclosure about the customer's business. Every channel therefore has +its own checkbox in the dialog, its own text, its own handle and its own row in +`SocialMilestonePost`. **No row means no consent.** A channel is only offered +where a publisher is configured — `SOCIAL_MILESTONE_CHANNELS` (app) must stay in +sync with `SOCIAL_WORKER_CHANNELS` (worker), otherwise approvals pile up in the +queue with nobody to publish them. + +## Test setup (manual SQL only) + +1. Apply, in this order, to `qrmaster_test`: + [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql), + [`sql/2026-08-16_social_milestone_retries.sql`](../../sql/2026-08-16_social_milestone_retries.sql), + [`sql/2026-08-16_social_milestone_channels.sql`](../../sql/2026-08-16_social_milestone_channels.sql). +2. Set distinct `CRON_SECRET` and `INTERNAL_API_SECRET` values in `.env.test`. + For an end-to-end test without 1,000 scans, also set + `SOCIAL_MILESTONE_THRESHOLDS=1` (or `1,2`). Do not set this on production. + Publishing is immediate after consent by default. Set + `SOCIAL_MILESTONE_POST_DELAY_HOURS=24` only if a revocation window is desired. + Set `SOCIAL_MILESTONE_MIN_GAP_HOURS=0` on test, otherwise the second + milestone waits a full day behind the first one. +3. Deploy using the documented test compose command. `CRON_SECRET` is forwarded + to the web service by `docker-compose.yml`. +4. Trigger detection manually: + +```bash +curl -H "Authorization: Bearer $CRON_SECRET" \ + https://testmodul.qrmaster.net/api/cron/social-milestones +``` + +The detector creates records at 1,000 and 10,000 unique scans only. It is safe +to call repeatedly because `(qrId, kind)` is unique. A QR code that is already +past several thresholds on first detection only produces the highest one. + +## Queue contract + +```bash +curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + "https://qrmaster.net/api/internal/social-milestones?channel=instagram" +``` + +Returns at most one approved post for that channel, claims it, and expects a +result report for the returned `id`: + +```bash +curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + -H "Content-Type: application/json" \ + -d '{"id":"","result":"posted","postUrl":"https://..."}' \ + https://qrmaster.net/api/internal/social-milestones +``` + +First attempts are spaced by `SOCIAL_MILESTONE_MIN_GAP_HOURS` (default 24) per +channel, so the brand timeline cannot be flooded when several customers consent +on the same day. A blocked poll answers +`{"milestone": null, "reason": "spacing", "nextPostAt": "..."}`. + +`milestone.text` is published **verbatim** — it is the text the customer read +before consenting. The image is not part of the payload: the worker downloads it +from `/s/m//og`, the same renderer that serves the +popup and the link preview. `?format=` takes `landscape` (1200×630, default), +`square` (1080×1080) or `portrait` (1080×1350). + +## Instagram + +Publishing runs against the QRMaster.net Business account (see +[social-accounts-and-jobs.md](social-accounts-and-jobs.md)) in three steps: +`POST /{ig-user-id}/media` → poll `status_code` until `FINISHED` → +`POST /{ig-user-id}/media_publish`. + +Worth knowing before enabling it: + +- **JPEG only.** The worker flattens the rendered PNG onto white and uploads it + through `POST /api/social-assets`; Meta downloads `image_url` itself, so it has + to be publicly readable on the verified domain. `SOCIAL_ASSET_ADMIN_KEY` is the + existing `TIKTOK_ADMIN_KEY`. +- **No clickable links in captions.** The Instagram text therefore ends in + hashtags instead of the share URL, and mentions use the customer's Instagram + handle, not their X handle. +- **50 posts / 24 h**, verifiable via `GET /{ig-user-id}/content_publishing_limit`. +- **Reconciliation is caption-based.** Before a repeated attempt the worker + compares the caption against the last 25 media items. Two milestones with an + identical caption — same QR title, same scan count — would be treated as the + same post; the 24 h spacing makes that combination unlikely but not impossible. +- Required environment: `SOCIAL_WORKER_CHANNELS=x,instagram`, `INSTAGRAM_USER_ID`, + `INSTAGRAM_ACCESS_TOKEN`, plus `SOCIAL_MILESTONE_CHANNELS=x,instagram` on the + web service so the dialog asks for it in the first place. + +Do not configure this worker against `testmodul`. LinkedIn has no approved +brand-posting integration in this project. Customers can self-share on all three: +X opens a prefilled intent, LinkedIn gets the text copied, Instagram opens the +system share sheet on a phone and falls back to an image download. + +## Failed posts + +A reported failure — and a claim the worker never confirmed — counts as one +attempt. The queue re-schedules the post itself after 5, then 10 minutes and only +parks it in `failed` once three attempts are used up. Before posting again the +worker reconciles against the account (share token on X, caption on Instagram), +so a failure reported after a successful post cannot duplicate it. + +The consent dialog opens once per milestone, so a customer who has closed it can +no longer see or restart a failed post from there. Settings → Milestone sharing +lists every milestone with one line per channel and offers "Try again" (re-queues +the approved text unchanged) and "Cancel" (revokes that channel before anything +is published). diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36406e2..b2e165b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,13 @@ model User { accounts Account[] sessions Session[] lifecycleLogs UserLifecycleLog[] + socialMilestones SocialMilestone[] + + // Social-success sharing preferences. A post is still never published + // without a per-milestone approval stored below. + xHandle String? + instagramHandle String? + socialPromptOptOut Boolean @default(false) } enum Plan { @@ -148,11 +155,73 @@ model QRCode { user User @relation(fields: [userId], references: [id], onDelete: Cascade) scans QRScan[] + socialMilestones SocialMilestone[] @@index([userId, createdAt]) @@index([userId, type, status]) } +model SocialMilestone { + id String @id @default(cuid()) + qrId String + userId String + kind String + status String @default("detected") + detectedAt DateTime @default(now()) + shownAt DateTime? + respondedAt DateTime? + claimedAt DateTime? + postedAt DateTime? + withName Boolean @default(false) + consentText String? + language String @default("en") + cardData Json? + brandStatus String @default("pending") + brandApprovedAt DateTime? + brandPostedAt DateTime? + brandPostUrl String? + brandPostError String? + selfSharedAt DateTime? + shareToken String? @unique + publicShareApprovedAt DateTime? + attempts Int @default(0) + nextAttemptAt DateTime? + + qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + posts SocialMilestonePost[] + + @@unique([qrId, kind]) + @@index([status, respondedAt]) + @@index([status, claimedAt]) + @@index([userId, status]) + @@index([brandStatus, brandApprovedAt]) +} + +model SocialMilestonePost { + id String @id @default(cuid()) + milestoneId String + /// "x" | "instagram". Consent is bound to the channel it was given for. + channel String + /// approved | processing | posted | failed | revoked + status String @default("approved") + /// The exact text the customer read before consenting. + consentText String + handle String? + approvedAt DateTime @default(now()) + claimedAt DateTime? + postedAt DateTime? + postUrl String? + error String? + attempts Int @default(0) + nextAttemptAt DateTime? + + milestone SocialMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade) + + @@unique([milestoneId, channel]) + @@index([channel, status, approvedAt]) +} + enum QRType { STATIC DYNAMIC diff --git a/scripts/social-worker/Dockerfile b/scripts/social-worker/Dockerfile new file mode 100644 index 0000000..061217d --- /dev/null +++ b/scripts/social-worker/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /worker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY worker.py . +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 CMD python -c "import os,requests; base=os.environ['QRMASTER_API_BASE'].rstrip('/'); secret=os.environ['INTERNAL_API_SECRET']; requests.get(base + '/api/internal/social-milestones?dryRun=true', headers={'Authorization':'Bearer ' + secret}, timeout=5).raise_for_status()" +CMD ["python", "worker.py"] diff --git a/scripts/social-worker/requirements.txt b/scripts/social-worker/requirements.txt new file mode 100644 index 0000000..8d2307a --- /dev/null +++ b/scripts/social-worker/requirements.txt @@ -0,0 +1,3 @@ +Pillow>=10 +requests>=2.31 +requests-oauthlib>=2.0 diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py new file mode 100644 index 0000000..0a09511 --- /dev/null +++ b/scripts/social-worker/worker.py @@ -0,0 +1,279 @@ +"""Always-on QR Master milestone publisher. + +Polls the app's internal queue per channel and publishes what a customer has +explicitly approved for that channel. The web app never receives X or Meta +credentials; this worker never composes text of its own. +""" +import base64 +import io +import json +import os +import tempfile +import time +from pathlib import Path + +import requests +from PIL import Image +from requests_oauthlib import OAuth1Session + +CHANNELS = ("x", "instagram") + + +def required(name): + value = os.getenv(name, "").strip() + if not value: + raise RuntimeError(f"Missing {name}") + return value + + +def enabled_channels(): + configured = [value.strip().lower() for value in os.getenv("SOCIAL_WORKER_CHANNELS", "x").split(",")] + return [channel for channel in configured if channel in CHANNELS] or ["x"] + + +def api(method, url, payload=None): + response = requests.request(method, url, json=payload, headers={"Authorization": f"Bearer {required('INTERNAL_API_SECRET')}"}, timeout=30) + response.raise_for_status() + return response.json() + + +def graph(path): + return f"https://graph.facebook.com/{os.getenv('GRAPH_API_VERSION', 'v22.0')}/{path}" + + +def graph_error(response): + """Meta answers with a JSON error body that says far more than the status.""" + try: + error = response.json().get("error") or {} + detail = error.get("error_user_msg") or error.get("message") + if detail: + return f"{detail} (code {error.get('code')})" + except ValueError: + pass + return f"HTTP {response.status_code}" + + +def milestone_image(milestone, image_format="landscape"): + """Download the card the app renders at /s/m//og. + + The popup, the link preview and the published post therefore show the exact + same image: one renderer, one source of truth, nothing to keep in sync here. + The internal base is used on purpose - the public host is not necessarily + reachable from inside the worker network. + """ + token = str(milestone.get("shareToken") or "").strip() + if not token: + return None + base = required("QRMASTER_API_BASE").rstrip("/") + response = requests.get(f"{base}/s/m/{token}/og", params={"format": image_format}, timeout=60) + response.raise_for_status() + path = Path(tempfile.mkstemp(suffix=".png")[1]) + path.write_bytes(response.content) + return path + + +# --- X --------------------------------------------------------------------- + + +def oauth_client(): + return OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET")) + + +def find_existing_x_post(oauth, milestone): + """Reconcile an uncertain prior attempt before creating another X post.""" + token = str(milestone.get("shareToken") or "").strip() + if not token: + raise RuntimeError("Milestone has no share token for duplicate-safe publishing") + identity = oauth.get("https://api.x.com/2/users/me", timeout=30) + identity.raise_for_status() + user_id = identity.json().get("data", {}).get("id") + if not user_id: + raise RuntimeError("X did not return the authenticated user id") + timeline = oauth.get( + f"https://api.x.com/2/users/{user_id}/tweets", + params={"max_results": 100, "tweet.fields": "created_at,entities", "exclude": "retweets,replies"}, + timeout=30, + ) + timeline.raise_for_status() + for post in timeline.json().get("data") or []: + urls = (post.get("entities") or {}).get("urls") or [] + expanded = " ".join(str(url.get("expanded_url") or url.get("unwound_url") or "") for url in urls) + if token in expanded: + return f"https://x.com/i/web/status/{post.get('id')}" + return None + + +def publish_x(milestone): + oauth = oauth_client() + # Reading the timeline costs far more X quota than writing a post, so + # reconcile only when this post was already attempted before. + if milestone.get("attempts"): + existing = find_existing_x_post(oauth, milestone) + if existing: + return existing, True + + path = milestone_image(milestone) + try: + media_id = None + if path: + with path.open("rb") as image: + upload = oauth.post("https://upload.x.com/1.1/media/upload.json", files={"media": image}, timeout=60) + upload.raise_for_status() + media_id = upload.json()["media_id_string"] + payload = {"text": milestone["text"]} + if media_id: + payload["media"] = {"media_ids": [media_id]} + result = oauth.post("https://api.x.com/2/tweets", json=payload, timeout=30) + result.raise_for_status() + tweet_id = result.json().get("data", {}).get("id") + return (f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None), False + finally: + if path: + path.unlink(missing_ok=True) + + +# --- Instagram ------------------------------------------------------------- + + +def instagram_jpeg(path): + """Instagram accepts JPEG only, so the rendered PNG is flattened onto white. + + The card is drawn at 1080x1350 (4:5), the tallest ratio Instagram allows. + """ + with Image.open(path) as image: + rgba = image.convert("RGBA") + canvas = Image.new("RGB", rgba.size, "white") + canvas.paste(rgba, mask=rgba.split()[3]) + buffer = io.BytesIO() + canvas.save(buffer, "JPEG", quality=92, optimize=True) + target = Path(tempfile.mkstemp(suffix=".jpg")[1]) + target.write_bytes(buffer.getvalue()) + return target + + +def host_asset(path): + """Meta downloads `image_url` itself, so the file must be publicly readable. + + /api/social-assets stores it in Postgres and serves it from the verified + qrmaster.net domain - no object storage and no deploy needed per post. + """ + base = required("QRMASTER_API_BASE").rstrip("/") + response = requests.post( + f"{base}/api/social-assets", + headers={"x-admin-key": required("SOCIAL_ASSET_ADMIN_KEY")}, + json={"files": [{"filename": path.name, "mimeType": "image/jpeg", "dataBase64": base64.b64encode(path.read_bytes()).decode()}]}, + timeout=60, + ) + if not response.ok: + raise RuntimeError(f"Asset upload failed: {response.text[:200]}") + url = ((response.json().get("assets") or [{}])[0]).get("url") + if not url: + raise RuntimeError("Asset upload returned no URL") + return url + + +def find_existing_instagram_post(caption): + """Reconcile by caption: a repeated attempt must not post twice. + + Instagram has nothing like a client-side idempotency key, and a share token + in the caption would only be visible clutter - the caption itself is the + identifying detail. + """ + response = requests.get( + graph(f"{required('INSTAGRAM_USER_ID')}/media"), + params={"fields": "id,caption,permalink", "limit": 25, "access_token": required("INSTAGRAM_ACCESS_TOKEN")}, + timeout=30, + ) + if not response.ok: + raise RuntimeError(graph_error(response)) + for media in response.json().get("data") or []: + if (media.get("caption") or "").strip() == caption.strip(): + return media.get("permalink") + return None + + +def publish_instagram(milestone): + caption = milestone["text"] + if milestone.get("attempts"): + existing = find_existing_instagram_post(caption) + if existing: + return existing, True + + user_id = required("INSTAGRAM_USER_ID") + token = required("INSTAGRAM_ACCESS_TOKEN") + png = milestone_image(milestone, "portrait") + if not png: + raise RuntimeError("Milestone has no share token, so no image can be published") + jpeg = None + try: + jpeg = instagram_jpeg(png) + image_url = host_asset(jpeg) + container = requests.post(graph(f"{user_id}/media"), data={"image_url": image_url, "caption": caption, "access_token": token}, timeout=60) + if not container.ok: + raise RuntimeError(graph_error(container)) + creation_id = container.json().get("id") + if not creation_id: + raise RuntimeError("Instagram did not return a container id") + + # Meta fetches and processes the image asynchronously. + deadline = time.time() + 120 + while True: + status = requests.get(graph(creation_id), params={"fields": "status_code,status", "access_token": token}, timeout=30) + if not status.ok: + raise RuntimeError(graph_error(status)) + code = status.json().get("status_code") + if code == "FINISHED": + break + if code in {"ERROR", "EXPIRED"}: + raise RuntimeError(f"Instagram rejected the media container: {status.json().get('status') or code}") + if time.time() > deadline: + raise RuntimeError("Instagram did not finish processing the image within 120s") + time.sleep(5) + + published = requests.post(graph(f"{user_id}/media_publish"), data={"creation_id": creation_id, "access_token": token}, timeout=60) + if not published.ok: + raise RuntimeError(graph_error(published)) + media_id = published.json().get("id") + permalink = None + if media_id: + link = requests.get(graph(media_id), params={"fields": "permalink", "access_token": token}, timeout=30) + permalink = link.json().get("permalink") if link.ok else None + return permalink, False + finally: + png.unlink(missing_ok=True) + if jpeg: + jpeg.unlink(missing_ok=True) + + +PUBLISHERS = {"x": publish_x, "instagram": publish_instagram} + + +def run_once(channel): + base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones" + milestone = api("GET", f"{base}?channel={channel}").get("milestone") + if not milestone: + return + try: + post_url, reconciled = PUBLISHERS[channel](milestone) + api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url}) + print(json.dumps({"posted": milestone["id"], "channel": channel, "reconciled": reconciled, "url": post_url}), flush=True) + except Exception as error: + api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]}) + print(f"Milestone post failed ({channel}): {error}", flush=True) + + +if __name__ == "__main__": + interval = max(5, int(os.getenv("SOCIAL_WORKER_INTERVAL_SECONDS", "10"))) + if os.getenv("SOCIAL_MILESTONE_POSTING_ENABLED", "").lower() not in {"true", "1", "yes"}: + raise RuntimeError("Set SOCIAL_MILESTONE_POSTING_ENABLED=true to run this worker") + channels = enabled_channels() + print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval, "channels": channels}), flush=True) + while True: + for channel in channels: + try: + run_once(channel) + except Exception as error: + # Stay alive and make configuration/network errors visible in the + # container logs instead of entering a silent restart loop. + print(f"Worker cycle failed ({channel}): {error}", flush=True) + time.sleep(interval) diff --git a/sql/2026-08-13_social_milestones.sql b/sql/2026-08-13_social_milestones.sql new file mode 100644 index 0000000..838f377 --- /dev/null +++ b/sql/2026-08-13_social_milestones.sql @@ -0,0 +1,43 @@ +-- Success-sharing milestones. Run once against the target database before +-- deploying the application version that uses this feature. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "xHandle" TEXT; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "socialPromptOptOut" BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS "SocialMilestone" ( + "id" TEXT PRIMARY KEY, + "qrId" TEXT NOT NULL REFERENCES "QRCode"("id") ON DELETE CASCADE, + "userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE, + "kind" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'detected', + "detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "shownAt" TIMESTAMP(3), + "respondedAt" TIMESTAMP(3), + "postedAt" TIMESTAMP(3), + "withName" BOOLEAN NOT NULL DEFAULT false, + "consentText" TEXT, + CONSTRAINT "SocialMilestone_qr_kind_key" UNIQUE ("qrId", "kind") +); + +CREATE INDEX IF NOT EXISTS "SocialMilestone_status_respondedAt_idx" + ON "SocialMilestone" ("status", "respondedAt"); +CREATE INDEX IF NOT EXISTS "SocialMilestone_userId_status_idx" + ON "SocialMilestone" ("userId", "status"); + +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3); +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL DEFAULT 'en'; +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "cardData" JSONB; +CREATE INDEX IF NOT EXISTS "SocialMilestone_status_claimedAt_idx" + ON "SocialMilestone" ("status", "claimedAt"); + +-- Version 2: independent brand and self-share state plus a consent-gated +-- unguessable URL for Open Graph previews. Execute manually once. +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandStatus" TEXT NOT NULL DEFAULT 'pending'; +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandApprovedAt" TIMESTAMP(3); +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostedAt" TIMESTAMP(3); +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostUrl" TEXT; +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostError" TEXT; +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "selfSharedAt" TIMESTAMP(3); +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "shareToken" TEXT; +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "publicShareApprovedAt" TIMESTAMP(3); +CREATE UNIQUE INDEX IF NOT EXISTS "SocialMilestone_shareToken_key" + ON "SocialMilestone" ("shareToken") WHERE "shareToken" IS NOT NULL; diff --git a/sql/2026-08-16_social_milestone_channels.sql b/sql/2026-08-16_social_milestone_channels.sql new file mode 100644 index 0000000..7fb84af --- /dev/null +++ b/sql/2026-08-16_social_milestone_channels.sql @@ -0,0 +1,42 @@ +-- Per-channel publishing for milestone posts. Run once against the target +-- database BEFORE deploying the application version that uses this table. +-- +-- Consent is channel-bound: agreeing to a post on X says nothing about +-- Instagram - different audience, different disclosure. Publishing state +-- therefore moves out of the SocialMilestone row into one row per channel. +-- The old "brand*" columns stay in place as a fallback and are backfilled +-- below; nothing reads them any more. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "instagramHandle" TEXT; + +CREATE TABLE IF NOT EXISTS "SocialMilestonePost" ( + "id" TEXT PRIMARY KEY, + "milestoneId" TEXT NOT NULL REFERENCES "SocialMilestone"("id") ON DELETE CASCADE, + "channel" TEXT NOT NULL, -- 'x' | 'instagram' + "status" TEXT NOT NULL DEFAULT 'approved', -- approved | processing | posted | failed | revoked + -- The text the customer read before consenting. Published verbatim. + "consentText" TEXT NOT NULL, + "handle" TEXT, + "approvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "claimedAt" TIMESTAMP(3), + "postedAt" TIMESTAMP(3), + "postUrl" TEXT, + "error" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "nextAttemptAt" TIMESTAMP(3), + -- A row exists only where consent exists. No row means: not approved. + CONSTRAINT "SocialMilestonePost_milestone_channel_key" UNIQUE ("milestoneId", "channel") +); + +CREATE INDEX IF NOT EXISTS "SocialMilestonePost_channel_status_approvedAt_idx" + ON "SocialMilestonePost" ("channel", "status", "approvedAt"); + +-- Existing X consents keep working. `processing` becomes `approved` again: the +-- publisher reconciles against the timeline before it posts, so a re-claim +-- cannot duplicate a post that already went out. +INSERT INTO "SocialMilestonePost" ("id", "milestoneId", "channel", "status", "consentText", "approvedAt", "postedAt", "postUrl", "error", "attempts") +SELECT gen_random_uuid()::text, "id", 'x', + CASE WHEN "brandStatus" = 'processing' THEN 'approved' ELSE "brandStatus" END, + "consentText", COALESCE("brandApprovedAt", "detectedAt"), "brandPostedAt", "brandPostUrl", "brandPostError", "attempts" +FROM "SocialMilestone" +WHERE "consentText" IS NOT NULL AND "brandStatus" IN ('approved', 'processing', 'posted', 'failed', 'revoked') +ON CONFLICT ("milestoneId", "channel") DO NOTHING; diff --git a/sql/2026-08-16_social_milestone_retries.sql b/sql/2026-08-16_social_milestone_retries.sql new file mode 100644 index 0000000..315b20b --- /dev/null +++ b/sql/2026-08-16_social_milestone_retries.sql @@ -0,0 +1,15 @@ +-- Milestone publishing retries. Run once against the target database before +-- deploying the application version that uses these columns. +-- +-- docker-compose exec db psql -U postgres -d qrmaster -f - < sql/2026-08-16_social_milestone_retries.sql +-- +-- A failed X post used to stay failed forever: the consent dialog only opens +-- for freshly detected milestones, so nobody ever saw the retry button again. +-- The publisher now re-queues a failed attempt on its own until it runs out of +-- attempts, and the customer can retry a permanently failed post from Settings. +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "nextAttemptAt" TIMESTAMP(3); + +-- The publisher polls for the oldest approved milestone that is due. +CREATE INDEX IF NOT EXISTS "SocialMilestone_brandStatus_brandApprovedAt_idx" + ON "SocialMilestone" ("brandStatus", "brandApprovedAt"); diff --git a/src/app/(main)/(app)/dashboard/page.tsx b/src/app/(main)/(app)/dashboard/page.tsx index 2aa229e..4820b89 100644 --- a/src/app/(main)/(app)/dashboard/page.tsx +++ b/src/app/(main)/(app)/dashboard/page.tsx @@ -16,6 +16,7 @@ import { QrCode } from 'lucide-react'; import { trackEvent, identifyUser } from '@/components/PostHogProvider'; import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans'; import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'; +import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog'; interface QRCodeData { id: string; @@ -322,6 +323,7 @@ export default function DashboardPage() { return (
+ {/* Header with Plan Badge */}
diff --git a/src/app/(main)/(app)/settings/page.tsx b/src/app/(main)/(app)/settings/page.tsx index 1f52914..8f6809e 100644 --- a/src/app/(main)/(app)/settings/page.tsx +++ b/src/app/(main)/(app)/settings/page.tsx @@ -10,11 +10,51 @@ import ChangePasswordModal from '@/components/settings/ChangePasswordModal'; type TabType = 'profile' | 'subscription'; +type MilestonePost = { + channel: string; + status: string; + postUrl: string | null; + error: string | null; + postedAt: string | null; +}; + +type MilestoneHistoryItem = { + id: string; + qrTitle: string; + uniqueScans: number; + detectedAt: string; + promptStatus: string; + selfSharedAt: string | null; + shareUrl: string | null; + posts: MilestonePost[]; +}; + +const CHANNEL_LABELS: Record = { x: 'X', instagram: 'Instagram' }; + +function milestoneStateLabel(milestone: MilestoneHistoryItem) { + if (milestone.posts.some(post => post.status !== 'revoked')) return 'Approved for publishing'; + if (milestone.selfSharedAt) return 'Shared by you'; + if (milestone.promptStatus === 'declined') return 'Declined'; + return 'Waiting for your decision'; +} + +function postStateLabel(post: MilestonePost) { + if (post.status === 'posted') return 'published'; + if (post.status === 'failed') return 'publishing failed'; + if (post.status === 'revoked') return 'revoked before publishing'; + return 'queued'; +} + export default function SettingsPage() { const { fetchWithCsrf } = useCsrf(); const [activeTab, setActiveTab] = useState('profile'); const [loading, setLoading] = useState(false); - const [showPasswordModal, setShowPasswordModal] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true); + const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false); + const [socialSaving, setSocialSaving] = useState(false); + const [milestones, setMilestones] = useState([]); + const [milestoneBusy, setMilestoneBusy] = useState(null); // Profile states const [name, setName] = useState(''); @@ -49,10 +89,23 @@ export default function SettingsPage() { // Fetch usage stats from API const statsResponse = await fetch('/api/user/stats'); - if (statsResponse.ok) { - const data = await statsResponse.json(); - setUsageStats(data); + if (statsResponse.ok) { + const data = await statsResponse.json(); + setUsageStats(data); + } + + const socialResponse = await fetch('/api/social-milestones/preferences'); + if (socialResponse.ok) { + const data = await socialResponse.json(); + setSocialPromptsEnabled(data.promptsEnabled !== false); + setSocialTestResetAvailable(data.testResetAvailable === true); } + + const historyResponse = await fetch('/api/social-milestones/history'); + if (historyResponse.ok) { + const data = await historyResponse.json(); + setMilestones(Array.isArray(data.milestones) ? data.milestones : []); + } } catch (e) { console.error('Failed to load user data:', e); } @@ -92,8 +145,50 @@ export default function SettingsPage() { } finally { setLoading(false); } + }; + + const updateSocialPrompts = async (action: 'enable' | 'disable' | 'reset_test') => { + setSocialSaving(true); + try { + const response = await fetchWithCsrf('/api/social-milestones/preferences', { + method: 'PATCH', + body: JSON.stringify({ action }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Could not update milestone prompts'); + setSocialPromptsEnabled(data.promptsEnabled !== false); + showToast(action === 'reset_test' ? 'Milestone test reset. Open the dashboard to test it again.' : 'Milestone preference updated.', 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not update milestone prompts', 'error'); + } finally { + setSocialSaving(false); + } }; + const updateMilestone = async (id: string, action: 'retry' | 'revoke', channel: string) => { + setMilestoneBusy(`${id}:${channel}`); + try { + const response = await fetchWithCsrf(`/api/social-milestones/${id}`, { + method: 'PATCH', + body: JSON.stringify({ action, channel }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Could not update this milestone'); + setMilestones(current => current.map(milestone => milestone.id === id ? { + ...milestone, + posts: milestone.posts.map(post => { + const next = (data.milestone?.posts || []).find((entry: MilestonePost) => entry.channel === post.channel); + return next ? { ...post, status: next.status, postUrl: next.postUrl, error: next.error } : post; + }), + } : milestone)); + showToast(action === 'retry' ? 'Post queued again.' : 'Post revoked. Nothing will be published.', 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not update this milestone', 'error'); + } finally { + setMilestoneBusy(null); + } + }; + const handleManageSubscription = async () => { setLoading(true); @@ -245,9 +340,78 @@ export default function SettingsPage() {

- - - {/* Security */} + + + + + Milestone sharing + + +
+
+

Show scan milestone prompts

+

Choose whether QR Master may ask you to share verified scan achievements. Nothing is published without your confirmation.

+
+ +
+ {milestones.length > 0 &&
+

Your milestones

+

Every scan milestone we detected and what happened to it.

+
    + {milestones.map(milestone => ( +
  • +
    +
    +

    {milestone.qrTitle}

    +

    + {milestone.uniqueScans.toLocaleString('en-US')} unique scans · {new Date(milestone.detectedAt).toLocaleDateString('en-US')} · {milestoneStateLabel(milestone)} +

    +
    + {milestone.shareUrl && ( + Open card + )} +
    + {/* One line per channel: consent, and everything that can be + withdrawn or restarted, is per channel. */} + {milestone.posts.map(post => ( +
    +
    +

    + {CHANNEL_LABELS[post.channel] || post.channel} — {postStateLabel(post)} +

    + {post.status === 'failed' && post.error && ( +

    {post.error}

    + )} +
    +
    + {post.postUrl && ( + View post + )} + {post.status === 'failed' && ( + + )} + {['approved', 'failed'].includes(post.status) && ( + + )} +
    +
    + ))} +
  • + ))} +
+
} + {socialTestResetAvailable &&
+
+

Test environment: reopen the latest milestone and clear its publishing state.

+ +
+
} +
+
+ + {/* Security */} Security diff --git a/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx new file mode 100644 index 0000000..de450b7 --- /dev/null +++ b/src/app/(main)/(marketing)/s/m/[token]/og/route.tsx @@ -0,0 +1,21 @@ +import { db } from '@/lib/db'; +import { createSocialMilestoneImage, socialMilestoneImageFormat } from '@/lib/social-milestone-image'; +import type { SocialMilestoneImageCard } from '@/lib/social-milestone-image'; + +export const runtime = 'nodejs'; + +export async function GET(request: Request, { params }: { params: { token: string } }) { + const share = await db.socialMilestone.findFirst({ + where: { shareToken: params.token, publicShareApprovedAt: { not: null } }, + select: { cardData: true, language: true }, + }); + if (!share) return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } }); + + // `format` serves the aspect ratios the networks accept: the default 1.91:1 + // for link previews, 1:1 and 4:5 for an Instagram post. + return createSocialMilestoneImage( + (share.cardData || {}) as SocialMilestoneImageCard, + share.language === 'de', + socialMilestoneImageFormat(new URL(request.url).searchParams.get('format')), + ); +} diff --git a/src/app/(main)/(marketing)/s/m/[token]/page.tsx b/src/app/(main)/(marketing)/s/m/[token]/page.tsx new file mode 100644 index 0000000..044821a --- /dev/null +++ b/src/app/(main)/(marketing)/s/m/[token]/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { db } from '@/lib/db'; +import { getWwwOrigin } from '@/lib/hosts'; + +type Props = { params: { token: string } }; + +async function getShare(token: string) { + return db.socialMilestone.findFirst({ + where: { shareToken: token, publicShareApprovedAt: { not: null } }, + select: { cardData: true, language: true, publicShareApprovedAt: true }, + }); +} + +export async function generateMetadata({ params }: Props): Promise { + const share = await getShare(params.token); + if (!share) return { robots: { index: false, follow: false } }; + const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null; + const count = card?.totalUniqueScans || 0; + const title = share.language === 'de' + ? `${count.toLocaleString('de-DE')} ${count === 1 ? 'eindeutiger QR-Scan' : 'eindeutige QR-Scans'} erreicht` + : `${count.toLocaleString('en-US')} unique QR ${count === 1 ? 'scan' : 'scans'} reached`; + const description = share.language === 'de' + ? `${card?.qrTitle || 'Ein QR-Code'} hat einen verifizierten Scan-Meilenstein mit QR Master erreicht.` + : `${card?.qrTitle || 'A QR code'} reached a verified scan milestone with QR Master.`; + const url = `${getWwwOrigin()}/s/m/${params.token}`; + const imageUrl = `${url}/og?v=${share.publicShareApprovedAt?.getTime() || 0}`; + return { + title, + description, + robots: { index: false, follow: false }, + openGraph: { type: 'website', title, description, url, images: [{ url: imageUrl, width: 1200, height: 630, alt: title }] }, + twitter: { card: 'summary_large_image', title, description, images: [imageUrl] }, + }; +} + +export default async function SocialMilestoneSharePage({ params }: Props) { + const share = await getShare(params.token); + if (!share) notFound(); + const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null; + const imageUrl = `${getWwwOrigin()}/s/m/${params.token}/og?v=${share.publicShareApprovedAt?.getTime() || 0}`; + const alt = share.language === 'de' + ? `${card?.qrTitle || 'QR-Code'}: ${(card?.totalUniqueScans || 0).toLocaleString('de-DE')} eindeutige Scans` + : `${card?.qrTitle || 'QR code'}: ${(card?.totalUniqueScans || 0).toLocaleString('en-US')} unique scans`; + return
{alt}

{share.language === 'de' ? 'Verifizierter Scan-Meilenstein von QR Master' : 'Verified scan milestone from QR Master'}

; +} diff --git a/src/app/(main)/api/cron/social-milestones/route.ts b/src/app/(main)/api/cron/social-milestones/route.ts new file mode 100644 index 0000000..e553149 --- /dev/null +++ b/src/app/(main)/api/cron/social-milestones/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { detectSocialMilestones } from '@/lib/social-milestones-server'; +import { getSocialMilestoneThresholds } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +function isAuthorized(request: NextRequest) { + const secret = process.env.CRON_SECRET; + return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`; +} + +// Detection only: this route never contacts customers or an external network. +export async function GET(request: NextRequest) { + if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const detected = await detectSocialMilestones(); + return NextResponse.json({ ok: true, detected, thresholds: getSocialMilestoneThresholds() }); +} diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts new file mode 100644 index 0000000..422a8d7 --- /dev/null +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getWwwOrigin } from '@/lib/hosts'; +import { isSocialChannel, SOCIAL_CHANNELS, SocialChannel } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +function isAuthorized(request: NextRequest) { + const secret = process.env.INTERNAL_API_SECRET; + return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`; +} + +function approvalDelayHours() { + const configured = Number(process.env.SOCIAL_MILESTONE_POST_DELAY_HOURS); + return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0; +} + +/** Timeline spacing between two brand posts. Set to 0 to publish back to back. */ +function minGapHours() { + const configured = Number(process.env.SOCIAL_MILESTONE_MIN_GAP_HOURS); + return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 24; +} + +// Route modules may only export request handlers, so these stay local. +const MAX_PUBLISH_ATTEMPTS = 3; + +/** One lock per channel, otherwise two publishers would block each other. */ +function claimLockId(channel: SocialChannel) { + return 920241 + SOCIAL_CHANNELS.indexOf(channel); +} + +/** 5, 10, then 20 minutes. A transient outage resolves without a human. */ +function retryDelayMs(attempts: number) { + return Math.min(60, 5 * 2 ** Math.max(0, attempts - 1)) * 60 * 1000; +} + +// This endpoint is intentionally a queue, not a social-media client. The +// external worker fetches an approved payload and marks it complete only after +// its own post succeeded. The app never receives X or Meta credentials. +export async function GET(request: NextRequest) { + if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const channelParam = request.nextUrl.searchParams.get('channel') || 'x'; + if (!isSocialChannel(channelParam)) return NextResponse.json({ error: 'Unknown channel' }, { status: 400 }); + const channel: SocialChannel = channelParam; + const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true'; + const now = Date.now(); + + // A worker can be interrupted after claiming a row. Count that as a spent + // attempt and re-queue it instead of leaving the dashboard in "processing" + // forever. The worker reconciles against the account before it posts again, + // so an interruption after a successful post cannot duplicate it. + await db.$executeRaw` + UPDATE "SocialMilestonePost" + SET "attempts" = "attempts" + 1, + "status" = CASE WHEN "attempts" + 1 < ${MAX_PUBLISH_ATTEMPTS} THEN 'approved' ELSE 'failed' END, + "nextAttemptAt" = CASE WHEN "attempts" + 1 < ${MAX_PUBLISH_ATTEMPTS} THEN ${new Date(now + retryDelayMs(1))} ELSE NULL END, + "error" = 'The publisher was interrupted before it confirmed the post.', + "claimedAt" = NULL + WHERE "channel" = ${channel} AND "status" = 'processing' AND "claimedAt" < ${new Date(now - 5 * 60 * 1000)} + `; + + const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000); + const post = await db.socialMilestonePost.findFirst({ + where: { + channel, + status: 'approved', + approvedAt: { lte: approvalNotBefore }, + OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: new Date(now) } }], + // A paused or deleted QR code stops being advertised. + milestone: { qr: { status: 'ACTIVE' } }, + }, + orderBy: { approvedAt: 'asc' }, + include: { milestone: { select: { id: true, shareToken: true } } }, + }); + if (!post) return NextResponse.json({ milestone: null }); + + // Several customers can consent on the same afternoon. Spacing keeps the + // brand timeline readable, per channel. A retry is exempt: nothing of it went + // out yet, and delaying recovery by a full day would strand the post. + if (post.attempts === 0 && minGapHours() > 0) { + const previous = await db.socialMilestonePost.findFirst({ + where: { channel, postedAt: { gt: new Date(now - minGapHours() * 60 * 60 * 1000) } }, + orderBy: { postedAt: 'desc' }, + select: { postedAt: true }, + }); + if (previous?.postedAt) { + const nextPostAt = new Date(previous.postedAt.getTime() + minGapHours() * 60 * 60 * 1000); + return NextResponse.json({ milestone: null, reason: 'spacing', nextPostAt: nextPostAt.toISOString() }); + } + } + + const shareUrl = post.milestone.shareToken ? `${getWwwOrigin()}/s/m/${post.milestone.shareToken}` : null; + if (dryRun) return NextResponse.json({ milestone: { id: post.id, channel, text: post.consentText, shareUrl }, dryRun: true }); + + const claimed = await db.$transaction(async (tx) => { + // The blocking advisory-lock function returns PostgreSQL `void`, which + // Prisma cannot deserialize. The try variant returns a real boolean and + // keeps the lock scoped to this transaction. + const [lock] = await tx.$queryRaw>` + SELECT pg_try_advisory_xact_lock(${claimLockId(channel)}) AS acquired + `; + if (!lock?.acquired) return 0; + const result = await tx.socialMilestonePost.updateMany({ + where: { id: post.id, status: 'approved' }, data: { status: 'processing', claimedAt: new Date() }, + }); + return result.count; + }); + if (!claimed) return NextResponse.json({ milestone: null, reason: 'claimed' }); + + return NextResponse.json({ milestone: { + id: post.id, + milestoneId: post.milestone.id, + channel, + text: post.consentText, + shareToken: post.milestone.shareToken, + shareUrl, + // Tells the worker whether an earlier attempt may already have published + // this post, so it only spends read quota when reconciling. + attempts: post.attempts, + approvedAt: post.approvedAt.toISOString(), + } }); +} + +export async function PATCH(request: NextRequest) { + if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const body = await request.json().catch(() => null) as { id?: string; result?: 'posted' | 'failed'; postUrl?: string; error?: string } | null; + if (!body?.id || !['posted', 'failed'].includes(body.result || '')) return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + const claimed = await db.socialMilestonePost.findFirst({ + where: { id: body.id, status: 'processing' }, + select: { id: true, attempts: true }, + }); + if (!claimed) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); + + if (body.result === 'posted') { + const updated = await db.socialMilestonePost.updateMany({ + where: { id: claimed.id, status: 'processing' }, + data: { status: 'posted', postedAt: new Date(), postUrl: body.postUrl || null, error: null, nextAttemptAt: null }, + }); + if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); + return NextResponse.json({ ok: true }); + } + + // Re-queue on its own until the attempts are used up. Only then does the post + // rest in `failed`, where the customer can restart it manually. + const attempts = claimed.attempts + 1; + const retry = attempts < MAX_PUBLISH_ATTEMPTS; + const updated = await db.socialMilestonePost.updateMany({ + where: { id: claimed.id, status: 'processing' }, + data: { + status: retry ? 'approved' : 'failed', + attempts, + nextAttemptAt: retry ? new Date(Date.now() + retryDelayMs(attempts)) : null, + postedAt: null, + postUrl: null, + error: body.error || 'The post could not be published.', + claimedAt: null, + }, + }); + if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 }); + return NextResponse.json({ ok: true, attempts, retryScheduled: retry }); +} diff --git a/src/app/(main)/api/social-assets/[id]/route.ts b/src/app/(main)/api/social-assets/[id]/route.ts index 2a846b1..89f8d06 100644 --- a/src/app/(main)/api/social-assets/[id]/route.ts +++ b/src/app/(main)/api/social-assets/[id]/route.ts @@ -38,7 +38,7 @@ export async function DELETE( request: NextRequest, { params }: { params: { id: string } } ) { - const adminKey = process.env.TIKTOK_ADMIN_KEY; + const adminKey = process.env.SOCIAL_ASSET_ADMIN_KEY || process.env.TIKTOK_ADMIN_KEY; const provided = request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key'); if (!adminKey || provided !== adminKey) { diff --git a/src/app/(main)/api/social-assets/route.ts b/src/app/(main)/api/social-assets/route.ts index c37c656..b3ab765 100644 --- a/src/app/(main)/api/social-assets/route.ts +++ b/src/app/(main)/api/social-assets/route.ts @@ -7,7 +7,9 @@ import { db } from '@/lib/db'; // are served from qrmaster.net via GET /api/social-assets/[id]. const isAdminRequest = (request: NextRequest) => { - const adminKey = process.env.TIKTOK_ADMIN_KEY; + // Asset hosting is not a TikTok feature - Instagram needs it too. The old + // TikTok key stays valid so existing deployments keep working. + const adminKey = process.env.SOCIAL_ASSET_ADMIN_KEY || process.env.TIKTOK_ADMIN_KEY; if (!adminKey) return false; const provided = request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key'); diff --git a/src/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts new file mode 100644 index 0000000..35febe4 --- /dev/null +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -0,0 +1,190 @@ +import { randomBytes } from 'crypto'; +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { csrfProtection } from '@/lib/csrf'; +import { getWwwOrigin } from '@/lib/hosts'; +import { getSessionUserId } from '@/lib/session'; +import { + buildChannelPost, getEnabledSocialChannels, isSocialChannel, milestoneThreshold, + normalizeChannelHandle, SocialChannel, socialLocale, +} from '@/lib/social-milestones'; +import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server'; + +type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke' | 'retry'; +type Body = { + action?: Action; + withName?: boolean; + channels?: string[]; + handles?: Record; + channel?: string; + language?: string; +}; + +async function ownedMilestone(id: string, userId: string) { + return db.socialMilestone.findFirst({ + where: { id, userId }, + include: { + user: { select: { primaryUseCase: true } }, + qr: { select: { id: true, title: true, createdAt: true } }, + posts: { select: { channel: true, status: true, postUrl: true, error: true } }, + }, + }); +} + +type ClientMilestone = { status: string; selfSharedAt: Date | null; posts: Array<{ channel: string; status: string; postUrl: string | null; error: string | null }> }; + +function clientState(milestone: ClientMilestone) { + return { + promptStatus: milestone.status, + selfSharedAt: milestone.selfSharedAt?.toISOString() || null, + posts: milestone.posts.map(post => ({ channel: post.channel, status: post.status, postUrl: post.postUrl, error: post.error })), + }; +} + +async function stateOf(milestoneId: string) { + const milestone = await db.socialMilestone.findUnique({ + where: { id: milestoneId }, + select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, + }); + return milestone ? clientState(milestone) : null; +} + +export async function GET(_request: NextRequest, { params }: { params: { id: string } }) { + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const milestone = await ownedMilestone(params.id, userId); + if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json({ milestone: clientState(milestone) }); +} + +export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) { + const csrf = csrfProtection(request); + if (!csrf.valid) return NextResponse.json({ error: csrf.error }, { status: 403 }); + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json().catch(() => null) as Body | null; + if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke', 'retry'].includes(body.action || '')) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + const milestone = await ownedMilestone(params.id, userId); + if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + const threshold = milestoneThreshold(milestone.kind); + if (!threshold) return NextResponse.json({ error: 'Invalid milestone' }, { status: 400 }); + const isDismissible = ['detected', 'shown'].includes(milestone.status); + + // Revoke and retry act on a single channel: consent for X is not consent for + // Instagram, and withdrawing one must not touch the other. + if (body.action === 'revoke' || body.action === 'retry') { + if (!isSocialChannel(body.channel)) return NextResponse.json({ error: 'Unknown channel' }, { status: 400 }); + const post = await db.socialMilestonePost.findUnique({ + where: { milestoneId_channel: { milestoneId: milestone.id, channel: body.channel } }, + }); + if (!post) return NextResponse.json({ error: 'Nothing was approved for this channel' }, { status: 404 }); + if (body.action === 'revoke') { + if (!['approved', 'failed'].includes(post.status)) return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 }); + await db.socialMilestonePost.update({ where: { id: post.id }, data: { status: 'revoked', error: null, nextAttemptAt: null } }); + } else { + if (post.status !== 'failed') return NextResponse.json({ error: 'Only failed posts can be restarted' }, { status: 409 }); + // Restarts reuse the approved text unchanged - a retry must never + // publish something the customer did not read before consenting. + await db.socialMilestonePost.update({ where: { id: post.id }, data: { status: 'approved', attempts: 0, nextAttemptAt: null, error: null } }); + } + return NextResponse.json({ ok: true, milestone: await stateOf(milestone.id) }); + } + + if (body.action === 'decline' || body.action === 'opt_out') { + if (!isDismissible) return NextResponse.json({ error: 'This milestone has already been dismissed' }, { status: 409 }); + const now = new Date(); + await db.$transaction([ + db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'declined', respondedAt: now } }), + ...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []), + ]); + return NextResponse.json({ ok: true }); + } + + const language = socialLocale(body.language); + const withName = body.withName === true; + const card = await ensureSocialMilestoneCard({ + milestoneId: milestone.id, + cardData: milestone.cardData, + kind: milestone.kind, + detectedAt: milestone.detectedAt, + language, + qr: milestone.qr, + primaryUseCase: milestone.user.primaryUseCase, + }); + const now = new Date(); + // 72 random bits keep public URLs unguessable while making the share URL + // much less disruptive in an X compose window than a full UUID. + const token = milestone.shareToken || randomBytes(9).toString('base64url'); + const shareUrl = `${getWwwOrigin()}/s/m/${token}`; + + if (body.action === 'self_share') { + const updated = await db.socialMilestone.update({ + where: { id: milestone.id }, + data: { + status: 'self_shared', respondedAt: milestone.respondedAt || now, + selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language, + }, + select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, + }); + return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) }); + } + + const enabled = getEnabledSocialChannels(); + const channels = (body.channels || []).filter(isSocialChannel).filter(channel => enabled.includes(channel)); + if (!channels.length) return NextResponse.json({ error: 'Choose at least one channel' }, { status: 400 }); + + const handles = new Map(); + for (const channel of channels) { + if (!withName) { + handles.set(channel, null); + continue; + } + const handle = normalizeChannelHandle(channel, body.handles?.[channel] || ''); + if (!handle) return NextResponse.json({ error: `Enter a valid ${channel === 'instagram' ? 'Instagram' : 'X'} handle` }, { status: 400 }); + handles.set(channel, handle); + } + + const locked = milestone.posts.filter(post => channels.includes(post.channel as SocialChannel) && ['processing', 'posted'].includes(post.status)); + if (locked.length) return NextResponse.json({ error: 'This post is already being processed' }, { status: 409 }); + + const updated = await db.$transaction(async tx => { + if (withName) { + await tx.user.update({ + where: { id: userId }, + data: { + ...(handles.has('x') ? { xHandle: handles.get('x') } : {}), + ...(handles.has('instagram') ? { instagramHandle: handles.get('instagram') } : {}), + }, + }); + } + for (const channel of channels) { + const consentText = buildChannelPost({ + channel, + primaryUseCase: milestone.user.primaryUseCase, + totalUniqueScans: (card as { totalUniqueScans?: number }).totalUniqueScans || threshold, + locale: language, + qrTitle: milestone.qr.title, + shareUrl, + handle: handles.get(channel) || null, + }); + await tx.socialMilestonePost.upsert({ + where: { milestoneId_channel: { milestoneId: milestone.id, channel } }, + create: { milestoneId: milestone.id, channel, consentText, handle: handles.get(channel) || null, approvedAt: now, status: 'approved' }, + // A re-approval after a correction or a withdrawal starts over. + update: { consentText, handle: handles.get(channel) || null, status: 'approved', attempts: 0, nextAttemptAt: null, error: null }, + }); + } + return tx.socialMilestone.update({ + where: { id: milestone.id }, + data: { + status: 'approved', withName, language, cardData: card, respondedAt: now, + shareToken: token, publicShareApprovedAt: now, + }, + select: { status: true, selfSharedAt: true, posts: { select: { channel: true, status: true, postUrl: true, error: true } } }, + }); + }); + return NextResponse.json({ ok: true, milestone: clientState(updated) }); +} diff --git a/src/app/(main)/api/social-milestones/history/route.ts b/src/app/(main)/api/social-milestones/history/route.ts new file mode 100644 index 0000000..263b270 --- /dev/null +++ b/src/app/(main)/api/social-milestones/history/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getWwwOrigin } from '@/lib/hosts'; +import { getSessionUserId } from '@/lib/session'; +import { milestoneThreshold } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +// The consent dialog opens once per milestone. Everything that happened before +// - a declined prompt, a queued post, a post that ran out of attempts - is only +// visible here, which is also the only place a failed post can be restarted. +export async function GET() { + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const milestones = await db.socialMilestone.findMany({ + where: { userId }, + orderBy: { detectedAt: 'desc' }, + take: 50, + select: { + id: true, kind: true, status: true, detectedAt: true, cardData: true, + selfSharedAt: true, shareToken: true, publicShareApprovedAt: true, + qr: { select: { title: true } }, + posts: { select: { channel: true, status: true, postUrl: true, error: true, postedAt: true } }, + }, + }); + + return NextResponse.json({ + milestones: milestones.map(milestone => { + const card = milestone.cardData as { totalUniqueScans?: number } | null; + return { + id: milestone.id, + qrTitle: milestone.qr.title, + uniqueScans: card?.totalUniqueScans || milestoneThreshold(milestone.kind) || 0, + detectedAt: milestone.detectedAt.toISOString(), + promptStatus: milestone.status, + selfSharedAt: milestone.selfSharedAt?.toISOString() || null, + posts: milestone.posts.map(post => ({ + channel: post.channel, + status: post.status, + postUrl: post.postUrl, + error: post.error, + postedAt: post.postedAt?.toISOString() || null, + })), + shareUrl: milestone.shareToken && milestone.publicShareApprovedAt + ? `${getWwwOrigin()}/s/m/${milestone.shareToken}` + : null, + }; + }), + }); +} diff --git a/src/app/(main)/api/social-milestones/preferences/route.ts b/src/app/(main)/api/social-milestones/preferences/route.ts new file mode 100644 index 0000000..1480785 --- /dev/null +++ b/src/app/(main)/api/social-milestones/preferences/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { csrfProtection } from '@/lib/csrf'; +import { db } from '@/lib/db'; +import { getSessionUserId } from '@/lib/session'; +import { getSocialMilestoneThresholds } from '@/lib/social-milestones'; + +export const dynamic = 'force-dynamic'; + +function testResetAvailable() { + return getSocialMilestoneThresholds().some(threshold => threshold < 100); +} + +export async function GET() { + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await db.user.findUnique({ where: { id: userId }, select: { socialPromptOptOut: true } }); + if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json({ promptsEnabled: !user.socialPromptOptOut, testResetAvailable: testResetAvailable() }); +} + +export async function PATCH(request: NextRequest) { + const csrf = csrfProtection(request); + if (!csrf.valid) return NextResponse.json({ error: csrf.error }, { status: 403 }); + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const body = await request.json().catch(() => null) as { action?: 'enable' | 'disable' | 'reset_test' } | null; + if (!body?.action || !['enable', 'disable', 'reset_test'].includes(body.action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + + if (body.action === 'reset_test') { + if (!testResetAvailable()) return NextResponse.json({ error: 'Test reset is not available in this environment' }, { status: 403 }); + const latest = await db.socialMilestone.findFirst({ + where: { userId }, + orderBy: { detectedAt: 'desc' }, + select: { id: true }, + }); + await db.$transaction([ + db.user.update({ where: { id: userId }, data: { socialPromptOptOut: false } }), + // Dropping the per-channel approvals is what makes the reset complete: + // no row means no consent, which is exactly the pre-prompt state. + ...(latest ? [ + db.socialMilestonePost.deleteMany({ where: { milestoneId: latest.id } }), + db.socialMilestone.update({ + where: { id: latest.id }, + data: { + status: 'detected', shownAt: null, respondedAt: null, + consentText: null, withName: false, + selfSharedAt: null, publicShareApprovedAt: null, shareToken: null, + }, + }), + ] : []), + ]); + return NextResponse.json({ ok: true, promptsEnabled: true, resetMilestone: Boolean(latest) }); + } + + const promptsEnabled = body.action === 'enable'; + await db.user.update({ where: { id: userId }, data: { socialPromptOptOut: !promptsEnabled } }); + return NextResponse.json({ ok: true, promptsEnabled }); +} diff --git a/src/app/(main)/api/social-milestones/route.ts b/src/app/(main)/api/social-milestones/route.ts new file mode 100644 index 0000000..1aac316 --- /dev/null +++ b/src/app/(main)/api/social-milestones/route.ts @@ -0,0 +1,83 @@ +import { randomBytes } from 'crypto'; +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getWwwOrigin } from '@/lib/hosts'; +import { getSessionUserId } from '@/lib/session'; +import { getEnabledSocialChannels, milestonePostParts, milestoneThreshold, SOCIAL_CHANNELS, socialLocale } from '@/lib/social-milestones'; +import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server'; + +export const dynamic = 'force-dynamic'; + +// Returns at most one item. A missing response is treated as no consent, never as approval. +export async function GET(request: NextRequest) { + const userId = getSessionUserId(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await db.user.findUnique({ + where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, instagramHandle: true, primaryUseCase: true }, + }); + if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null }); + + const milestone = await db.socialMilestone.findFirst({ + // `shown` is a durable delivery receipt: one milestone may auto-open only + // once, even across refreshes, tabs and later dashboard visits. + where: { userId, status: 'detected' }, + orderBy: { detectedAt: 'asc' }, + include: { qr: { select: { id: true, title: true, createdAt: true } } }, + }); + if (!milestone) return NextResponse.json({ milestone: null }); + + const threshold = milestoneThreshold(milestone.kind); + if (!threshold) return NextResponse.json({ milestone: null }); + const locale = socialLocale(request.nextUrl.searchParams.get('locale')); + const shareToken = milestone.shareToken || randomBytes(9).toString('base64url'); + const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`; + const card = await ensureSocialMilestoneCard({ + milestoneId: milestone.id, + cardData: milestone.cardData, + kind: milestone.kind, + detectedAt: milestone.detectedAt, + language: locale, + qr: milestone.qr, + primaryUseCase: user.primaryUseCase, + refresh: true, + snapshotAt: new Date(), + }); + const delivered = await db.socialMilestone.updateMany({ + where: { id: milestone.id, status: 'detected' }, + data: { status: 'shown', shownAt: new Date(), shareToken }, + }); + if (!delivered.count) return NextResponse.json({ milestone: null }); + + // One entry per channel: its own text, its own handle. The dialog recombines + // head + mention + tail while the customer types, so what is on screen is + // exactly what the server will store as the consent text. `available` marks + // the channels a publisher is configured for - the others are still listed + // because sharing them yourself works without any publisher. + const enabled = getEnabledSocialChannels(); + const channels = SOCIAL_CHANNELS.map(channel => ({ + channel, + available: enabled.includes(channel), + defaultHandle: (channel === 'instagram' ? user.instagramHandle : user.xHandle) || '', + ...milestonePostParts({ + channel, + primaryUseCase: user.primaryUseCase, + totalUniqueScans: card.totalUniqueScans || threshold, + locale, + qrTitle: milestone.qr.title, + shareUrl, + }), + })); + + return NextResponse.json({ + milestone: { + id: milestone.id, qrTitle: milestone.qr.title, threshold, + promptStatus: 'shown', + language: locale, + shareUrl, + channels, + posts: [], + card, + }, + }); +} diff --git a/src/app/(main)/r/[slug]/route.ts b/src/app/(main)/r/[slug]/route.ts index d6ddc54..cd364ac 100644 --- a/src/app/(main)/r/[slug]/route.ts +++ b/src/app/(main)/r/[slug]/route.ts @@ -5,6 +5,7 @@ import { getWwwOrigin } from '@/lib/hosts'; import { db } from '@/lib/db'; import { hashIP } from '@/lib/hash'; import { triggerLifecycleScoring } from '@/lib/revops-server'; +import { detectSocialMilestones } from '@/lib/social-milestones-server'; export async function GET( request: NextRequest, @@ -260,6 +261,12 @@ async function trackScan(qrId: string, userId: string, request: NextRequest) { }, }); + // The customer sees a newly crossed milestone on their next dashboard + // visit; no separate cron invocation is required after a real scan. + if (isUnique) { + await detectSocialMilestones(qrId); + } + const activatedUsers = await db.user.updateMany({ where: { id: userId, diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx new file mode 100644 index 0000000..6922506 --- /dev/null +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -0,0 +1,333 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Check, Copy, ExternalLink, Instagram, LineChart, Linkedin, QrCode, Send, X } from 'lucide-react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog'; +import { Button } from '@/components/ui/Button'; +import { useCsrf } from '@/hooks/useCsrf'; +import { useTranslation } from '@/hooks/useTranslation'; +import { showToast } from '@/components/ui/Toast'; +import { roundedChartPath } from '@/lib/rounded-chart-path'; + +type Channel = 'x' | 'instagram'; +type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null }; +type ChannelOption = { channel: Channel; available: boolean; defaultHandle: string; head: string; tail: string; mentionWord: string }; +type PostState = { channel: string; status: string; postUrl: string | null; error: string | null }; +type Milestone = { id: string; qrTitle: string; threshold: number; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; channels: ChannelOption[]; posts: PostState[] }; +type BrandState = { promptStatus: string; selfSharedAt: string | null; posts: PostState[] }; + +const CHANNEL_LABELS: Record = { x: 'X', instagram: 'Instagram' }; + +function Trend({ trend, locale }: { trend: NonNullable; locale: 'en' | 'de' }) { + const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25; + const ticks = trend.target <= 5 + ? [1, 2, 3, 4, 5] + : Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4); + const first = new Date(trend.points[0].at).getTime(); + const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1); + const chartPoints = trend.points.map(point => { + const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410; + const y = 142 - (point.total / ceiling) * 126; + return { x, y }; + }); + const path = roundedChartPath(chartPoints, 18); + const endPoint = chartPoints[chartPoints.length - 1] || { x: 470, y: 142 }; + const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); + return + {ticks.map(tick => { + const y = 142 - (tick / ceiling) * 126; + return {number.format(tick)}; + })} + + + {trend.startLabel} + {trend.endLabel} + ; +} + +async function copyShareText(text: string) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const copied = document.execCommand('copy'); + textarea.remove(); + if (!copied) throw new Error('Copying is blocked by this browser'); + } +} + +export function SocialMilestoneDialog() { + const { fetchWithCsrf } = useCsrf(); + const { locale } = useTranslation(); + const [milestone, setMilestone] = useState(null); + const [withName, setWithName] = useState(false); + const [handles, setHandles] = useState>({}); + const [selected, setSelected] = useState([]); + const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | 'instagram' | null>(null); + const [brand, setBrand] = useState(null); + const loadedMilestone = useRef(false); + + useEffect(() => { + if (loadedMilestone.current) return; + loadedMilestone.current = true; + fetch(`/api/social-milestones?locale=${locale}`).then(async response => { + if (response.ok) { + const next = (await response.json()).milestone as Milestone | null; + setMilestone(next); + if (next) setBrand({ promptStatus: next.promptStatus, selfSharedAt: null, posts: next.posts || [] }); + } + }).catch(() => undefined); + }, [locale]); + useEffect(() => { + if (!milestone) return; + setHandles(Object.fromEntries(milestone.channels.map(option => [option.channel, option.defaultHandle]))); + // Instagram stays unticked on purpose: consent for one channel is not + // consent for the next, so the second one has to be an actual decision. + setSelected(milestone.channels.filter(option => option.available && option.channel === 'x').map(option => option.channel)); + }, [milestone]); + + const posts = brand?.posts || []; + const postFor = (channel: Channel) => posts.find(post => post.channel === channel); + const pending = posts.some(post => ['approved', 'processing'].includes(post.status)); + useEffect(() => { + if (!milestone || !pending) return; + const poll = async () => { + const response = await fetch(`/api/social-milestones/${milestone.id}`); + if (response.ok) setBrand((await response.json()).milestone); + }; + const timer = window.setInterval(poll, 3000); + void poll(); + return () => window.clearInterval(timer); + }, [milestone, pending]); + + const german = milestone?.language === 'de'; + const copy = german + ? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf den eigenen Kanälen veröffentlichen?', name: 'Meinen Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Von QR Master posten', queued: 'Wird veröffentlicht …', posted: 'Veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' } + : { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success on its own channels?', name: 'Mention my handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing …', posted: 'Published', failed: 'Publishing failed' }; + + const optionFor = (channel: Channel) => milestone?.channels.find(option => option.channel === channel); + const mentionOf = (channel: Channel) => { + const option = optionFor(channel); + const handle = (handles[channel] || '').trim().replace(/^@/, ''); + return option && withName && handle ? `\n\n${option.mentionWord} @${handle}.` : ''; + }; + /** Without the channel suffix - used where the share URL is added by hand. */ + const composeBody = (channel: Channel) => { + const option = optionFor(channel); + return option ? `${option.head}${mentionOf(channel)}` : ''; + }; + // Head + mention + tail is exactly how the server assembles the consent text. + const compose = (channel: Channel) => { + const option = optionFor(channel); + return option ? `${composeBody(channel)}${option.tail}` : ''; + }; + const previews = useMemo( + () => selected.map(channel => ({ channel, text: compose(channel) })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selected, handles, withName, milestone], + ); + + const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out', extra?: Record) => { + if (!milestone) return null; + const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { + method: 'PATCH', + body: JSON.stringify({ action, withName, handles, channels: selected, language: milestone.language, ...extra }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Could not save your choice'); + return result; + }; + const prepareSelfShare = async () => { + const result = await update('self_share'); + const shareUrl = `${result.shareUrl}?v=${result.shareVersion}`; + setBrand(result.milestone); + return { + shareUrl, + // 4:5 is the tallest ratio Instagram accepts and the one that keeps the + // numbers readable in a phone feed. + imageUrl: `${result.shareUrl}/og?format=portrait&v=${result.shareVersion}`, + text: `${composeBody('x')}\n\n${shareUrl}`, + }; + }; + const shareSelf = async (network: 'x' | 'linkedin') => { + if (!milestone) return; + // LinkedIn's public share dialog accepts only a URL. Start copying the + // prepared commentary while this click still owns browser focus, then + // open the LinkedIn share dialog after public-share consent is persisted. + const commentary = composeBody('x'); + const linkedinCopy = network === 'linkedin' + ? copyShareText(commentary).then(() => true).catch(() => false) + : Promise.resolve(true); + // Open synchronously from the user gesture. Awaiting the API first can make + // LinkedIn treat the new window as a blocked popup. + const shareWindow = window.open('about:blank', '_blank'); + if (shareWindow) shareWindow.opener = null; + setSaving('self'); + try { + const { shareUrl, text } = await prepareSelfShare(); + const copied = await linkedinCopy; + const targetUrl = network === 'x' + ? `https://x.com/intent/post?text=${encodeURIComponent(text)}` + : `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; + if (shareWindow) shareWindow.location.href = targetUrl; + else window.location.assign(targetUrl); + showToast(network === 'linkedin' + ? copied + ? (german ? 'LinkedIn-Text kopiert. Jetzt nur noch einfügen.' : 'LinkedIn text copied. Paste it into the composer.') + : (german ? 'LinkedIn ist geöffnet. Bitte nutze „Text kopieren“ im QR-Master-Fenster.' : 'LinkedIn is open. Please use “Copy text” in the QR Master window.') + : 'X share composer opened.', copied ? 'success' : 'error'); + } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } + finally { setSaving(null); } + }; + // Instagram has no composer a website can prefill: there is no intent URL, + // and the story deep links need a native pasteboard the browser cannot + // reach. What is left is the system share sheet on a phone, and a download + // plus the caption in the clipboard everywhere else. + const shareInstagram = async () => { + if (!milestone) return; + setSaving('instagram'); + try { + const caption = compose('instagram'); + const { imageUrl } = await prepareSelfShare(); + const response = await fetch(imageUrl); + if (!response.ok) throw new Error(german ? 'Das Meilenstein-Bild konnte nicht geladen werden.' : 'Could not load the milestone image'); + const blob = await response.blob(); + const file = new File([blob], 'qr-master-milestone.png', { type: blob.type || 'image/png' }); + const copied = await copyShareText(caption).then(() => true).catch(() => false); + if (navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ files: [file], text: caption }); + return; + } catch (error) { + // Sheet dismissed on purpose - do not push a download nobody asked for. + if (error instanceof Error && error.name === 'AbortError') return; + } + } + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = file.name; + link.click(); + URL.revokeObjectURL(objectUrl); + showToast(copied + ? (german ? 'Bild geladen, Text kopiert. Beides in Instagram einfügen.' : 'Image downloaded, caption copied. Add both in Instagram.') + : (german ? 'Bild geladen. Bitte „Nur Text kopieren“ für die Bildunterschrift nutzen.' : 'Image downloaded. Use “Copy text only” for the caption.'), + copied ? 'success' : 'error'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); + } finally { + setSaving(null); + } + }; + const copyLinkedInText = async () => { + setSaving('copy'); + try { + const { text } = await prepareSelfShare(); + await copyShareText(text); + showToast(german ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', 'error'); + } finally { setSaving(null); } + }; + const approveBrand = async () => { + setSaving('brand'); + try { + const result = await update('approve_brand'); + setBrand(result.milestone); + showToast(copy.queued, 'success'); + } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); } + finally { setSaving(null); } + }; + const dismiss = async (action: 'decline' | 'opt_out') => { + try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); } + }; + const optOut = () => { + const message = german + ? 'Meilenstein-Hinweise dauerhaft ausblenden? Du kannst sie später in den Einstellungen wieder aktivieren.' + : 'Turn off milestone prompts? You can enable them again later in Settings.'; + if (window.confirm(message)) void dismiss('opt_out'); + }; + const toggleChannel = (channel: Channel) => { + setSelected(current => current.includes(channel) ? current.filter(entry => entry !== channel) : [...current, channel]); + }; + + if (!milestone) return null; + const card = milestone.card; + const count = card.totalUniqueScans || milestone.threshold; + const promptStatus = brand?.promptStatus || milestone.promptStatus; + const brandChannels = milestone.channels.filter(option => option.available); + // A channel that is already published or in flight cannot be re-approved. + const canApprove = selected.length > 0 && !selected.some(channel => ['processing', 'posted'].includes(postFor(channel)?.status || '')); + return !open && setMilestone(null)} containerClassName="max-w-[960px]"> + +
+ +
+
{copy.heading}{milestone.qrTitle} {copy.subtitle}
+ +
+
+
+
+
QR MASTERVerified scan milestone
+
UNIQUE SCANS
{count.toLocaleString(german ? 'de-DE' : 'en-US')}
{german ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(german ? 'de-DE' : 'en-US')}
+
{card.trend ? :
{german ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
+ {card.trend &&
{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
} +
{card.qrTitle}
+
+
+
+

{copy.consent}

+
+ {brandChannels.map(option => ( + + ))} +
+ {previews.length === 0 + ?

{german ? 'Kein Kanal ausgewählt – QR Master veröffentlicht nichts.' : 'No channel selected – QR Master publishes nothing.'}

+ : previews.map(preview => ( +
+
{CHANNEL_LABELS[preview.channel]}
+
{preview.text}
+
+ ))} +
+
+ + {withName && selected.map(channel => ( + setHandles(current => ({ ...current, [channel]: event.target.value }))} + disabled={saving !== null} + maxLength={channel === 'instagram' ? 31 : 16} + placeholder={channel === 'instagram' ? '@your.instagram' : '@yourhandle'} + className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-[#0256ff] focus:ring-2 focus:ring-blue-100" + /> + ))} +
+
{copy.self}

{german ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch. Instagram lässt sich nicht vorbefüllen: am Handy öffnet das Teilen-Menü, sonst wird das Bild geladen und der Text kopiert.' : 'LinkedIn opens the composer and copies the finished text automatically. Instagram cannot be prefilled: on a phone the share sheet opens, otherwise the image is downloaded and the caption copied.'}

+ {posts.filter(post => post.status !== 'revoked').map(post => ( +
+ {CHANNEL_LABELS[post.channel as Channel] || post.channel}: {post.status === 'posted' ? copy.posted : post.status === 'failed' ? `${copy.failed}${post.error ? ` – ${post.error}` : ''}` : copy.queued} + {post.postUrl && View} +
+ ))} +
+
+
{promptStatus === 'shown' && }
+
+
; +} diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx index a23f659..c95b772 100644 --- a/src/components/ui/Dialog.tsx +++ b/src/components/ui/Dialog.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { cn } from '@/lib/utils'; -interface DialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - children: React.ReactNode; -} - -export const Dialog: React.FC = ({ open, onOpenChange, children }) => { +interface DialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + containerClassName?: string; +} + +export const Dialog: React.FC = ({ open, onOpenChange, children, containerClassName }) => { if (!open) return null; return ( @@ -16,7 +17,7 @@ export const Dialog: React.FC = ({ open, onOpenChange, children }) className="fixed inset-0 bg-black/50" onClick={() => onOpenChange(false)} /> -
+
{children}
@@ -89,4 +90,4 @@ export const DialogFooter = React.forwardRef( /> ) ); -DialogFooter.displayName = 'DialogFooter'; \ No newline at end of file +DialogFooter.displayName = 'DialogFooter'; diff --git a/src/lib/rounded-chart-path.ts b/src/lib/rounded-chart-path.ts new file mode 100644 index 0000000..ae50e76 --- /dev/null +++ b/src/lib/rounded-chart-path.ts @@ -0,0 +1,50 @@ +export type ChartPoint = { x: number; y: number }; + +function distance(a: ChartPoint, b: ChartPoint) { + return Math.hypot(b.x - a.x, b.y - a.y); +} + +function pointTowards(from: ChartPoint, to: ChartPoint, amount: number): ChartPoint { + const length = distance(from, to); + if (length === 0) return from; + + return { + x: from.x + ((to.x - from.x) / length) * amount, + y: from.y + ((to.y - from.y) / length) * amount, + }; +} + +function coordinate(value: number) { + return Number(value.toFixed(2)); +} + +/** + * Turns the factual scan points into one continuous SVG path while rounding + * only the visual corners. Source values and timestamps stay untouched; the + * path merely eases into and out of each factual turning point. + */ +export function roundedChartPath(points: ChartPoint[], cornerRadius: number): string { + if (points.length === 0) return ''; + if (points.length === 1) return `M ${coordinate(points[0].x)} ${coordinate(points[0].y)}`; + + let path = `M ${coordinate(points[0].x)} ${coordinate(points[0].y)}`; + + for (let index = 1; index < points.length - 1; index += 1) { + const previous = points[index - 1]; + const current = points[index]; + const next = points[index + 1]; + const radius = Math.min( + cornerRadius, + distance(previous, current) / 2, + distance(current, next) / 2, + ); + const before = pointTowards(current, previous, radius); + const after = pointTowards(current, next, radius); + + path += ` L ${coordinate(before.x)} ${coordinate(before.y)}`; + path += ` Q ${coordinate(current.x)} ${coordinate(current.y)} ${coordinate(after.x)} ${coordinate(after.y)}`; + } + + const last = points[points.length - 1]; + return `${path} L ${coordinate(last.x)} ${coordinate(last.y)}`; +} diff --git a/src/lib/social-milestone-image.tsx b/src/lib/social-milestone-image.tsx new file mode 100644 index 0000000..0bece15 --- /dev/null +++ b/src/lib/social-milestone-image.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { ImageResponse } from 'next/og'; +import { roundedChartPath } from '@/lib/rounded-chart-path'; + +type Trend = { + points: Array<{ at: string; total: number }>; + startLabel: string; + endLabel: string; + target: number; +}; + +export type SocialMilestoneImageCard = { + qrTitle?: string; + totalScans?: number; + totalUniqueScans?: number; + milestoneThreshold?: number; + trend?: Trend | null; +}; + +/** + * Link previews are 1.91:1, Instagram wants 1:1 or 4:5 and rejects anything + * outside that window. Same card, three canvases - never a cropped variant, + * because the numbers must stay legible. + */ +export type SocialMilestoneImageFormat = 'landscape' | 'square' | 'portrait'; + +const FORMATS: Record = { + landscape: { width: 1200, height: 630, stacked: false, chart: { width: 650, height: 285 } }, + square: { width: 1080, height: 1080, stacked: true, chart: { width: 956, height: 470 } }, + portrait: { width: 1080, height: 1350, stacked: true, chart: { width: 956, height: 720 } }, +}; + +export function socialMilestoneImageFormat(value?: string | null): SocialMilestoneImageFormat { + return value === 'square' || value === 'portrait' ? value : 'landscape'; +} + +function chart(card: SocialMilestoneImageCard, german: boolean, box: { width: number; height: number }) { + const target = Math.max(1, Number(card.totalUniqueScans || card.milestoneThreshold || 1)); + const trend = card.trend; + const rawPoints = Array.isArray(trend?.points) ? trend.points : []; + if (!trend || rawPoints.length === 0) return null; + + const ceiling = target <= 5 ? 5 : target * 1.25; + const ticks = target <= 5 + ? [1, 2, 3, 4, 5] + : Array.from({ length: 5 }, (_, index) => target * (index + 1) / 4); + const timestamps = rawPoints.map(point => new Date(point.at).getTime()).filter(Number.isFinite); + const first = timestamps.length ? Math.min(...timestamps) : 0; + const last = Math.max(timestamps.length ? Math.max(...timestamps) : first, first + 1); + const plotLeft = 72; + const plotRight = box.width - 30; + const plotTop = 12; + // Leaves room for the two date labels and the caption below the plot. + const plotBottom = box.height - 67; + const chartPoints = rawPoints.map(point => { + const time = new Date(point.at).getTime(); + const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft); + const y = plotBottom - Math.min(Math.max(Number(point.total) || 0, 0), ceiling) / ceiling * (plotBottom - plotTop); + return { x, y }; + }); + const path = roundedChartPath(chartPoints, 30); + const number = new Intl.NumberFormat(german ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); + const endPoint = chartPoints[chartPoints.length - 1] || { x: plotRight, y: plotBottom }; + + // Satori cannot render SVG nodes in the deployed Node runtime. SVG + // draws geometry only; the aligned labels are ordinary positioned text. + return
+ {ticks.map(tick => { + const y = plotBottom - tick / ceiling * (plotBottom - plotTop); + const reached = tick === target; + return
+
{number.format(tick)}
+
+
; + })} + + + {path && } + +
+ {trend.startLabel || (german ? 'Erstellt' : 'Created')} + {trend.endLabel || (german ? 'Erreicht' : 'Reached')} +
+
{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
+
; +} + +export function createSocialMilestoneImage( + card: SocialMilestoneImageCard, + german: boolean, + format: SocialMilestoneImageFormat = 'landscape', +) { + const canvas = FORMATS[format]; + const unique = Math.max(0, Number(card.totalUniqueScans || card.milestoneThreshold || 0)); + const total = Math.max(unique, Number(card.totalScans || unique)); + const locale = german ? 'de-DE' : 'en-US'; + const uniqueText = unique.toLocaleString(locale); + const uniqueFontSize = uniqueText.length >= 9 ? 78 : uniqueText.length >= 6 ? 96 : uniqueText.length >= 4 ? 108 : 124; + + return new ImageResponse( +
+
+
+ QR MASTER + {german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'} +
+
+
+ UNIQUE SCANS + {uniqueText} + {total.toLocaleString(locale)} {german ? 'Scans insgesamt' : 'total scans'} +
+ {chart(card, german, canvas.chart)} +
+
{card.qrTitle || (german ? 'QR-Code' : 'QR code')}
+
+
, + { width: canvas.width, height: canvas.height, headers: { 'Cache-Control': 'no-store' } }, + ); +} diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts new file mode 100644 index 0000000..2026166 --- /dev/null +++ b/src/lib/social-milestones-server.ts @@ -0,0 +1,145 @@ +import { db } from '@/lib/db'; +import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, isCompleteSocialMilestoneCard, milestoneKind, milestoneThreshold, SocialLocale, SocialMilestoneCard, socialLocale } from '@/lib/social-milestones'; + +function excludedEmails() { + return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '') + .split(',').map(email => email.trim().toLowerCase()).filter(Boolean); +} + +/** Creates any newly crossed milestones. Safe to call repeatedly. */ +export async function detectSocialMilestones(qrId?: string) { + const excluded = excludedEmails(); + const thresholds = getSocialMilestoneThresholds(); + const candidates = await db.qRScan.groupBy({ + by: ['qrId'], + where: { + isUnique: true, + ...(qrId ? { qrId } : {}), + qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined }, + }, + _count: { _all: true }, + }); + const crossedByQr = new Map(); + candidates.forEach(({ qrId: candidateQrId, _count }) => { + const crossed = thresholds.filter(threshold => _count._all >= threshold); + if (crossed.length) crossedByQr.set(candidateQrId, crossed); + }); + if (!crossedByQr.size) return 0; + + // Card snapshots read a QR code's complete scan history, so the milestones + // that already exist are filtered out before any of that work begins. This + // runs after every unique scan; without the check, each scan past the + // threshold would re-read every scan row only to hit `skipDuplicates`. + const known = await db.socialMilestone.findMany({ + where: { qrId: { in: Array.from(crossedByQr.keys()) } }, + select: { qrId: true, kind: true }, + }); + const knownByQr = new Map>(); + known.forEach(milestone => { + const kinds = knownByQr.get(milestone.qrId) || new Set(); + kinds.add(milestone.kind); + knownByQr.set(milestone.qrId, kinds); + }); + + const records: Array<{ qrId: string; kind: string }> = []; + crossedByQr.forEach((crossed, candidateQrId) => { + const seen = knownByQr.get(candidateQrId); + // A QR code seen for the first time may already be past several + // thresholds. Announce the highest one only: the post quotes the current + // scan count rather than the threshold, so the lower ones would produce a + // second prompt and a second brand post with the very same number in it. + const pending = seen + ? crossed.filter(threshold => !seen.has(milestoneKind(threshold))) + : crossed.slice(-1); + pending.forEach(threshold => records.push({ qrId: candidateQrId, kind: milestoneKind(threshold) })); + }); + if (!records.length) return 0; + + const qrs = await db.qRCode.findMany({ + where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } }, + select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } }, + }); + const cardByQr = new Map>>(); + await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr, new Date())))); + const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId])); + const created = await db.socialMilestone.createMany({ + data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({ + ...record, + userId: userIdByQr.get(record.qrId)!, + cardData: cardByQr.get(record.qrId), + })), + skipDuplicates: true, + }); + return created.count; +} + +async function createCardSnapshot( + qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }, + reachedAt: Date, + locale: SocialLocale = 'en', + configuredThreshold?: number, +) { + const scans = await db.qRScan.findMany({ + where: { qrId: qr.id, ts: { lte: reachedAt } }, + select: { ts: true, isUnique: true }, + orderBy: { ts: 'asc' }, + }); + const uniqueScans = scans.filter(scan => scan.isUnique); + // Keep a representative, cumulative history in the immutable snapshot. + // It starts at QR creation and ends at the moment the milestone is detected. + const stride = Math.max(1, Math.ceil(uniqueScans.length / 24)); + const points = [{ at: qr.createdAt.toISOString(), total: 0 }]; + uniqueScans.forEach((scan, index) => { + const total = index + 1; + if (total % stride === 0 || total === uniqueScans.length) points.push({ at: scan.ts.toISOString(), total }); + }); + const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' }); + const trend = { + points, + startLabel: month.format(qr.createdAt), + endLabel: month.format(reachedAt), + target: uniqueScans.length, + // Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above. + // For 20 scans this is precisely 5, 10, 15, 20, 25. + ceiling: uniqueScans.length < 5 ? 5 : Math.max(1.25, uniqueScans.length * 1.25), + }; + + // The snapshot is made at detection time and never silently changes after consent. + return buildMilestoneCardSnapshot({ + primaryUseCase: qr.user.primaryUseCase, + qrTitle: qr.title, + totalScans: scans.length, + totalUniqueScans: uniqueScans.length, + milestoneThreshold: configuredThreshold || uniqueScans.length, + reachedAt, + trend, + locale, + }); +} + +/** + * Old test rows may contain the v1 card or a partial v2 snapshot. Repair once, + * persist it, and return the exact same immutable payload to popup, OG and X. + */ +export async function ensureSocialMilestoneCard(input: { + milestoneId: string; + cardData: unknown; + kind: string; + detectedAt: Date; + language: string; + qr: { id: string; title: string; createdAt: Date }; + primaryUseCase: string | null; + refresh?: boolean; + snapshotAt?: Date; +}): Promise { + if (!input.refresh && isCompleteSocialMilestoneCard(input.cardData)) return input.cardData; + const threshold = milestoneThreshold(input.kind) || 1; + const card = await createCardSnapshot( + { ...input.qr, user: { primaryUseCase: input.primaryUseCase } }, + input.snapshotAt || input.detectedAt, + socialLocale(input.language), + threshold, + ); + await db.socialMilestone.update({ where: { id: input.milestoneId }, data: { cardData: card } }); + return card; +} diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts new file mode 100644 index 0000000..7b1c8f4 --- /dev/null +++ b/src/lib/social-milestones.ts @@ -0,0 +1,212 @@ +export const DEFAULT_SOCIAL_MILESTONE_THRESHOLDS = [1000, 10000] as const; + +export type SocialMilestoneKind = `unique_scans_${number}`; + +/** + * Staging can set SOCIAL_MILESTONE_THRESHOLDS=1 (or e.g. 1,2) so the complete + * flow is testable without fabricating thousands of scans. Production keeps + * the conservative defaults unless its environment explicitly changes them. + */ +export function getSocialMilestoneThresholds(): number[] { + const configured = process.env.SOCIAL_MILESTONE_THRESHOLDS; + if (!configured) return [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS]; + + const thresholds = Array.from(new Set( + configured.split(',') + .map(value => Number(value.trim())) + .filter(value => Number.isInteger(value) && value > 0 && value <= 1_000_000) + )).sort((a, b) => a - b); + + return thresholds.length ? thresholds : [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS]; +} + +/** + * Channels the consent dialog may offer. A channel is only offered where a + * publisher is actually configured - asking for consent for a post that nobody + * can publish would be dishonest. Server-side read; the dialog receives the + * resulting list in its payload. + */ +export function getEnabledSocialChannels(): SocialChannel[] { + const configured = (process.env.SOCIAL_MILESTONE_CHANNELS || 'x') + .split(',').map(value => value.trim().toLowerCase()).filter(isSocialChannel); + return configured.length ? Array.from(new Set(configured)) : ['x']; +} + +const useCaseLabels: Record = { + menu_pdf: 'menu QR code', + marketing_campaign: 'campaign QR code', + vcard: 'digital business-card QR code', + event: 'event QR code', + feedback: 'feedback QR code', +}; + +export function milestoneKind(threshold: number): SocialMilestoneKind { + return `unique_scans_${threshold}` as SocialMilestoneKind; +} + +export function milestoneThreshold(kind: string): number | null { + const result = /^unique_scans_(\d+)$/.exec(kind); + return result ? Number(result[1]) : null; +} + +export type SocialLocale = 'en' | 'de'; + +export type SocialMilestoneCard = { + version: 'milestone-card-v2'; + language: SocialLocale; + qrTitle: string; + label: string; + title: string; + totalScans: number; + totalUniqueScans: number; + milestoneThreshold: number; + reachedAt: string; + trend: { + points: Array<{ at: string; total: number }>; + startLabel: string; + endLabel: string; + target: number; + ceiling: number; + } | null; +}; + +export function isCompleteSocialMilestoneCard(value: unknown): value is SocialMilestoneCard { + if (!value || typeof value !== 'object') return false; + const card = value as Partial; + const trend = card.trend; + return card.version === 'milestone-card-v2' + && typeof card.qrTitle === 'string' + && typeof card.totalScans === 'number' + && Number.isFinite(card.totalScans) + && typeof card.totalUniqueScans === 'number' + && Number.isFinite(card.totalUniqueScans) + && Boolean(trend) + && Array.isArray(trend?.points) + && trend.points.length >= 2 + && trend.points.every(point => typeof point?.at === 'string' && typeof point?.total === 'number'); +} + +export function socialLocale(value?: string | null): SocialLocale { + return value === 'de' ? 'de' : 'en'; +} + +export function usageLabel(primaryUseCase: string | null, locale: SocialLocale = 'en'): string { + if (locale === 'de') { + const german: Record = { menu_pdf: 'Speisekarten-QR-Code', marketing_campaign: 'Kampagnen-QR-Code', vcard: 'Visitenkarten-QR-Code', event: 'Event-QR-Code', feedback: 'Feedback-QR-Code' }; + return (primaryUseCase && german[primaryUseCase]) || 'QR-Code'; + } + return (primaryUseCase && useCaseLabels[primaryUseCase]) || 'QR code'; +} + +export function buildMilestonePost(primaryUseCase: string | null, threshold: number, xHandle?: string | null, locale: SocialLocale = 'en'): string { + const count = threshold.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US'); + const base = locale === 'de' + ? `Ein ${usageLabel(primaryUseCase, locale)} hat gerade ${count} eindeutige Scans erreicht. 🎉` + : `A ${usageLabel(primaryUseCase, locale)} just reached ${count} unique scans. 🎉`; + return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base; +} + +export function buildMilestoneCard(primaryUseCase: string | null, threshold: number, locale: SocialLocale) { + return { version: 'milestone-card-v1', language: locale, threshold, label: usageLabel(primaryUseCase, locale), title: locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone' }; +} + +export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniqueScans: number, xHandle: string | null | undefined, locale: SocialLocale, qrTitle: string): string { + const count = totalUniqueScans.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US'); + const rawSubject = qrTitle.trim() || usageLabel(primaryUseCase, locale); + const subject = rawSubject.length > 72 ? `${rawSubject.slice(0, 69).trimEnd()}…` : rawSubject; + const scanLabel = locale === 'de' + ? `${count} ${totalUniqueScans === 1 ? 'verifizierten eindeutigen Scan' : 'verifizierte eindeutige Scans'}` + : `${count} verified unique ${totalUniqueScans === 1 ? 'scan' : 'scans'}`; + const base = locale === 'de' + ? `QR-Meilenstein erreicht.\n\n„${subject}“ hat ${scanLabel} erzielt.\n\nErstellt und gemessen mit QR Master.` + : `QR milestone unlocked.\n\n“${subject}” has reached ${scanLabel}.\n\nCreated and measured with QR Master.`; + if (!xHandle) return base; + const mention = locale === 'de' ? 'Glückwunsch' : 'Congratulations'; + return `${base}\n\n${mention} @${xHandle.replace(/^@/, '')}.`; +} + +export function buildMilestoneCardSnapshot(input: { + primaryUseCase: string | null; + qrTitle: string; + totalScans: number; + totalUniqueScans: number; + milestoneThreshold: number; + reachedAt: Date; + trend: SocialMilestoneCard['trend']; + locale: SocialLocale; +}): SocialMilestoneCard { + return { + version: 'milestone-card-v2', + language: input.locale, + qrTitle: input.qrTitle, + label: usageLabel(input.primaryUseCase, input.locale), + title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone', + totalScans: input.totalScans, + totalUniqueScans: input.totalUniqueScans, + milestoneThreshold: input.milestoneThreshold, + reachedAt: input.reachedAt.toISOString(), + trend: input.trend, + }; +} + +export function normalizeXHandle(value: string): string | null { + const handle = value.trim().replace(/^@/, ''); + return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null; +} + +export function normalizeInstagramHandle(value: string): string | null { + const handle = value.trim().replace(/^@/, ''); + return /^[A-Za-z0-9._]{1,30}$/.test(handle) ? handle : null; +} + +/** + * Publishing channels of the QR Master brand accounts. + * + * Consent is bound to a channel: agreeing to a post on X says nothing about + * Instagram. Every channel therefore carries its own approval, its own text and + * its own handle. + */ +export const SOCIAL_CHANNELS = ['x', 'instagram'] as const; +export type SocialChannel = typeof SOCIAL_CHANNELS[number]; + +export function isSocialChannel(value: unknown): value is SocialChannel { + return typeof value === 'string' && (SOCIAL_CHANNELS as readonly string[]).includes(value); +} + +export function normalizeChannelHandle(channel: SocialChannel, value: string): string | null { + return channel === 'instagram' ? normalizeInstagramHandle(value) : normalizeXHandle(value); +} + +function instagramHashtags(locale: SocialLocale): string { + return locale === 'de' + ? '#qrcode #qrcodes #marketing #kleinunternehmen #analytics #digitalisierung' + : '#qrcode #qrcodes #qrcodemarketing #smallbusiness #marketing #analytics'; +} + +/** + * The post split into the parts the consent dialog recombines while the + * customer types a handle. Server and client must never build this text + * differently - what stands in the preview is what gets published. + */ +export function milestonePostParts(input: { + channel: SocialChannel; + primaryUseCase: string | null; + totalUniqueScans: number; + locale: SocialLocale; + qrTitle: string; + shareUrl: string; +}) { + return { + head: buildMilestonePostForQr(input.primaryUseCase, input.totalUniqueScans, null, input.locale, input.qrTitle), + // A link in an Instagram caption is not clickable, so the share URL would + // be dead weight there. Hashtags do the reach work instead. + tail: input.channel === 'instagram' ? `\n\n${instagramHashtags(input.locale)}` : `\n\n${input.shareUrl}`, + mentionWord: input.locale === 'de' ? 'Glückwunsch' : 'Congratulations', + }; +} + +export function buildChannelPost(input: Parameters[0] & { handle: string | null }): string { + const { head, tail, mentionWord } = milestonePostParts(input); + const mention = input.handle ? `\n\n${mentionWord} @${input.handle.replace(/^@/, '')}.` : ''; + return `${head}${mention}${tail}`; +}