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 Portal — Cover Image Self-Serve API Contract

Base URL: /api/partner/v1
Authentication: Bearer token (JWT via Knock or Doorkeeper OAuth)
Authorization: Authenticated staff must have access to the restaurant (default_restaurant). No creator-ownership check on image edits — Admin-created packages are editable for images by anyone with restaurant access.

Feature flag gate: All three new endpoints (1, 2, 3) require the global Flipper flag ff_partner_cover_image_selfserve_v1 to be enabled. When the flag is off, every endpoint returns 501 Not Implemented with { "success": false, "message": "Feature not enabled" }.

Restaurant ownership rule: Image edits never check package_created_by_partner?. Only restaurant-level multi-tenant isolation applies. This is a deliberate divergence from the existing PATCH /restaurant_packages/:id (whole-package edit) flow.

Common Error Responses

StatusWhenBody shape
400 Bad RequestMissing required file field{ "success": false, "message": "image is required" }
401 UnauthorizedMissing/invalid Bearer token(Knock default)
403 ForbiddenWrong restaurant OR (change_active only) Admin-created package with no flipper override{ "success": false, "message": "<i18n>" }
404 Not Foundpackage_menu belongs to different restaurant{ "success": false, "message": "Not found" }
422 Unprocessable EntityFile validation failure (format, size) OR unsupported package type{ "success": false, "message": "<i18n>", "errors": [...] }
429 Too Many RequestsRate limit exceeded (>10 image changes per record per rolling 24h){ "success": false, "message": "Too many image changes. Try again later.", "retry_after_seconds": <int> } + Retry-After header
501 Not ImplementedFeature flag ff_partner_cover_image_selfserve_v1 is disabled{ "success": false, "message": "Feature not enabled" }

1. Set Package Cover Image

PATCH /restaurant_packages/:id/cover_image

Instant, creator-agnostic. Does not go through PackageSubmission / PackageDraftVersion review. Bypasses the existing PATCH /restaurant_packages/:id review flow entirely. Applies the image directly to the package’s cover_image field on the HhPackage::Package::* subtype.

Path Parameters

ParamTypeRequiredDescription
idintegerYesHhPackage::RestaurantPackage.id (the restaurant-package link, not the package subtype id)

Request Body

Content-Type: multipart/form-data

FieldTypeRequiredDescription
cover_imagefileYesImage file. Max 10 MB. Allowed formats: jpg, jpeg, png, heic, tif, tiff, webp. Any other format → 422.

Response 200 OK (success — set)

{
  "success": true,
  "message": "Cover image updated",
  "data": {
    /* full Api::Partner::RestaurantPackageSerializer payload for this restaurant_package */
    "id": "123",
    "type": "restaurant_package",
    "attributes": {
      "package_id": 456,
      "cover_image": "https://cdn.example.com/uploads/hh_package/package/ayce/cover_image/456/foo.jpg",
      "created_by_admin": false,
      "can_toggle": true,
      "can_edit_other_fields": true,
      /* ... all other existing serializer fields ... */
    }
  }
}

Response 422 Unprocessable Entity (file too large)

{
  "success": false,
  "message": "File too large (Max 10MB). Please upload a smaller image.",
  "errors": ["cover_image file size must be between 1 byte and 10 MB"]
}

Response 422 Unprocessable Entity (unsupported format, e.g. PDF)

{
  "success": false,
  "message": "Unsupported format. Please upload a supported image (JPEG, JPG, PNG, HEIC, TIFF, WEBP).",
  "errors": ["cover_image has an invalid content type (application/pdf)"]
}

Response 403 Forbidden (wrong restaurant)

{
  "success": false,
  "message": "Restaurant package missing"
}

Same i18n key as PATCH /restaurant_packages/:id — the controller’s set_restaurant_package raises this for any cross-restaurant access.

Response 429 Too Many Requests

HTTP/1.1 429 Too Many Requests
Retry-After: 4523
Content-Type: application/json

{
  "success": false,
  "message": "Too many image changes. Try again later.",
  "retry_after_seconds": 4523
}

retry_after_seconds = seconds until the earliest change in the rolling 24h window expires (i.e. when the partner will have at least one slot free again).


2. Remove Package Cover Image

DELETE /restaurant_packages/:id/cover_image

Instant. Clears cover_image to nil. After removal, the package falls back to its default cover placeholder (driven by existing cover_image_url logic on the package subtype). Audit entry recorded with action_type: remove.

No request body. All other headers/cookies same as Endpoint 1.

Response 200 OK (success — remove)

{
  "success": true,
  "message": "Cover image removed",
  "data": {
    "package_id": 123,
    "cover_image_url": null
  }
}

Note: response shape is slim for DELETE (unlike PATCH which returns the full serializer). FE should clear the local row’s image and re-render the “Add Cover Image” action label.

Error responses

Identical to Endpoint 1 (404, 403, 429, 501). No 422 possible — no file to validate.


3. Set Menu Item Image

PATCH /package_menus/:id

Instant, single-record, single-field. Strict — any field other than image is rejected (parameter filtering drops it silently; field validation rejects missing image).

This is the action that was previously missing on PackageMenusController — the route existed but the controller action did not. Now implemented.

Path Parameters

ParamTypeRequiredDescription
idintegerYesHhPackage::PackageMenu.id

Request Body

Content-Type: multipart/form-data

{
  "package_menu": {
    "image": "<file>"
  }
}
FieldTypeRequiredDescription
package_menu[image]fileYesImage file. Max 10 MB. Allowed formats: jpg, jpeg, png, pdf, heic. Note: format list differs from cover image — allows PDF, does NOT allow tif/tiff/webp.

The body MUST be wrapped in package_menu to match the existing pattern used by PATCH /restaurant_packages/:id (which wraps in package).

Response 200 OK (success)

{
  "success": true,
  "message": "Menu image updated",
  "data": {
    /* Api::Partner::PackageMenuSerializer payload */
    "id": "789",
    "type": "package_menu",
    "attributes": {
      /* ... existing serializer fields ... */
      "image_url": "https://cdn.example.com/.../menu.jpg",
      "image_menu_url": "https://cdn.example.com/.../menu.jpg"
    }
  }
}

Response 422 Unprocessable Entity (file validation)

{
  "success": false,
  "message": "File too large (Max 10MB). Please upload a smaller file."
}
{
  "success": false,
  "message": "Unsupported format. Please upload a supported file (JPG, JPEG, PNG, PDF, HEIC)."
}

Response 422 Unprocessable Entity (package menu_type: imenupro)

{
  "success": false,
  "message": "Menu image not supported for this package type"
}

Returned when the menu item’s parent package uses menu_type: imenupro and has no package_menus content to attach a photo to. Per PRD, this is a content-availability state, not a permission block.

Response 404 Not Found (wrong restaurant)

{
  "success": false,
  "message": "Not found"
}

Response 400 Bad Request (missing image field)

{
  "success": false,
  "message": "image is required"
}

Response 429 Too Many Requests

Same shape as Endpoint 1.


4. change_active (existing endpoint — behavior change)

POST /restaurant_packages/change_active (existing route — same shape)

Behavior change

Previously: no ownership check. Any partner with restaurant access could toggle any package’s active flag, including Admin-created packages.

After this feature: a package_created_by_partner? guard is added. Toggle is allowed only if one of:

  1. The package was created by the partner (existing HhPackage::PackageSubmission.package_created_by_partner? returns true), OR
  2. The per-restaurant Flipper flag :partner_full_package_control is enabled for the partner’s default_restaurant.

Response 200 OK (toggle allowed)

{
  "success": true,
  "message": "Change success"
}

Response 403 Forbidden (toggle blocked — admin-created, no flipper override)

{
  "success": false,
  "message": "This package was created by Admin and cannot be toggled from the partner portal."
}

i18n key: partner.restaurant_packages.change_active_admin_blocked. Already present in partner.{en,th,cn}.yml.


5. New fields on Api::Partner::RestaurantPackageSerializer

Three new attributes added to the existing serializer. Returned in every GET /restaurant_packages, GET /restaurant_packages/:id, and inside PATCH /restaurant_packages/:id / PATCH /restaurant_packages/:id/cover_image responses.

AttributeTypeMeaning
created_by_adminbooleantrue iff !HhPackage::PackageSubmission.package_created_by_partner?(package). Informational only — drives the “Created by Admin” badge on package cards.
can_togglebooleantrue iff partner-created OR :partner_full_package_control Flipper enabled for the restaurant. Drives visibility of the on/off toggle control.
can_edit_other_fieldsbooleanSame formula as can_toggle. Drives visibility of the “Edit Package” / edit-other-fields action.

Computing rules (server-side — do NOT re-derive client-side)

created_by_admin     = !package_created_by_partner?(package)
can_toggle           = package_created_by_partner?(package)
                    || Flipper.enabled?(:partner_full_package_control, restaurant)
can_edit_other_fields = same as can_toggle

Why server-side: package_created_by_partner? depends on HhPackage::PackageSubmission data the client doesn’t have. The Flipper flag is per-restaurant, also server-side. Trust the server booleans — do not derive these client-side.

Independence of fields

  • The created_by_admin badge stays visible even when can_toggle is true via the Flipper override.
  • Image-edit controls (cover image, menu image) are always rendered and enabled regardless of created_by_admin, can_toggle, or can_edit_other_fields.

6. Feature flag summary

FlagTypeDefaultWhat it gates
ff_partner_cover_image_selfserve_v1Global (no actor)offAll three new image endpoints (1, 2, 3). When off → 501 Not Implemented.
:partner_full_package_controlPer-restaurant actor (Flipper actor = Restaurant instance)offchange_active permission for Admin-created packages + can_toggle / can_edit_other_fields on serializer. Does NOT affect image-edit endpoints (which never check this flag).

7. Rate-limit semantics

10 image changes per HhPackage::RestaurantPackage (for cover) or per HhPackage::PackageMenu (for menu item) per rolling 24h window.

The window slides — every change starts a fresh 24h clock from its created_at. The 11th change in any 24h-since-first-change window returns 429 Too Many Requests with Retry-After: <seconds until window slides>.

There is no cross-record rate limit (changing cover on package A does not consume slots on package B).

The limit applies independently to set and remove operations on the same record — 5 sets + 5 removes + 1 more = 11th rejected.


8. Cross-references