Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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).

OwnerBackend (hh-server)
PRDPartner_Cover_Image_SelfServe_20260909000000.md
GlossaryGlossary in PM PRD
StatusRough 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/:id action (image-field only)
  • Flow 4 — Ownership check on change_active endpoint + :partner_full_package_control Flipper flag
  • Flow 5 — created_by_admin on restaurant package serializer; can_toggle / can_edit_other_fields booleans
  • 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.

ModelFieldNotes
HhPackage::Package::* (all subtypes)cover_imageExists today; no dedicated self-serve path
HhPackage::PackageMenuimageExists 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).

MethodPATCH / PUT
PathTBD — a narrow cover-image-only endpoint
AuthPartner auth, default_restaurant
Request body (set/change){ cover_image: <file> }
Request body (remove){ cover_image: null } or dedicated DELETE sub-action — confirm pattern.
ValidationsFile ≤ 10MB, format in {jpeg, jpg, png, heic, tif, tiff, webp} (reuse Restaurants::Picture constants)
ChecksRestaurant 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 casesWrong 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.

MethodPATCH / PUT
Path/api/partner/v1/package_menus/:id
AuthPartner restaurant, resolves restaurant ownership through default_restaurant
Request body{ image: <file> } — should reject any other field
ValidationFile ≤ 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.
ChecksRestaurant ownership (resolve through section’s package if package_id is nil); no creator check, no menu_type check
Post-applyimage 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 casesWrong 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

ChangeAdd 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)
Exposecan_toggle, can_edit_other_fields booleans on serializer — partner app must NOT re-derive client-side

Flow 5: Serializer

Expose on Api::Partner::RestaurantPackageSerializer:

FieldComputed as
created_by_admin!package_created_by_partner?(package) — purely informational
can_toggle`package_created_by_partner?(package)
can_edit_other_fieldssame 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):

  1. Restaurant ownership — does this package/menu item belong to a restaurant this partner manages? Applies to everything, non-negotiable, multi-tenant isolation.
  2. 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).
  3. Content availability — does this package actually have package_menus records to edit? Relevant only to Flow 2. Not a permission, just whether there’s anything there. imenupro-only packages typically have nothing.
  4. 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.

ScenarioBehavior
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_fields correctness with and without flag
  • change_active behavior 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

  1. Cover image remove: cover_image: null or dedicated DELETE? Confirm pattern with FE. After clear, what does image_cover_url return? (Same fallback as empty — AdminSetting.default_hh_menu_image or placeholder?) Verify TouchWorker/search-sync fires on removal too.
  2. 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.
  3. Pending-draft clobber risk: if a partner has a pending whole-package edit_request and 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.
  4. Concurrent edits (partner + Admin) → last-write-wins for v1; log both in audit trail.
  5. Rate limit implementation: per-package vs per-menu-item? Rolling 24h window via Redis?
  6. Restaurants::Picture uploader constants vs dedicated uploader class for cover image — confirm with Eng.
  7. Menu item with nil package_id (nested under Menu Section) — confirm the resolution path through the section’s package.
  8. Should the audit record reuse PackageSubmission with approval_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/:id action (image-field only)
  • Flow 4: change_active ownership check + :partner_full_package_control Flipper
  • Flow 5: created_by_admin + can_toggle + can_edit_other_fields on 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, field cover_image) — set; missing file is treated as remove and returns 200
    • DELETE /api/partner/v1/restaurant_packages/:id/cover_image — remove
    • PATCH /api/partner/v1/package_menus/:id (body: { "package_menu": { "image": <file> } }) — image only; package must not be imenupro (returns 422)
  • Audit table: hh_package_package_image_audit_logs (migration 20260914094301_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-After header (seconds until oldest entry in window expires).
  • Flipper flags:
    • ff_partner_cover_image_selfserve_v1 — global master switch on both new image endpoints
    • partner_full_package_control — per-restaurant actor, gates change_active + serializer booleans for Admin-created packages
  • Serializer additions: created_by_admin, can_toggle, can_edit_other_fields on Api::Partner::RestaurantPackageSerializer.
  • Uploader: HhPackage::CoverImageUploader < ApplicationUploader, whitelist [jpg, jpeg, png, heic, tif, tiff, webp], size_range 1..10MB, mounted on HhPackage::Package::Base via the CommonRelations concern (replaces TncUploader for cover_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_control is disabled for the restaurant.
  • i18n: new key partner.restaurant_packages.change_active_admin_blocked added to partner.{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.md step 18.