Skip to content

Content Studio 승인 variant를 Instagram feed로 관리자 발행할 수 있게 만들기 #593

@thxforall

Description

@thxforall

Context

Content Studio는 decoded post에서 마케팅 draft를 만들고 admin이 approve/reject할 수 있다. 하지만 현재 Instagram publish는 manual publish only라 content marketer가 copy와 asset을 수동으로 옮겨 발행해야 한다.

이번 slice는 일반 유저 기능이 아니다. 관리자/content marketer가 승인된 Instagram feed variant 1개를 Meta API로 발행하고, 성공/실패 상태를 Content Studio에서 확인하는 것이 목표다.

Current State

검증일: 2026-05-28

영역 현재 상태 근거
Admin UI variant approve/reject 버튼 있음 packages/web/app/admin/content-studio/page.tsx:340
Publish UI manual publish only 상태 packages/web/app/admin/content-studio/page.tsx:520
Variant schema instagram_feed 없음 packages/web/lib/content-studio/schemas.ts:4
Asset schema instagram_feed asset target 있음 packages/web/lib/content-studio/schemas.ts:110
Storage generated image는 public Supabase Storage URL로 업로드 packages/web/lib/content-studio/assets/openai-client.ts:173
API pattern Next route가 admin auth 후 Rust API proxy packages/web/app/api/v1/content/variants/[id]/approve/route.ts:50
Rust API approve/reject route만 있음 packages/api-server/src/domains/content_studio/handlers.rs:107
DB content_packets, content_variants만 있음 supabase/migrations/20260514160000_content_studio_tables.sql:7

Meta publishing constraints:

  • Instagram publishing requires publicly reachable media.
  • MVP uses Instagram API with Instagram Login against graph.instagram.com, not the Facebook Page token publishing path.
  • Publish flow is: create media container via /media, poll /?fields=status_code, then publish via /media_publish with creation_id.
  • Status values include EXPIRED, ERROR, FINISHED, IN_PROGRESS, PUBLISHED.
  • Instagram image publishing supports JPEG only.
  • Source: Meta Postman public collection, Publish Content: https://www.postman.com/meta/instagram/folder/23987686-bc459e67-42aa-4ea0-ad25-e5a6e42c3a83

Resolved design decisions from $grill-with-docs:

  • Use Instagram API with Instagram Login, graph.instagram.com, and one internal decoded Instagram Publish Account.
  • Do not accept arbitrary media URLs. Publishing uses a Content Studio-owned publishable asset from the content-studio-assets bucket.
  • Do not expose accountId in the MVP request. Rust resolves and lazy-upserts the single publish account from env.
  • Treat instagram_feed.body as the Instagram caption body, not slide copy.
  • Make instagram_feed publishable assets JPEG during asset generation; publish route revalidates as a final defense.
  • Keep publish synchronous and bounded for MVP. A completed request leaves the job in published or failed, not indefinitely processing.
  • Treat supabase/migrations/*.sql as the primary DB SOT; SeaORM migration is Rust/prod parity.
  • Return latest publish jobs from packet detail as a publishJobsByVariantId map. Do not mix review status and publish status on ContentVariant.
  • Keep content_publish_accounts durable even for the single env-backed MVP account. Publish jobs FK to the account row; token_ref names the env secret, not the raw token.
  • Use publish statuses queued, processing, published, and failed; do not include uploading because the publish flow uses an existing public asset URL.
  • imageAssetUrl means a Content Studio Publishable Asset URL, not an arbitrary external media URL.
  • Create a Publish Job only after the Instagram Publish Account and approved Instagram Feed Variant are resolved. Auth/config/account-disabled/request-shape/variant-ineligible failures return API errors without a job row; asset preflight and Meta failures persist failed jobs.
  • Rust composes the final Caption at publish time from variant.body, optional variant.disclosure, and normalized hashtags, then stores the exact string sent to Meta in content_publish_jobs.caption.
  • In content-studio: add Instagram publish DB foundation (#593) #596, limit type work to DB row/entity foundations and instagram_feed format acceptance. Publish DTOs, publishJobsByVariantId, Next schemas, UI state, and generation behavior belong to later tasks.
  • Use admin-only RLS policies matching content_packets / content_variants; product writes still go through admin routes/services, not direct browser writes.
  • Keep publish job audit rows durable: variant_id and account_id use ON DELETE RESTRICT; requested_by uses ON DELETE SET NULL; disabling a publish account is done with status = 'disabled', not deletion.
  • Keep content_publish_accounts.created_by nullable because the MVP env-backed account may be lazy-upserted by Rust with no human creator. content_publish_jobs.requested_by records the admin who requested each publish attempt.
  • Store both platform and format on publish jobs: platform is the external channel, format is the Content Variant publish format.
  • For content-studio: add Instagram publish DB foundation (#593) #596 verification, document the actual DB check run. Prefer supabase db reset; if unavailable, use direct migration apply against LOCAL_DATABASE_URL, plus TypeScript/Rust compile or focused tests.
  • Update content_variants.format by confirming the current CHECK constraint name, then using DROP CONSTRAINT IF EXISTS <current_name> plus ADD CONSTRAINT with the same name.
  • Store canonicalized media_url after validation: parse as URL, require https, require the Content Studio asset public URL, remove fragments, and preserve path/query.
  • Keep error_json as unconstrained JSONB in DB. Rust should persist a common shape with code, message, stage, retryable, and details; stages include asset_preflight, meta_create_container, meta_poll_container, meta_publish, and internal.
  • MVP blocks duplicate publishing after a latest published job for the variant. Retry creates a new job only after failed jobs or when no prior job exists; force republish is out of scope.
  • Duplicate publish attempts for an already-published latest job return 409 Conflict with code already_published and create no job.
  • Latest Publish Job ordering is created_at DESC, id DESC; publishJobsByVariantId and duplicate-publish checks use the same ordering.

Proposed Change

  1. Add instagram_feed as a first-class Content Studio variant format.
  2. Add publish account/job persistence.
  3. Use server env secrets for the first internal Instagram account.
  4. Add a Rust API publish service that executes the Meta container flow.
  5. Add a Next route that proxies publish requests to Rust API.
  6. Show publish action/status in Content Studio UI for approved instagram_feed variants.
  7. Store every publish attempt as a new content_publish_jobs row.

Implementation Details

Data Model

Add a new Supabase SQL migration as the primary schema change, plus a SeaORM migration for Rust/prod parity.

Files:

  • supabase/migrations/20260528160000_content_publish_jobs.sql
  • packages/api-server/migration/src/m20260528_000001_create_content_publish_jobs.rs
  • packages/api-server/migration/src/lib.rs

Add instagram_feed to content_variants.format check constraint with an idempotent forward migration (DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT using the current constraint name).

Add tables:

CREATE TABLE IF NOT EXISTS public.content_publish_accounts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  provider TEXT NOT NULL CHECK (provider IN ('instagram')),
  account_label TEXT NOT NULL,
  ig_user_id TEXT NOT NULL,
  token_ref TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
  permissions JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (provider, ig_user_id)
);

CREATE TABLE IF NOT EXISTS public.content_publish_jobs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  variant_id UUID NOT NULL REFERENCES public.content_variants(id) ON DELETE RESTRICT,
  account_id UUID NOT NULL REFERENCES public.content_publish_accounts(id) ON DELETE RESTRICT,
  platform TEXT NOT NULL CHECK (platform IN ('instagram')),
  format TEXT NOT NULL CHECK (format IN ('instagram_feed')),
  status TEXT NOT NULL CHECK (
    status IN ('queued', 'processing', 'published', 'failed')
  ),
  media_url TEXT NOT NULL,
  caption TEXT NOT NULL,
  container_id TEXT,
  published_media_id TEXT,
  error_json JSONB,
  requested_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ
);

Indexes:

CREATE INDEX IF NOT EXISTS content_publish_jobs_variant_created_idx
  ON public.content_publish_jobs(variant_id, created_at DESC, id DESC);

CREATE INDEX IF NOT EXISTS content_publish_jobs_status_created_idx
  ON public.content_publish_jobs(status, created_at DESC);

RLS:

  • Enable RLS on both new tables.
  • Add admin-only policies matching content_packets / content_variants.

Config

Use env secrets only for MVP:

  • INSTAGRAM_PUBLISH_IG_USER_ID
  • INSTAGRAM_PUBLISH_ACCESS_TOKEN
  • INSTAGRAM_GRAPH_API_VERSION
  • Optional: INSTAGRAM_PUBLISH_ACCOUNT_LABEL

Rust resolves the single internal Instagram Publish Account from env and lazy-upserts content_publish_accounts on first publish. DB stores token_ref = 'INSTAGRAM_PUBLISH_ACCESS_TOKEN', not the raw token. If env is missing, return 503 publish not configured. If the matching DB row is disabled, reject publishing even when env is present.

API Shape

Add Rust endpoint:

POST /api/v1/content/variants/{id}/publish/instagram

Request:

{
  "imageAssetUrl": "https://..."
}

Response:

{
  "job": {
    "id": "uuid",
    "variantId": "uuid",
    "status": "published",
    "mediaUrl": "https://...",
    "containerId": "178...",
    "publishedMediaId": "180...",
    "errorJson": null,
    "createdAt": "2026-05-28T00:00:00Z",
    "publishedAt": "2026-05-28T00:00:05Z"
  }
}

Packet detail response:

{
  "packet": {},
  "variants": [],
  "publishJobsByVariantId": {
    "variant_uuid": {
      "id": "job_uuid",
      "status": "published",
      "mediaUrl": "https://...",
      "publishedMediaId": "180...",
      "errorJson": null,
      "createdAt": "2026-05-28T00:00:00Z",
      "publishedAt": "2026-05-28T00:00:05Z"
    }
  }
}

Validation:

  • Admin auth required.
  • Variant must exist.
  • Variant must have status = 'approved'.
  • Variant must have channel = 'instagram'.
  • Variant must have format = 'instagram_feed'.
  • imageAssetUrl must be https://.
  • imageAssetUrl must not be a data: URL.
  • imageAssetUrl must be a public URL from the content-studio-assets bucket.
  • Preflight must fetch the asset URL and require:
    • HTTP 2xx
    • Content-Type starts with image/jpeg
    • no auth required
  • If preflight fails after account and variant eligibility are resolved, create a failed job with error_json and return 400/422.
  • Request must not include accountId; multi-account selection is out of scope.
  • If the latest Publish Job for the variant is published, reject duplicate publishing with 409 Conflict before creating a new job.

Meta flow:

  1. Insert job as queued.
  2. Build the final caption from variant.body, optional disclosure, and normalized hashtags; persist the exact sent caption on the job.
  3. Save container_id, set status processing.
  4. Poll container status up to 5 times.
  5. If status becomes FINISHED, call /media_publish.
  6. Save published_media_id, status published, published_at.
  7. If Meta returns ERROR, EXPIRED, timeout, or non-2xx, save status failed and error_json.
  8. MVP is synchronous and bounded: after the request completes, the persisted job should be published or failed. Background recovery for stuck IN_PROGRESS containers is out of scope.

Next Route

Add:

packages/web/app/api/v1/content/variants/[id]/publish/instagram/route.ts

Behavior:

  • Require admin session.
  • Validate request with Zod.
  • If API_BASE_URL is missing, return 503 with a clear message: Instagram publishing requires API_BASE_URL / Rust API.
  • Do not execute Meta calls in Next fallback.
  • Proxy to Rust API with bearer token.

UI

Update:

packages/web/app/admin/content-studio/page.tsx

Add publish controls only when:

  • variant status is approved
  • variant channel is instagram
  • variant format is instagram_feed
  • selected instagram_feed publishable asset exists and is public JPEG

Display:

  • Publish button
  • latest job status: published, failed; request-in-flight state is client loading UI
  • published_media_id when published
  • failure message from error_json when failed
  • latest persisted job read from publishJobsByVariantId[variant.id]

Replace global manual publish only with a state-aware label:

  • No publish config: publish not configured
  • Configured: Instagram feed publish enabled
  • Existing jobs: show latest job summary near the variant

Asset Handling

Because Meta image publishing requires JPEG:

  • Asset generation must convert instagram_feed image assets to JPEG before upload.
  • The uploaded instagram_feed publishable asset must use Content-Type: image/jpeg and .jpg extension.
  • The publish route must reject non-JPEG media with a clear error as a final defense.
  • PNG or data: fallback URLs must never be considered publishable assets.

Retry Semantics

Every retry after a failed publish creates a new content_publish_jobs row.

Do not mutate an old failed job into success. Old container_id, error_json, and media_url remain auditable. If the latest job is already published, MVP blocks duplicate publishing and does not create a new job.

Implementation Split

Implement this as separate task cards rather than one broad patch:

  1. DB + types foundation
    • Supabase SQL migration is primary.
    • SeaORM migration/entities are Rust/prod parity.
    • Add/update only DB row/entity types needed by web and Rust: Supabase table types, instagram_feed format acceptance, SeaORM entities, and entity exports.
    • Do not add publish DTOs, publishJobsByVariantId, Next route schemas, UI state, generation defaults/templates/prompts, UI behavior, or Meta calls in this task.
  2. Rust publish service
    • Add env config for Instagram publishing.
    • Lazy-upsert the single internal publish account.
    • Validate approved instagram_feed variants.
    • Allowlist and preflight content-studio-assets JPEG assets.
    • Add Meta client trait/mocks.
    • Persist publish jobs and return publishJobsByVariantId from packet detail.
  3. Next proxy route + web schemas
    • Add Zod request/response schemas.
    • Require admin auth.
    • Return 503 when API_BASE_URL is missing.
    • Proxy to Rust only; do not call Meta or Supabase fallback for publishing.
  4. Instagram feed publishable JPEG asset
    • Generate instagram_feed publishable assets as JPEG.
    • Upload with Content-Type: image/jpeg and .jpg extension.
    • Expose a testable eligibility path for later UI work.
  5. Content Studio publish UI/status controls
    • Show publish button only for eligible variants with a publishable JPEG asset.
    • Render latest publish job status.
    • Handle request loading and failure states.

Acceptance Criteria

  1. content_variants.format accepts instagram_feed.
  2. Admin can publish only an approved instagram_feed variant.
  3. Non-admin users receive 403 from publish route.
  4. Draft/rejected/non-Instagram/non-feed variants cannot be published.
  5. Publish route rejects missing, non-HTTPS, data:, non-content-studio-assets, unreachable, or non-JPEG asset URLs.
  6. Successful publish stores container_id, published_media_id, status = published, and published_at.
  7. Failed publish stores status = failed and structured error_json.
  8. Retrying after failure creates a second job row; retrying after a latest published job is blocked.
  9. Content Studio UI shows publish button only for eligible variants.
  10. Content Studio UI shows latest publish status and published media id or failure reason.
  11. Next route does not call Meta directly when Rust API is unavailable.
  12. Missing Instagram env returns 503; disabled publish account rejects publishing.
  13. instagram_feed.body is generated and treated as caption body, not carousel slide copy.
  14. Packet detail returns publishJobsByVariantId with the latest publish job per variant using created_at DESC, id DESC ordering.
  15. Tests written and passing.

Testing Plan

Layer What Count
Rust unit Meta status mapping: FINISHED, ERROR, EXPIRED, timeout +4
Rust integration/service approved feed variant publishes successfully with mocked Meta client and configured publish account +1
Rust integration/service draft/rejected/non-feed variant rejected +3
Rust integration/service failed Meta response persists failed job with error_json +1
Rust integration/service missing env, disabled account, non-allowlisted asset URL rejected +3
Rust integration/service packet detail returns latest publish job map without mutating variant review status +1
Next route test admin required, proxies when API_BASE_URL exists, 503 without API base +3
Web unit/UI publish button visibility, publishable JPEG asset selection, and status rendering +3
DB/migration new tables, indexes, RLS policies, format constraint +1 migration verification

Suggested commands:

bun test packages/web/lib/content-studio
bun test packages/web/app/api/v1/content
cargo test -p api-server content_studio

Rollback Plan

  • Revert the migration adding publish tables and instagram_feed constraint changes.
  • Revert Rust publish route/service/entity additions.
  • Revert Next route and UI changes.
  • Already published Instagram posts are external side effects and must be deleted manually in Instagram/Meta tools if needed.

Effort Estimate

  • DB migration/entities/types: 2-3h
  • Rust publish service + mocked Meta client: 4-6h
  • Next proxy route: 1h
  • UI publish/status controls: 2-3h
  • Tests: 3-5h
  • Total: 12-18h

Files Reference

File Change
packages/api-server/migration/src/m20260507_000001_create_content_studio_tables.rs:33 Add instagram_feed to existing format constraint via new migration
supabase/migrations/20260514160000_content_studio_tables.sql:26 Local parity constraint needs forward migration
packages/web/lib/content-studio/schemas.ts:4 Add instagram_feed to variant format schema
packages/api-server/src/domains/content_studio/dto.rs:90 Add publish request/response DTOs
packages/api-server/src/domains/content_studio/handlers.rs:107 Add /variants/{id}/publish/instagram route
packages/api-server/src/domains/content_studio/service.rs Add publish validation/job persistence/Meta flow
packages/api-server/src/entities/mod.rs:14 Export new publish account/job entities
packages/web/app/api/v1/content/variants/[id]/publish/instagram/route.ts New admin proxy route
packages/web/lib/content-studio/assets/openai-client.ts:173 Ensure publishable instagram_feed assets are public JPEG or fail clearly
packages/web/app/admin/content-studio/page.tsx:340 Add publish action/status near variant actions

Out of Scope

  • OAuth login
  • token refresh
  • scheduling
  • carousel publishing
  • reel publishing
  • multi-account selection UI
  • arbitrary external media URL publishing
  • background publish workers / stuck container recovery
  • general user publish UI
  • Instagram post deletion/unpublish
  • webhooks
  • comment moderation
  • insights

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions