# Social milestone worker The app detects QR-code scan milestones and stores customer consent. It does not hold X or Meta credentials. The external worker publishes what was approved — per channel, never more. ## Consent is bound to a channel Approving a post on X says nothing about Instagram: different audience, different disclosure about the customer's business. Every channel therefore has its own checkbox in the dialog, its own text, its own handle and its own row in `SocialMilestonePost`. **No row means no consent.** A channel is only offered where a publisher is configured — `SOCIAL_MILESTONE_CHANNELS` (app) must stay in sync with `SOCIAL_WORKER_CHANNELS` (worker), otherwise approvals pile up in the queue with nobody to publish them. ## Test setup (manual SQL only) 1. Apply, in this order, to `qrmaster_test`: [`sql/2026-08-13_social_milestones.sql`](../../sql/2026-08-13_social_milestones.sql), [`sql/2026-08-16_social_milestone_retries.sql`](../../sql/2026-08-16_social_milestone_retries.sql), [`sql/2026-08-16_social_milestone_channels.sql`](../../sql/2026-08-16_social_milestone_channels.sql). 2. Set distinct `CRON_SECRET` and `INTERNAL_API_SECRET` values in `.env.test`. For an end-to-end test without 1,000 scans, also set `SOCIAL_MILESTONE_THRESHOLDS=1` (or `1,2`). Do not set this on production. Publishing is immediate after consent by default. Set `SOCIAL_MILESTONE_POST_DELAY_HOURS=24` only if a revocation window is desired. Set `SOCIAL_MILESTONE_MIN_GAP_HOURS=0` on test, otherwise the second milestone waits a full day behind the first one. 3. Deploy using the documented test compose command. `CRON_SECRET` is forwarded to the web service by `docker-compose.yml`. 4. Trigger detection manually: ```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. A QR code that is already past several thresholds on first detection only produces the highest one. ## Queue contract ```bash curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ "https://qrmaster.net/api/internal/social-milestones?channel=instagram" ``` Returns at most one approved post for that channel, claims it, and expects a result report for the returned `id`: ```bash curl -X PATCH -H "Authorization: Bearer $INTERNAL_API_SECRET" \ -H "Content-Type: application/json" \ -d '{"id":"","result":"posted","postUrl":"https://..."}' \ https://qrmaster.net/api/internal/social-milestones ``` First attempts are spaced by `SOCIAL_MILESTONE_MIN_GAP_HOURS` (default 24) per channel, so the brand timeline cannot be flooded when several customers consent on the same day. A blocked poll answers `{"milestone": null, "reason": "spacing", "nextPostAt": "..."}`. `milestone.text` is published **verbatim** — it is the text the customer read before consenting. The image is not part of the payload: the worker downloads it from `/s/m//og`, the same renderer that serves the popup and the link preview. `?format=` takes `landscape` (1200×630, default), `square` (1080×1080) or `portrait` (1080×1350). ## Instagram Publishing runs against the QRMaster.net Business account (see [social-accounts-and-jobs.md](social-accounts-and-jobs.md)) in three steps: `POST /{ig-user-id}/media` → poll `status_code` until `FINISHED` → `POST /{ig-user-id}/media_publish`. Worth knowing before enabling it: - **JPEG only.** The worker flattens the rendered PNG onto white and uploads it through `POST /api/social-assets`; Meta downloads `image_url` itself, so it has to be publicly readable on the verified domain. `SOCIAL_ASSET_ADMIN_KEY` is the existing `TIKTOK_ADMIN_KEY`. - **No clickable links in captions.** The Instagram text therefore ends in hashtags instead of the share URL, and mentions use the customer's Instagram handle, not their X handle. - **50 posts / 24 h**, verifiable via `GET /{ig-user-id}/content_publishing_limit`. - **Reconciliation is caption-based.** Before a repeated attempt the worker compares the caption against the last 25 media items. Two milestones with an identical caption — same QR title, same scan count — would be treated as the same post; the 24 h spacing makes that combination unlikely but not impossible. - Required environment: `SOCIAL_WORKER_CHANNELS=x,instagram`, `INSTAGRAM_USER_ID`, `INSTAGRAM_ACCESS_TOKEN`, plus `SOCIAL_MILESTONE_CHANNELS=x,instagram` on the web service so the dialog asks for it in the first place. Do not configure this worker against `testmodul`. LinkedIn has no approved brand-posting integration in this project. Customers can self-share on all three: X opens a prefilled intent, LinkedIn gets the text copied, Instagram opens the system share sheet on a phone and falls back to an image download. ## Failed posts A reported failure — and a claim the worker never confirmed — counts as one attempt. The queue re-schedules the post itself after 5, then 10 minutes and only parks it in `failed` once three attempts are used up. Before posting again the worker reconciles against the account (share token on X, caption on Instagram), so a failure reported after a successful post cannot duplicate it. The consent dialog opens once per milestone, so a customer who has closed it can no longer see or restart a failed post from there. Settings → Milestone sharing lists every milestone with one line per channel and offers "Try again" (re-queues the approved text unchanged) and "Cancel" (revokes that channel before anything is published).