アーキテクチャ Architecture

全体アーキテクチャは root / アーキテクチャ が正: The canonical architecture spec lives at root / Architecture: BFF の役割・Supabase との接続・認可モデル(RLS + コード層二重防御)・R2 の使い方など横断事項は全て root で説明済みです。本ページはマーケティングポータルだけに関係するスライスを扱います。 Cross-cutting topics — BFF responsibilities, Supabase wiring, the two-layer authz model (RLS + code-level), R2 — are all described at the root level. This page only covers the slice specific to the marketing portal.

マーケティングポータルのクライアントは React 19 + Vite 8 + TypeScript の SPA で、 React Router v7 / TanStack Query v5 / Bootstrap 5 / Lucide / Mapbox GL JS / Sentry React を採用しています。 BFF 側の requireMarketing ミドルウェアで認可し、 コンテンツ管理(記事・広告・アセット)・SNS 運用(X / Instagram-legacy)・キャンペーン・ニュースレター・アナリティクスを担当します。 外部 API キー(X / Resend / GA4 SA JSON 等)は marketing.marketing_integrationsMARKETING_ENCRYPT_KEY で暗号化保管され、復号は Workers BFF 内に閉じます(クライアントへ戻らない)。 画面ファサードは src/pages/LoginPage / DashboardPage / PlaceholderPage の 3 つのみ存在し、 実体は src/features/{x, instagram-legacy, newsletter, campaigns, analytics, content, content-pool, calendar, assets, system, settings, notifications, coming-soon} に集約されています(薄い page wrapper 規約)。

The marketing portal client is a React 19 + Vite 8 + TypeScript SPA built with React Router v7, TanStack Query v5, Bootstrap 5, Lucide, Mapbox GL JS, and Sentry React. Authorization is enforced by the BFF middleware requireMarketing. It covers content ops (articles, ads, assets), SNS ops (X / Instagram-legacy), campaigns, newsletter, and analytics. External API keys (X, Resend, GA4 SA JSON, etc.) live in marketing.marketing_integrations, encrypted with MARKETING_ENCRYPT_KEY; decryption happens entirely inside Workers BFF (never returned to the client). Only three actual page modules exist under src/pages/ (LoginPage, DashboardPage, PlaceholderPage) — every other route mounts components from src/features/ per the thin-page-wrapper convention.

マーケティングポータルのチャネル接続 Marketing portal channel view

flowchart LR
  MP["Marketing Portal SPA
React 19 + Vite 8
dev-marketing.parky.co.jp"] subgraph BFF["Cloudflare Workers BFF"] MW["requireMarketing
middleware"] API["/v1/marketing/* routes
(MARKETING_ROUTES manifest)"] CRON["Cron triggers
(EVERY_MINUTE / X_LISTEN /
X_INSIGHTS / HOURLY)"] UF["/v1/marketing/assets/upload
→ R2 presigned PUT"] end subgraph Supabase["Supabase"] AUTH["Auth (email + password)"] DB_PUB[("public schema
articles / ads / assets /
article_categories")] DB_MKT[("marketing schema
marketing_campaigns / x_posts /
x_schedule_rules / x_listen_rules /
newsletter_broadcasts /
newsletter_subscribers /
marketing_integrations / brand /
store_sales_daily / store_reviews")] DB_ANA[("analytics schema
sns_follower_snapshots /
client_events / error_reports")] ENC["marketing_integrations.config_enc
(AES-GCM via MARKETING_ENCRYPT_KEY)"] end subgraph External["外部 API"] X["X (Twitter) API v2"] IG["Instagram Graph API
(未実装 / Phase 5)"] GA4["GA4 Reporting API
(integration scaffolded;
articles-pv は累積値)"] RESEND["Resend
(Newsletter dispatch)"] end subgraph Storage["Object storage"] R2["Cloudflare R2
(parky-assets)"] CDN["cdn.parky.co.jp"] end MP -->|"Supabase JWT
+ Idempotency-Key (auto)"| MW MW --> API API --> DB_PUB API --> DB_MKT API --> DB_ANA API -->|"decrypt"| ENC API --> X API --> GA4 API --> RESEND API -.->|"Phase 5 予定"| IG CRON --> X CRON --> RESEND CRON --> DB_ANA API --> UF UF --> R2 R2 --> CDN MP -->|"signIn"| AUTH

/v1/marketing/* チャネルマウント /v1/marketing/* channel mounts

api/src/routes/marketing-routes.tsMARKETING_ROUTES/v1/marketing/* 配下のサブルーターを宣言しています(requireMarketing は各サブルーター側で use("*"))。 articles / ads / instagram は admin と同じハンドラを alias mount しているため OAS 上の path は marketing channel に出ます。 idempotent: true な entry は Idempotency-Key 必須で、Marketing Portal クライアントは変更系リクエストに自動付与します(fix(marketing): 変更系リクエストに Idempotency-Key を自動付与)。

api/src/routes/marketing-routes.ts declares the sub-routers mounted under /v1/marketing/* (each sub-router applies requireMarketing via use("*")). The articles / ads / instagram endpoints are aliased to the admin handlers so they appear on the marketing channel in OAS. Entries marked idempotent: true require an Idempotency-Key header — the Marketing Portal client adds one automatically to every mutation.

flowchart LR
  C["SPA fetch wrapper
(adds Idempotency-Key)"] --> MW["requireMarketing"] MW --> D["dashboard"] MW --> CAMP["campaigns + items + metrics"] MW --> CAL["calendar"] MW --> CP["content-pool"] MW --> AN["analytics
(summary / articles / ads /
cross-channel)"] MW --> ACT["activity"] MW --> XS["x.* (accounts / posts /
schedule-rules / listen-rules /
insights / competitors /
automation-log)"] MW --> NL["newsletter.subscribers +
newsletter.broadcasts
(send-test / schedule /
send-now / cancel / duplicate)"] MW --> INT["integrations + integrations/ga4"] MW --> OAUTH["oauth (start / callback /
upload-sa-json)"] MW --> AS["assets (upload / confirm)"] MW --> NOTIF["notifications
(read / mark-all-read)"] MW --> AC["article-categories"] MW --> ST["stores ({store} / sync stub)"] MW --> BR["brand"] MW --> AL_ART["articles (admin alias)"] MW --> AL_AD["ads (admin alias)"] MW --> AL_IG["instagram (admin alias /
instagram-legacy)"]

requireMarketing 認可フロー requireMarketing authorization flow

sequenceDiagram
  participant C as Marketing Portal (SPA)
  participant B as BFF (Cloudflare Workers)
  participant DB as Supabase DB
  participant ENC as marketing_integrations.config_enc

  C->>B: リクエスト + Supabase JWT
(変更系は Idempotency-Key 自動付与) B->>B: verifyAndSetJwt() で JWT 検証 B->>DB: SELECT 1 FROM admins
JOIN role_permissions ON ...
WHERE auth_user_id = $sub
AND permission IN marketing.* alt 権限なし DB-->>B: 0 rows B-->>C: 403 Forbidden else 認可済み DB-->>B: 権限あり B->>ENC: 必要なら config_enc を AES-GCM で復号
(MARKETING_ENCRYPT_KEY) ENC-->>B: 復号済み資格情報 B->>DB: core / data 層で SQL 実行 B->>B: 必要なら外部 API 呼出 (X / Resend / GA4) B-->>C: 200 OK + ViewEnvelope end

X 投稿ライフサイクル X post lifecycle

marketing.x_posts.statusdraft → scheduled → publishing → published(または失敗時 failed / 取消時 canceled)の 6 値遷移。 即時公開 (POST /v1/marketing/x/posts/{id}/publish-now) と予約 (POST /v1/marketing/x/posts/{id}/schedule) は status 遷移を 1 UPDATE にまとめ、cron が scheduled_at <= now() を毎分拾って外部 X API へ送信します。

stateDiagram-v2
  [*] --> draft: POST /posts
  draft --> scheduled: POST /posts/{id}/schedule
(scheduled_at 設定) draft --> publishing: POST /posts/{id}/publish-now scheduled --> publishing: cron EVERY_MINUTE
handleXScheduledPost scheduled --> canceled: PUT /posts/{id}
(status=canceled) publishing --> published: X API 200
(x_post_id 記録) publishing --> failed: X API error
(error_message 記録) failed --> scheduled: 手動再投入 published --> [*] canceled --> [*]

Newsletter 配信ライフサイクル Newsletter broadcast lifecycle

marketing.newsletter_broadcasts はテストメール (/send-test) → 予約 (/schedule) → 即時送信 (/send-now) → 取消 (/cancel) → 複製 (/duplicate) の操作 verb で動かします。 実際の Resend 配信は cron EVERY_MINUTEhandleNewsletterBroadcast がキューを処理。送信系は dry-run トグル + 件数モーダルの二段確認必須(CLAUDE.md ルール)。

stateDiagram-v2
  [*] --> draft: POST /newsletter/broadcasts
  draft --> draft: POST /broadcasts/{id}/send-test
(自分宛のみ) draft --> scheduled: POST /broadcasts/{id}/schedule draft --> sending: POST /broadcasts/{id}/send-now scheduled --> sending: cron EVERY_MINUTE
handleNewsletterBroadcast scheduled --> canceled: POST /broadcasts/{id}/cancel sending --> sent: Resend 全件送信完了 sending --> failed: Resend エラー sent --> draft: POST /broadcasts/{id}/duplicate
(新規 draft を作成) sent --> [*]

ダッシュボードのデータフロー Dashboard data flow

ダッシュボードは 4 タイル構成で、SnsMetricsTile / PostCalendarTile / ArticlePvTile / AdCtrTile がそれぞれ独立した BFF endpoint を叩きます。 現状 SnsMetrics は analytics.sns_follower_snapshots 取込 cron が未稼働のため 0 埋めPostCalendar の Instagram 側は IG mirror table 未実装で 0、X 側は実 SQLArticlesPv は GA4 連携前のため累積 view_count 上位 10 件AdsCtr のみ DB 実数。各レスポンスは _meta.synced / _meta.reason で同期状態を返します(pending チケット: PARKY-SNS-SNAPSHOT-INGEST / PARKY-IG-MIRROR / PARKY-GA4-INTEGRATION)。

flowchart LR
  D["DashboardPage
(2x2 grid)"] D --> T1["SnsMetricsTile"] D --> T2["PostCalendarTile"] D --> T3["ArticlePvTile"] D --> T4["AdCtrTile"] T1 -->|"GET /v1/marketing/dashboard/sns-metrics"| BFF1["buildSnsMetrics()"] T2 -->|"GET /v1/marketing/dashboard/post-calendar?year&month"| BFF2["buildPostCalendar()"] T3 -->|"GET /v1/marketing/dashboard/articles-pv?period"| BFF3["buildArticlesPv()"] T4 -->|"GET /v1/marketing/dashboard/ads-ctr"| BFF4["buildAdsCtr()"] BFF1 -.->|"_meta: not synced"| SNAP[("analytics.sns_follower_snapshots
(未稼働)")] BFF2 --> XPOSTS[("marketing.x_posts
status / scheduled_at /
published_at")] BFF2 -.->|"IG side = 0"| IGMIR[("ig_campaigns mirror
(未実装)")] BFF3 --> ARTS[("public.articles
view_count desc")] BFF4 --> ADS[("public.ads
status='active'")]

外部 API 連携 External API integrations

外部サービスService 用途Purpose 実装状況Status
X (Twitter) API v2 投稿 (publish-now / 予約)・スケジュールルール・リスニングルール・competitorsPublish (now / scheduled), schedule rules, listen rules, competitors 実装済み
(insights は現状 stub レスポンス)(insights endpoint currently stubbed)
Instagram Graph API 公式 IG 連携(投稿・インサイト)Official IG integration (publish, insights) Phase 5 予定
現状の /instagram 配下は admin alias の instagram-legacy(Supabase 直管理の旧 CRUD)Today /instagram is the admin-aliased instagram-legacy CRUD; no Graph API yet
GA4 Reporting API 記事・広告のアクセス解析データ取得Fetch article and ad analytics scaffold のみ
OAuth + SA JSON 投入 (POST /v1/marketing/integrations/ga4/upload-sa-json) は実装済み。ダッシュボードや analytics は GA4 取得まで届いておらず累積値で代替(PARKY-GA4-INTEGRATION)。OAuth + SA JSON upload (POST /v1/marketing/integrations/ga4/upload-sa-json) is wired; dashboard / analytics still fall back to cumulative DB values (ticket PARKY-GA4-INTEGRATION).
Resend ニュースレターのメール一斉配信Bulk newsletter email delivery 実装済み
App Store Connect / Google Play 日次売上 / レビュー取込(marketing.store_*Daily sales / reviews ingest (marketing.store_* tables) scaffold のみ
BFF (POST /v1/marketing/stores/{store}/sync) は _meta stub を返すだけで cron は未稼働。BFF (POST /v1/marketing/stores/{store}/sync) currently returns a stub _meta; no cron yet.
Threads / TikTok / YouTube / LINE 公式 将来対応予定 SNS プラットフォームFuture SNS platforms Placeholder
features/coming-soon/pages/ 配下の固定画面で受けるServed by the static screens under features/coming-soon/pages/

DB スキーマ構成 DB schema layout

Marketing Portal は親 CLAUDE.md の 5-schema 分離(public / admin / marketing / analytics / extensions)に従い、SPA は marketing schema 専用クライアントを使い、横断参照は必ず BFF 経由です。 「marketing 専用テーブル」と「marketing が触るが他 schema に居るテーブル」を区別してください:

The portal follows the parent 5-schema split (public / admin / marketing / analytics / extensions). The SPA uses a marketing-scoped Supabase client; any cross-schema reads go through the BFF. Tables actually owned by marketing are distinct from tables the marketing BFF touches that live in other schemas:

Schema 主なテーブルKey tables 用途Purpose
public articles, article_categories, article_tags, ads, assets, user_notifications エンドユーザーにも公開されるコンテンツ。Marketing BFF は admin alias で書込(articles / ads)。User-facing content. The marketing BFF writes via admin-aliased handlers for articles / ads.
marketing marketing_campaigns, marketing_campaign_items, x_accounts, x_posts, x_schedule_rules, x_listen_rules, x_competitors, x_automation_log, newsletter_broadcasts, newsletter_subscribers, marketing_assets, marketing_brand, marketing_integrations, marketing_notifications, marketing_activity_log, store_integrations, store_sales_daily, store_app_metrics_daily, store_reviews, store_sync_runs マーケティング運用データ。service_role のみアクセス可(anon / authenticated には GRANT しない)。Marketing-only ops data. service_role access only (no anon / authenticated GRANT).
analytics sns_follower_snapshots, client_events, error_reports append-only テレメトリ。sns_follower_snapshots はダッシュボード SnsMetricsTile が参照(取込 cron 未稼働)。Append-only telemetry. sns_follower_snapshots backs the dashboard SnsMetricsTile (ingest cron not yet running).
注意: Note: store_sales_dailymarketing schema(過去 doc では analytics と書かれていたが誤り)。content_pool 専用テーブルや instagram_posts テーブルは存在しない: コンテンツプールは articles / ads / x_posts / newsletter_broadcasts を横断検索するビューロジック、Instagram は admin schema の ig_*(instagram-legacy)を alias 参照。 store_sales_daily lives in the marketing schema (older docs wrongly placed it under analytics). There is no dedicated content_pool or instagram_posts table — the content pool is a cross-table search over articles / ads / x_posts / newsletter_broadcasts, and Instagram screens reuse admin-side ig_* tables (instagram-legacy) via the admin handler alias.

Cron ジョブ構成 Cron job topology

Cron は api/wrangler.tomltriggers.crons(5 枠制限)に登録され、api/src/lib/cron-dispatcher.tsCRON_HANDLERS Record で振り分けます。Marketing 関連スロットだけ抽出:

Crons are declared in api/wrangler.toml (triggers.crons, capped at 5 slots) and dispatched by CRON_HANDLERS in api/src/lib/cron-dispatcher.ts. The marketing-related slots:

スロットSlot ハンドラHandler 処理内容What it does
* * * * *
(EVERY_MINUTE)
handleXScheduledPost
handleXScheduleRuleFire
handleNewsletterBroadcast
handleSessionNotificationsFire
X 予約投稿の発火、X スケジュールルールからの自動投稿、ニュースレター配信キュー処理(と driving notifications)。Fire scheduled X posts, fire X schedule-rule auto-posts, drain the newsletter dispatch queue (alongside session notification fires).
*/5 * * * *
(X_LISTEN)
handleXListen X リスニングルールに該当するツイートを取得し自動リアクション。Poll for tweets matching X listen rules and auto-react.
*/10 * * * *
(X_INSIGHTS)
handleXInsights + refreshTrending 公開済み X 投稿のインプレッション / エンゲージメントを取得し x_posts を更新。同枠で trending KV をリフレッシュ。Refresh impressions / engagement on published X posts; co-tenant trending KV refresh.
0 * * * *
(HOURLY, UTC 00 のみ)
handleSnsSnapshots UTC 00:00 (JST 09:00) のみ analytics.sns_follower_snapshots にフォロワー数記録(現状停止中: PARKY-SNS-SNAPSHOT-INGEST)。At UTC 00:00 (JST 09:00) only, snapshot follower counts into analytics.sns_follower_snapshots (currently dormant — ticket PARKY-SNS-SNAPSHOT-INGEST).
App Store / Play Store の日次同期 cron は未稼働: App Store / Play Store daily sync cron is not running yet: POST /v1/marketing/stores/{store}/sync は呼ぶと _meta stub を返し、marketing.store_sales_daily / store_reviews へ書き込む scheduled handler は実装されていません。 POST /v1/marketing/stores/{store}/sync just returns a stub _meta; no scheduled handler writes into marketing.store_sales_daily / store_reviews yet.

エラー監視 (Sentry) Error monitoring (Sentry)

Marketing Portal SPA は @sentry/react でフロント側の未処理例外・ErrorBoundary 捕捉・ルーティング Breadcrumb を Sentry に送信します。 DSN は Vite の VITE_SENTRY_DSN でビルド時に注入。 外部 API (X / Instagram / GA4 / Resend) の呼出は BFF (/v1/marketing/*) 内で完結するため、外部 API 由来の 5xx・rate limit・auth 失敗は Workers 側で toucan-js 経由で capture されます(API キー文字列は Sentry に流さない)。 SPA からはクライアント発生の例外のみを送る役割分担です。詳細は ops / sentry-setup を参照。

The Marketing Portal SPA forwards unhandled exceptions, ErrorBoundary captures, and routing breadcrumbs via @sentry/react. The DSN is injected at Vite build time via VITE_SENTRY_DSN. External API calls (X / Instagram / GA4 / Resend) stay inside the BFF (/v1/marketing/*), so 5xx / rate-limit / auth failures from those providers are captured by Workers via toucan-js (API key strings are never forwarded to Sentry). The SPA only owns client-originated errors. See ops / sentry-setup for details.

デプロイ経路 Deployment pipeline

環境Env URL トリガーTrigger
Devdev-marketing.parky.co.jpdev ブランチへの push → GitHub Actions → Cloudflare PagesPush to dev branch → GitHub Actions → Cloudflare Pages
Productionmarketing.parky.co.jpmain ブランチへの push → GitHub Actions → Cloudflare PagesPush to main branch → GitHub Actions → Cloudflare Pages