Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
86061af
feat(raw_posts): StarStyle.com adapter (#466) (#481)
cocoyoon May 7, 2026
efcb74f
chore: retrigger CI with bump label
cocoyoon May 7, 2026
4c0b3ff
fix(starstyle-backfill): mirror hero images to R2 before INSERT (#466…
cocoyoon May 7, 2026
3b5d5fc
Merge remote-tracking branch 'origin/main' into dev
cocoyoon May 7, 2026
47f31a8
feat(raw_posts): R2 cleanup on delete + storage rename (#466) (#486)
cocoyoon May 7, 2026
5443b42
feat(web): add vton item and post selection
thxforall May 7, 2026
23a7eb8
fix(web): use local supabase fallback for posts
thxforall May 7, 2026
6a0316a
fix(web): serve local vton seed images
thxforall May 7, 2026
2dbcdb5
fix(web): seed local post images for vton qa
thxforall May 7, 2026
8c57c3a
docs: design profile tries detail modal
thxforall May 7, 2026
8201801
feat(web): add try detail snapshots
thxforall May 7, 2026
b5ca3ea
fix(raw_posts): preserve vision log thumbnails (#490)
CIOI May 7, 2026
6647c59
fix(web): remove missing supabase client export (#491)
CIOI May 7, 2026
28da9df
feat(verify): R2 relocate raw → operation at verify time (#466) (#492)
cocoyoon May 8, 2026
910081f
fix(wiki): remove unknown tags (profile, vton) from profile-tries spe…
cocoyoon May 8, 2026
c53f0d4
Merge remote-tracking branch 'origin/main' into dev
cocoyoon May 8, 2026
0a08891
feat(cost-tracking): Gemini API per-call cost tracking + admin dashbo…
cocoyoon May 14, 2026
01b8c5a
feat(content-studio): AI-powered content generation pipeline (#498)
thxforall May 14, 2026
1b048cf
feat(admin): verify observability — stats page + daily-digest nag (#499)
cocoyoon May 14, 2026
b8dab14
merge: main into dev — resolve admin router/sidebar/home conflicts
cocoyoon May 14, 2026
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
99 changes: 95 additions & 4 deletions .github/scripts/daily-digest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,75 @@ echo "new issues: $ISSUES_COUNT"
echo "commits main/dev: $COMMITS_MAIN_COUNT/$COMMITS_DEV_COUNT"
echo "::endgroup::"

# --- admin verify stats (assets DB + operation DB) ---
# 두 DB 모두 secret 설정되어 있을 때만 활성. 없으면 silent skip.
echo "::group::verify-stats"

VERIFY_STATS_JSON="[]"
VERIFY_TOTALS_JSON='{"yesterday":0,"last_7d":0}'
NAG_ADMINS_TEXT=""

if [ -n "${ASSETS_DATABASE_URL_RO:-}" ] && [ -n "${OPERATION_DATABASE_URL_RO:-}" ]; then
# 1) admins from operation DB
admins_json=$(PGCONNECT_TIMEOUT=10 psql "$OPERATION_DATABASE_URL_RO" -t -A -F$'\t' -c \
"SELECT id::text, username, COALESCE(display_name, '') FROM public.users WHERE is_admin = true ORDER BY username" \
2>/dev/null \
| jq -R -s 'split("\n") | map(select(length > 0) | split("\t") | {id:.[0], username:.[1], display_name:(.[2] // "" | if length == 0 then null else . end)})' \
|| echo "[]")

admin_count=$(echo "$admins_json" | jq 'length')
echo "admins: $admin_count"

if [ "$admin_count" -gt 0 ]; then
# 2) verified counts per admin from assets DB
# yesterday 윈도우 = [어제 00:00 KST, 오늘 00:00 KST). last_7d 윈도우 = 7일 rolling.
admin_ids_array=$(echo "$admins_json" | jq -r '[.[].id] | join("\",\"")')
counts_json=$(PGCONNECT_TIMEOUT=10 psql "$ASSETS_DATABASE_URL_RO" -t -A -F$'\t' -c \
"SELECT verified_by::text,
COUNT(*) FILTER (
WHERE verified_at >= (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') - interval '1 day') AT TIME ZONE 'Asia/Seoul'
AND verified_at < date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul'
) AS yesterday,
COUNT(*) FILTER (WHERE verified_at >= now() - interval '7 days') AS last_7d
FROM public.raw_posts
WHERE verified_by = ANY(ARRAY[\"$admin_ids_array\"]::uuid[])
AND verified_at IS NOT NULL
AND verified_at >= now() - interval '7 days'
GROUP BY verified_by" \
2>/dev/null \
| jq -R -s 'split("\n") | map(select(length > 0) | split("\t") | {admin_id:.[0], yesterday:(.[1] | tonumber), last_7d:(.[2] | tonumber)})' \
|| echo "[]")

# 3) zip — admin + counts (fall-through to 0 if no row)
VERIFY_STATS_JSON=$(jq -n \
--argjson admins "$admins_json" \
--argjson counts "$counts_json" \
'[$admins[] as $a
| ($counts[] | select(.admin_id == $a.id)) // {yesterday: 0, last_7d: 0}
| {admin_id: $a.id, username: $a.username, display_name: $a.display_name,
yesterday: .yesterday, last_7d: .last_7d, needs_nag: (.yesterday == 0)}]
| sort_by(-.yesterday)')

# 4) totals + nag list
VERIFY_TOTALS_JSON=$(echo "$VERIFY_STATS_JSON" | jq '{
yesterday: (map(.yesterday) | add // 0),
last_7d: (map(.last_7d) | add // 0)
}')

NAG_ADMINS_TEXT=$(echo "$VERIFY_STATS_JSON" | jq -r '
map(select(.needs_nag)) |
if length == 0 then "" else
map((.display_name // .username)) | join(", ")
end')

echo "totals: $(echo "$VERIFY_TOTALS_JSON" | jq -c .)"
echo "nag: ${NAG_ADMINS_TEXT:-(none)}"
fi
else
echo "skip — ASSETS_DATABASE_URL_RO and/or OPERATION_DATABASE_URL_RO not set"
fi
echo "::endgroup::"

# --- Claude Haiku summary ---
echo "::group::summarize"

Expand All @@ -69,25 +138,32 @@ DATA_JSON=$(jq -n \
--argjson open_issues_assigned "$OPEN_ISSUES_ASSIGNED" \
--argjson commits_main "$COMMITS_MAIN" \
--argjson commits_dev "$COMMITS_DEV" \
--argjson verify_stats "$VERIFY_STATS_JSON" \
--argjson verify_totals "$VERIFY_TOTALS_JSON" \
'{
merged_prs: $merged_prs,
open_prs: $open_prs,
new_issues: $new_issues,
open_issues_assigned: $open_issues_assigned,
commits_main: $commits_main,
commits_dev: $commits_dev
commits_dev: $commits_dev,
admin_verify_stats: $verify_stats,
admin_verify_totals: $verify_totals
}')

PROMPT_TEXT='당신은 decoded 모노레포의 일일 리포트를 한국어로 작성합니다.
아래 JSON은 지난 24시간의 GitHub 활동입니다.
아래 JSON은 지난 24시간의 GitHub 활동 + admin verify 통계입니다.

브랜치 맥락: decoded는 `feature/* → dev → main` 플로우. 대부분 작업은 dev에 병합되고, main은 릴리즈/CI 전용.

admin_verify_stats: admin 별 어제 verify (raw_post 검수) 카운트. `needs_nag=true` 인 admin 은 어제 0건이므로 부드럽게 독촉 메시지 추가.

요청:
- 주요 변화 3~5개를 **base 브랜치별로 그룹핑**해서 제시 (main → dev 순서)
- 브랜치에 해당 항목이 없으면 해당 그룹 생략
- review 대기중이거나 오래된 open PR 있으면 "주의" 섹션 (주의는 그룹핑 없이 평평하게)
- 전체 400자 이내, plain text (마크다운 금지)
- admin_verify_stats 가 있고 needs_nag 인 admin 이 있으면 "독촉" 섹션 추가 (이름 명시, 가볍게 — 죄책감 X, 동기부여 톤)
- 전체 500자 이내, plain text (마크다운 금지)
- 형식 (정확히 이 들여쓰기/기호 사용):
✨ 하이라이트

Expand All @@ -98,7 +174,10 @@ PROMPT_TEXT='당신은 decoded 모노레포의 일일 리포트를 한국어로
• 내용 요약 (#PR번호)

⚠️ 주의
• 이슈 설명 (#번호, →base) — (해당 없으면 이 섹션 전체 생략)'
• 이슈 설명 (#번호, →base) — (해당 없으면 이 섹션 전체 생략)

📣 독촉 — (needs_nag=true 인 admin 이 있을 때만 등장, 없으면 섹션 전체 생략)
• [이름]님 어제 검수 한 건도 없네요. 오늘은 한 건만이라도 부탁드려요!'

CLAUDE_REQ=$(jq -n \
--arg model "claude-haiku-4-5-20251001" \
Expand Down Expand Up @@ -176,6 +255,18 @@ if [ -n "$top_issues" ]; then
BODY+="📌 new issues (${ISSUES_COUNT})"$'\n'"$top_issues"$'\n\n'
fi

# admin verify table (deterministic — Claude 요약과 별개로 항상 노출)
verify_table=$(echo "$VERIFY_STATS_JSON" | jq -r '
if length == 0 then "" else
map("• \(.display_name // .username): 어제 \(.yesterday) / 7d \(.last_7d)\(if .needs_nag then " ⚠️" else "" end)")
| join("\n")
end')
if [ -n "$verify_table" ]; then
vy=$(echo "$VERIFY_TOTALS_JSON" | jq -r '.yesterday')
v7=$(echo "$VERIFY_TOTALS_JSON" | jq -r '.last_7d')
BODY+="🔍 admin verify (어제 ${vy} / 7d ${v7})"$'\n'"$verify_table"$'\n\n'
fi

MSG=$(cat <<EOF
🌅 decoded 일일 요약 — ${TODAY_KST}
━━━━━━━━━━━━━━━━
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/daily-digest.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Daily digest of decoded monorepo activity → Telegram.
# Required repo secrets: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, ANTHROPIC_API_KEY
# Optional repo secrets (admin verify section): ASSETS_DATABASE_URL_RO, OPERATION_DATABASE_URL_RO
# 둘 다 없으면 verify 섹션 silent skip (회귀 안전).
name: Daily digest

on:
Expand Down Expand Up @@ -46,11 +48,21 @@ jobs:
exit 1
fi

- name: Install postgresql-client (admin verify section)
run: |
# ubuntu-latest 이미 postgresql-client 포함 — 누락 시 fallback.
if ! command -v psql >/dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client
fi
psql --version

- name: Run daily digest
env:
GH_TOKEN: ${{ github.token }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
WINDOW_HOURS: ${{ github.event.inputs.window_hours || '24' }}
ASSETS_DATABASE_URL_RO: ${{ secrets.ASSETS_DATABASE_URL_RO }}
OPERATION_DATABASE_URL_RO: ${{ secrets.OPERATION_DATABASE_URL_RO }}
run: bash .github/scripts/daily-digest.sh
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(())
}
}
6 changes: 5 additions & 1 deletion packages/api-server/src/domains/admin/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use super::{
badges, categories, curations, dashboard, editorial_article_chat, editorial_articles,
editorial_candidates, editorial_discovery_settings, editorial_pipeline_settings,
editorial_recommendations, gemini_cost, magazine_sessions, monitoring, posts, solutions, spots,
synonyms,
synonyms, verify_stats,
};
use crate::domains::reports;

Expand Down Expand Up @@ -58,6 +58,10 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router<AppState> {
"/gemini-cost",
gemini_cost::router(state.clone(), app_config.clone()),
)
.nest(
"/verify-stats",
verify_stats::router(state.clone(), app_config.clone()),
)
.nest("/badges", badges::router(app_config.clone()))
.nest("/reports", reports::admin_router(app_config.clone()))
.nest(
Expand Down
1 change: 1 addition & 0 deletions packages/api-server/src/domains/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod posts;
pub mod solutions;
pub mod spots;
pub mod synonyms;
pub mod verify_stats;

pub use handlers::router;

Expand Down
Loading
Loading