Worker Jobs
All workers live in apps/media-api/src/workers/ and are registered on startup in apps/media-api/src/index.ts.
Each job type has its own BullMQ queue (named after the job). Workers only process their own queue — they never compete with each other.
Default job options (set in queue.service.ts):
- 3 attempts with exponential backoff (2s initial delay)
- Completed jobs: retain last 100
- Failed jobs: retain last 500
Job names are defined as constants in packages/config/src/jobs.ts. Never hardcode job name strings.
process_selfie
Triggered by POST /v1/selfie/upload-url after the selfie record is created.
Payload { selfieId, userId, key }
What it does
- Checks selfie status — skips if already
approvedorrejected(idempotency) - Calls the Retina clustering service with the selfie image
- If face detection score
>= 0.9: setsselfie.status = 'approved', stores the 512-dim embedding and quality score - If no face or low score: sets
selfie.status = 'rejected'with a reason - On approval: enqueues
cluster_facesfor each project the user is a member of
Idempotency — status check at the top prevents reprocessing.
generate_thumbnail
Triggered by photo upload URL request.
Payload { photoId, key }
What it does
- Downloads the original from R2
- Sharp-resizes to 400px and 800px (fit inside, no enlargement)
- Strips EXIF data (GPS must never reach clients)
- Uploads
thumb-400.jpgandthumb-800.jpgto R2 - Updates
photo.thumbnailKey - Enqueues
cluster_facesfor every project member with an approved selfie
Idempotency — re-running re-uploads the same thumbnails, which is safe.
generate_watermark
Triggered by photo upload URL request.
Payload { photoId, key }
What it does
- Downloads the original from R2
- Builds an SVG text overlay (
Wairo, 45% white opacity, rotated −30°) - Composites the overlay onto the image using Sharp
- Strips EXIF data
- Uploads
watermarked.jpgto R2 - Updates
photo.watermarkedKeyand setsphoto.status = 'ready'
Idempotency — re-running re-uploads the same watermarked file, which is safe.
Container requirement
The media-api Docker image must have fontconfig and fonts-liberation installed for SVG text rendering to work.
cluster_faces
Triggered by
generate_thumbnailcompletion — for each project member with an approved selfieprocess_selfieapproval — for each project the user is enrolled inPOST /v1/qr/join— when a user joins a project
Payload { userId, projectId }
What it does
- Checks the user has an approved selfie — skips gracefully if not
- Fetches all
readyproject photos with athumbnailKey - Generates fresh presigned GET URLs for the selfie and each thumbnail (avoids expiry issues)
- Calls the Retina service in chunks of 50 images, with the selfie included in every chunk as
__selfie__ - Collects the selfie's
cluster_idvalues across all chunks - For each photo face that shares a cluster ID with the selfie, inserts a
photoTagwithreviewStatus = 'confirmed' - New inserts trigger a
notify_user_taggedjob
Idempotency — duplicate (photoId, userId) inserts are silently skipped by the unique index (onConflictDoNothing); notify_user_tagged is only enqueued for genuinely new inserts.
notify_user_tagged
Triggered by cluster_faces when a new confirmed tag is created.
Payload { photoTagId, userId, photoId }
What it does
- Re-fetches the tag — skips if it no longer exists or is not
confirmed(idempotency) - Fetches user name and email
- Fetches project name for email context
- Sends a transactional email via Resend
Idempotency — tag status check prevents sending for non-confirmed tags.
TIP
If RESEND_API_KEY is not configured, this worker skips gracefully with a log message.
notify_user_invited
Triggered by POST /v1/projects/:projectId/invites/bulk — one job per new invite after the DB insert commits.
Payload { inviteId, email, projectId, token }
What it does
- Re-fetches the invite row — skips if it no longer exists or
status ≠ 'pending'(idempotency) - Fetches project name for email context
- Builds the invite link:
${MAIN_WEB_URL}/register?invite=<token> - Sends a transactional email via Resend with the join link
Idempotency — invite status check prevents duplicate sends if the job is retried.
TIP
If RESEND_API_KEY is not configured, this worker skips gracefully with a log message. If MAIN_WEB_URL is not set, the link falls back to a relative path.