Partner Cover Image Self-Serve — Operational Runbook
Ship: Sep 2026. Endpoint PATCH/DELETE /api/partner/v1/restaurant_packages/:id/cover_image
and PATCH /api/partner/v1/package_menus/:id.
Flipper flags
| Flag | Type | Default | Purpose |
|---|---|---|---|
ff_partner_cover_image_selfserve_v1 | global | disabled | Master switch on both image endpoints. When disabled, endpoints return HTTP 501. |
partner_full_package_control | per-restaurant actor | disabled | Opts a single restaurant back into Admin-created package management. Affects change_active and serializer can_toggle / can_edit_other_fields. |
Enable the global feature flag
Flipper.enable(:ff_partner_cover_image_selfserve_v1)
Opt a single restaurant into Admin-created package management
Flipper.enable(:partner_full_package_control, Restaurant.find(123))
Verify state
Flipper.enabled?(:ff_partner_cover_image_selfserve_v1)
Flipper.enabled?(:partner_full_package_control, Restaurant.find(123))
Audit log queries
Every successful image set/remove writes one row to
hh_package_package_image_audit_logs. The same table backs the rate-limit
query, so a single source of truth exists for both billing/audit and
throttling.
Recent edits for a specific package
HhPackage::PackageImageAuditLog
.for_target(restaurant_package)
.order(created_at: :desc)
.limit(20)
.pluck(:action_type, :restaurant_id, :staff_id, :created_at)
All edits in last 24h for a restaurant
HhPackage::PackageImageAuditLog
.where(restaurant_id: restaurant.id)
.where('created_at >= ?', 24.hours.ago)
.count
Identify rate-limited partners
Rate-limit failures emit BUSINESS_LOGGER entries via the controller’s
handle_service_failure path. Filter by message:
HhPackage::PackageImageAuditLog
.where(action_type: nil) # placeholder; use log search instead
The controller’s BUSINESS_LOGGER calls do not currently emit a structured
“rate_limited” log line. Add one if you need alerting — see
app/controllers/api/partner/v1/restaurant_packages_controller.rb#cover_image
as the right place to instrument.
Uploader change (admin impact)
HhPackage::Package::Base previously mounted TncUploader on the
cover_image column; this PR replaces it with HhPackage::CoverImageUploader.
Behaviour changes for Admin:
- Whitelist narrower: PDF no longer accepted. Allowed extensions:
[jpg, jpeg, png, heic, tif, tiff, webp]. - Size limit enforced: 1 byte .. 10 MB (previously unlimited for non-birthday covers).
Existing cover images on disk are not migrated — they keep rendering. New uploads must conform.
Rate-limit rationale
10 changes per record per rolling 24h in production (configurable via
AdminSetting.partner_image_rate_limit_per_24h, default 100 to allow
broader staging testing). The threshold is enforced via
HhPackage::PackageImageAuditLog.recent_changes_for(record, limit: <config>).
Count is on image_target_type, image_target_id, created_at. Reset semantics:
rolling window — once the oldest in-window entry ages out (≥ 24h old),
a new edit becomes available immediately.
Retry-After header value: seconds until the oldest in-window entry exits the
window, clamped to [0, 86400].
Adjusting the limit (no deploy needed)
AdminSetting.partner_image_rate_limit_per_24h = 100 # staging
AdminSetting.partner_image_rate_limit_per_24h = 10 # production
Unblocking a rate-limited partner in staging
Two options:
- Raise the limit (preferred):
AdminSetting.partner_image_rate_limit_per_24h = 100— instantly allows up to 100 changes/24h. - Clear audit rows for the specific record:
HhPackage::PackageImageAuditLog .for_target(HhPackage::RestaurantPackage.find(5155)) .delete_all
Rollback plan
- Disable the global flag:
Flipper.disable(:ff_partner_cover_image_selfserve_v1). - The new endpoints immediately return HTTP 501.
change_activekeeps the new tightened behaviour (no flag gate). If restaurants need to be opted back in, enablepartner_full_package_controlper-restaurant. Or revert the controller action to the previous behaviour by removing theHhPackage::PackageSubmission.package_created_by_partner?guard and theFlipper.enabled?(:partner_full_package_control, …)check.- The audit table can stay — it’s write-only and harmless if unused.