Harden milestone sharing and X publishing
This commit is contained in:
@@ -115,8 +115,35 @@ def _format_axis(value):
|
|||||||
return f"{value:g}" if value < 1000 else f"{value:,.0f}"
|
return f"{value:g}" if value < 1000 else f"{value:,.0f}"
|
||||||
|
|
||||||
|
|
||||||
def post_x(text, card):
|
def oauth_client():
|
||||||
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"))
|
return OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET"))
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_post(oauth, milestone):
|
||||||
|
"""Reconcile an uncertain prior attempt before creating another X post."""
|
||||||
|
token = str(milestone.get("shareToken") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("Milestone has no share token for duplicate-safe publishing")
|
||||||
|
identity = oauth.get("https://api.x.com/2/users/me", timeout=30)
|
||||||
|
identity.raise_for_status()
|
||||||
|
user_id = identity.json().get("data", {}).get("id")
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("X did not return the authenticated user id")
|
||||||
|
timeline = oauth.get(
|
||||||
|
f"https://api.x.com/2/users/{user_id}/tweets",
|
||||||
|
params={"max_results": 100, "tweet.fields": "created_at,entities", "exclude": "retweets,replies"},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
timeline.raise_for_status()
|
||||||
|
for post in timeline.json().get("data") or []:
|
||||||
|
urls = (post.get("entities") or {}).get("urls") or []
|
||||||
|
expanded = " ".join(str(url.get("expanded_url") or url.get("unwound_url") or "") for url in urls)
|
||||||
|
if token in expanded:
|
||||||
|
return post
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def post_x(text, card, oauth):
|
||||||
path = render_card(card) if card else None
|
path = render_card(card) if card else None
|
||||||
try:
|
try:
|
||||||
media_id = None
|
media_id = None
|
||||||
@@ -142,11 +169,13 @@ def run_once():
|
|||||||
if not milestone:
|
if not milestone:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
result = post_x(milestone["text"], milestone.get("card"))
|
oauth = oauth_client()
|
||||||
|
existing = find_existing_post(oauth, milestone)
|
||||||
|
result = {"data": existing, "reconciled": True} if existing else post_x(milestone["text"], milestone.get("card"), oauth)
|
||||||
tweet_id = result.get("data", {}).get("id")
|
tweet_id = result.get("data", {}).get("id")
|
||||||
post_url = f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None
|
post_url = 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})
|
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
|
||||||
print(json.dumps({"posted": milestone["id"], "x": result}), flush=True)
|
print(json.dumps({"posted": milestone["id"], "reconciled": bool(existing), "x": result}), flush=True)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
|
api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
|
||||||
print(f"Milestone post failed: {error}", flush=True)
|
print(f"Milestone post failed: {error}", flush=True)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
|
const card = share.cardData as { qrTitle?: string; totalUniqueScans?: number } | null;
|
||||||
const count = card?.totalUniqueScans || 0;
|
const count = card?.totalUniqueScans || 0;
|
||||||
const title = share.language === 'de'
|
const title = share.language === 'de'
|
||||||
? `${count.toLocaleString('de-DE')} eindeutige QR-Scans erreicht`
|
? `${count.toLocaleString('de-DE')} ${count === 1 ? 'eindeutiger QR-Scan' : 'eindeutige QR-Scans'} erreicht`
|
||||||
: `${count.toLocaleString('en-US')} unique QR scans reached`;
|
: `${count.toLocaleString('en-US')} unique QR ${count === 1 ? 'scan' : 'scans'} reached`;
|
||||||
const description = share.language === 'de'
|
const description = share.language === 'de'
|
||||||
? `${card?.qrTitle || 'Ein QR-Code'} hat einen verifizierten Scan-Meilenstein mit QR Master erreicht.`
|
? `${card?.qrTitle || 'Ein QR-Code'} hat einen verifizierten Scan-Meilenstein mit QR Master erreicht.`
|
||||||
: `${card?.qrTitle || 'A QR code'} reached a verified scan milestone with QR Master.`;
|
: `${card?.qrTitle || 'A QR code'} reached a verified scan milestone with QR Master.`;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { getWwwOrigin } from '@/lib/hosts';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!milestone || !milestone.user || !milestone.qr || milestone.qr.status !== 'ACTIVE') {
|
if (!milestone || !milestone.user || !milestone.qr || milestone.qr.status !== 'ACTIVE') {
|
||||||
return NextResponse.json({ milestone: null });
|
return NextResponse.json({ milestone: null });
|
||||||
}
|
}
|
||||||
if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText }, dryRun: true });
|
const shareUrl = milestone.shareToken ? `${getWwwOrigin()}/s/m/${milestone.shareToken}` : null;
|
||||||
|
if (dryRun) return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, shareUrl }, dryRun: true });
|
||||||
|
|
||||||
const claimed = await db.$transaction(async (tx) => {
|
const claimed = await db.$transaction(async (tx) => {
|
||||||
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
|
await tx.$queryRawUnsafe('SELECT pg_advisory_xact_lock(920241)');
|
||||||
@@ -47,7 +49,14 @@ export async function GET(request: NextRequest) {
|
|||||||
return result.count;
|
return result.count;
|
||||||
});
|
});
|
||||||
if (!claimed) return NextResponse.json({ milestone: null, reason: 'claimed' });
|
if (!claimed) return NextResponse.json({ milestone: null, reason: 'claimed' });
|
||||||
return NextResponse.json({ milestone: { id: milestone.id, text: milestone.consentText, card: milestone.cardData } });
|
return NextResponse.json({ milestone: {
|
||||||
|
id: milestone.id,
|
||||||
|
text: milestone.consentText,
|
||||||
|
card: milestone.cardData,
|
||||||
|
shareToken: milestone.shareToken,
|
||||||
|
shareUrl,
|
||||||
|
approvedAt: milestone.brandApprovedAt?.toISOString() || null,
|
||||||
|
} });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import { randomBytes } from 'crypto';
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
import { getWwwOrigin } from '@/lib/hosts';
|
||||||
import { getSessionUserId } from '@/lib/session';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
import { buildMilestonePostForQr, milestoneThreshold, normalizeXHandle, socialLocale } from '@/lib/social-milestones';
|
||||||
|
import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server';
|
||||||
|
|
||||||
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
|
type Action = 'approve_brand' | 'self_share' | 'decline' | 'opt_out' | 'revoke';
|
||||||
|
|
||||||
async function ownedMilestone(id: string, userId: string) {
|
async function ownedMilestone(id: string, userId: string) {
|
||||||
return db.socialMilestone.findFirst({
|
return db.socialMilestone.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
include: { user: { select: { primaryUseCase: true } }, qr: { select: { title: true } } },
|
include: { user: { select: { primaryUseCase: true } }, qr: { select: { id: true, title: true, createdAt: true } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,49 +65,51 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
const language = socialLocale(body.language);
|
const language = socialLocale(body.language);
|
||||||
const withName = body.action === 'approve_brand' && body.withName === true;
|
const withName = body.withName === true;
|
||||||
const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null;
|
const xHandle = withName ? normalizeXHandle(body.xHandle || '') : null;
|
||||||
if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 });
|
if (withName && !xHandle) return NextResponse.json({ error: 'Enter a valid X handle' }, { status: 400 });
|
||||||
const card = milestone.cardData || buildMilestoneCardSnapshot({
|
const card = await ensureSocialMilestoneCard({
|
||||||
|
milestoneId: milestone.id,
|
||||||
|
cardData: milestone.cardData,
|
||||||
|
kind: milestone.kind,
|
||||||
|
detectedAt: milestone.detectedAt,
|
||||||
|
language,
|
||||||
|
qr: milestone.qr,
|
||||||
primaryUseCase: milestone.user.primaryUseCase,
|
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();
|
const now = new Date();
|
||||||
|
const token = milestone.shareToken || randomBytes(9).toString('base64url');
|
||||||
|
const shareUrl = `${getWwwOrigin()}/s/m/${token}`;
|
||||||
|
|
||||||
if (body.action === 'self_share') {
|
if (body.action === 'self_share') {
|
||||||
// 72 random bits keep public URLs unguessable while making the share URL
|
// 72 random bits keep public URLs unguessable while making the share URL
|
||||||
// much less disruptive in an X compose window than a full UUID.
|
// 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({
|
const updated = await db.socialMilestone.update({
|
||||||
where: { id: milestone.id },
|
where: { id: milestone.id },
|
||||||
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
|
data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language },
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ok: true, shareToken: token, shareVersion: now.getTime(), milestone: clientState(updated) });
|
return NextResponse.json({ ok: true, shareToken: token, shareUrl, shareVersion: now.getTime(), milestone: clientState(updated) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
|
if (!['pending', 'failed', 'revoked'].includes(milestone.brandStatus)) {
|
||||||
return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 });
|
return NextResponse.json({ error: 'This brand post is already being processed' }, { status: 409 });
|
||||||
}
|
}
|
||||||
const consentText = buildMilestonePostForQr(
|
const postText = buildMilestonePostForQr(
|
||||||
milestone.user.primaryUseCase,
|
milestone.user.primaryUseCase,
|
||||||
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
|
(card as { totalUniqueScans?: number }).totalUniqueScans || threshold,
|
||||||
xHandle,
|
xHandle,
|
||||||
language,
|
language,
|
||||||
milestone.qr.title,
|
milestone.qr.title,
|
||||||
);
|
);
|
||||||
|
const consentText = `${postText}\n\n${shareUrl}`;
|
||||||
const updated = await db.$transaction(async tx => {
|
const updated = await db.$transaction(async tx => {
|
||||||
if (withName) await tx.user.update({ where: { id: userId }, data: { xHandle } });
|
if (withName) await tx.user.update({ where: { id: userId }, data: { xHandle } });
|
||||||
return tx.socialMilestone.update({
|
return tx.socialMilestone.update({
|
||||||
where: { id: milestone.id },
|
where: { id: milestone.id },
|
||||||
data: {
|
data: {
|
||||||
brandStatus: 'approved', brandApprovedAt: now, brandPostError: null,
|
brandStatus: 'approved', brandApprovedAt: milestone.brandApprovedAt || now, brandPostError: null,
|
||||||
withName, consentText, language, cardData: card, respondedAt: now,
|
status: 'approved', withName, consentText, language, cardData: card, respondedAt: now,
|
||||||
|
shareToken: token, publicShareApprovedAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { getWwwOrigin } from '@/lib/hosts';
|
||||||
import { getSessionUserId } from '@/lib/session';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { buildMilestoneCardSnapshot, buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
import { buildMilestonePostForQr, milestoneThreshold, socialLocale } from '@/lib/social-milestones';
|
||||||
|
import { ensureSocialMilestoneCard } from '@/lib/social-milestones-server';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -16,9 +19,15 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
if (!user || user.socialPromptOptOut) return NextResponse.json({ milestone: null });
|
||||||
|
|
||||||
const milestone = await db.socialMilestone.findFirst({
|
const milestone = await db.socialMilestone.findFirst({
|
||||||
where: { userId, status: { in: ['detected', 'shown'] } },
|
where: {
|
||||||
|
userId,
|
||||||
|
OR: [
|
||||||
|
{ status: { in: ['detected', 'shown'] } },
|
||||||
|
{ status: 'approved', brandStatus: { in: ['approved', 'processing', 'failed'] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
orderBy: { detectedAt: 'asc' },
|
orderBy: { detectedAt: 'asc' },
|
||||||
include: { qr: { select: { id: true, title: true } } },
|
include: { qr: { select: { id: true, title: true, createdAt: true } } },
|
||||||
});
|
});
|
||||||
if (!milestone) return NextResponse.json({ milestone: null });
|
if (!milestone) return NextResponse.json({ milestone: null });
|
||||||
|
|
||||||
@@ -28,20 +37,22 @@ export async function GET(request: NextRequest) {
|
|||||||
const threshold = milestoneThreshold(milestone.kind);
|
const threshold = milestoneThreshold(milestone.kind);
|
||||||
if (!threshold) return NextResponse.json({ milestone: null });
|
if (!threshold) return NextResponse.json({ milestone: null });
|
||||||
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
const locale = socialLocale(request.nextUrl.searchParams.get('locale'));
|
||||||
const allScanCount = await db.qRScan.count({ where: { qrId: milestone.qr.id } });
|
const shareToken = milestone.shareToken || randomBytes(9).toString('base64url');
|
||||||
const storedCard = milestone.cardData as Record<string, unknown> | null;
|
if (!milestone.shareToken) {
|
||||||
const card = storedCard
|
await db.socialMilestone.update({ where: { id: milestone.id }, data: { shareToken } });
|
||||||
? { ...storedCard, totalScans: typeof storedCard.totalScans === 'number' ? storedCard.totalScans : allScanCount }
|
}
|
||||||
: buildMilestoneCardSnapshot({
|
const shareUrl = `${getWwwOrigin()}/s/m/${shareToken}`;
|
||||||
primaryUseCase: user.primaryUseCase,
|
const card = await ensureSocialMilestoneCard({
|
||||||
qrTitle: milestone.qr.title,
|
milestoneId: milestone.id,
|
||||||
totalScans: allScanCount,
|
cardData: milestone.cardData,
|
||||||
totalUniqueScans: threshold,
|
kind: milestone.kind,
|
||||||
milestoneThreshold: threshold,
|
detectedAt: milestone.detectedAt,
|
||||||
reachedAt: milestone.detectedAt,
|
language: locale,
|
||||||
trend: null,
|
qr: milestone.qr,
|
||||||
locale,
|
primaryUseCase: user.primaryUseCase,
|
||||||
});
|
refresh: ['detected', 'shown'].includes(milestone.status),
|
||||||
|
snapshotAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
milestone: {
|
milestone: {
|
||||||
@@ -51,9 +62,10 @@ export async function GET(request: NextRequest) {
|
|||||||
brandPostUrl: milestone.brandPostUrl,
|
brandPostUrl: milestone.brandPostUrl,
|
||||||
brandPostError: milestone.brandPostError,
|
brandPostError: milestone.brandPostError,
|
||||||
language: locale,
|
language: locale,
|
||||||
|
shareUrl,
|
||||||
preview: buildMilestonePostForQr(
|
preview: buildMilestonePostForQr(
|
||||||
user.primaryUseCase,
|
user.primaryUseCase,
|
||||||
((milestone.cardData as { totalUniqueScans?: number } | null)?.totalUniqueScans || threshold),
|
card.totalUniqueScans || threshold,
|
||||||
null,
|
null,
|
||||||
locale,
|
locale,
|
||||||
milestone.qr.title,
|
milestone.qr.title,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Check, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react';
|
import { Check, Copy, ExternalLink, LineChart, Linkedin, Send, Sparkles, X } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
@@ -9,7 +9,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
|||||||
import { showToast } from '@/components/ui/Toast';
|
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 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 Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; shareUrl: 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 };
|
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
|
||||||
|
|
||||||
function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: 'en' | 'de' }) {
|
function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: 'en' | 'de' }) {
|
||||||
@@ -44,7 +44,7 @@ export function SocialMilestoneDialog() {
|
|||||||
const [milestone, setMilestone] = useState<Milestone | null>(null);
|
const [milestone, setMilestone] = useState<Milestone | null>(null);
|
||||||
const [withName, setWithName] = useState(false);
|
const [withName, setWithName] = useState(false);
|
||||||
const [xHandle, setXHandle] = useState('');
|
const [xHandle, setXHandle] = useState('');
|
||||||
const [saving, setSaving] = useState<'brand' | 'self' | null>(null);
|
const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | null>(null);
|
||||||
const [brand, setBrand] = useState<BrandState | null>(null);
|
const [brand, setBrand] = useState<BrandState | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,11 +71,12 @@ export function SocialMilestoneDialog() {
|
|||||||
const copy = milestone?.language === 'de'
|
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: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf dem eigenen X-Account veröffentlichen?', name: 'Meinen X-Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Auf QR Master posten', queued: 'Wird auf X veröffentlicht …', posted: 'Auf X veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' }
|
||||||
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Waiting for the X publisher …', posted: 'Published on X', failed: 'Publishing failed' };
|
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Waiting for the X publisher …', posted: 'Published on X', failed: 'Publishing failed' };
|
||||||
const preview = useMemo(() => {
|
const postCopy = useMemo(() => {
|
||||||
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
|
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
|
||||||
const mention = milestone.language === 'de' ? 'Glückwunsch' : 'Congratulations';
|
const mention = milestone.language === 'de' ? 'Glückwunsch' : 'Congratulations';
|
||||||
return `${milestone.preview}\n\n${mention} @${xHandle.trim().replace(/^@/, '')}.`;
|
return `${milestone.preview}\n\n${mention} @${xHandle.trim().replace(/^@/, '')}.`;
|
||||||
}, [milestone, withName, xHandle]);
|
}, [milestone, withName, xHandle]);
|
||||||
|
const preview = milestone ? `${postCopy}\n\n${milestone.shareUrl}` : '';
|
||||||
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
||||||
if (!milestone) return null;
|
if (!milestone) return null;
|
||||||
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }) });
|
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }) });
|
||||||
@@ -83,6 +84,12 @@ export function SocialMilestoneDialog() {
|
|||||||
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
|
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
const prepareSelfShare = async () => {
|
||||||
|
const result = await update('self_share');
|
||||||
|
const shareUrl = `${result.shareUrl}?v=${result.shareVersion}`;
|
||||||
|
setBrand(result.milestone);
|
||||||
|
return { shareUrl, text: `${postCopy}\n\n${shareUrl}` };
|
||||||
|
};
|
||||||
const shareSelf = async (network: 'x' | 'linkedin') => {
|
const shareSelf = async (network: 'x' | 'linkedin') => {
|
||||||
if (!milestone) return;
|
if (!milestone) return;
|
||||||
// Open synchronously from the user gesture. Awaiting the API first can make
|
// Open synchronously from the user gesture. Awaiting the API first can make
|
||||||
@@ -91,19 +98,39 @@ export function SocialMilestoneDialog() {
|
|||||||
if (shareWindow) shareWindow.opener = null;
|
if (shareWindow) shareWindow.opener = null;
|
||||||
setSaving('self');
|
setSaving('self');
|
||||||
try {
|
try {
|
||||||
const result = await update('self_share');
|
const { shareUrl, text } = await prepareSelfShare();
|
||||||
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}?v=${result.shareVersion}`;
|
|
||||||
const text = `${preview}\n\n${shareUrl}`;
|
|
||||||
const targetUrl = network === 'x'
|
const targetUrl = network === 'x'
|
||||||
? `https://x.com/intent/post?text=${encodeURIComponent(text)}`
|
? `https://x.com/intent/post?text=${encodeURIComponent(text)}`
|
||||||
: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
||||||
if (shareWindow) shareWindow.location.href = targetUrl;
|
if (shareWindow) shareWindow.location.href = targetUrl;
|
||||||
else window.location.assign(targetUrl);
|
else window.location.assign(targetUrl);
|
||||||
setBrand(result.milestone);
|
|
||||||
showToast(network === 'linkedin' ? 'LinkedIn share window opened.' : 'X share composer opened.', 'success');
|
showToast(network === 'linkedin' ? 'LinkedIn share window opened.' : 'X share composer opened.', 'success');
|
||||||
} catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
|
} catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); }
|
||||||
finally { setSaving(null); }
|
finally { setSaving(null); }
|
||||||
};
|
};
|
||||||
|
const copyLinkedInText = async () => {
|
||||||
|
setSaving('copy');
|
||||||
|
try {
|
||||||
|
const { text } = await prepareSelfShare();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const copied = document.execCommand('copy');
|
||||||
|
textarea.remove();
|
||||||
|
if (!copied) throw new Error('Copying is blocked by this browser');
|
||||||
|
}
|
||||||
|
showToast(milestone?.language === 'de' ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', 'error');
|
||||||
|
} finally { setSaving(null); }
|
||||||
|
};
|
||||||
const approveBrand = async () => {
|
const approveBrand = async () => {
|
||||||
setSaving('brand');
|
setSaving('brand');
|
||||||
try {
|
try {
|
||||||
@@ -139,12 +166,12 @@ export function SocialMilestoneDialog() {
|
|||||||
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
||||||
</section>
|
</section>
|
||||||
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 whitespace-pre-line border-l border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 whitespace-pre-line border-l 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={!canApprove} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
|
<label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
|
||||||
{withName && <input aria-label="X handle" value={xHandle} onChange={event => setXHandle(event.target.value)} disabled={!canApprove} 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" />}
|
{withName && <input aria-label="X handle" value={xHandle} onChange={event => setXHandle(event.target.value)} disabled={saving !== null} maxLength={16} pattern="@?[A-Za-z0-9_]{1,15}" placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-[#0256ff] focus:ring-2 focus:ring-blue-100" />}
|
||||||
<div 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>
|
<div className="space-y-2"><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={copyLinkedInText} disabled={saving !== null}><Copy className="mr-1.5 h-3.5 w-3.5" />{milestone.language === 'de' ? 'Text kopieren' : 'Copy text'}</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><p className="text-[11px] leading-4 text-slate-500">{milestone.language === 'de' ? 'Für LinkedIn zuerst den Text kopieren, dann LinkedIn öffnen und einfügen.' : 'For LinkedIn, copy the post text first, then open LinkedIn and paste it.'}</p></div>
|
||||||
{status !== 'pending' && <div className={`flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
{status !== 'pending' && <div className={`flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? `${copy.failed}${brand?.brandPostError ? `: ${brand.brandPostError}` : ''}` : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="shrink-0 bg-slate-50 px-6 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 || !canApprove}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : status === 'failed' ? (milestone.language === 'de' ? 'Erneut versuchen' : 'Retry post') : copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={optOut} disabled={saving !== null}>{milestone.language === 'de' ? 'Nicht mehr anzeigen' : 'Do not show again'}</button></div></DialogFooter>
|
<DialogFooter className="shrink-0 bg-slate-50 px-6 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => status === 'pending' ? dismiss('decline') : setMilestone(null)} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || !canApprove}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : status === 'failed' ? (milestone.language === 'de' ? 'Erneut versuchen' : 'Retry post') : copy.approve}</Button>{status === 'pending' && <button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={optOut} disabled={saving !== null}>{milestone.language === 'de' ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, milestoneKind, SocialLocale } from '@/lib/social-milestones';
|
import { buildMilestoneCardSnapshot, getSocialMilestoneThresholds, isCompleteSocialMilestoneCard, milestoneKind, milestoneThreshold, SocialLocale, SocialMilestoneCard, socialLocale } from '@/lib/social-milestones';
|
||||||
|
|
||||||
function excludedEmails() {
|
function excludedEmails() {
|
||||||
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
|
return (process.env.SOCIAL_MILESTONE_EXCLUDED_EMAILS || '')
|
||||||
@@ -30,7 +30,7 @@ export async function detectSocialMilestones(qrId?: string) {
|
|||||||
select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } },
|
select: { id: true, userId: true, title: true, createdAt: true, user: { select: { primaryUseCase: true } } },
|
||||||
});
|
});
|
||||||
const cardByQr = new Map<string, Awaited<ReturnType<typeof createCardSnapshot>>>();
|
const cardByQr = new Map<string, Awaited<ReturnType<typeof createCardSnapshot>>>();
|
||||||
await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr))));
|
await Promise.all(qrs.map(async qr => cardByQr.set(qr.id, await createCardSnapshot(qr, new Date()))));
|
||||||
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
|
const userIdByQr = new Map(qrs.map(qr => [qr.id, qr.userId]));
|
||||||
const created = await db.socialMilestone.createMany({
|
const created = await db.socialMilestone.createMany({
|
||||||
data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({
|
data: records.filter(record => userIdByQr.has(record.qrId)).map(record => ({
|
||||||
@@ -43,10 +43,14 @@ export async function detectSocialMilestones(qrId?: string) {
|
|||||||
return created.count;
|
return created.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
|
async function createCardSnapshot(
|
||||||
const now = new Date();
|
qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } },
|
||||||
|
reachedAt: Date,
|
||||||
|
locale: SocialLocale = 'en',
|
||||||
|
configuredThreshold?: number,
|
||||||
|
) {
|
||||||
const scans = await db.qRScan.findMany({
|
const scans = await db.qRScan.findMany({
|
||||||
where: { qrId: qr.id },
|
where: { qrId: qr.id, ts: { lte: reachedAt } },
|
||||||
select: { ts: true, isUnique: true },
|
select: { ts: true, isUnique: true },
|
||||||
orderBy: { ts: 'asc' },
|
orderBy: { ts: 'asc' },
|
||||||
});
|
});
|
||||||
@@ -63,7 +67,7 @@ async function createCardSnapshot(qr: { id: string; title: string; createdAt: Da
|
|||||||
const trend = {
|
const trend = {
|
||||||
points,
|
points,
|
||||||
startLabel: month.format(qr.createdAt),
|
startLabel: month.format(qr.createdAt),
|
||||||
endLabel: month.format(now),
|
endLabel: month.format(reachedAt),
|
||||||
target: uniqueScans.length,
|
target: uniqueScans.length,
|
||||||
// Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above.
|
// 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.
|
// For 20 scans this is precisely 5, 10, 15, 20, 25.
|
||||||
@@ -76,9 +80,36 @@ async function createCardSnapshot(qr: { id: string; title: string; createdAt: Da
|
|||||||
qrTitle: qr.title,
|
qrTitle: qr.title,
|
||||||
totalScans: scans.length,
|
totalScans: scans.length,
|
||||||
totalUniqueScans: uniqueScans.length,
|
totalUniqueScans: uniqueScans.length,
|
||||||
milestoneThreshold: uniqueScans.length,
|
milestoneThreshold: configuredThreshold || uniqueScans.length,
|
||||||
reachedAt: now,
|
reachedAt,
|
||||||
trend,
|
trend,
|
||||||
locale: 'en' as SocialLocale,
|
locale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Old test rows may contain the v1 card or a partial v2 snapshot. Repair once,
|
||||||
|
* persist it, and return the exact same immutable payload to popup, OG and X.
|
||||||
|
*/
|
||||||
|
export async function ensureSocialMilestoneCard(input: {
|
||||||
|
milestoneId: string;
|
||||||
|
cardData: unknown;
|
||||||
|
kind: string;
|
||||||
|
detectedAt: Date;
|
||||||
|
language: string;
|
||||||
|
qr: { id: string; title: string; createdAt: Date };
|
||||||
|
primaryUseCase: string | null;
|
||||||
|
refresh?: boolean;
|
||||||
|
snapshotAt?: Date;
|
||||||
|
}): Promise<SocialMilestoneCard> {
|
||||||
|
if (!input.refresh && isCompleteSocialMilestoneCard(input.cardData)) return input.cardData;
|
||||||
|
const threshold = milestoneThreshold(input.kind) || 1;
|
||||||
|
const card = await createCardSnapshot(
|
||||||
|
{ ...input.qr, user: { primaryUseCase: input.primaryUseCase } },
|
||||||
|
input.snapshotAt || input.detectedAt,
|
||||||
|
socialLocale(input.language),
|
||||||
|
threshold,
|
||||||
|
);
|
||||||
|
await db.socialMilestone.update({ where: { id: input.milestoneId }, data: { cardData: card } });
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ export type SocialMilestoneCard = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isCompleteSocialMilestoneCard(value: unknown): value is SocialMilestoneCard {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const card = value as Partial<SocialMilestoneCard>;
|
||||||
|
const trend = card.trend;
|
||||||
|
return card.version === 'milestone-card-v2'
|
||||||
|
&& typeof card.qrTitle === 'string'
|
||||||
|
&& typeof card.totalScans === 'number'
|
||||||
|
&& Number.isFinite(card.totalScans)
|
||||||
|
&& typeof card.totalUniqueScans === 'number'
|
||||||
|
&& Number.isFinite(card.totalUniqueScans)
|
||||||
|
&& Boolean(trend)
|
||||||
|
&& Array.isArray(trend?.points)
|
||||||
|
&& trend.points.length >= 2
|
||||||
|
&& trend.points.every(point => typeof point?.at === 'string' && typeof point?.total === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
export function socialLocale(value?: string | null): SocialLocale {
|
export function socialLocale(value?: string | null): SocialLocale {
|
||||||
return value === 'de' ? 'de' : 'en';
|
return value === 'de' ? 'de' : 'en';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user