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>
114 lines
5.6 KiB
Markdown
114 lines
5.6 KiB
Markdown
# 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":"<post-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 `<QRMASTER_API_BASE>/s/m/<shareToken>/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).
|