アーキテクチャ Architecture
マーケティングポータルのクライアントは 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_integrations に MARKETING_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.ts の MARKETING_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.status は draft → 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_MINUTE の handleNewsletterBroadcast がキューを処理。送信系は 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 側は実 SQL、ArticlesPv は 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 | Placeholderfeatures/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). |
store_sales_daily は marketing 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.toml の triggers.crons(5 枠制限)に登録され、api/src/lib/cron-dispatcher.ts の CRON_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) |
handleXScheduledPosthandleXScheduleRuleFirehandleNewsletterBroadcasthandleSessionNotificationsFire |
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). |
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 |
|---|---|---|
| Dev | dev-marketing.parky.co.jp | dev ブランチへの push → GitHub Actions → Cloudflare PagesPush to dev branch → GitHub Actions → Cloudflare Pages |
| Production | marketing.parky.co.jp | main ブランチへの push → GitHub Actions → Cloudflare PagesPush to main branch → GitHub Actions → Cloudflare Pages |