Compare commits
12 Commits
master
...
8ef5221f71
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef5221f71 | |||
| 8e34f97afb | |||
| aa3b4d02ab | |||
| 925540f3c6 | |||
| e0c32542f9 | |||
| 6081b9e6ae | |||
| 9d1d3a2062 | |||
| 72392e8cec | |||
| f7d82aa5bd | |||
| 14c429ff30 | |||
| d2c5f2848a | |||
| 31cba6d883 |
@@ -38,6 +38,17 @@ services:
|
|||||||
# April 2026 and the schema has moved on through manual SQL since, so running them
|
# April 2026 and the schema has moved on through manual SQL since, so running them
|
||||||
# against a fresh database would build a stale schema the app cannot work with.
|
# against a fresh database would build a stale schema the app cannot work with.
|
||||||
# Bring the schema in with `pg_dump --schema-only` from production instead.
|
# Bring the schema in with `pg_dump --schema-only` from production instead.
|
||||||
|
environment:
|
||||||
|
# Docker sets HOSTNAME=<container-id>, and the Next.js standalone server binds to
|
||||||
|
# that single interface. With two networks Caddy then cannot reach the container.
|
||||||
|
HOSTNAME: "0.0.0.0"
|
||||||
|
# `db` and `redis` are taken in BOTH networks - by this stack in test-internal and
|
||||||
|
# by production in qrmaster-network. Production wins the lookup every time, so the
|
||||||
|
# base file's hostnames point the staging app at the production instances. Container
|
||||||
|
# names are unique per daemon and cannot be shadowed.
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
|
||||||
|
DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@qrmaster-test-db:5432/${POSTGRES_DB}?schema=public
|
||||||
|
REDIS_URL: redis://qrmaster-test-redis:6379
|
||||||
entrypoint: ["node", "server.js"]
|
entrypoint: ["node", "server.js"]
|
||||||
build:
|
build:
|
||||||
args:
|
args:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
- qrmaster-network
|
- qrmaster-network
|
||||||
|
|
||||||
# Next.js Application
|
# Next.js Application
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -62,6 +62,9 @@ services:
|
|||||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
|
||||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
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:-}
|
||||||
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
||||||
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
||||||
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
||||||
@@ -111,8 +114,27 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
networks:
|
networks:
|
||||||
- qrmaster-network
|
- 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
|
||||||
|
|
||||||
|
|
||||||
# Adminer - Database Management UI (Optional)
|
# Adminer - Database Management UI (Optional)
|
||||||
|
|||||||
54
docker/init-db.sh
Normal file → Executable file
54
docker/init-db.sh
Normal file → Executable file
@@ -1,26 +1,28 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# This script runs when the PostgreSQL container is first created
|
# This script runs when the PostgreSQL container is first created
|
||||||
# It ensures the database is properly initialized
|
# It ensures the database is properly initialized
|
||||||
|
#
|
||||||
echo "🚀 Initializing QR Master database..."
|
# Keep this database-name agnostic: the staging stack (docker-compose.test.yml)
|
||||||
|
# runs the same script with POSTGRES_DB=qrmaster_test. A hardcoded name aborts
|
||||||
# Create the database if it doesn't exist (already created by POSTGRES_DB)
|
# the init, and the container never becomes healthy.
|
||||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
# Must stay LF-only and executable - Postgres sources non-executable init
|
||||||
-- Enable required extensions
|
# scripts, and CRLF breaks them on the first line.
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
echo "🚀 Initializing QR Master database..."
|
||||||
|
|
||||||
-- Grant privileges
|
# The database itself is already created by POSTGRES_DB
|
||||||
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
|
psql -v ON_ERROR_STOP=1 -v dbname="$POSTGRES_DB" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
|
-- Enable required extensions
|
||||||
-- Set timezone
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
ALTER DATABASE qrmaster SET timezone TO 'UTC';
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||||
EOSQL
|
|
||||||
|
-- Set timezone
|
||||||
echo "✅ Database initialization complete!"
|
ALTER DATABASE :"dbname" SET timezone TO 'UTC';
|
||||||
echo "📊 Database: $POSTGRES_DB"
|
EOSQL
|
||||||
echo "👤 User: $POSTGRES_USER"
|
|
||||||
echo "🌐 Ready to accept connections on port 5432"
|
echo "✅ Database initialization complete!"
|
||||||
|
echo "📊 Database: $POSTGRES_DB"
|
||||||
|
echo "👤 User: $POSTGRES_USER"
|
||||||
|
echo "🌐 Ready to accept connections on port 5432"
|
||||||
|
|||||||
49
docs/automations/social-milestone-worker.md
Normal file
49
docs/automations/social-milestone-worker.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Test setup (manual SQL only)
|
||||||
|
|
||||||
|
1. Apply [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql)
|
||||||
|
to `qrmaster_test`.
|
||||||
|
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.
|
||||||
|
3. Deploy using the documented test compose command. `CRON_SECRET` is forwarded
|
||||||
|
to the web service by `docker-compose.yml`.
|
||||||
|
4. Trigger detection manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $CRON_SECRET" \
|
||||||
|
https://testmodul.qrmaster.net/api/cron/social-milestones
|
||||||
|
```
|
||||||
|
|
||||||
|
The detector creates records at 1,000 and 10,000 unique scans only. It is safe
|
||||||
|
to call repeatedly because `(qrId, kind)` is unique.
|
||||||
|
|
||||||
|
## X worker contract
|
||||||
|
|
||||||
|
After an explicit rollout approval, the existing QRMaster X worker may poll:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
|
||||||
|
https://qrmaster.net/api/internal/social-milestones
|
||||||
|
```
|
||||||
|
|
||||||
|
It receives at most one approved item per 24 hours. The worker must post `milestone.text`
|
||||||
|
without altering it, then report its result:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id":"<milestone-id>","result":"posted"}' \
|
||||||
|
https://qrmaster.net/api/internal/social-milestones
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -89,6 +89,12 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
lifecycleLogs UserLifecycleLog[]
|
lifecycleLogs UserLifecycleLog[]
|
||||||
|
socialMilestones SocialMilestone[]
|
||||||
|
|
||||||
|
// Social-success sharing preferences. A post is still never published
|
||||||
|
// without a per-milestone approval stored below.
|
||||||
|
xHandle String?
|
||||||
|
socialPromptOptOut Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Plan {
|
enum Plan {
|
||||||
@@ -148,11 +154,45 @@ model QRCode {
|
|||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
scans QRScan[]
|
scans QRScan[]
|
||||||
|
socialMilestones SocialMilestone[]
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
@@index([userId, type, status])
|
@@index([userId, type, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SocialMilestone {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
qrId String
|
||||||
|
userId String
|
||||||
|
kind String
|
||||||
|
status String @default("detected")
|
||||||
|
detectedAt DateTime @default(now())
|
||||||
|
shownAt DateTime?
|
||||||
|
respondedAt DateTime?
|
||||||
|
claimedAt DateTime?
|
||||||
|
postedAt DateTime?
|
||||||
|
withName Boolean @default(false)
|
||||||
|
consentText String?
|
||||||
|
language String @default("en")
|
||||||
|
cardData Json?
|
||||||
|
brandStatus String @default("pending")
|
||||||
|
brandApprovedAt DateTime?
|
||||||
|
brandPostedAt DateTime?
|
||||||
|
brandPostUrl String?
|
||||||
|
brandPostError String?
|
||||||
|
selfSharedAt DateTime?
|
||||||
|
shareToken String? @unique
|
||||||
|
publicShareApprovedAt DateTime?
|
||||||
|
|
||||||
|
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([qrId, kind])
|
||||||
|
@@index([status, respondedAt])
|
||||||
|
@@index([status, claimedAt])
|
||||||
|
@@index([userId, status])
|
||||||
|
}
|
||||||
|
|
||||||
enum QRType {
|
enum QRType {
|
||||||
STATIC
|
STATIC
|
||||||
DYNAMIC
|
DYNAMIC
|
||||||
|
|||||||
6
scripts/social-worker/Dockerfile
Normal file
6
scripts/social-worker/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /worker
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY worker.py .
|
||||||
|
CMD ["python", "worker.py"]
|
||||||
3
scripts/social-worker/requirements.txt
Normal file
3
scripts/social-worker/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Pillow>=10
|
||||||
|
requests>=2.31
|
||||||
|
requests-oauthlib>=2.0
|
||||||
151
scripts/social-worker/worker.py
Normal file
151
scripts/social-worker/worker.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Always-on QR Master X milestone worker. The web app never receives X keys."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from requests_oauthlib import OAuth1Session
|
||||||
|
|
||||||
|
|
||||||
|
def required(name):
|
||||||
|
value = os.getenv(name, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"Missing {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
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 logo():
|
||||||
|
"""Use the actual deployed QR Master favicon, not an invented icon."""
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
draw.line((68, 548, 108, 548), fill=blue, width=4)
|
||||||
|
draw.text((126, 530), "QR code milestone", font=regular, 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 = float(trend.get("ceiling") or max(total * 1.25, 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)
|
||||||
|
# Exactly five levels: for 20 scans, 5 / 10 / 15 / 20 / 25.
|
||||||
|
for index in range(1, 6):
|
||||||
|
value = total * index / 4
|
||||||
|
y = top + height - round(value / ceiling * height)
|
||||||
|
is_target = index == 4
|
||||||
|
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.text((870, 530), "VERIFIED SCAN DATA", font=font("DejaVuSans-Bold.ttf", 18), fill=mint)
|
||||||
|
path = Path(tempfile.mkstemp(suffix=".png")[1])
|
||||||
|
image.save(path, "PNG", optimize=True)
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
def post_x(text, card):
|
||||||
|
oauth = 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"))
|
||||||
|
path = render_card(card) if card else None
|
||||||
|
try:
|
||||||
|
media_id = None
|
||||||
|
if path:
|
||||||
|
with path.open("rb") as image:
|
||||||
|
upload = oauth.post("https://upload.x.com/1.1/media/upload.json", files={"media": image}, timeout=60)
|
||||||
|
upload.raise_for_status()
|
||||||
|
media_id = upload.json()["media_id_string"]
|
||||||
|
payload = {"text": 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()
|
||||||
|
finally:
|
||||||
|
if path:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run_once():
|
||||||
|
base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones"
|
||||||
|
milestone = api("GET", base).get("milestone")
|
||||||
|
if not milestone:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = post_x(milestone["text"], milestone.get("card"))
|
||||||
|
tweet_id = result.get("data", {}).get("id")
|
||||||
|
post_url = f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None
|
||||||
|
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
|
||||||
|
print(json.dumps({"posted": milestone["id"], "x": result}), 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)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
while True:
|
||||||
|
run_once()
|
||||||
|
time.sleep(interval)
|
||||||
43
sql/2026-08-13_social_milestones.sql
Normal file
43
sql/2026-08-13_social_milestones.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- Success-sharing milestones. Run once against the target database before
|
||||||
|
-- deploying the application version that uses this feature.
|
||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "xHandle" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "socialPromptOptOut" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "SocialMilestone" (
|
||||||
|
"id" TEXT PRIMARY KEY,
|
||||||
|
"qrId" TEXT NOT NULL REFERENCES "QRCode"("id") ON DELETE CASCADE,
|
||||||
|
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'detected',
|
||||||
|
"detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"shownAt" TIMESTAMP(3),
|
||||||
|
"respondedAt" TIMESTAMP(3),
|
||||||
|
"postedAt" TIMESTAMP(3),
|
||||||
|
"withName" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"consentText" TEXT,
|
||||||
|
CONSTRAINT "SocialMilestone_qr_kind_key" UNIQUE ("qrId", "kind")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_respondedAt_idx"
|
||||||
|
ON "SocialMilestone" ("status", "respondedAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "SocialMilestone_userId_status_idx"
|
||||||
|
ON "SocialMilestone" ("userId", "status");
|
||||||
|
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "language" TEXT NOT NULL DEFAULT 'en';
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "cardData" JSONB;
|
||||||
|
CREATE INDEX IF NOT EXISTS "SocialMilestone_status_claimedAt_idx"
|
||||||
|
ON "SocialMilestone" ("status", "claimedAt");
|
||||||
|
|
||||||
|
-- Version 2: independent brand and self-share state plus a consent-gated
|
||||||
|
-- unguessable URL for Open Graph previews. Execute manually once.
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandStatus" TEXT NOT NULL DEFAULT 'pending';
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandApprovedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostUrl" TEXT;
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "brandPostError" TEXT;
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "selfSharedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "shareToken" TEXT;
|
||||||
|
ALTER TABLE "SocialMilestone" ADD COLUMN IF NOT EXISTS "publicShareApprovedAt" TIMESTAMP(3);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "SocialMilestone_shareToken_key"
|
||||||
|
ON "SocialMilestone" ("shareToken") WHERE "shareToken" IS NOT NULL;
|
||||||
@@ -16,6 +16,7 @@ import { QrCode } from 'lucide-react';
|
|||||||
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
||||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||||
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
||||||
|
import { SocialMilestoneDialog } from '@/components/dashboard/SocialMilestoneDialog';
|
||||||
|
|
||||||
interface QRCodeData {
|
interface QRCodeData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -322,6 +323,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<SocialMilestoneDialog />
|
||||||
{/* Header with Plan Badge */}
|
{/* Header with Plan Badge */}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
15
src/app/(main)/(marketing)/s/m/[token]/og/route.tsx
Normal file
15
src/app/(main)/(marketing)/s/m/[token]/og/route.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: { token: string } }) {
|
||||||
|
const share = await db.socialMilestone.findFirst({
|
||||||
|
where: { shareToken: params.token, publicShareApprovedAt: { not: null } },
|
||||||
|
select: { cardData: true, language: true },
|
||||||
|
});
|
||||||
|
if (!share) return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number; trend?: { periodDays: number; recentTotal: number } | null } | null;
|
||||||
|
const german = share.language === 'de';
|
||||||
|
return new ImageResponse(<div style={{ height: '100%', width: '100%', display: 'flex', background: '#f8fafc', padding: 46, color: '#061b31' }}><div style={{ display: 'flex', flexDirection: 'column', width: '100%', border: '2px solid #e2e8f0', borderRadius: 24, background: 'white', padding: 44 }}><div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 23, fontWeight: 700 }}><span>QR MASTER</span><span style={{ color: '#059669', background: '#ecfdf5', padding: '8px 14px', borderRadius: 8 }}>Verified milestone</span></div><div style={{ display: 'flex', flexDirection: 'column', marginTop: 58 }}><span style={{ fontSize: 20, color: '#94a3b8', fontWeight: 700 }}>TOTAL UNIQUE SCANS</span><span style={{ fontSize: 116, fontWeight: 700, letterSpacing: -5 }}>{(card?.totalUniqueScans || 0).toLocaleString(german ? 'de-DE' : 'en-US')}</span><span style={{ fontSize: 29, color: '#64748b' }}>{german ? 'eindeutige Scans' : 'unique scans'}</span></div><div style={{ display: 'flex', marginTop: 'auto', paddingTop: 28, borderTop: '2px solid #e2e8f0', justifyContent: 'space-between', fontSize: 25 }}><span>{card?.qrTitle || 'QR code'}</span><span style={{ color: '#0256ff' }}>{card?.trend ? `${card.trend.recentTotal} in ${card.trend.periodDays} days` : (german ? 'Erste Dynamik' : 'Early momentum')}</span></div></div></div>, { width: 1200, height: 630, headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
38
src/app/(main)/(marketing)/s/m/[token]/page.tsx
Normal file
38
src/app/(main)/(marketing)/s/m/[token]/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getWwwOrigin } from '@/lib/hosts';
|
||||||
|
|
||||||
|
type Props = { params: { token: string } };
|
||||||
|
|
||||||
|
async function getShare(token: string) {
|
||||||
|
return db.socialMilestone.findFirst({
|
||||||
|
where: { shareToken: token, publicShareApprovedAt: { not: null } },
|
||||||
|
select: { cardData: true, language: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const share = await getShare(params.token);
|
||||||
|
if (!share) return { robots: { index: false, follow: false } };
|
||||||
|
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
|
||||||
|
const count = card?.totalUniqueScans || 0;
|
||||||
|
const title = share.language === 'de'
|
||||||
|
? `${count.toLocaleString('de-DE')} eindeutige QR-Scans erreicht`
|
||||||
|
: `${count.toLocaleString('en-US')} unique QR scans reached`;
|
||||||
|
const url = `${getWwwOrigin()}/s/m/${params.token}`;
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: card?.qrTitle || 'A verified QR Master scan milestone.',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
openGraph: { type: 'website', title, description: card?.qrTitle, url, images: [`${url}/og`] },
|
||||||
|
twitter: { card: 'summary_large_image', title, description: card?.qrTitle, images: [`${url}/og`] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SocialMilestoneSharePage({ params }: Props) {
|
||||||
|
const share = await getShare(params.token);
|
||||||
|
if (!share) notFound();
|
||||||
|
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
|
||||||
|
return <main className="min-h-screen bg-slate-50 px-6 py-20 text-center text-[#061b31]"><div className="mx-auto max-w-xl rounded-xl border border-slate-200 bg-white p-10 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.35)]"><p className="text-xs font-semibold tracking-[0.14em] text-[#0256ff]">QR MASTER · VERIFIED MILESTONE</p><h1 className="mt-5 text-5xl font-semibold tracking-[-0.05em] tabular-nums">{(card?.totalUniqueScans || 0).toLocaleString(share.language === 'de' ? 'de-DE' : 'en-US')}</h1><p className="mt-2 text-slate-500">{share.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</p><p className="mt-8 text-lg font-medium">{card?.qrTitle}</p></div></main>;
|
||||||
|
}
|
||||||
17
src/app/(main)/api/cron/social-milestones/route.ts
Normal file
17
src/app/(main)/api/cron/social-milestones/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { detectSocialMilestones } from '@/lib/social-milestones-server';
|
||||||
|
import { getSocialMilestoneThresholds } from '@/lib/social-milestones';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function isAuthorized(request: NextRequest) {
|
||||||
|
const secret = process.env.CRON_SECRET;
|
||||||
|
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detection only: this route never contacts customers or an external network.
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const detected = await detectSocialMilestones();
|
||||||
|
return NextResponse.json({ ok: true, detected, thresholds: getSocialMilestoneThresholds() });
|
||||||
|
}
|
||||||
62
src/app/(main)/api/internal/social-milestones/route.ts
Normal file
62
src/app/(main)/api/internal/social-milestones/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function isAuthorized(request: NextRequest) {
|
||||||
|
const secret = process.env.INTERNAL_API_SECRET;
|
||||||
|
return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalDelayHours() {
|
||||||
|
const configured = Number(process.env.SOCIAL_MILESTONE_POST_DELAY_HOURS);
|
||||||
|
return Number.isFinite(configured) && configured >= 0 && configured <= 168 ? configured : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!isAuthorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const dryRun = request.nextUrl.searchParams.get('dryRun') === 'true';
|
||||||
|
const now = Date.now();
|
||||||
|
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 } } },
|
||||||
|
});
|
||||||
|
// 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 (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText }, dryRun: true });
|
||||||
|
|
||||||
|
const claimed = await db.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
|
||||||
|
const result = await tx.socialMilestone.updateMany({
|
||||||
|
where: { id: milestone.id, brandStatus: 'approved' }, data: { brandStatus: '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 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
113
src/app/(main)/api/social-milestones/[id]/route.ts
Normal file
113
src/app/(main)/api/social-milestones/[id]/route.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
import { getSessionUserId } from '@/lib/session';
|
||||||
|
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
||||||
|
|
||||||
|
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
|
||||||
|
|
||||||
|
async function ownedMilestone(id: string, userId: string) {
|
||||||
|
return db.socialMilestone.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
include: { user: { select: { primaryUseCase: true } }, qr: { select: { title: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientState(milestone: { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: Date | null }) {
|
||||||
|
return {
|
||||||
|
brandStatus: milestone.brandStatus,
|
||||||
|
brandPostUrl: milestone.brandPostUrl,
|
||||||
|
brandPostError: milestone.brandPostError,
|
||||||
|
selfSharedAt: milestone.selfSharedAt?.toISOString() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
const userId = getSessionUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const milestone = await ownedMilestone(params.id, userId);
|
||||||
|
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
return NextResponse.json({ milestone: clientState(milestone) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
const csrf = csrfProtection(request);
|
||||||
|
if (!csrf.valid) return NextResponse.json({ error: csrf.error }, { status: 403 });
|
||||||
|
const userId = getSessionUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null) as { action?: Action; withName?: boolean; xHandle?: string; language?: string } | null;
|
||||||
|
if (!body || !['approve_brand', 'self_share', 'decline', 'opt_out', 'revoke'].includes(body.action || '')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const milestone = await ownedMilestone(params.id, userId);
|
||||||
|
if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
const threshold = milestoneThreshold(milestone.kind);
|
||||||
|
if (!threshold) return NextResponse.json({ error: 'Invalid milestone' }, { status: 400 });
|
||||||
|
const isDismissible = ['detected', 'shown'].includes(milestone.status);
|
||||||
|
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
if (body.action === 'decline' || body.action === 'opt_out') {
|
||||||
|
if (!isDismissible) return NextResponse.json({ error: 'This milestone has already been dismissed' }, { status: 409 });
|
||||||
|
const now = new Date();
|
||||||
|
await db.$transaction([
|
||||||
|
db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'declined', respondedAt: now } }),
|
||||||
|
...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = socialLocale(body.language);
|
||||||
|
const withName = body.action === 'approve_brand' && 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 = milestone.cardData || buildMilestoneCardSnapshot({
|
||||||
|
primaryUseCase: milestone.user.primaryUseCase,
|
||||||
|
qrTitle: milestone.qr.title,
|
||||||
|
totalScans: threshold,
|
||||||
|
totalUniqueScans: threshold,
|
||||||
|
milestoneThreshold: threshold,
|
||||||
|
reachedAt: milestone.detectedAt,
|
||||||
|
trend: null,
|
||||||
|
locale: language,
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
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 token = milestone.shareToken || randomBytes(9).toString('base64url');
|
||||||
|
const updated = await db.socialMilestone.update({
|
||||||
|
where: { id: milestone.id },
|
||||||
|
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, shareToken: token, milestone: clientState(updated) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
|
||||||
|
return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 });
|
||||||
|
}
|
||||||
|
const consentText = buildMilestonePostForQr(
|
||||||
|
milestone.user.primaryUseCase,
|
||||||
|
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
|
||||||
|
xHandle,
|
||||||
|
language,
|
||||||
|
milestone.qr.title,
|
||||||
|
);
|
||||||
|
const updated = await db.$transaction(async tx => {
|
||||||
|
if (withName) await tx.user.update({ where: { id: userId }, data: { xHandle } });
|
||||||
|
return tx.socialMilestone.update({
|
||||||
|
where: { id: milestone.id },
|
||||||
|
data: {
|
||||||
|
brandStatus: 'approved', brandApprovedAt: now, brandPostError: null,
|
||||||
|
withName, consentText, language, cardData: card, respondedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, consentText, milestone: clientState(updated) });
|
||||||
|
}
|
||||||
64
src/app/(main)/api/social-milestones/route.ts
Normal file
64
src/app/(main)/api/social-milestones/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSessionUserId } from '@/lib/session';
|
||||||
|
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// Returns at most one item. A missing response is treated as no consent, never as approval.
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const userId = getSessionUserId();
|
||||||
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId }, select: { socialPromptOptOut: true, xHandle: true, primaryUseCase: true },
|
||||||
|
});
|
||||||
|
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
||||||
|
|
||||||
|
const milestone = await db.socialMilestone.findFirst({
|
||||||
|
where: { userId, status: { in: ['detected', 'shown'] } },
|
||||||
|
orderBy: { detectedAt: 'asc' },
|
||||||
|
include: { qr: { select: { id: true, title: true } } },
|
||||||
|
});
|
||||||
|
if (!milestone) return NextResponse.json({ milestone: null });
|
||||||
|
|
||||||
|
if (milestone.status === 'detected') {
|
||||||
|
await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'shown', shownAt: new Date() } });
|
||||||
|
}
|
||||||
|
const threshold = milestoneThreshold(milestone.kind);
|
||||||
|
if (!threshold) return NextResponse.json({ milestone: null });
|
||||||
|
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
||||||
|
const allScanCount = await db.qRScan.count({ where: { qrId: milestone.qr.id } });
|
||||||
|
const storedCard = milestone.cardData as Record<string, unknown> | null;
|
||||||
|
const card = storedCard
|
||||||
|
? { ...storedCard, totalScans: typeof storedCard.totalScans === 'number' ? storedCard.totalScans : allScanCount }
|
||||||
|
: buildMilestoneCardSnapshot({
|
||||||
|
primaryUseCase: user.primaryUseCase,
|
||||||
|
qrTitle: milestone.qr.title,
|
||||||
|
totalScans: allScanCount,
|
||||||
|
totalUniqueScans: threshold,
|
||||||
|
milestoneThreshold: threshold,
|
||||||
|
reachedAt: milestone.detectedAt,
|
||||||
|
trend: null,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
milestone: {
|
||||||
|
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
||||||
|
defaultXHandle: user.xHandle,
|
||||||
|
brandStatus: milestone.brandStatus,
|
||||||
|
brandPostUrl: milestone.brandPostUrl,
|
||||||
|
brandPostError: milestone.brandPostError,
|
||||||
|
language: locale,
|
||||||
|
preview: buildMilestonePostForQr(
|
||||||
|
user.primaryUseCase,
|
||||||
|
((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold),
|
||||||
|
null,
|
||||||
|
locale,
|
||||||
|
milestone.qr.title,
|
||||||
|
),
|
||||||
|
card,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { getWwwOrigin } from '@/lib/hosts';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { hashIP } from '@/lib/hash';
|
import { hashIP } from '@/lib/hash';
|
||||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
import { detectSocialMilestones } from '@/lib/social-milestones-server';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -260,6 +261,12 @@ async function trackScan(qrId: string, userId: string, request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The customer sees a newly crossed milestone on their next dashboard
|
||||||
|
// visit; no separate cron invocation is required after a real scan.
|
||||||
|
if (isUnique) {
|
||||||
|
await detectSocialMilestones(qrId);
|
||||||
|
}
|
||||||
|
|
||||||
const activatedUsers = await db.user.updateMany({
|
const activatedUsers = await db.user.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|||||||
128
src/components/dashboard/SocialMilestoneDialog.tsx
Normal file
128
src/components/dashboard/SocialMilestoneDialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Check, ExternalLink, LineChart, Linkedin, Send, Sparkles, 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 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; language: 'en' | 'de'; card: Card; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
|
||||||
|
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
|
||||||
|
|
||||||
|
function Trend({ trend }: { trend: NonNullable<Card['trend']> }) {
|
||||||
|
const first = new Date(trend.points[0].at).getTime();
|
||||||
|
const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1);
|
||||||
|
const points = trend.points.map(point => {
|
||||||
|
const x = 5 + ((new Date(point.at).getTime() - first) / (last - first)) * 90;
|
||||||
|
const y = 88 - (point.total / trend.ceiling) * 72;
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
const targetY = 88 - (trend.target / trend.ceiling) * 72;
|
||||||
|
const ticks = Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4);
|
||||||
|
return <div className="grid grid-cols-[2.5rem_1fr] gap-2"><div className="flex h-24 flex-col justify-between py-1 text-right text-[10px] font-medium tabular-nums text-[#45617f]">{ticks.slice().reverse().map(tick => <span key={tick} className={tick === trend.target ? 'text-[#0256ff]' : undefined}>{tick.toLocaleString()}</span>)}</div><div><svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-24 w-full overflow-visible" aria-label="Cumulative unique scan trend">{ticks.map(tick => { const y = 88 - (tick / trend.ceiling) * 72; return <line key={tick} x1="5" x2="95" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? '1.4' : '0.8'} vectorEffect="non-scaling-stroke" />; })}<polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /><circle cx="95" cy={targetY} r="2.7" fill="#0256ff" /></svg><div className="mt-1 flex justify-between text-[11px] font-medium tracking-wide text-[#45617f]"><span>{trend.startLabel}</span><span>{trend.endLabel}</span></div></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialMilestoneDialog() {
|
||||||
|
const { fetchWithCsrf } = useCsrf();
|
||||||
|
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' | null>(null);
|
||||||
|
const [brand, setBrand] = useState<BrandState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/social-milestones?locale=${locale}`).then(async response => {
|
||||||
|
if (response.ok) {
|
||||||
|
const next = (await response.json()).milestone as Milestone | null;
|
||||||
|
setMilestone(next);
|
||||||
|
if (next) setBrand({ brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null });
|
||||||
|
}
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}, [locale]);
|
||||||
|
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!milestone || !['approved', 'processing'].includes(brand?.brandStatus || '')) return;
|
||||||
|
const poll = async () => {
|
||||||
|
const response = await fetch(`/api/social-milestones/${milestone.id}`);
|
||||||
|
if (response.ok) setBrand((await response.json()).milestone);
|
||||||
|
};
|
||||||
|
const timer = window.setInterval(poll, 3000);
|
||||||
|
void poll();
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [milestone, brand?.brandStatus]);
|
||||||
|
|
||||||
|
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: 'Publishing to X …', posted: 'Published on X', failed: 'Publishing failed' };
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
|
||||||
|
return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`;
|
||||||
|
}, [milestone, withName, xHandle]);
|
||||||
|
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
||||||
|
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 result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const shareSelf = async (network: 'x' | 'linkedin') => {
|
||||||
|
if (!milestone) return;
|
||||||
|
// Open synchronously from the user gesture. Awaiting the API first can make
|
||||||
|
// LinkedIn treat the new window as a blocked popup.
|
||||||
|
const shareWindow = window.open('', '_blank', 'noopener,noreferrer');
|
||||||
|
setSaving('self');
|
||||||
|
try {
|
||||||
|
const result = await update('self_share');
|
||||||
|
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
|
||||||
|
const text = `${preview} ${shareUrl}`;
|
||||||
|
if (network === 'x') shareWindow?.location.replace(`https://x.com/intent/post?text=${encodeURIComponent(text)}`);
|
||||||
|
else {
|
||||||
|
await navigator.clipboard?.writeText(text);
|
||||||
|
shareWindow?.location.replace(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`);
|
||||||
|
}
|
||||||
|
setBrand(result.milestone);
|
||||||
|
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
|
||||||
|
} catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
|
||||||
|
finally { setSaving(null); }
|
||||||
|
};
|
||||||
|
const approveBrand = async () => {
|
||||||
|
setSaving('brand');
|
||||||
|
try {
|
||||||
|
const result = await update('approve_brand');
|
||||||
|
setBrand(result.milestone);
|
||||||
|
showToast(copy.queued, 'success');
|
||||||
|
} catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
|
||||||
|
finally { setSaving(null); }
|
||||||
|
};
|
||||||
|
const dismiss = async (action: 'decline' | 'opt_out') => {
|
||||||
|
try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!milestone) return null;
|
||||||
|
const card = milestone.card;
|
||||||
|
const count = card.totalUniqueScans || milestone.threshold;
|
||||||
|
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
||||||
|
return <Dialog open onOpenChange={open => !open && setMilestone(null)}>
|
||||||
|
<DialogContent className="max-w-xl overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
|
||||||
|
<div className="px-7 pb-5 pt-7"><DialogHeader><div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-5 w-5" /></div><DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle><DialogDescription className="pt-1 text-sm leading-6 text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></DialogHeader></div>
|
||||||
|
<div className="space-y-5 border-y border-slate-100 px-7 py-6">
|
||||||
|
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 pb-4 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-5 text-[11px] font-semibold tracking-[0.12em] text-slate-400">TOTAL UNIQUE SCANS</div><div className="mt-1 text-5xl font-semibold tracking-[-0.05em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-1 text-sm text-slate-500">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div><div className="mt-3 border-l border-slate-200 pl-3 text-xs text-[#4b5e76]"><span className="font-semibold tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</span> {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}</div>
|
||||||
|
{card.trend ? <div className="mt-5 text-[#0256ff]"><Trend trend={card.trend} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{milestone.language === 'de' ? 'Kumulierte eindeutige Scans seit Erstellung' : 'Cumulative unique scans since creation'}</div></div> : <div className="mt-5 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-xs font-medium text-blue-700"><LineChart className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}</div>}
|
||||||
|
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
||||||
|
</section>
|
||||||
|
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 border-l-2 border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
||||||
|
<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={status !== 'pending'} 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={status !== 'pending'} 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 className="flex flex-wrap items-center gap-2"><span className="mr-1 text-xs font-medium text-slate-500">{copy.self}</span><Button variant="outline" size="sm" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />LinkedIn</Button></div>
|
||||||
|
{status !== 'pending' && <div className={`flex items-center justify-between 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 : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="bg-slate-50 px-7 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => dismiss('decline')} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || status !== 'pending'}><Send className="mr-1.5 h-4 w-4" />{copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => dismiss('opt_out')} disabled={saving !== null}>Do not show again</button></div></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
84
src/lib/social-milestones-server.ts
Normal file
84
src/lib/social-milestones-server.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, milestoneKind, SocialLocale } from '@/lib/social-milestones';
|
||||||
|
|
||||||
|
function excludedEmails() {
|
||||||
|
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
|
||||||
|
.split(',').map(email => email.trim().toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates any newly crossed milestones. Safe to call repeatedly. */
|
||||||
|
export async function detectSocialMilestones(qrId?: string) {
|
||||||
|
const excluded = excludedEmails();
|
||||||
|
const candidates = await db.qRScan.groupBy({
|
||||||
|
by: ['qrId'],
|
||||||
|
where: {
|
||||||
|
isUnique: true,
|
||||||
|
...(qrId ? { qrId } : {}),
|
||||||
|
qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const records = candidates.flatMap(({ qrId: candidateQrId, _count }) =>
|
||||||
|
getSocialMilestoneThresholds()
|
||||||
|
.filter(threshold => _count._all >= threshold)
|
||||||
|
.map(threshold => ({ qrId: candidateQrId, kind: milestoneKind(threshold) }))
|
||||||
|
);
|
||||||
|
if (!records.length) return 0;
|
||||||
|
|
||||||
|
const qrs = await db.qRCode.findMany({
|
||||||
|
where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } },
|
||||||
|
select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } },
|
||||||
|
});
|
||||||
|
const cardByQr = new Map<string, Awaited<ReturnType<typeof createCardSnapshot>>>();
|
||||||
|
await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr))));
|
||||||
|
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
|
||||||
|
const created = await db.socialMilestone.createMany({
|
||||||
|
data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({
|
||||||
|
...record,
|
||||||
|
userId: userIdByQr.get(record.qrId)!,
|
||||||
|
cardData: cardByQr.get(record.qrId),
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
return created.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
|
||||||
|
const now = new Date();
|
||||||
|
const scans = await db.qRScan.findMany({
|
||||||
|
where: { qrId: qr.id },
|
||||||
|
select: { ts: true, isUnique: true },
|
||||||
|
orderBy: { ts: 'asc' },
|
||||||
|
});
|
||||||
|
const uniqueScans = scans.filter(scan => scan.isUnique);
|
||||||
|
// Keep a representative, cumulative history in the immutable snapshot.
|
||||||
|
// It starts at QR creation and ends at the moment the milestone is detected.
|
||||||
|
const stride = Math.max(1, Math.ceil(uniqueScans.length / 24));
|
||||||
|
const points = [{ at: qr.createdAt.toISOString(), total: 0 }];
|
||||||
|
uniqueScans.forEach((scan, index) => {
|
||||||
|
const total = index + 1;
|
||||||
|
if (total % stride === 0 || total === uniqueScans.length) points.push({ at: scan.ts.toISOString(), total });
|
||||||
|
});
|
||||||
|
const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
||||||
|
const trend = {
|
||||||
|
points,
|
||||||
|
startLabel: month.format(qr.createdAt),
|
||||||
|
endLabel: month.format(now),
|
||||||
|
target: uniqueScans.length,
|
||||||
|
// Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above.
|
||||||
|
// For 20 scans this is precisely 5, 10, 15, 20, 25.
|
||||||
|
ceiling: uniqueScans.length < 5 ? 5 : Math.max(1.25, uniqueScans.length * 1.25),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The snapshot is made at detection time and never silently changes after consent.
|
||||||
|
return buildMilestoneCardSnapshot({
|
||||||
|
primaryUseCase: qr.user.primaryUseCase,
|
||||||
|
qrTitle: qr.title,
|
||||||
|
totalScans: scans.length,
|
||||||
|
totalUniqueScans: uniqueScans.length,
|
||||||
|
milestoneThreshold: uniqueScans.length,
|
||||||
|
reachedAt: now,
|
||||||
|
trend,
|
||||||
|
locale: 'en' as SocialLocale,
|
||||||
|
});
|
||||||
|
}
|
||||||
121
src/lib/social-milestones.ts
Normal file
121
src/lib/social-milestones.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
export const DEFAULT_SOCIAL_MILESTONE_THRESHOLDS = [1000, 10000] as const;
|
||||||
|
|
||||||
|
export type SocialMilestoneKind = `unique_scans_${number}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staging can set SOCIAL_MILESTONE_THRESHOLDS=1 (or e.g. 1,2) so the complete
|
||||||
|
* flow is testable without fabricating thousands of scans. Production keeps
|
||||||
|
* the conservative defaults unless its environment explicitly changes them.
|
||||||
|
*/
|
||||||
|
export function getSocialMilestoneThresholds(): number[] {
|
||||||
|
const configured = process.env.SOCIAL_MILESTONE_THRESHOLDS;
|
||||||
|
if (!configured) return [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
|
||||||
|
|
||||||
|
const thresholds = Array.from(new Set(
|
||||||
|
configured.split(',')
|
||||||
|
.map(value => Number(value.trim()))
|
||||||
|
.filter(value => Number.isInteger(value) && value > 0 && value <= 1_000_000)
|
||||||
|
)).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
return thresholds.length ? thresholds : [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCaseLabels: Record<string, string> = {
|
||||||
|
menu_pdf: 'menu QR code',
|
||||||
|
marketing_campaign: 'campaign QR code',
|
||||||
|
vcard: 'digital business-card QR code',
|
||||||
|
event: 'event QR code',
|
||||||
|
feedback: 'feedback QR code',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function milestoneKind(threshold: number): SocialMilestoneKind {
|
||||||
|
return `unique_scans_${threshold}` as SocialMilestoneKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function milestoneThreshold(kind: string): number | null {
|
||||||
|
const result = /^unique_scans_(\d+)$/.exec(kind);
|
||||||
|
return result ? Number(result[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SocialLocale = 'en' | 'de';
|
||||||
|
|
||||||
|
export type SocialMilestoneCard = {
|
||||||
|
version: 'milestone-card-v2';
|
||||||
|
language: SocialLocale;
|
||||||
|
qrTitle: string;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
totalScans: number;
|
||||||
|
totalUniqueScans: number;
|
||||||
|
milestoneThreshold: number;
|
||||||
|
reachedAt: string;
|
||||||
|
trend: {
|
||||||
|
points: Array<{ at: string; total: number }>;
|
||||||
|
startLabel: string;
|
||||||
|
endLabel: string;
|
||||||
|
target: number;
|
||||||
|
ceiling: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function socialLocale(value?: string | null): SocialLocale {
|
||||||
|
return value === 'de' ? 'de' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usageLabel(primaryUseCase: string | null, locale: SocialLocale = 'en'): string {
|
||||||
|
if (locale === 'de') {
|
||||||
|
const german: Record<string, string> = { menu_pdf: 'Speisekarten-QR-Code', marketing_campaign: 'Kampagnen-QR-Code', vcard: 'Visitenkarten-QR-Code', event: 'Event-QR-Code', feedback: 'Feedback-QR-Code' };
|
||||||
|
return (primaryUseCase && german[primaryUseCase]) || 'QR-Code';
|
||||||
|
}
|
||||||
|
return (primaryUseCase && useCaseLabels[primaryUseCase]) || 'QR code';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMilestonePost(primaryUseCase: string | null, threshold: number, xHandle?: string | null, locale: SocialLocale = 'en'): string {
|
||||||
|
const count = threshold.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
|
||||||
|
const base = locale === 'de'
|
||||||
|
? `Ein ${usageLabel(primaryUseCase, locale)} hat gerade ${count} eindeutige Scans erreicht. 🎉`
|
||||||
|
: `A ${usageLabel(primaryUseCase, locale)} just reached ${count} unique scans. 🎉`;
|
||||||
|
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMilestoneCard(primaryUseCase: string | null, threshold: number, locale: SocialLocale) {
|
||||||
|
return { version: 'milestone-card-v1', language: locale, threshold, label: usageLabel(primaryUseCase, locale), title: locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMilestonePostForQr(primaryUseCase: string | null, totalUniqueScans: number, xHandle: string | null | undefined, locale: SocialLocale, qrTitle: string): string {
|
||||||
|
const count = totalUniqueScans.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
|
||||||
|
const subject = qrTitle.trim() || usageLabel(primaryUseCase, locale);
|
||||||
|
const base = locale === 'de'
|
||||||
|
? `„${subject}“ hat ${count} eindeutige Scans erreicht.`
|
||||||
|
: `“${subject}” reached ${count} unique scans.`;
|
||||||
|
return xHandle ? `${base} By @${xHandle.replace(/^@/, '')}.` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMilestoneCardSnapshot(input: {
|
||||||
|
primaryUseCase: string | null;
|
||||||
|
qrTitle: string;
|
||||||
|
totalScans: number;
|
||||||
|
totalUniqueScans: number;
|
||||||
|
milestoneThreshold: number;
|
||||||
|
reachedAt: Date;
|
||||||
|
trend: SocialMilestoneCard['trend'];
|
||||||
|
locale: SocialLocale;
|
||||||
|
}): SocialMilestoneCard {
|
||||||
|
return {
|
||||||
|
version: 'milestone-card-v2',
|
||||||
|
language: input.locale,
|
||||||
|
qrTitle: input.qrTitle,
|
||||||
|
label: usageLabel(input.primaryUseCase, input.locale),
|
||||||
|
title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone',
|
||||||
|
totalScans: input.totalScans,
|
||||||
|
totalUniqueScans: input.totalUniqueScans,
|
||||||
|
milestoneThreshold: input.milestoneThreshold,
|
||||||
|
reachedAt: input.reachedAt.toISOString(),
|
||||||
|
trend: input.trend,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeXHandle(value: string): string | null {
|
||||||
|
const handle = value.trim().replace(/^@/, '');
|
||||||
|
return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user