Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ Topic 질의(아키텍처 / API / DB / 디자인 시스템 / AI playbook)는 **

요약: `feature/*` → `dev` → `main` 플로우. `main` 직접 push 금지, `dev`→`main` PR 머지만 허용. 긴급 시 `hotfix/*`→`main` 예외. 상세는 **[docs/GIT-WORKFLOW.md](docs/GIT-WORKFLOW.md)**.

## Commit discipline

- 사용자가 코드/문서 수정을 요청하고 작업이 완료되면, 검증 후 관련 파일만 선별해 커밋한다.
- 커밋 금지는 `push`, `merge`, PR 병합 금지와 구분한다. 로컬 커밋은 기본 완료 조건이다.
- 더러운 워크트리에서는 unrelated 변경을 건드리지 말고, 이번 작업 파일만 `git add <path>`로 스테이징한다.
- 커밋하지 않아야 하는 명시 요청, review-only/plan-only 모드, 또는 human checkpoint가 필요한 위험 작업이면 커밋하지 않고 이유를 남긴다.

## Codebase documentation

| 문서 | 내용 |
Expand Down
2 changes: 1 addition & 1 deletion packages/api-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ base64 = "0.22"
tokio-cron-scheduler = "0.13"

# Utils
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
tracing = "0.1"
Expand Down
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 @@ -63,6 +63,7 @@ mod m20260502_000003_public_missing_tables_and_rls;
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;

pub struct Migrator;

Expand Down Expand Up @@ -142,6 +143,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260502_000004_embeddings_and_search_similar::Migration),
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),
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use sea_orm_migration::prelude::*;

/// Content Studio persistence tables for admin-generated channel drafts.
#[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#"
CREATE TABLE IF NOT EXISTS public.content_packets (
id UUID PRIMARY KEY,
post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE,
title TEXT NOT NULL,
hook TEXT NOT NULL,
risk_level TEXT NOT NULL CHECK (risk_level IN ('low', 'medium', 'high')),
review_status TEXT NOT NULL DEFAULT 'draft'
CHECK (review_status IN ('draft', 'needs_review', 'approved', 'rejected')),
packet_json JSONB NOT NULL,
created_by UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (post_id)
);

CREATE TABLE IF NOT EXISTS public.content_variants (
id UUID PRIMARY KEY,
packet_id UUID NOT NULL REFERENCES public.content_packets(id) ON DELETE CASCADE,
channel TEXT NOT NULL CHECK (channel IN ('instagram', 'youtube', 'x')),
format TEXT NOT NULL CHECK (
format IN ('instagram_carousel', 'instagram_reel', 'youtube_shorts', 'x_thread')
),
title TEXT NOT NULL,
body TEXT NOT NULL,
media_plan JSONB NOT NULL,
hashtags JSONB NOT NULL DEFAULT '[]'::jsonb,
disclosure TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'needs_review', 'approved', 'rejected')),
governance_result JSONB,
reviewed_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (packet_id, format)
);

CREATE INDEX IF NOT EXISTS content_packets_review_status_idx
ON public.content_packets(review_status, updated_at DESC);
CREATE INDEX IF NOT EXISTS content_variants_packet_status_idx
ON public.content_variants(packet_id, status);

ALTER TABLE public.content_packets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.content_variants ENABLE ROW LEVEL SECURITY;

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

DROP POLICY IF EXISTS "admin_can_manage_content_variants" ON public.content_variants;
CREATE POLICY "admin_can_manage_content_variants"
ON public.content_variants 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_variants;
DROP TABLE IF EXISTS public.content_packets;
"#,
)
.await?;

Ok(())
}
}
155 changes: 155 additions & 0 deletions packages/api-server/src/domains/content_studio/dto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;

#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateContentPacketRequest {
pub post_id: Uuid,
}

#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct GenerateVariantsRequest {
pub packet: ContentPacket,
}

#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReviewVariantRequest {
pub packet: ContentPacket,
#[serde(default)]
pub variants: Vec<ContentVariant>,
}

#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct VariantStatusRequest {
pub variant: ContentVariant,
}

#[derive(Debug, Clone, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContentPacketListQuery {
pub status: Option<String>,
pub limit: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ItemEntity {
pub id: String,
pub title: String,
pub brand: Option<String>,
pub thumbnail_url: Option<String>,
pub source_url: Option<String>,
pub confidence: String,
pub verified: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContentAlternatives {
pub budget: Vec<ItemEntity>,
pub mid: Vec<ItemEntity>,
pub premium: Vec<ItemEntity>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct DisclosureFlags {
pub ai_generated: bool,
pub synthetic_media: bool,
pub sponsored: bool,
pub rights_risk: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContentPacket {
pub id: String,
pub post_id: String,
pub source_image: String,
pub title: String,
pub hook: String,
pub artist: Option<String>,
pub group: Option<String>,
pub context: Option<String>,
pub detected_items: Vec<ItemEntity>,
pub style_summary: String,
pub why_it_works: String,
pub alternatives: ContentAlternatives,
pub disclosure_flags: DisclosureFlags,
pub risk_level: String,
pub review_status: String,
pub created_at: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContentVariant {
pub id: String,
pub packet_id: String,
pub channel: String,
pub format: String,
pub title: String,
pub body: String,
pub media_plan: Value,
pub hashtags: Vec<String>,
pub disclosure: String,
pub status: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct GovernanceResult {
pub verdict: String,
pub risk_level: String,
pub flags: Vec<String>,
pub required_actions: Vec<String>,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ContentPacketResponse {
pub packet: ContentPacket,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ContentVariantsResponse {
pub variants: Vec<ContentVariant>,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GovernanceResponse {
pub result: GovernanceResult,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ContentVariantResponse {
pub variant: ContentVariant,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ContentPacketDetailResponse {
pub packet: ContentPacket,
pub variants: Vec<ContentVariant>,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ContentPacketListItem {
pub id: Uuid,
pub post_id: Uuid,
pub title: String,
pub hook: String,
pub risk_level: String,
pub review_status: String,
pub created_at: DateTime<FixedOffset>,
pub updated_at: DateTime<FixedOffset>,
}

#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ContentPacketListResponse {
pub items: Vec<ContentPacketListItem>,
}
Loading
Loading