Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/api-server/migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ mod m20260502_000004_embeddings_and_search_similar;
mod m20260502_000005_magazine_approval_and_rpcs;
mod m20260502_000006_backfill_public_columns;
mod m20260507_000001_create_content_studio_tables;
mod m20260528_000001_create_content_publish_jobs;

pub struct Migrator;

Expand Down Expand Up @@ -144,6 +145,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260502_000005_magazine_approval_and_rpcs::Migration),
Box::new(m20260502_000006_backfill_public_columns::Migration),
Box::new(m20260507_000001_create_content_studio_tables::Migration),
Box::new(m20260528_000001_create_content_publish_jobs::Migration),
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
use sea_orm_migration::prelude::*;

/// Content Studio Instagram publish persistence foundation.
///
/// The existing content_variants format constraint name is confirmed from the
/// primary Supabase migration 20260514160000_content_studio_tables.sql.
#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
ALTER TABLE public.content_variants
DROP CONSTRAINT IF EXISTS content_variants_format_check;

ALTER TABLE public.content_variants
ADD CONSTRAINT content_variants_format_check
CHECK (
format IN (
'instagram_carousel',
'instagram_feed',
'instagram_reel',
'youtube_shorts',
'x_thread'
)
);

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

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

ALTER TABLE public.content_publish_accounts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.content_publish_jobs ENABLE ROW LEVEL SECURITY;

DROP POLICY IF EXISTS "admin_can_manage_content_publish_accounts"
ON public.content_publish_accounts;
CREATE POLICY "admin_can_manage_content_publish_accounts"
ON public.content_publish_accounts FOR ALL
USING (public.is_admin(auth.uid()))
WITH CHECK (public.is_admin(auth.uid()));

DROP POLICY IF EXISTS "admin_can_manage_content_publish_jobs"
ON public.content_publish_jobs;
CREATE POLICY "admin_can_manage_content_publish_jobs"
ON public.content_publish_jobs FOR ALL
USING (public.is_admin(auth.uid()))
WITH CHECK (public.is_admin(auth.uid()));
"#,
)
.await?;

Ok(())
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
DROP TABLE IF EXISTS public.content_publish_jobs;
DROP TABLE IF EXISTS public.content_publish_accounts;

ALTER TABLE public.content_variants
DROP CONSTRAINT IF EXISTS content_variants_format_check;

ALTER TABLE public.content_variants
ADD CONSTRAINT content_variants_format_check
CHECK (
format IN (
'instagram_carousel',
'instagram_reel',
'youtube_shorts',
'x_thread'
)
);
"#,
)
.await?;

Ok(())
}
}
59 changes: 59 additions & 0 deletions packages/api-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub struct AppConfig {
pub ai_service: AiServiceConfig,
pub agent_service: AgentServiceConfig,
pub embedding: EmbeddingConfig,
pub instagram_publish: Option<InstagramPublishConfig>,
}

/// 서버 설정
Expand Down Expand Up @@ -146,6 +147,14 @@ pub struct AgentServiceConfig {
pub url: String,
}

#[derive(Debug, Clone)]
pub struct InstagramPublishConfig {
pub ig_user_id: String,
pub access_token: String,
pub graph_api_version: String,
pub account_label: String,
}

/// 임베딩 설정 (Vector Search용 OpenAI Embeddings API)
#[derive(Debug, Clone)]
pub struct EmbeddingConfig {
Expand Down Expand Up @@ -320,6 +329,7 @@ impl AppConfig {
.parse()
.unwrap_or(256),
},
instagram_publish: InstagramPublishConfig::from_env(),
})
}

Expand Down Expand Up @@ -347,6 +357,23 @@ impl AppConfig {
}
}

impl InstagramPublishConfig {
fn from_env() -> Option<Self> {
let ig_user_id = std::env::var("INSTAGRAM_PUBLISH_IG_USER_ID").ok()?;
let access_token = std::env::var("INSTAGRAM_PUBLISH_ACCESS_TOKEN").ok()?;
let graph_api_version = std::env::var("INSTAGRAM_GRAPH_API_VERSION").ok()?;
let account_label = std::env::var("INSTAGRAM_PUBLISH_ACCOUNT_LABEL")
.unwrap_or_else(|_| "decoded internal".to_string());

Some(Self {
ig_user_id,
access_token,
graph_api_version,
account_label,
})
}
}

#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
Expand Down Expand Up @@ -580,6 +607,38 @@ mod tests {
assert_eq!(config.agent_service.url, "http://localhost:11000");
}

#[test]
fn instagram_publish_config_reads_required_and_optional_env() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
set_required_env();
std::env::set_var("INSTAGRAM_PUBLISH_IG_USER_ID", "17841400000000000");
std::env::set_var("INSTAGRAM_PUBLISH_ACCESS_TOKEN", "secret-token");
std::env::set_var("INSTAGRAM_GRAPH_API_VERSION", "v23.0");
std::env::set_var("INSTAGRAM_PUBLISH_ACCOUNT_LABEL", "decoded main");

let config = AppConfig::from_env().expect("config should parse");

let instagram = config.instagram_publish.expect("instagram config");
assert_eq!(instagram.ig_user_id, "17841400000000000");
assert_eq!(instagram.access_token, "secret-token");
assert_eq!(instagram.graph_api_version, "v23.0");
assert_eq!(instagram.account_label, "decoded main");
}

#[test]
fn instagram_publish_config_is_none_when_required_env_missing() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
set_required_env();
std::env::remove_var("INSTAGRAM_PUBLISH_IG_USER_ID");
std::env::remove_var("INSTAGRAM_PUBLISH_ACCESS_TOKEN");
std::env::remove_var("INSTAGRAM_GRAPH_API_VERSION");
std::env::remove_var("INSTAGRAM_PUBLISH_ACCOUNT_LABEL");

let config = AppConfig::from_env().expect("config should parse");

assert!(config.instagram_publish.is_none());
}

#[test]
fn storage_endpoint_built_from_r2_account_id() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
Expand Down
28 changes: 28 additions & 0 deletions packages/api-server/src/domains/content_studio/dto.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;

Expand Down Expand Up @@ -30,6 +31,12 @@ pub struct VariantStatusRequest {
pub variant: ContentVariant,
}

#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PublishInstagramRequest {
pub image_asset_url: String,
}

#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContentPacketListQuery {
Expand Down Expand Up @@ -131,10 +138,31 @@ pub struct ContentVariantResponse {
pub variant: ContentVariant,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContentPublishJob {
pub id: String,
pub variant_id: String,
pub status: String,
pub media_url: String,
pub container_id: Option<String>,
pub published_media_id: Option<String>,
pub error_json: Option<Value>,
pub created_at: String,
pub published_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PublishInstagramResponse {
pub job: ContentPublishJob,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ContentPacketDetailResponse {
pub packet: ContentPacket,
pub variants: Vec<ContentVariant>,
#[serde(rename = "publishJobsByVariantId")]
pub publish_jobs_by_variant_id: HashMap<String, ContentPublishJob>,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
Expand Down
40 changes: 37 additions & 3 deletions packages/api-server/src/domains/content_studio/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ use super::{
ContentPacketDetailResponse, ContentPacketListQuery, ContentPacketListResponse,
ContentPacketResponse, ContentVariantResponse, ContentVariantsResponse,
CreateContentPacketRequest, GenerateVariantsRequest, GovernanceResponse,
ReviewVariantRequest, VariantStatusRequest,
PublishInstagramRequest, PublishInstagramResponse, ReviewVariantRequest,
VariantStatusRequest,
},
service,
};
Expand All @@ -44,8 +45,13 @@ pub async fn get_packet(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> AppResult<Json<ContentPacketDetailResponse>> {
let (packet, variants) = service::get_packet_detail(state.db.as_ref(), id).await?;
Ok(Json(ContentPacketDetailResponse { packet, variants }))
let (packet, variants, publish_jobs_by_variant_id) =
service::get_packet_detail(state.db.as_ref(), id).await?;
Ok(Json(ContentPacketDetailResponse {
packet,
variants,
publish_jobs_by_variant_id,
}))
}

pub async fn generate_variants(
Expand Down Expand Up @@ -104,6 +110,30 @@ pub async fn reject_variant(
Ok(Json(ContentVariantResponse { variant }))
}

pub async fn publish_instagram_variant(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path(id): Path<String>,
Json(req): Json<PublishInstagramRequest>,
) -> AppResult<Json<PublishInstagramResponse>> {
let raw_id = id.strip_prefix("variant_").unwrap_or(&id);
let variant_id = Uuid::parse_str(raw_id)
.map_err(|_| crate::error::AppError::bad_request("Invalid variant id"))?;
let client = service::ReqwestInstagramPublishClient::new()?;
let preflight = service::ReqwestPublishAssetPreflight::new()?;
let job = service::publish_instagram_feed_variant(
state.db.as_ref(),
state.config.instagram_publish.as_ref(),
variant_id,
user.id,
&req.image_asset_url,
&client,
&preflight,
)
.await?;
Ok(Json(PublishInstagramResponse { job }))
}

pub fn router(state: AppState, app_config: AppConfig) -> Router<AppState> {
Router::new()
.route("/packets", post(create_packet).get(list_packets))
Expand All @@ -112,6 +142,10 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router<AppState> {
.route("/variants/{id}/review", post(review_variant))
.route("/variants/{id}/approve", post(approve_variant))
.route("/variants/{id}/reject", post(reject_variant))
.route(
"/variants/{id}/publish/instagram",
post(publish_instagram_variant),
)
.layer(middleware::from_fn_with_state(
state,
crate::middleware::admin_db_middleware,
Expand Down
Loading
Loading