Publish milestones per channel and add Instagram
Consent is bound to the channel it was given for: approving a post on X says nothing about Instagram. Publishing state moves from the SocialMilestone row into SocialMilestonePost, one row per channel, where a missing row means no consent. The dialog asks per channel, shows the text each one will publish and keeps a separate handle for each; Instagram captions end in hashtags because a link there is not clickable. Also fixes three problems in the existing X path: - A QR code already past several thresholds produced one prompt per threshold, and since the post quotes the current scan count, every one of them would have published the same number. Only the highest threshold is announced now. - Detection ran after every unique scan and re-read the QR code's full scan history just to hit skipDuplicates. Known milestones are filtered first. - A failed post stayed failed forever because the consent dialog only opens once. The queue now retries three times on its own, spaces first attempts by SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per channel with restart and revoke. The worker no longer renders the card itself; it downloads the image the app renders at /s/m/<token>/og, which also serves the new square and portrait formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and SOCIAL_WORKER_CHANNELS both name it. Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before deploying this version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
38
.env.example
38
.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 <timo@qrmaster.net>"
|
||||
EMAIL_FROM_SECURITY="QR Master Security <noreply@qrmaster.net>"
|
||||
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=
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -94,3 +94,6 @@ src/lib/blog-data.snapshot-*.ts
|
||||
/public/Real Estate/
|
||||
/public/restaurant/
|
||||
/.qr-master-api-health-state
|
||||
|
||||
# Python worker bytecode
|
||||
__pycache__/
|
||||
|
||||
@@ -39,7 +39,7 @@ services:
|
||||
- qrmaster-network
|
||||
|
||||
# Next.js Application
|
||||
web:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -62,13 +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:-}
|
||||
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}
|
||||
@@ -114,27 +121,37 @@ services:
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks:
|
||||
- qrmaster-network
|
||||
|
||||
social-worker:
|
||||
build:
|
||||
context: ./scripts/social-worker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
QRMASTER_API_BASE: http://web:3000
|
||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
||||
SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false}
|
||||
SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10}
|
||||
X_API_KEY: ${X_API_KEY:-}
|
||||
X_API_SECRET: ${X_API_SECRET:-}
|
||||
X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-}
|
||||
X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-}
|
||||
depends_on:
|
||||
web:
|
||||
condition: service_started
|
||||
networks:
|
||||
- qrmaster-network
|
||||
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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
# Social milestone worker
|
||||
|
||||
The app detects QR-code scan milestones and stores customer consent. It does not
|
||||
hold X or LinkedIn credentials. An external X worker can use the internal queue
|
||||
after the test rollout is approved.
|
||||
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 [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql)
|
||||
to `qrmaster_test`.
|
||||
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:
|
||||
@@ -23,27 +37,77 @@ curl -H "Authorization: Bearer $CRON_SECRET" \
|
||||
```
|
||||
|
||||
The detector creates records at 1,000 and 10,000 unique scans only. It is safe
|
||||
to call repeatedly because `(qrId, kind)` is unique.
|
||||
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.
|
||||
|
||||
## X worker contract
|
||||
|
||||
After an explicit rollout approval, the existing QRMaster X worker may poll:
|
||||
## Queue contract
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
|
||||
https://qrmaster.net/api/internal/social-milestones
|
||||
"https://qrmaster.net/api/internal/social-milestones?channel=instagram"
|
||||
```
|
||||
|
||||
It receives at most one approved item per 24 hours. The worker must post `milestone.text`
|
||||
without altering it, then report its result:
|
||||
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":"<milestone-id>","result":"posted"}' \
|
||||
-d '{"id":"<post-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 `<QRMASTER_API_BASE>/s/m/<shareToken>/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: X opens a
|
||||
prefilled intent; the same text is copied for a LinkedIn post.
|
||||
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).
|
||||
|
||||
@@ -94,6 +94,7 @@ model User {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -183,14 +184,42 @@ model SocialMilestone {
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Always-on QR Master X milestone worker. The web app never receives X keys."""
|
||||
"""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
|
||||
@@ -7,9 +13,11 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image
|
||||
from requests_oauthlib import OAuth1Session
|
||||
|
||||
CHANNELS = ("x", "instagram")
|
||||
|
||||
|
||||
def required(name):
|
||||
value = os.getenv(name, "").strip()
|
||||
@@ -18,108 +26,60 @@ def required(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 font(name, size):
|
||||
for candidate in (f"/usr/share/fonts/truetype/dejavu/{name}", f"C:/Windows/Fonts/{'arialbd.ttf' if 'Bold' in name else 'arial.ttf'}"):
|
||||
if Path(candidate).exists():
|
||||
return ImageFont.truetype(candidate, size)
|
||||
return ImageFont.load_default()
|
||||
def graph(path):
|
||||
return f"https://graph.facebook.com/{os.getenv('GRAPH_API_VERSION', 'v22.0')}/{path}"
|
||||
|
||||
|
||||
def logo():
|
||||
"""Use the actual deployed QR Master favicon, not an invented icon."""
|
||||
def graph_error(response):
|
||||
"""Meta answers with a JSON error body that says far more than the status."""
|
||||
try:
|
||||
base = required("QRMASTER_API_BASE").rstrip("/")
|
||||
response = requests.get(f"{base}/favicon.ico", timeout=10)
|
||||
response.raise_for_status()
|
||||
mark = Image.open(io.BytesIO(response.content)).convert("RGBA")
|
||||
mark.thumbnail((56, 56))
|
||||
return mark
|
||||
except Exception:
|
||||
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/<token>/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
|
||||
|
||||
|
||||
def render_card(card):
|
||||
"""Render an immutable cumulative scan timeline from the stored snapshot."""
|
||||
image = Image.new("RGB", (1200, 630), "#f8f7f4")
|
||||
draw = ImageDraw.Draw(image)
|
||||
navy, blue, slate, mint = "#061b31", "#0256ff", "#64748b", "#059669"
|
||||
regular, medium, display = font("DejaVuSans.ttf", 27), font("DejaVuSans-Bold.ttf", 27), font("DejaVuSans.ttf", 142)
|
||||
mark = logo()
|
||||
if mark:
|
||||
image.paste(mark, (68, 58), mark)
|
||||
logo_x = 140
|
||||
else:
|
||||
logo_x = 68
|
||||
draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy)
|
||||
badge_text = "Verified scan milestone"
|
||||
badge_font = font("DejaVuSans.ttf", 19)
|
||||
badge_box = draw.textbbox((0, 0), badge_text, font=badge_font)
|
||||
badge_width = badge_box[2] - badge_box[0]
|
||||
draw.rounded_rectangle((1120 - badge_width - 32, 61, 1132, 106), radius=7, fill="#ecfdf5")
|
||||
draw.text((1116, 73), badge_text, font=badge_font, fill=mint, anchor="ra")
|
||||
draw.line((68, 124, 1132, 124), fill="#e5edf5", width=2)
|
||||
|
||||
total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
|
||||
total_scans = int(card.get("totalScans") or total)
|
||||
draw.text((66, 212), f"{total:,}", font=display, fill=navy)
|
||||
draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy)
|
||||
draw.text((74, 410), f"{total_scans:,} total scans", font=font("DejaVuSans.ttf", 20), fill=slate)
|
||||
|
||||
trend = card.get("trend") or {}
|
||||
raw_points = trend.get("points") or []
|
||||
left, top, width, height = 590, 140, 540, 330
|
||||
if raw_points:
|
||||
start_ms = min(_timestamp(point.get("at")) for point in raw_points)
|
||||
end_ms = max(_timestamp(point.get("at")) for point in raw_points)
|
||||
span = max(1, end_ms - start_ms)
|
||||
ceiling = 5.0 if total <= 5 else float(trend.get("ceiling") or total * 1.25)
|
||||
points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points]
|
||||
target_y = top + height - round(float(trend.get("target") or total) / ceiling * height)
|
||||
# Small milestones use whole scans (1..5); larger ones keep the
|
||||
# reached milestone on the fourth of five levels.
|
||||
axis_values = list(range(1, 6)) if total <= 5 else [total * index / 4 for index in range(1, 6)]
|
||||
for value in axis_values:
|
||||
y = top + height - round(value / ceiling * height)
|
||||
is_target = value == total
|
||||
draw.line((left, y, left + width, y), fill="#bfdbfe" if is_target else "#dde5ef", width=2 if is_target else 1)
|
||||
draw.text((left - 15, y - 12), _format_axis(value), font=font("DejaVuSans.ttf", 18), fill=blue if is_target else slate, anchor="ra")
|
||||
draw.line(points, fill=blue, width=5, joint="curve")
|
||||
x, y = points[-1]
|
||||
draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5)
|
||||
draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate)
|
||||
draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra")
|
||||
draw.line((68, 536, 1132, 536), fill="#e5edf5", width=2)
|
||||
title = str(card.get("qrTitle") or "QR code")
|
||||
if len(title) > 52:
|
||||
title = title[:49].rstrip() + "..."
|
||||
draw.text((68, 558), title, font=medium, fill=navy)
|
||||
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])
|
||||
image.save(path, "PNG", optimize=True)
|
||||
path.write_bytes(response.content)
|
||||
return path
|
||||
|
||||
|
||||
def _timestamp(value):
|
||||
try:
|
||||
return int(__import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _format_axis(value):
|
||||
return f"{value:g}" if value < 1000 else f"{value:,.0f}"
|
||||
# --- 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_post(oauth, milestone):
|
||||
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:
|
||||
@@ -139,12 +99,20 @@ def find_existing_post(oauth, milestone):
|
||||
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 post
|
||||
return f"https://x.com/i/web/status/{post.get('id')}"
|
||||
return None
|
||||
|
||||
|
||||
def post_x(text, card, oauth):
|
||||
path = render_card(card) if card else 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:
|
||||
@@ -152,45 +120,160 @@ def post_x(text, card, oauth):
|
||||
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": text}
|
||||
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()
|
||||
return result.json()
|
||||
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)
|
||||
|
||||
|
||||
def run_once():
|
||||
# --- 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", base).get("milestone")
|
||||
milestone = api("GET", f"{base}?channel={channel}").get("milestone")
|
||||
if not milestone:
|
||||
return
|
||||
try:
|
||||
oauth = oauth_client()
|
||||
existing = find_existing_post(oauth, milestone)
|
||||
result = {"data": existing, "reconciled": True} if existing else post_x(milestone["text"], milestone.get("card"), oauth)
|
||||
tweet_id = result.get("data", {}).get("id")
|
||||
post_url = f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None
|
||||
post_url, reconciled = PUBLISHERS[channel](milestone)
|
||||
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
|
||||
print(json.dumps({"posted": milestone["id"], "reconciled": bool(existing), "x": result}), flush=True)
|
||||
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: {error}", flush=True)
|
||||
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")
|
||||
print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval}), flush=True)
|
||||
channels = enabled_channels()
|
||||
print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval, "channels": channels}), flush=True)
|
||||
while True:
|
||||
try:
|
||||
run_once()
|
||||
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: {error}", flush=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)
|
||||
|
||||
42
sql/2026-08-16_social_milestone_channels.sql
Normal file
42
sql/2026-08-16_social_milestone_channels.sql
Normal file
@@ -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;
|
||||
15
sql/2026-08-16_social_milestone_retries.sql
Normal file
15
sql/2026-08-16_social_milestone_retries.sql
Normal file
@@ -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");
|
||||
@@ -10,6 +10,41 @@ 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<string, string> = { 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<TabType>('profile');
|
||||
@@ -18,6 +53,8 @@ export default function SettingsPage() {
|
||||
const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true);
|
||||
const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false);
|
||||
const [socialSaving, setSocialSaving] = useState(false);
|
||||
const [milestones, setMilestones] = useState<MilestoneHistoryItem[]>([]);
|
||||
const [milestoneBusy, setMilestoneBusy] = useState<string | null>(null);
|
||||
|
||||
// Profile states
|
||||
const [name, setName] = useState('');
|
||||
@@ -62,6 +99,12 @@ export default function SettingsPage() {
|
||||
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);
|
||||
@@ -119,6 +162,30 @@ export default function SettingsPage() {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -289,6 +356,52 @@ export default function SettingsPage() {
|
||||
{socialPromptsEnabled ? 'Turn off' : 'Turn on'}
|
||||
</Button>
|
||||
</div>
|
||||
{milestones.length > 0 && <div className="border-t border-gray-100 pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900">Your milestones</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Every scan milestone we detected and what happened to it.</p>
|
||||
<ul className="mt-3 divide-y divide-gray-100">
|
||||
{milestones.map(milestone => (
|
||||
<li key={milestone.id} className="py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-gray-900">{milestone.qrTitle}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{milestone.uniqueScans.toLocaleString('en-US')} unique scans · {new Date(milestone.detectedAt).toLocaleDateString('en-US')} · {milestoneStateLabel(milestone)}
|
||||
</p>
|
||||
</div>
|
||||
{milestone.shareUrl && (
|
||||
<a href={milestone.shareUrl} target="_blank" rel="noreferrer" className="shrink-0 text-sm font-medium text-blue-600 hover:underline">Open card</a>
|
||||
)}
|
||||
</div>
|
||||
{/* One line per channel: consent, and everything that can be
|
||||
withdrawn or restarted, is per channel. */}
|
||||
{milestone.posts.map(post => (
|
||||
<div key={post.channel} className="mt-2 flex flex-col gap-2 rounded-md bg-gray-50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-600">
|
||||
<span className="font-medium text-gray-900">{CHANNEL_LABELS[post.channel] || post.channel}</span> — {postStateLabel(post)}
|
||||
</p>
|
||||
{post.status === 'failed' && post.error && (
|
||||
<p className="mt-0.5 text-xs text-rose-600">{post.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{post.postUrl && (
|
||||
<a href={post.postUrl} target="_blank" rel="noreferrer" className="text-sm font-medium text-blue-600 hover:underline">View post</a>
|
||||
)}
|
||||
{post.status === 'failed' && (
|
||||
<Button variant="outline" disabled={milestoneBusy === `${milestone.id}:${post.channel}`} onClick={() => updateMilestone(milestone.id, 'retry', post.channel)}>Try again</Button>
|
||||
)}
|
||||
{['approved', 'failed'].includes(post.status) && (
|
||||
<Button variant="outline" disabled={milestoneBusy === `${milestone.id}:${post.channel}`} onClick={() => updateMilestone(milestone.id, 'revoke', post.channel)}>Cancel</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>}
|
||||
{socialTestResetAvailable && <div className="border-t border-gray-100 pt-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-gray-500">Test environment: reopen the latest milestone and clear its publishing state.</p>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { createSocialMilestoneImage } from '@/lib/social-milestone-image';
|
||||
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 } }) {
|
||||
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')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -14,54 +15,109 @@ function approvalDelayHours() {
|
||||
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 X worker fetches an approved payload and marks it complete only
|
||||
// after its own post succeeded. The app never receives X credentials.
|
||||
// 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. Surface that state as a
|
||||
// retryable failure instead of leaving the dashboard in "processing" forever.
|
||||
await db.socialMilestone.updateMany({
|
||||
where: { brandStatus: 'processing', claimedAt: { lt: new Date(now - 5 * 60 * 1000) } },
|
||||
data: { brandStatus: 'failed', brandPostError: 'The publisher was interrupted before it confirmed the post. Please retry.' },
|
||||
});
|
||||
|
||||
// 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 milestone = await db.socialMilestone.findFirst({
|
||||
where: { brandStatus: 'approved', brandApprovedAt: { lte: approvalNotBefore } },
|
||||
orderBy: { brandApprovedAt: 'asc' },
|
||||
include: { user: { select: { id: true } }, qr: { select: { id: true, status: true } } },
|
||||
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 } } },
|
||||
});
|
||||
// Relations are required by the schema. This guard makes the intended
|
||||
// revalidation explicit if retention policies are changed later.
|
||||
if (!milestone || !milestone.user || !milestone.qr || milestone.qr.status !== 'ACTIVE') {
|
||||
return NextResponse.json({ milestone: null });
|
||||
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 = milestone.shareToken ? `${getWwwOrigin()}/s/m/${milestone.shareToken}` : null;
|
||||
if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, shareUrl }, dryRun: true });
|
||||
|
||||
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<Array<{ acquired: boolean }>>`
|
||||
SELECT pg_try_advisory_xact_lock(920241) AS acquired
|
||||
SELECT pg_try_advisory_xact_lock(${claimLockId(channel)}) AS acquired
|
||||
`;
|
||||
if (!lock?.acquired) return 0;
|
||||
const result = await tx.socialMilestone.updateMany({
|
||||
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: 'processing', claimedAt: new Date() },
|
||||
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: milestone.id,
|
||||
text: milestone.consentText,
|
||||
card: milestone.cardData,
|
||||
shareToken: milestone.shareToken,
|
||||
id: post.id,
|
||||
milestoneId: post.milestone.id,
|
||||
channel,
|
||||
text: post.consentText,
|
||||
shareToken: post.milestone.shareToken,
|
||||
shareUrl,
|
||||
approvedAt: milestone.brandApprovedAt?.toISOString() || null,
|
||||
// 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(),
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -69,15 +125,37 @@ 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 updated = await db.socialMilestone.updateMany({
|
||||
where: { id: body.id, brandStatus: 'processing' },
|
||||
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: {
|
||||
brandStatus: body.result!,
|
||||
brandPostedAt: body.result === 'posted' ? new Date() : null,
|
||||
brandPostUrl: body.result === 'posted' ? body.postUrl || null : null,
|
||||
brandPostError: body.result === 'failed' ? (body.error || 'The post could not be published.') : null,
|
||||
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: 'Milestone is no longer available' }, { status: 409 });
|
||||
return NextResponse.json({ ok: true });
|
||||
if (!updated.count) return NextResponse.json({ error: 'Post is no longer available' }, { status: 409 });
|
||||
return NextResponse.json({ ok: true, attempts, retryScheduled: retry });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -4,28 +4,51 @@ import { db } from '@/lib/db';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
||||
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';
|
||||
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke' | 'retry';
|
||||
type Body = {
|
||||
action?: Action;
|
||||
withName?: boolean;
|
||||
channels?: string[];
|
||||
handles?: Record<string, string>;
|
||||
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 } } },
|
||||
include: {
|
||||
user: { select: { primaryUseCase: true } },
|
||||
qr: { select: { id: true, title: true, createdAt: true } },
|
||||
posts: { select: { channel: true, status: true, postUrl: true, error: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clientState(milestone: { status: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) {
|
||||
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,
|
||||
brandStatus: milestone.brandStatus,
|
||||
brandPostUrl: milestone.brandPostUrl,
|
||||
brandPostError: milestone.brandPostError,
|
||||
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 });
|
||||
@@ -40,8 +63,8 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
const userId = getSessionUserId();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null) as { action?: Action; withName?: boolean; xHandle?: string; language?: string } | null;
|
||||
if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke'].includes(body.action || '')) {
|
||||
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);
|
||||
@@ -50,11 +73,26 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
if (!threshold) return NextResponse.json({ error: 'Invalid milestone' }, { status: 400 });
|
||||
const isDismissible = ['detected', 'shown'].includes(milestone.status);
|
||||
|
||||
if (body.action === 'revoke') {
|
||||
if (!['approved', 'failed'].includes(milestone.brandStatus)) return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 });
|
||||
const updated = await db.socialMilestone.update({ where: { id: milestone.id }, data: { brandStatus: 'revoked', brandPostError: null } });
|
||||
return NextResponse.json({ ok: true, milestone: clientState(updated) });
|
||||
// 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();
|
||||
@@ -67,8 +105,6 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
|
||||
const language = socialLocale(body.language);
|
||||
const withName = body.withName === true;
|
||||
const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null;
|
||||
if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 });
|
||||
const card = await ensureSocialMilestoneCard({
|
||||
milestoneId: milestone.id,
|
||||
cardData: milestone.cardData,
|
||||
@@ -79,43 +115,76 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
||||
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') {
|
||||
// 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 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) });
|
||||
}
|
||||
|
||||
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
|
||||
return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 });
|
||||
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<SocialChannel, string | null>();
|
||||
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 postText = buildMilestonePostForQr(
|
||||
milestone.user.primaryUseCase,
|
||||
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
|
||||
xHandle,
|
||||
language,
|
||||
milestone.qr.title,
|
||||
);
|
||||
const consentText = `${postText}\n\n${shareUrl}`;
|
||||
|
||||
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: { xHandle } });
|
||||
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: {
|
||||
brandStatus: 'approved', brandApprovedAt: milestone.brandApprovedAt || now, brandPostError: null,
|
||||
status: 'approved', withName, consentText, language, cardData: card, respondedAt: now,
|
||||
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, consentText, milestone: clientState(updated) });
|
||||
return NextResponse.json({ ok: true, milestone: clientState(updated) });
|
||||
}
|
||||
|
||||
51
src/app/(main)/api/social-milestones/history/route.ts
Normal file
51
src/app/(main)/api/social-milestones/history/route.ts
Normal file
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -37,16 +37,19 @@ export async function PATCH(request: NextRequest) {
|
||||
});
|
||||
await db.$transaction([
|
||||
db.user.update({ where: { id: userId }, data: { socialPromptOptOut: false } }),
|
||||
...(latest ? [db.socialMilestone.update({
|
||||
where: { id: latest.id },
|
||||
data: {
|
||||
status: 'detected', shownAt: null, respondedAt: null,
|
||||
brandStatus: 'pending', brandApprovedAt: null, claimedAt: null,
|
||||
brandPostedAt: null, brandPostUrl: null, brandPostError: null,
|
||||
consentText: null, withName: false,
|
||||
selfSharedAt: null, publicShareApprovedAt: null, shareToken: null,
|
||||
},
|
||||
})] : []),
|
||||
// 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) });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getWwwOrigin } from '@/lib/hosts';
|
||||
import { getSessionUserId } from '@/lib/session';
|
||||
import { buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
||||
import { getEnabledSocialChannels, milestonePostParts, milestoneThreshold, SOCIAL_CHANNELS, socialLocale } from '@/lib/social-milestones';
|
||||
import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, primaryUseCase: true },
|
||||
where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, instagramHandle: true, primaryUseCase: true },
|
||||
});
|
||||
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
||||
|
||||
@@ -49,23 +49,34 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
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,
|
||||
defaultXHandle: user.xHandle,
|
||||
brandStatus: milestone.brandStatus,
|
||||
promptStatus: 'shown',
|
||||
brandPostUrl: milestone.brandPostUrl,
|
||||
brandPostError: milestone.brandPostError,
|
||||
language: locale,
|
||||
shareUrl,
|
||||
preview: buildMilestonePostForQr(
|
||||
user.primaryUseCase,
|
||||
card.totalUniqueScans || threshold,
|
||||
null,
|
||||
locale,
|
||||
milestone.qr.title,
|
||||
),
|
||||
channels,
|
||||
posts: [],
|
||||
card,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, Copy, ExternalLink, LineChart, Linkedin, QrCode, Send, X } from 'lucide-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';
|
||||
|
||||
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 Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
|
||||
type BrandState = { promptStatus: string; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | 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<Channel, string> = { x: 'X', instagram: 'Instagram' };
|
||||
|
||||
function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: 'en' | 'de' }) {
|
||||
const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25;
|
||||
@@ -61,8 +66,9 @@ export function SocialMilestoneDialog() {
|
||||
const { locale } = useTranslation();
|
||||
const [milestone, setMilestone] = useState<Milestone | null>(null);
|
||||
const [withName, setWithName] = useState(false);
|
||||
const [xHandle, setXHandle] = useState('');
|
||||
const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null);
|
||||
const [handles, setHandles] = useState<Record<string, string>>({});
|
||||
const [selected, setSelected] = useState<Channel[]>([]);
|
||||
const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | 'instagram' | null>(null);
|
||||
const [brand, setBrand] = useState<BrandState | null>(null);
|
||||
const loadedMilestone = useRef(false);
|
||||
|
||||
@@ -73,13 +79,23 @@ export function SocialMilestoneDialog() {
|
||||
if (response.ok) {
|
||||
const next = (await response.json()).milestone as Milestone | null;
|
||||
setMilestone(next);
|
||||
if (next) setBrand({ promptStatus: next.promptStatus, brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null });
|
||||
if (next) setBrand({ promptStatus: next.promptStatus, selfSharedAt: null, posts: next.posts || [] });
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}, [locale]);
|
||||
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
|
||||
useEffect(() => {
|
||||
if (!milestone || !['approved', 'processing'].includes(brand?.brandStatus || '')) return;
|
||||
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);
|
||||
@@ -87,20 +103,41 @@ export function SocialMilestoneDialog() {
|
||||
const timer = window.setInterval(poll, 3000);
|
||||
void poll();
|
||||
return () => window.clearInterval(timer);
|
||||
}, [milestone, brand?.brandStatus]);
|
||||
}, [milestone, pending]);
|
||||
|
||||
const copy = milestone?.language === 'de'
|
||||
? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf dem eigenen X-Account veröffentlichen?', name: 'Meinen X-Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Auf QR Master posten', queued: 'Wird auf X veröffentlicht …', posted: 'Auf X veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' }
|
||||
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Waiting for the X publisher …', posted: 'Published on X', failed: 'Publishing failed' };
|
||||
const postCopy = useMemo(() => {
|
||||
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
|
||||
const mention = milestone.language === 'de' ? 'Glückwunsch' : 'Congratulations';
|
||||
return `${milestone.preview}\n\n${mention} @${xHandle.trim().replace(/^@/, '')}.`;
|
||||
}, [milestone, withName, xHandle]);
|
||||
const preview = milestone ? `${postCopy}\n\n${milestone.shareUrl}` : '';
|
||||
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
||||
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<string, unknown>) => {
|
||||
if (!milestone) return null;
|
||||
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }) });
|
||||
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;
|
||||
@@ -109,15 +146,22 @@ export function SocialMilestoneDialog() {
|
||||
const result = await update('self_share');
|
||||
const shareUrl = `${result.shareUrl}?v=${result.shareVersion}`;
|
||||
setBrand(result.milestone);
|
||||
return { shareUrl, text: `${postCopy}\n\n${shareUrl}` };
|
||||
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(postCopy).then(() => true).catch(() => false)
|
||||
? 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.
|
||||
@@ -134,18 +178,58 @@ export function SocialMilestoneDialog() {
|
||||
else window.location.assign(targetUrl);
|
||||
showToast(network === 'linkedin'
|
||||
? copied
|
||||
? (milestone.language === 'de' ? 'LinkedIn-Text kopiert. Jetzt nur noch einfügen.' : 'LinkedIn text copied. Paste it into the composer.')
|
||||
: (milestone.language === 'de' ? 'LinkedIn ist geöffnet. Bitte nutze „Text kopieren“ im QR-Master-Fenster.' : 'LinkedIn is open. Please use “Copy text” in the QR Master window.')
|
||||
? (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(milestone?.language === 'de' ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success');
|
||||
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); }
|
||||
@@ -163,43 +247,85 @@ export function SocialMilestoneDialog() {
|
||||
try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
|
||||
};
|
||||
const optOut = () => {
|
||||
const message = milestone?.language === 'de'
|
||||
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 status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
||||
const promptStatus = brand?.promptStatus || milestone.promptStatus;
|
||||
const canApprove = ['pending', 'failed', 'revoked'].includes(status);
|
||||
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 <Dialog open onOpenChange={open => !open && setMilestone(null)} containerClassName="max-w-[960px]">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-full max-w-none flex-col overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)] sm:max-h-[calc(100dvh-1.5rem)]">
|
||||
<div className="shrink-0 px-4 py-4 sm:px-6">
|
||||
<DialogHeader className="flex-row items-center space-y-0 text-left">
|
||||
<div className="mr-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-[#0256ff] shadow-sm"><QrCode className="h-5 w-5" /></div>
|
||||
<div className="min-w-0 flex-1"><DialogTitle className="text-xl font-semibold tracking-[-0.03em] text-[#061b31] sm:text-[22px]">{copy.heading}</DialogTitle><DialogDescription className="truncate pt-1 text-sm text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></div>
|
||||
<button type="button" aria-label={milestone.language === 'de' ? 'Schließen' : 'Close'} onClick={() => setMilestone(null)} className="ml-3 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus:ring-2 focus:ring-[#0256ff]"><X className="h-5 w-5" /></button>
|
||||
<button type="button" aria-label={german ? 'Schließen' : 'Close'} onClick={() => setMilestone(null)} className="ml-3 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus:ring-2 focus:ring-[#0256ff]"><X className="h-5 w-5" /></button>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto border-y border-slate-100 px-4 py-4 overscroll-contain sm:px-6 md:grid md:grid-cols-[minmax(0,1.12fr)_minmax(320px,0.88fr)] md:gap-6 md:space-y-0">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3 text-xs"><span className="flex items-center gap-2 font-semibold tracking-wide text-[#061b31]"><img src="/favicon.ico" alt="" className="h-5 w-5" />QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
|
||||
<div className="mt-4 flex items-end justify-between gap-6"><div><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">UNIQUE SCANS</div><div className="mt-1 text-5xl font-normal tracking-[-0.04em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div></div><div className="pb-1 text-right"><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">{milestone.language === 'de' ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}</div><div className="mt-1 text-2xl font-normal tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div></div></div>
|
||||
<div className="mt-3 border-t border-slate-100 pt-3">{card.trend ? <Trend trend={card.trend} locale={milestone.language} /> : <div className="py-8 text-center text-xs text-[#45617f]">{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}</div>}</div>
|
||||
{card.trend && <div className="mt-1 flex items-center justify-end gap-2 text-[11px] text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>}
|
||||
<div className="mt-4 flex items-end justify-between gap-6"><div><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">UNIQUE SCANS</div><div className="mt-1 text-5xl font-normal tracking-[-0.04em] tabular-nums text-[#061b31]">{count.toLocaleString(german ? 'de-DE' : 'en-US')}</div></div><div className="pb-1 text-right"><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">{german ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}</div><div className="mt-1 text-2xl font-normal tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(german ? 'de-DE' : 'en-US')}</div></div></div>
|
||||
<div className="mt-3 border-t border-slate-100 pt-3">{card.trend ? <Trend trend={card.trend} locale={milestone.language} /> : <div className="py-8 text-center text-xs text-[#45617f]">{german ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}</div>}</div>
|
||||
{card.trend && <div className="mt-1 flex items-center justify-end gap-2 text-[11px] text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>}
|
||||
<div className="mt-3 truncate border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
||||
</section>
|
||||
<div className="min-w-0 space-y-4 md:pt-1">
|
||||
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 max-h-44 overflow-y-auto whitespace-pre-line break-words border-l border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
||||
<div className="space-y-2"><label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>{withName && <input aria-label="X handle" value={xHandle} onChange={event => setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" placeholder="@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" />}</div>
|
||||
<div className="space-y-2"><div className="text-xs font-medium text-slate-500">{copy.self}</div><div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2"><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />{milestone.language === 'de' ? 'Kopieren & LinkedIn' : 'Copy & open LinkedIn'}</Button></div><button type="button" onClick={copyLinkedInText} disabled={saving !== null} className="inline-flex items-center gap-1.5 text-xs font-medium text-[#45617f] underline underline-offset-2 hover:text-[#0256ff] disabled:opacity-50"><Copy className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Nur Text kopieren' : 'Copy text only'}</button><p className="text-[11px] leading-4 text-slate-500">{milestone.language === 'de' ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch.' : 'LinkedIn opens the composer and copies the finished text automatically.'}</p></div>
|
||||
{status !== 'pending' && <div className={`flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
||||
<div>
|
||||
<p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
{brandChannels.map(option => (
|
||||
<label key={option.channel} className="flex cursor-pointer items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:border-[#0256ff]">
|
||||
<input type="checkbox" checked={selected.includes(option.channel)} onChange={() => toggleChannel(option.channel)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />
|
||||
{option.channel === 'instagram' ? <Instagram className="h-3.5 w-3.5" /> : <X className="h-3.5 w-3.5" />}
|
||||
{CHANNEL_LABELS[option.channel]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{previews.length === 0
|
||||
? <p className="mt-3 text-xs text-slate-500">{german ? 'Kein Kanal ausgewählt – QR Master veröffentlicht nichts.' : 'No channel selected – QR Master publishes nothing.'}</p>
|
||||
: previews.map(preview => (
|
||||
<div key={preview.channel} className="mt-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{CHANNEL_LABELS[preview.channel]}</div>
|
||||
<blockquote className="mt-1 max-h-40 overflow-y-auto whitespace-pre-line break-words border-l border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview.text}</blockquote>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
|
||||
{withName && selected.map(channel => (
|
||||
<input
|
||||
key={channel}
|
||||
aria-label={`${CHANNEL_LABELS[channel]} handle`}
|
||||
value={handles[channel] || ''}
|
||||
onChange={event => 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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2"><div className="text-xs font-medium text-slate-500">{copy.self}</div><div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2"><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />{german ? 'Kopieren & LinkedIn' : 'Copy & open LinkedIn'}</Button><Button variant="outline" size="sm" className="w-full" onClick={shareInstagram} disabled={saving !== null}><Instagram className="mr-1.5 h-3.5 w-3.5" />Instagram</Button></div><button type="button" onClick={copyLinkedInText} disabled={saving !== null} className="inline-flex items-center gap-1.5 text-xs font-medium text-[#45617f] underline underline-offset-2 hover:text-[#0256ff] disabled:opacity-50"><Copy className="h-3.5 w-3.5" />{german ? 'Nur Text kopieren' : 'Copy text only'}</button><p className="text-[11px] leading-4 text-slate-500">{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.'}</p></div>
|
||||
{posts.filter(post => post.status !== 'revoked').map(post => (
|
||||
<div key={post.channel} className={`flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm ${post.status === 'posted' ? 'bg-emerald-50 text-emerald-800' : post.status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}>
|
||||
<span>{CHANNEL_LABELS[post.channel as Channel] || post.channel}: {post.status === 'posted' ? copy.posted : post.status === 'failed' ? `${copy.failed}${post.error ? ` – ${post.error}` : ''}` : copy.queued}</span>
|
||||
{post.postUrl && <a href={post.postUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 bg-slate-50 px-4 py-3 sm:px-6"><div className="grid w-full grid-cols-2 gap-2 sm:ml-auto sm:flex sm:w-auto sm:flex-wrap sm:justify-end"><Button variant="outline" onClick={() => promptStatus === 'shown' ? dismiss('decline') : setMilestone(null)} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || !canApprove}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : status === 'failed' ? (milestone.language === 'de' ? 'Erneut versuchen' : 'Retry post') : copy.approve}</Button>{promptStatus === 'shown' && <button type="button" className="col-span-2 pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700 sm:w-full" onClick={optOut} disabled={saving !== null}>{milestone.language === 'de' ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
|
||||
<DialogFooter className="shrink-0 bg-slate-50 px-4 py-3 sm:px-6"><div className="grid w-full grid-cols-2 gap-2 sm:ml-auto sm:flex sm:w-auto sm:flex-wrap sm:justify-end"><Button variant="outline" onClick={() => promptStatus === 'shown' ? dismiss('decline') : setMilestone(null)} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || !canApprove}><Send className="mr-1.5 h-4 w-4" />{pending ? copy.queued : copy.approve}</Button>{promptStatus === 'shown' && <button type="button" className="col-span-2 pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700 sm:w-full" onClick={optOut} disabled={saving !== null}>{german ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,26 @@ export type SocialMilestoneImageCard = {
|
||||
trend?: Trend | null;
|
||||
};
|
||||
|
||||
function chart(card: SocialMilestoneImageCard, german: boolean) {
|
||||
/**
|
||||
* 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<SocialMilestoneImageFormat, {
|
||||
width: number; height: number; stacked: boolean; chart: { width: number; height: number };
|
||||
}> = {
|
||||
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 : [];
|
||||
@@ -30,9 +49,10 @@ function chart(card: SocialMilestoneImageCard, german: boolean) {
|
||||
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 = 620;
|
||||
const plotRight = box.width - 30;
|
||||
const plotTop = 12;
|
||||
const plotBottom = 218;
|
||||
// Leaves room for the two date labels and the caption below the plot.
|
||||
const plotBottom = box.height - 67;
|
||||
const points = rawPoints.map(point => {
|
||||
const time = new Date(point.at).getTime();
|
||||
const x = plotLeft + ((Number.isFinite(time) ? time : first) - first) / (last - first) * (plotRight - plotLeft);
|
||||
@@ -44,20 +64,20 @@ function chart(card: SocialMilestoneImageCard, german: boolean) {
|
||||
|
||||
// Satori cannot render SVG <text> nodes in the deployed Node runtime. SVG
|
||||
// draws geometry only; the aligned labels are ordinary positioned text.
|
||||
return <div style={{ display: 'flex', position: 'relative', width: 650, height: 285 }}>
|
||||
return <div style={{ display: 'flex', position: 'relative', width: box.width, height: box.height }}>
|
||||
{ticks.map(tick => {
|
||||
const y = plotBottom - tick / ceiling * (plotBottom - plotTop);
|
||||
const reached = tick === target;
|
||||
return <div key={tick} style={{ display: 'flex', position: 'absolute', left: 0, top: y - 10, width: 620, height: 22, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', width: 72, justifyContent: 'flex-end', paddingRight: 14, color: reached ? '#0256ff' : '#64748b', fontSize: 16, fontWeight: reached ? 700 : 500 }}>{number.format(tick)}</div>
|
||||
<div style={{ display: 'flex', width: 548, height: reached ? 2 : 1, background: reached ? '#bfdbfe' : '#e2e8f0' }} />
|
||||
return <div key={tick} style={{ display: 'flex', position: 'absolute', left: 0, top: y - 10, width: plotRight, height: 22, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', width: plotLeft, justifyContent: 'flex-end', paddingRight: 14, color: reached ? '#0256ff' : '#64748b', fontSize: 16, fontWeight: reached ? 700 : 500 }}>{number.format(tick)}</div>
|
||||
<div style={{ display: 'flex', width: plotRight - plotLeft, height: reached ? 2 : 1, background: reached ? '#bfdbfe' : '#e2e8f0' }} />
|
||||
</div>;
|
||||
})}
|
||||
<svg width="650" height="230" viewBox="0 0 650 230" style={{ position: 'absolute', left: 0, top: 0 }}>
|
||||
<svg width={box.width} height={plotBottom + 12} viewBox={`0 0 ${box.width} ${plotBottom + 12}`} style={{ position: 'absolute', left: 0, top: 0 }}>
|
||||
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
{points && <circle cx={endPoint[0]} cy={endPoint[1]} r="7" fill="white" stroke="#0256ff" strokeWidth="4" />}
|
||||
</svg>
|
||||
<div style={{ display: 'flex', position: 'absolute', left: plotLeft, right: 30, top: 232, justifyContent: 'space-between', color: '#45617f', fontSize: 16, fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', position: 'absolute', left: plotLeft, right: 30, top: box.height - 53, justifyContent: 'space-between', color: '#45617f', fontSize: 16, fontWeight: 600 }}>
|
||||
<span>{trend.startLabel || (german ? 'Erstellt' : 'Created')}</span>
|
||||
<span>{trend.endLabel || (german ? 'Erreicht' : 'Reached')}</span>
|
||||
</div>
|
||||
@@ -65,7 +85,12 @@ function chart(card: SocialMilestoneImageCard, german: boolean) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function createSocialMilestoneImage(card: SocialMilestoneImageCard, german: boolean) {
|
||||
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';
|
||||
@@ -79,17 +104,17 @@ export function createSocialMilestoneImage(card: SocialMilestoneImageCard, germa
|
||||
<span style={{ fontSize: 22, fontWeight: 700 }}>QR MASTER</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#108c3d', background: '#eafaf0', padding: '8px 13px', borderRadius: 6, fontSize: 18 }}><span style={{ display: 'flex', width: 8, height: 8, borderRadius: 4, background: '#15be53' }} />{german ? 'Verifizierter Scan-Meilenstein' : 'Verified scan milestone'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', gap: 28, paddingTop: 14 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: 340 }}>
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: canvas.stacked ? 'column' : 'row', alignItems: canvas.stacked ? 'flex-start' : 'center', justifyContent: 'center', gap: canvas.stacked ? 10 : 28, paddingTop: 14 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: canvas.stacked ? '100%' : 340 }}>
|
||||
<span style={{ fontSize: 17, color: '#64748b', letterSpacing: 1.4 }}>UNIQUE SCANS</span>
|
||||
<span style={{ fontSize: uniqueFontSize, fontWeight: 400, letterSpacing: -4 }}>{uniqueText}</span>
|
||||
<span style={{ fontSize: 22, color: '#45617f' }}><b>{total.toLocaleString(locale)}</b> {german ? 'Scans insgesamt' : 'total scans'}</span>
|
||||
</div>
|
||||
{chart(card, german)}
|
||||
{chart(card, german, canvas.chart)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', borderTop: '1px solid #e5edf5', paddingTop: 14, fontSize: 21, fontWeight: 600 }}>{card.qrTitle || (german ? 'QR-Code' : 'QR code')}</div>
|
||||
</div>
|
||||
</div>,
|
||||
{ width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } },
|
||||
{ width: canvas.width, height: canvas.height, headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ function excludedEmails() {
|
||||
/** 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: {
|
||||
@@ -18,11 +19,40 @@ export async function detectSocialMilestones(qrId?: string) {
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
const records = candidates.flatMap(({ qrId: candidateQrId, _count }) =>
|
||||
getSocialMilestoneThresholds()
|
||||
.filter(threshold => _count._all >= threshold)
|
||||
.map(threshold => ({ qrId: candidateQrId, kind: milestoneKind(threshold) }))
|
||||
);
|
||||
const crossedByQr = new Map<string, number[]>();
|
||||
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<string, Set<string>>();
|
||||
known.forEach(milestone => {
|
||||
const kinds = knownByQr.get(milestone.qrId) || new Set<string>();
|
||||
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({
|
||||
|
||||
@@ -20,6 +20,18 @@ export function getSocialMilestoneThresholds(): number[] {
|
||||
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<string, string> = {
|
||||
menu_pdf: 'menu QR code',
|
||||
marketing_campaign: 'campaign QR code',
|
||||
@@ -141,3 +153,60 @@ 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<typeof milestonePostParts>[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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user