Add consented social milestone posting
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
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,37 @@ 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?
|
||||||
|
|
||||||
|
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
|
||||||
86
scripts/social-worker/worker.py
Normal file
86
scripts/social-worker/worker.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Always-on QRMaster X milestone worker. The web app never receives X keys."""
|
||||||
|
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 render_card(card):
|
||||||
|
image = Image.new("RGB", (1200, 675), "#061b31")
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
fonts = Path("/usr/share/fonts/truetype/dejavu")
|
||||||
|
bold = ImageFont.truetype(str(fonts / "DejaVuSans-Bold.ttf"), 112)
|
||||||
|
regular = ImageFont.truetype(str(fonts / "DejaVuSans.ttf"), 34)
|
||||||
|
image_draw = draw
|
||||||
|
image_draw.rounded_rectangle((55, 55, 1145, 620), radius=28, outline="#304866", width=2)
|
||||||
|
image_draw.text((100, 105), "QR MASTER", font=regular, fill="#dce8f7")
|
||||||
|
image_draw.text((100, 210), f"{card['threshold']:,}", font=bold, fill="#ffffff")
|
||||||
|
scans = "eindeutige Scans" if card.get("language") == "de" else "unique scans"
|
||||||
|
image_draw.text((105, 350), scans, font=regular, fill="#b8c7da")
|
||||||
|
image_draw.line((100, 500, 1100, 500), fill="#304866", width=2)
|
||||||
|
image_draw.text((100, 535), f"{card['label']} · {card['title']}", font=regular, fill="#dce8f7")
|
||||||
|
path = Path(tempfile.mkstemp(suffix=".png")[1])
|
||||||
|
image.save(path, "PNG", optimize=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
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"))
|
||||||
|
api("PATCH", base, {"id": milestone["id"], "result": "posted"})
|
||||||
|
print(json.dumps({"posted": milestone["id"], "x": result}), flush=True)
|
||||||
|
except Exception as error:
|
||||||
|
api("PATCH", base, {"id": milestone["id"], "result": "failed"})
|
||||||
|
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)
|
||||||
30
sql/2026-08-13_social_milestones.sql
Normal file
30
sql/2026-08-13_social_milestones.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- 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");
|
||||||
@@ -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">
|
||||||
|
|||||||
53
src/app/(main)/api/cron/social-milestones/route.ts
Normal file
53
src/app/(main)/api/cron/social-milestones/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSocialMilestoneThresholds, milestoneKind } 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function excludedEmails() {
|
||||||
|
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
|
||||||
|
.split(',').map(email => email.trim().toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 excluded = excludedEmails();
|
||||||
|
const thresholds = getSocialMilestoneThresholds();
|
||||||
|
const candidates = await db.qRScan.groupBy({
|
||||||
|
by: ['qrId'],
|
||||||
|
where: {
|
||||||
|
isUnique: true,
|
||||||
|
qr: { user: excluded.length ? { email: { notIn: excluded, mode: 'insensitive' } } : undefined },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = candidates.flatMap(({ qrId, _count }) =>
|
||||||
|
thresholds
|
||||||
|
.filter(threshold => _count._all >= threshold)
|
||||||
|
.map(threshold => ({ qrId, kind: milestoneKind(threshold) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (records.length) {
|
||||||
|
const qrs = await db.qRCode.findMany({
|
||||||
|
where: { id: { in: Array.from(new Set(records.map(record => record.qrId))) } },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
|
||||||
|
await db.socialMilestone.createMany({
|
||||||
|
data: records
|
||||||
|
.filter(record => userIdByQr.has(record.qrId))
|
||||||
|
.map(record => ({ ...record, userId: userIdByQr.get(record.qrId)! })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, detected: records.length, thresholds });
|
||||||
|
}
|
||||||
65
src/app/(main)/api/internal/social-milestones/route.ts
Normal file
65
src/app/(main)/api/internal/social-milestones/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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 dayAgo = new Date(now - 24 * 60 * 60 * 1000);
|
||||||
|
const approvalNotBefore = new Date(now - approvalDelayHours() * 60 * 60 * 1000);
|
||||||
|
const postedToday = await db.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } });
|
||||||
|
if (postedToday > 0) return NextResponse.json({ milestone: null, reason: 'daily_limit' });
|
||||||
|
|
||||||
|
const milestone = await db.socialMilestone.findFirst({
|
||||||
|
where: { status: 'approved', respondedAt: { lte: approvalNotBefore } },
|
||||||
|
orderBy: { respondedAt: '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 });
|
||||||
|
|
||||||
|
// A claimed item also occupies the daily slot. This prevents two workers
|
||||||
|
// from each claiming a different milestone before either one posts.
|
||||||
|
const claimed = await db.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
|
||||||
|
const occupied = await tx.socialMilestone.count({ where: { OR: [{ status: 'posted', postedAt: { gte: dayAgo } }, { status: 'processing', claimedAt: { gte: dayAgo } }] } });
|
||||||
|
if (occupied) return 0;
|
||||||
|
const result = await tx.socialMilestone.updateMany({
|
||||||
|
where: { id: milestone.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 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
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' } | 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, status: 'processing' },
|
||||||
|
data: { status: body.result!, postedAt: body.result === 'posted' ? new Date() : null },
|
||||||
|
});
|
||||||
|
if (!updated.count) return NextResponse.json({ error: 'Milestone is no longer available' }, { status: 409 });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
48
src/app/(main)/api/social-milestones/[id]/route.ts
Normal file
48
src/app/(main)/api/social-milestones/[id]/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
import { getSessionUserId } from '@/lib/session';
|
||||||
|
import { buildMilestoneCard, buildMilestonePost, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
||||||
|
|
||||||
|
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
|
||||||
|
|
||||||
|
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 db.socialMilestone.findFirst({
|
||||||
|
where: { id: params.id, userId }, include: { user: { select: { primaryUseCase: true } } },
|
||||||
|
});
|
||||||
|
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 });
|
||||||
|
|
||||||
|
if (body.action === 'revoke') {
|
||||||
|
if (milestone.status !== 'approved') return NextResponse.json({ error: 'Only queued approvals can be revoked' }, { status: 409 });
|
||||||
|
await db.socialMilestone.update({ where: { id: milestone.id }, data: { status: 'revoked', respondedAt: new Date() } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
if (!['detected', 'shown'].includes(milestone.status)) return NextResponse.json({ error: 'This milestone has already been answered' }, { status: 409 });
|
||||||
|
|
||||||
|
const withName = body.action === 'approve_brand' && body.withName === true;
|
||||||
|
const language = socialLocale(body.language);
|
||||||
|
const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null;
|
||||||
|
if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 });
|
||||||
|
const status = body.action === 'approve_brand' ? 'approved' : body.action === 'self_share' ? 'self_shared' : 'declined';
|
||||||
|
const consentText = body.action === 'approve_brand'
|
||||||
|
? buildMilestonePost(milestone.user.primaryUseCase, threshold, xHandle, language)
|
||||||
|
: null;
|
||||||
|
const now = new Date();
|
||||||
|
await db.$transaction([
|
||||||
|
db.socialMilestone.update({ where: { id: milestone.id }, data: { status, withName, consentText, language, cardData: body.action === 'approve_brand' ? buildMilestoneCard(milestone.user.primaryUseCase, threshold, language) : undefined, respondedAt: now } }),
|
||||||
|
...(body.action === 'opt_out' ? [db.user.update({ where: { id: userId }, data: { socialPromptOptOut: true } })] : []),
|
||||||
|
...(withName ? [db.user.update({ where: { id: userId }, data: { xHandle } })] : []),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ ok: true, consentText });
|
||||||
|
}
|
||||||
41
src/app/(main)/api/social-milestones/route.ts
Normal file
41
src/app/(main)/api/social-milestones/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSessionUserId } from '@/lib/session';
|
||||||
|
import { buildMilestoneCard, buildMilestonePost, 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: { 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'));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
milestone: {
|
||||||
|
id: milestone.id, qrTitle: milestone.qr.title, threshold,
|
||||||
|
defaultXHandle: user.xHandle,
|
||||||
|
language: locale,
|
||||||
|
preview: buildMilestonePost(user.primaryUseCase, threshold, null, locale),
|
||||||
|
card: buildMilestoneCard(user.primaryUseCase, threshold, locale),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
104
src/components/dashboard/SocialMilestoneDialog.tsx
Normal file
104
src/components/dashboard/SocialMilestoneDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { QrCode, Sparkles } 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 Milestone = {
|
||||||
|
id: string;
|
||||||
|
qrTitle: string;
|
||||||
|
threshold: number;
|
||||||
|
defaultXHandle: string | null;
|
||||||
|
preview: string;
|
||||||
|
language: 'en' | 'de';
|
||||||
|
card: { title: string; label: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
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(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/social-milestones?locale=${locale}`)
|
||||||
|
.then(async (response) => response.ok && setMilestone((await response.json()).milestone))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
|
||||||
|
|
||||||
|
const copy = milestone?.language === 'de'
|
||||||
|
? { heading: 'Ein echter Erfolg', subtitle: 'hat gerade einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg mit der unten stehenden Karte auf dem eigenen X-Account teilen?', name: 'Meinen X-Handle nennen', decline: 'Nein, danke', optOut: 'Nicht mehr anzeigen', self: 'Selbst teilen', approve: 'Auf QR Master posten', published: 'Der Beitrag wird jetzt auf dem QR Master X-Account veroeffentlicht.' }
|
||||||
|
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master share this success, including the card below, from our X account?', name: 'Mention my X handle', decline: 'No thanks', optOut: 'Do not show again', self: 'Share myself', approve: 'Post from QR Master', published: 'This will now be published from the QR Master X account.' };
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
|
||||||
|
return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`;
|
||||||
|
}, [milestone, withName, xHandle]);
|
||||||
|
|
||||||
|
const respond = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
||||||
|
if (!milestone) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
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');
|
||||||
|
if (action === 'self_share') {
|
||||||
|
await navigator.clipboard?.writeText(preview);
|
||||||
|
window.open(`https://x.com/intent/post?text=${encodeURIComponent(preview)}`, '_blank', 'noopener,noreferrer');
|
||||||
|
window.open('https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.qrmaster.net', '_blank', 'noopener,noreferrer');
|
||||||
|
showToast('Post text copied for LinkedIn.', 'success');
|
||||||
|
} else if (action === 'approve_brand') {
|
||||||
|
showToast(copy.published, 'success');
|
||||||
|
}
|
||||||
|
setMilestone(null);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!milestone) return null;
|
||||||
|
|
||||||
|
return <Dialog open onOpenChange={(open) => !open && setMilestone(null)}>
|
||||||
|
<DialogContent className="max-w-lg overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.4)]">
|
||||||
|
<div className="border-b border-slate-100 px-6 pb-5 pt-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-violet-50 text-violet-700"><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-slate-600"><strong className="font-medium text-slate-900">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5 px-6 py-5">
|
||||||
|
<div className="rounded-xl bg-[#061b31] p-5 text-white shadow-[0_18px_36px_-20px_rgba(50,50,93,0.65)]">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-300"><span className="flex items-center gap-2 font-medium tracking-wide"><QrCode className="h-4 w-4" />QR MASTER</span><span>Verified</span></div>
|
||||||
|
<div className="mt-7 text-5xl font-semibold tracking-[-0.04em] tabular-nums">{milestone.threshold.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-300">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div>
|
||||||
|
<div className="mt-7 border-t border-white/15 pt-3 text-xs text-slate-300">{milestone.card.label} · {milestone.card.title}</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-slate-600">{copy.consent}</p>
|
||||||
|
<blockquote className="border-l-2 border-violet-500 pl-3 text-sm leading-6 text-slate-700">{preview}</blockquote>
|
||||||
|
<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)} className="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />{copy.name}</label>
|
||||||
|
{withName && <input aria-label="X handle" value={xHandle} onChange={(event) => setXHandle(event.target.value)} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-2 focus:ring-violet-100" />}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="border-t border-slate-100 bg-slate-50 px-6 py-4">
|
||||||
|
<div className="flex w-full flex-wrap items-center justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => respond('decline')} disabled={saving}>{copy.decline}</Button>
|
||||||
|
<Button variant="outline" onClick={() => respond('self_share')} disabled={saving}>{copy.self}</Button>
|
||||||
|
<Button variant="primary" onClick={() => respond('approve_brand')} disabled={saving}>{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={() => respond('opt_out')} disabled={saving}>{copy.optOut}</button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
69
src/lib/social-milestones.ts
Normal file
69
src/lib/social-milestones.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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 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 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