You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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에서 확인하는 것이 목표다.
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
Add instagram_feed as a first-class Content Studio variant format.
Add publish account/job persistence.
Use server env secrets for the first internal Instagram account.
Add a Rust API publish service that executes the Meta container flow.
Add a Next route that proxies publish requests to Rust API.
Show publish action/status in Content Studio UI for approved instagram_feed variants.
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.
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:
CREATETABLEIF NOT EXISTS public.content_publish_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider TEXTNOT NULLCHECK (provider IN ('instagram')),
account_label TEXTNOT NULL,
ig_user_id TEXTNOT NULL,
token_ref TEXTNOT NULL,
status TEXTNOT NULL DEFAULT 'active'CHECK (status IN ('active', 'disabled')),
permissions JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by UUID REFERENCESpublic.users(id) ON DELETESETNULL,
created_at TIMESTAMPTZNOT NULL DEFAULT now(),
updated_at TIMESTAMPTZNOT NULL DEFAULT now(),
UNIQUE (provider, ig_user_id)
);
CREATETABLEIF NOT EXISTS public.content_publish_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id UUID NOT NULLREFERENCESpublic.content_variants(id) ON DELETE RESTRICT,
account_id UUID NOT NULLREFERENCESpublic.content_publish_accounts(id) ON DELETE RESTRICT,
platform TEXTNOT NULLCHECK (platform IN ('instagram')),
format TEXTNOT NULLCHECK (format IN ('instagram_feed')),
status TEXTNOT NULLCHECK (
status IN ('queued', 'processing', 'published', 'failed')
),
media_url TEXTNOT NULL,
caption TEXTNOT NULL,
container_id TEXT,
published_media_id TEXT,
error_json JSONB,
requested_by UUID REFERENCESpublic.users(id) ON DELETESETNULL,
created_at TIMESTAMPTZNOT NULL DEFAULT now(),
updated_at TIMESTAMPTZNOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
Indexes:
CREATEINDEXIF NOT EXISTS content_publish_jobs_variant_created_idx
ONpublic.content_publish_jobs(variant_id, created_at DESC, id DESC);
CREATEINDEXIF NOT EXISTS content_publish_jobs_status_created_idx
ONpublic.content_publish_jobs(status, created_at DESC);
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
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:
Insert job as queued.
Build the final caption from variant.body, optional disclosure, and normalized hashtags; persist the exact sent caption on the job.
Save container_id, set status processing.
Poll container status up to 5 times.
If status becomes FINISHED, call /media_publish.
Save published_media_id, status published, published_at.
If Meta returns ERROR, EXPIRED, timeout, or non-2xx, save status failed and error_json.
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.
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:
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.
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.
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.
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.
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
content_variants.format accepts instagram_feed.
Admin can publish only an approved instagram_feed variant.
Non-admin users receive 403 from publish route.
Draft/rejected/non-Instagram/non-feed variants cannot be published.
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
packages/web/app/admin/content-studio/page.tsx:340manual publish only상태packages/web/app/admin/content-studio/page.tsx:520instagram_feed없음packages/web/lib/content-studio/schemas.ts:4instagram_feedasset target 있음packages/web/lib/content-studio/schemas.ts:110packages/web/lib/content-studio/assets/openai-client.ts:173packages/web/app/api/v1/content/variants/[id]/approve/route.ts:50packages/api-server/src/domains/content_studio/handlers.rs:107content_packets,content_variants만 있음supabase/migrations/20260514160000_content_studio_tables.sql:7Meta publishing constraints:
graph.instagram.com, not the Facebook Page token publishing path./media, poll/?fields=status_code, then publish via/media_publishwithcreation_id.EXPIRED,ERROR,FINISHED,IN_PROGRESS,PUBLISHED.Resolved design decisions from
$grill-with-docs:graph.instagram.com, and one internal decoded Instagram Publish Account.content-studio-assetsbucket.accountIdin the MVP request. Rust resolves and lazy-upserts the single publish account from env.instagram_feed.bodyas the Instagram caption body, not slide copy.instagram_feedpublishable assets JPEG during asset generation; publish route revalidates as a final defense.publishedorfailed, not indefinitelyprocessing.supabase/migrations/*.sqlas the primary DB SOT; SeaORM migration is Rust/prod parity.publishJobsByVariantIdmap. Do not mix review status and publish status onContentVariant.content_publish_accountsdurable even for the single env-backed MVP account. Publish jobs FK to the account row;token_refnames the env secret, not the raw token.queued,processing,published, andfailed; do not includeuploadingbecause the publish flow uses an existing public asset URL.imageAssetUrlmeans a Content Studio Publishable Asset URL, not an arbitrary external media URL.Publish Jobonly 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 persistfailedjobs.variant.body, optionalvariant.disclosure, and normalized hashtags, then stores the exact string sent to Meta incontent_publish_jobs.caption.instagram_feedformat acceptance. Publish DTOs,publishJobsByVariantId, Next schemas, UI state, and generation behavior belong to later tasks.content_packets/content_variants; product writes still go through admin routes/services, not direct browser writes.variant_idandaccount_iduseON DELETE RESTRICT;requested_byusesON DELETE SET NULL; disabling a publish account is done withstatus = 'disabled', not deletion.content_publish_accounts.created_bynullable because the MVP env-backed account may be lazy-upserted by Rust with no human creator.content_publish_jobs.requested_byrecords the admin who requested each publish attempt.platformandformaton publish jobs:platformis the external channel,formatis the Content Variant publish format.supabase db reset; if unavailable, use direct migration apply againstLOCAL_DATABASE_URL, plus TypeScript/Rust compile or focused tests.content_variants.formatby confirming the current CHECK constraint name, then usingDROP CONSTRAINT IF EXISTS <current_name>plusADD CONSTRAINTwith the same name.media_urlafter validation: parse as URL, requirehttps, require the Content Studio asset public URL, remove fragments, and preserve path/query.error_jsonas unconstrained JSONB in DB. Rust should persist a common shape withcode,message,stage,retryable, anddetails; stages includeasset_preflight,meta_create_container,meta_poll_container,meta_publish, andinternal.publishedjob for the variant. Retry creates a new job only afterfailedjobs or when no prior job exists; force republish is out of scope.409 Conflictwith codealready_publishedand create no job.created_at DESC, id DESC;publishJobsByVariantIdand duplicate-publish checks use the same ordering.Proposed Change
instagram_feedas a first-class Content Studio variant format.instagram_feedvariants.content_publish_jobsrow.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.sqlpackages/api-server/migration/src/m20260528_000001_create_content_publish_jobs.rspackages/api-server/migration/src/lib.rsAdd
instagram_feedtocontent_variants.formatcheck constraint with an idempotent forward migration (DROP CONSTRAINT IF EXISTS+ADD CONSTRAINTusing the current constraint name).Add tables:
Indexes:
RLS:
content_packets/content_variants.Config
Use env secrets only for MVP:
INSTAGRAM_PUBLISH_IG_USER_IDINSTAGRAM_PUBLISH_ACCESS_TOKENINSTAGRAM_GRAPH_API_VERSIONINSTAGRAM_PUBLISH_ACCOUNT_LABELRust resolves the single internal Instagram Publish Account from env and lazy-upserts
content_publish_accountson first publish. DB storestoken_ref = 'INSTAGRAM_PUBLISH_ACCESS_TOKEN', not the raw token. If env is missing, return503 publish not configured. If the matching DB row isdisabled, reject publishing even when env is present.API Shape
Add Rust endpoint:
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:
status = 'approved'.channel = 'instagram'.format = 'instagram_feed'.imageAssetUrlmust behttps://.imageAssetUrlmust not be adata:URL.imageAssetUrlmust be a public URL from thecontent-studio-assetsbucket.Content-Typestarts withimage/jpegfailedjob witherror_jsonand return 400/422.accountId; multi-account selection is out of scope.published, reject duplicate publishing with409 Conflictbefore creating a new job.Meta flow:
queued.variant.body, optional disclosure, and normalized hashtags; persist the exact sent caption on the job.container_id, set statusprocessing.FINISHED, call/media_publish.published_media_id, statuspublished,published_at.ERROR,EXPIRED, timeout, or non-2xx, save statusfailedanderror_json.publishedorfailed. Background recovery for stuckIN_PROGRESScontainers is out of scope.Next Route
Add:
Behavior:
API_BASE_URLis missing, return 503 with a clear message:Instagram publishing requires API_BASE_URL / Rust API.UI
Update:
Add publish controls only when:
approvedinstagraminstagram_feedinstagram_feedpublishable asset exists and is public JPEGDisplay:
published,failed; request-in-flight state is client loading UIpublished_media_idwhen publishederror_jsonwhen failedpublishJobsByVariantId[variant.id]Replace global
manual publish onlywith a state-aware label:publish not configuredInstagram feed publish enabledAsset Handling
Because Meta image publishing requires JPEG:
instagram_feedimage assets to JPEG before upload.instagram_feedpublishable asset must useContent-Type: image/jpegand.jpgextension.data:fallback URLs must never be considered publishable assets.Retry Semantics
Every retry after a failed publish creates a new
content_publish_jobsrow.Do not mutate an old failed job into success. Old
container_id,error_json, andmedia_urlremain auditable. If the latest job is alreadypublished, MVP blocks duplicate publishing and does not create a new job.Implementation Split
Implement this as separate task cards rather than one broad patch:
instagram_feedformat acceptance, SeaORM entities, and entity exports.publishJobsByVariantId, Next route schemas, UI state, generation defaults/templates/prompts, UI behavior, or Meta calls in this task.instagram_feedvariants.content-studio-assetsJPEG assets.publishJobsByVariantIdfrom packet detail.API_BASE_URLis missing.instagram_feedpublishable assets as JPEG.Content-Type: image/jpegand.jpgextension.Acceptance Criteria
content_variants.formatacceptsinstagram_feed.instagram_feedvariant.data:, non-content-studio-assets, unreachable, or non-JPEG asset URLs.container_id,published_media_id,status = published, andpublished_at.status = failedand structurederror_json.publishedjob is blocked.instagram_feed.bodyis generated and treated as caption body, not carousel slide copy.publishJobsByVariantIdwith the latest publish job per variant usingcreated_at DESC, id DESCordering.Testing Plan
FINISHED,ERROR,EXPIRED, timeouterror_jsonAPI_BASE_URLexists, 503 without API baseSuggested commands:
Rollback Plan
instagram_feedconstraint changes.Effort Estimate
Files Reference
packages/api-server/migration/src/m20260507_000001_create_content_studio_tables.rs:33instagram_feedto existing format constraint via new migrationsupabase/migrations/20260514160000_content_studio_tables.sql:26packages/web/lib/content-studio/schemas.ts:4instagram_feedto variant format schemapackages/api-server/src/domains/content_studio/dto.rs:90packages/api-server/src/domains/content_studio/handlers.rs:107/variants/{id}/publish/instagramroutepackages/api-server/src/domains/content_studio/service.rspackages/api-server/src/entities/mod.rs:14packages/web/app/api/v1/content/variants/[id]/publish/instagram/route.tspackages/web/lib/content-studio/assets/openai-client.ts:173instagram_feedassets are public JPEG or fail clearlypackages/web/app/admin/content-studio/page.tsx:340Out of Scope
Related
docs/superpowers/specs/2026-05-14-content-studio-pipeline-v2-design.mddocs/superpowers/plans/2026-05-14-content-studio-pipeline-v2.md