Consent is bound to the channel it was given for: approving a post on X says nothing about Instagram. Publishing state moves from the SocialMilestone row into SocialMilestonePost, one row per channel, where a missing row means no consent. The dialog asks per channel, shows the text each one will publish and keeps a separate handle for each; Instagram captions end in hashtags because a link there is not clickable. Also fixes three problems in the existing X path: - A QR code already past several thresholds produced one prompt per threshold, and since the post quotes the current scan count, every one of them would have published the same number. Only the highest threshold is announced now. - Detection ran after every unique scan and re-read the QR code's full scan history just to hit skipDuplicates. Known milestones are filtered first. - A failed post stayed failed forever because the consent dialog only opens once. The queue now retries three times on its own, spaces first attempts by SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per channel with restart and revoke. The worker no longer renders the card itself; it downloads the image the app renders at /s/m/<token>/og, which also serves the new square and portrait formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and SOCIAL_WORKER_CHANNELS both name it. Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before deploying this version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
8.7 KiB
TypeScript
213 lines
8.7 KiB
TypeScript
export const DEFAULT_SOCIAL_MILESTONE_THRESHOLDS = [1000, 10000] as const;
|
|
|
|
export type SocialMilestoneKind = `unique_scans_${number}`;
|
|
|
|
/**
|
|
* Staging can set SOCIAL_MILESTONE_THRESHOLDS=1 (or e.g. 1,2) so the complete
|
|
* flow is testable without fabricating thousands of scans. Production keeps
|
|
* the conservative defaults unless its environment explicitly changes them.
|
|
*/
|
|
export function getSocialMilestoneThresholds(): number[] {
|
|
const configured = process.env.SOCIAL_MILESTONE_THRESHOLDS;
|
|
if (!configured) return [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
|
|
|
|
const thresholds = Array.from(new Set(
|
|
configured.split(',')
|
|
.map(value => Number(value.trim()))
|
|
.filter(value => Number.isInteger(value) && value > 0 && value <= 1_000_000)
|
|
)).sort((a, b) => a - b);
|
|
|
|
return thresholds.length ? thresholds : [...DEFAULT_SOCIAL_MILESTONE_THRESHOLDS];
|
|
}
|
|
|
|
/**
|
|
* Channels the consent dialog may offer. A channel is only offered where a
|
|
* publisher is actually configured - asking for consent for a post that nobody
|
|
* can publish would be dishonest. Server-side read; the dialog receives the
|
|
* resulting list in its payload.
|
|
*/
|
|
export function getEnabledSocialChannels(): SocialChannel[] {
|
|
const configured = (process.env.SOCIAL_MILESTONE_CHANNELS || 'x')
|
|
.split(',').map(value => value.trim().toLowerCase()).filter(isSocialChannel);
|
|
return configured.length ? Array.from(new Set(configured)) : ['x'];
|
|
}
|
|
|
|
const useCaseLabels: Record<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 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 {
|
|
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 rawSubject = qrTitle.trim() || usageLabel(primaryUseCase, locale);
|
|
const subject = rawSubject.length > 72 ? `${rawSubject.slice(0, 69).trimEnd()}…` : rawSubject;
|
|
const scanLabel = locale === 'de'
|
|
? `${count} ${totalUniqueScans === 1 ? 'verifizierten eindeutigen Scan' : 'verifizierte eindeutige Scans'}`
|
|
: `${count} verified unique ${totalUniqueScans === 1 ? 'scan' : 'scans'}`;
|
|
const base = locale === 'de'
|
|
? `QR-Meilenstein erreicht.\n\n„${subject}“ hat ${scanLabel} erzielt.\n\nErstellt und gemessen mit QR Master.`
|
|
: `QR milestone unlocked.\n\n“${subject}” has reached ${scanLabel}.\n\nCreated and measured with QR Master.`;
|
|
if (!xHandle) return base;
|
|
const mention = locale === 'de' ? 'Glückwunsch' : 'Congratulations';
|
|
return `${base}\n\n${mention} @${xHandle.replace(/^@/, '')}.`;
|
|
}
|
|
|
|
export function buildMilestoneCardSnapshot(input: {
|
|
primaryUseCase: string | null;
|
|
qrTitle: string;
|
|
totalScans: number;
|
|
totalUniqueScans: number;
|
|
milestoneThreshold: number;
|
|
reachedAt: Date;
|
|
trend: SocialMilestoneCard['trend'];
|
|
locale: SocialLocale;
|
|
}): SocialMilestoneCard {
|
|
return {
|
|
version: 'milestone-card-v2',
|
|
language: input.locale,
|
|
qrTitle: input.qrTitle,
|
|
label: usageLabel(input.primaryUseCase, input.locale),
|
|
title: input.locale === 'de' ? 'Erfolgsmeilenstein' : 'Success milestone',
|
|
totalScans: input.totalScans,
|
|
totalUniqueScans: input.totalUniqueScans,
|
|
milestoneThreshold: input.milestoneThreshold,
|
|
reachedAt: input.reachedAt.toISOString(),
|
|
trend: input.trend,
|
|
};
|
|
}
|
|
|
|
export function normalizeXHandle(value: string): string | null {
|
|
const handle = value.trim().replace(/^@/, '');
|
|
return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : null;
|
|
}
|
|
|
|
export function normalizeInstagramHandle(value: string): string | null {
|
|
const handle = value.trim().replace(/^@/, '');
|
|
return /^[A-Za-z0-9._]{1,30}$/.test(handle) ? handle : null;
|
|
}
|
|
|
|
/**
|
|
* Publishing channels of the QR Master brand accounts.
|
|
*
|
|
* Consent is bound to a channel: agreeing to a post on X says nothing about
|
|
* Instagram. Every channel therefore carries its own approval, its own text and
|
|
* its own handle.
|
|
*/
|
|
export const SOCIAL_CHANNELS = ['x', 'instagram'] as const;
|
|
export type SocialChannel = typeof SOCIAL_CHANNELS[number];
|
|
|
|
export function isSocialChannel(value: unknown): value is SocialChannel {
|
|
return typeof value === 'string' && (SOCIAL_CHANNELS as readonly string[]).includes(value);
|
|
}
|
|
|
|
export function normalizeChannelHandle(channel: SocialChannel, value: string): string | null {
|
|
return channel === 'instagram' ? normalizeInstagramHandle(value) : normalizeXHandle(value);
|
|
}
|
|
|
|
function instagramHashtags(locale: SocialLocale): string {
|
|
return locale === 'de'
|
|
? '#qrcode #qrcodes #marketing #kleinunternehmen #analytics #digitalisierung'
|
|
: '#qrcode #qrcodes #qrcodemarketing #smallbusiness #marketing #analytics';
|
|
}
|
|
|
|
/**
|
|
* The post split into the parts the consent dialog recombines while the
|
|
* customer types a handle. Server and client must never build this text
|
|
* differently - what stands in the preview is what gets published.
|
|
*/
|
|
export function milestonePostParts(input: {
|
|
channel: SocialChannel;
|
|
primaryUseCase: string | null;
|
|
totalUniqueScans: number;
|
|
locale: SocialLocale;
|
|
qrTitle: string;
|
|
shareUrl: string;
|
|
}) {
|
|
return {
|
|
head: buildMilestonePostForQr(input.primaryUseCase, input.totalUniqueScans, null, input.locale, input.qrTitle),
|
|
// A link in an Instagram caption is not clickable, so the share URL would
|
|
// be dead weight there. Hashtags do the reach work instead.
|
|
tail: input.channel === 'instagram' ? `\n\n${instagramHashtags(input.locale)}` : `\n\n${input.shareUrl}`,
|
|
mentionWord: input.locale === 'de' ? 'Glückwunsch' : 'Congratulations',
|
|
};
|
|
}
|
|
|
|
export function buildChannelPost(input: Parameters<typeof milestonePostParts>[0] & { handle: string | null }): string {
|
|
const { head, tail, mentionWord } = milestonePostParts(input);
|
|
const mention = input.handle ? `\n\n${mentionWord} @${input.handle.replace(/^@/, '')}.` : '';
|
|
return `${head}${mention}${tail}`;
|
|
}
|