Partner Cover Image Self-Serve — Backend Spec (Rough Draft)
Companion to
Partner_Cover_Image_SelfServe_20260909000000.md(the product PRD). This document captures the backend side of the feature in hh-server (api/partner/v1).
| Owner | Backend (hh-server) |
| PRD | Partner_Cover_Image_SelfServe_20260909000000.md |
| Glossary | Glossary in PM PRD |
| Status | Rough draft — to be grilled by BE team |
1. Scope
In scope (this spec)
- Flow 1 — New cover-image endpoint (instant, creator-agnostic, bypasses review)
- Flow 2 — Implement missing
PATCH/PUT /api/partner/v1/package_menus/:idaction (image-field only) - Flow 4 — Ownership check on
change_activeendpoint +:partner_full_package_controlFlipper flag - Flow 5 —
created_by_adminon restaurant package serializer;can_toggle/can_edit_other_fieldsbooleans - Audit trail for instant image edits
- Rate limit (10 per 24h per package/menu item)
- Global Flipper flag
ff_partner_cover_image_selfserve_v1
Out of scope
- Diner-facing changes
- The existing whole-package edit flow (Flow 3) — stays unchanged, though the destroy-by-omission risk is flagged for a defensive fix
- Analytics events (BE fires them via existing analytics pattern)
2. Data Model
No schema change required — image fields already exist on the records.
| Model | Field | Notes |
|---|---|---|
HhPackage::Package::* (all subtypes) | cover_image | Exists today; no dedicated self-serve path |
HhPackage::PackageMenu | image | Exists today; only editable via whole-package package_menus_attributes array |
3. API Contracts
Flow 1: Cover Image Update (NEW)
Not yet named — BE team to define exact route. Suggested: PATCH/PUT /api/partner/v1/restaurant_packages/:id/cover_image or reuse restaurant_packages/:id with a narrow scope. Must not route through PartnerPackages::UpdatePackageService or touch PackageSubmission/PackageDraftVersion. Must also support clearing (remove).
| Method | PATCH / PUT |
| Path | TBD — a narrow cover-image-only endpoint |
| Auth | Partner auth, default_restaurant |
| Request body (set/change) | { cover_image: <file> } |
| Request body (remove) | { cover_image: null } or dedicated DELETE sub-action — confirm pattern. |
| Validations | File ≤ 10MB, format in {jpeg, jpg, png, heic, tif, tiff, webp} (reuse Restaurants::Picture constants) |
| Checks | Restaurant ownership only (no creator check, no review). Status-based gate (Pending Review / Rejected) is UI-only for v1 — design hides the action but the endpoint should remain permissive for now; flag to confirm with PM/Eng. |
| Post-conditions (set/change) | cover_image updated; TouchWorker/search-sync fires; audit entry recorded. |
| Post-conditions (remove) | cover_image cleared (set to nil); TouchWorker/search-sync fires; audit entry recorded; package’s image_cover_url falls back to placeholder/default. |
| Error cases | Wrong restaurant → reject; upload fails → 422 with error; unsupported format / too large → validation error |
Flow 2: Menu Item Image Update (EXISTING ROUTE, MISSING ACTION)
Route already exists: PATCH/PUT /api/partner/v1/package_menus/:id — controller action doesn’t. Implement it, scoped strictly to the image field.
| Method | PATCH / PUT |
| Path | /api/partner/v1/package_menus/:id |
| Auth | Partner restaurant, resolves restaurant ownership through default_restaurant |
| Request body | { image: <file> } — should reject any other field |
| Validation | File ≤ 10MB, format in {jpg, jpeg, png, pdf, heic} (matches HhPackage::PackageMenuUploader — differs from restaurant uploader: allows PDF, no tif/tiff/webp). Don’t reuse Restaurants::Picture copy verbatim. |
| Checks | Restaurant ownership (resolve through section’s package if package_id is nil); no creator check, no menu_type check |
| Post-apply | image updated; image_menu_url reflects new photo (or falls back to AdminSetting.default_hh_menu_image); MenuSectionGeneratorWorker fan-out fires (already triggered by after_update) |
| Error cases | Wrong restaurant → reject; soft-deleted item → edit unavailable; no package_menus content (imenupro-only) → empty state, not a block |
Flow 3: (No change — existing whole-package flow)
PATCH /api/partner/v1/restaurant_packages/:id → UpdatePackageService. Unchanged.
Flow 4: Toggle + Flipper Override
| Change | Add HhPackage::PackageSubmission.package_created_by_partner? guard to POST /api/partner/v1/restaurant_packages/change_active. Currently has NO ownership check. |
| New flag | :partner_full_package_control — restaurant-actor-scoped Flipper flag (pattern: Flipper.enabled?(:owner_dashboard_v2, current_owner), Flipper.enabled?(:kiosque, reservation.restaurant)) |
| Effective permission | `package_created_by_partner?(package) |
| Expose | can_toggle, can_edit_other_fields booleans on serializer — partner app must NOT re-derive client-side |
Flow 5: Serializer
Expose on Api::Partner::RestaurantPackageSerializer:
| Field | Computed as |
|---|---|
created_by_admin | !package_created_by_partner?(package) — purely informational |
can_toggle | `package_created_by_partner?(package) |
can_edit_other_fields | same as can_toggle |
4. Business Logic
Two independent permission gates, one content-availability check, and an optional per-restaurant override (four separate things, don’t conflate them):
- Restaurant ownership — does this package/menu item belong to a restaurant this partner manages? Applies to everything, non-negotiable, multi-tenant isolation.
- Creator ownership (
package_created_by_partner?) — applies ONLY to the on/off toggle and non-image field edits (Flow 4). Does NOT apply to image edits (Flows 1-2). - Content availability — does this package actually have
package_menusrecords to edit? Relevant only to Flow 2. Not a permission, just whether there’s anything there.imenupro-only packages typically have nothing. - Per-restaurant Flipper override (
:partner_full_package_control) — bypasses gate #2 specifically, for a whole restaurant, for testing. Never bypasses gate #1. No effect on image/content gates.
Code structure (proposed)
app/controllers/api/partner/v1/
├── restaurant_packages_controller.rb # existing — add cover_image action
└── package_menus_controller.rb # existing — implement update action (route exists)
app/services/partner_packages/
├── (existing) CreatePackageService, UpdatePackageService
app/models/hh_package/
├── (existing) Package::*, PackageMenu
├── (new concept) audit — lightweight audit record per instant image edit
Audit trail without review
Because image changes bypass admin review entirely, log them — a lightweight audit record, or a PackageSubmission-style row with approval_status: approved, mirroring the pattern CreatePackageService already uses — so Admin/Support can see who changed an image and when.
Rate/abuse guardrail
Soft per-package/per-menu-item rate limit: 10 image changes per rolling 24h.
| Scenario | Behavior |
|---|---|
| Normal partner (fixing a photo a couple times) | 2-3 changes — never hits limit |
| Abuse/bug (client retries failed upload in a loop) | 50+ attempts/min — blocked |
7. Testing Strategy (Rough)
- Endpoint tests (RSpec): Flow 1 + Flow 2 happy path, validation errors (format, size), restaurant-ownership rejection
change_active: ownership check blocks Admin-created packages; Flipper override lifts it- Serializer:
created_by_admin,can_toggle,can_edit_other_fieldscorrectness with and without flag change_activebehavior change: tests confirming previously-toggleable Admin-created packages now fail (permission tightening)- Route: existing package_menus route + new action wired correctly
- Contract tests: restaurant package serializer shape
This section is rough — BE team to refine with full test matrix + coverage targets.
8. Edge Cases / Open Questions for BE Team
- Cover image remove:
cover_image: nullor dedicated DELETE? Confirm pattern with FE. After clear, what doesimage_cover_urlreturn? (Same fallback as empty —AdminSetting.default_hh_menu_imageor placeholder?) VerifyTouchWorker/search-sync fires on removal too. - Status-based gate (UI vs endpoint): design hides the image action on Pending Review / Rejected rows. Confirm whether the endpoint should also enforce this status check, or if the UI-only gate is sufficient for v1.
- Pending-draft clobber risk: if a partner has a pending whole-package
edit_requestand then makes an instant cover image edit, does the pending draft’s stale snapshot overwrite the newer image when approved? Likely need a conditional skip on the image field during draft application. Confirm behavior. - Concurrent edits (partner + Admin) → last-write-wins for v1; log both in audit trail.
- Rate limit implementation: per-package vs per-menu-item? Rolling 24h window via Redis?
Restaurants::Pictureuploader constants vs dedicated uploader class for cover image — confirm with Eng.- Menu item with nil package_id (nested under Menu Section) — confirm the resolution path through the section’s package.
- Should the audit record reuse
PackageSubmissionwithapproval_status: approved, or a new lightweight table?
8. Deliverables Checklist
- Flow 1: cover-image endpoint (instant, creator-agnostic, audit trail, supports set + remove)
- Flow 2:
PATCH /api/partner/v1/package_menus/:idaction (image-field only) - Flow 4:
change_activeownership check +:partner_full_package_controlFlipper - Flow 5:
created_by_admin+can_toggle+can_edit_other_fieldson serializer - Audit trail records (for both set and remove)
- Rate limit
- Global Flipper
ff_partner_cover_image_selfserve_v1 - Tests for all of the above
9. Implementation Notes (Sep 2026)
- Endpoint shapes (final):
PATCH /api/partner/v1/restaurant_packages/:id/cover_image(multipart, fieldcover_image) — set; missing file is treated as remove and returns 200DELETE /api/partner/v1/restaurant_packages/:id/cover_image— removePATCH /api/partner/v1/package_menus/:id(body:{ "package_menu": { "image": <file> } }) — image only; package must not beimenupro(returns 422)
- Audit table:
hh_package_package_image_audit_logs(migration20260914094301_create_hh_package_package_image_audit_logs.rb). - Rate limit: 10 image changes per record per rolling 24h, enforced via
HhPackage::PackageImageAuditLog.recent_changes_for. Returns HTTP 429 +Retry-Afterheader (seconds until oldest entry in window expires). - Flipper flags:
ff_partner_cover_image_selfserve_v1— global master switch on both new image endpointspartner_full_package_control— per-restaurant actor, gateschange_active+ serializer booleans for Admin-created packages
- Serializer additions:
created_by_admin,can_toggle,can_edit_other_fieldsonApi::Partner::RestaurantPackageSerializer. - Uploader:
HhPackage::CoverImageUploader < ApplicationUploader, whitelist[jpg, jpeg, png, heic, tif, tiff, webp], size_range1..10MB, mounted onHhPackage::Package::Basevia theCommonRelationsconcern (replacesTncUploaderforcover_image— Admin uploads of PDF covers will now fail). - Service classes:
PartnerPackages::CoverImageService(set+remove),PartnerPackages::PackageMenuImageService. - change_active tightening: returns 403 +
I18n.t('partner.restaurant_packages.change_active_admin_blocked')when package is admin-created AND:partner_full_package_controlis disabled for the restaurant. - i18n: new key
partner.restaurant_packages.change_active_admin_blockedadded topartner.{en,th,cn}.yml. - Analytics: no Mixpanel/Amplitude wrapper exists in the app today — analytics events (
partner_cover_image_updated,partner_cover_image_removed,partner_menu_image_updated) are no-op stubs with a TODO to wire once an SDK is introduced. FE owns the client-side events for now. - Manual smoke block: see
docs/superpowers/plans/2026-09-14-partner-cover-image-selfserve.mdstep 18.