diff --git a/.gitignore b/.gitignore index cbdef90..76197e7 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ next-env.d.ts # turbo .turbo -.cursor \ No newline at end of file +.cursor + +# package build artifacts +packages/autogtm-core/dist/ \ No newline at end of file diff --git a/README.md b/README.md index c3ffd5d..2f6a082 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ # autogtm -**autogtm is an open-source AI GTM engine that runs cold outbound on autopilot.** +**autogtm is an open-source AI GTM engine that runs cold outbound and socials on autopilot.** -Describe your target audience in plain English with optional targeted briefs, and autogtm discovers leads daily, enriches them with AI, creates tailored email campaigns, and sends via Instantly. System on, autopilot on, you sleep. +Describe your target audience in plain English with optional targeted briefs, and autogtm discovers leads daily, enriches them with AI, creates tailored email campaigns, sends via Instantly, and can also draft/schedule social content through Postiz. --- @@ -38,7 +38,10 @@ Describe your target audience in plain English with optional targeted briefs, an | 8:30 AM | Generate queued search queries from briefs and company context | | 9:00 AM | Run searches, discover and enrich leads | | 10:00 AM ET | **Autopilot sweep** — auto-add top N Ready-to-Add leads + digest email (when enabled) | +| Sun 6:00 PM ET | **Social weekly planner** — maps themes to next week's slots from company schedule | +| Mon 8:00 AM ET | **Social auto-approve fallback** — drafts captions for untouched week plans | | Hourly | Sync campaign status and analytics from Instantly | +| Every 5 min | **Social publish sweep** — pushes due approved posts to Postiz | | 2:00 PM ET | Send daily discovery digest email | @@ -55,6 +58,8 @@ Describe your target audience in plain English with optional targeted briefs, an - **Exploration mode:** When no new briefs exist, AI generates creative queries to keep pipeline coverage fresh. - **Daily digests:** Two summary emails — a per-company Autopilot digest (what was auto-added and to which campaigns) and a global discovery digest (leads found, emails sent, opens, replies). - **Multi-company:** Manage multiple company profiles from a single dashboard. +- **Socials module (v1):** Company-level schedule presets + theme-based content generation from raw dumps, with two review gates (weekly plan, then captions) before publishing. +- **Postiz publishing:** Auto-uploads generated images and publishes approved Instagram posts via Postiz Public API. ## Stack @@ -67,6 +72,7 @@ Describe your target audience in plain English with optional targeted briefs, an | Background Jobs | [Inngest](https://inngest.com) | | Lead Discovery | [Exa.ai](https://exa.ai) (Websets API) | | Email Sending | [Instantly.ai](https://instantly.ai) | +| Social Publishing | [Postiz](https://docs.postiz.com/public-api/introduction) | | AI | [OpenAI](https://openai.com) (GPT-4.1 / GPT-5-mini) | | Digest Emails | [Resend](https://resend.com) | @@ -82,6 +88,7 @@ Accounts needed: - [Supabase](https://supabase.com) — database and authentication - [Exa.ai](https://exa.ai) — lead discovery via Websets API - [Instantly.ai](https://instantly.ai) — email campaign sending +- [Postiz](https://docs.postiz.com/public-api/introduction) — social account publishing - [OpenAI](https://platform.openai.com) — AI enrichment and generation - [Inngest](https://inngest.com) — background job scheduling - [Resend](https://resend.com) — daily digest emails (optional) @@ -124,6 +131,64 @@ This creates all required tables, indexes, RLS policies, and helper functions. If you already have a Supabase project from an earlier version, apply incremental migrations from `[migrations/](./migrations/)` instead — they're safe to re-run (`IF NOT EXISTS` guarded). +## Socials Module (v1) + +The Socials tab adds an Instagram-first content pipeline: + +1. Define reusable **themes** (`Audition Wins`, `Breakdown Weekly`, etc.) with: + - caption prompt template + - image prompt template + - brand voice + - priority (how often it should win a slot) +2. Configure a company-wide **schedule preset** (`creator_mwf`, `brand_weekday`, etc.). +3. Paste or upload a raw **data dump** (CSV/text). The LLM auto-classifies rows into themes. +4. Every week, planner maps `themes -> slots -> data items` and creates `planned` posts. +5. You approve the week plan, then caption drafts move to `pending_review`. +6. Once approved, image generation runs close to publish time and post is pushed to Postiz. + +### Supabase Storage bucket setup (required for socials images) + +Create a bucket named `social-images` in Supabase Storage and keep `SUPABASE_STORAGE_BUCKET_SOCIAL=social-images` in env. + +- **Public bucket (recommended for v1):** + - Mark bucket as public so `getPublicUrl()` links work directly in Postiz publish flow. +- **Access policy expectation:** + - Backend writes run with `SUPABASE_SERVICE_ROLE_KEY`. + - If you enforce storage policies manually, allow `service_role` to upload/update/list objects for `bucket_id = 'social-images'`. + +The app also attempts to create the bucket on first image generation if it does not exist, but setting it up explicitly in Supabase avoids first-run surprises. + +### Reels/video model switch (OpenRouter) + +For reel-first workflows, set: + +- `SOCIAL_MEDIA_ASSET_MODE=video` +- `OPENROUTER_API_KEY=...` +- `OPENROUTER_VIDEO_MODEL=google/veo-3.1-fast` or `bytedance/seedance-2.0-fast` + +Defaults: + +- `SOCIAL_MEDIA_ASSET_MODE=image` (OpenAI `gpt-image-1`) +- `OPENROUTER_VIDEO_MODEL=google/veo-3.1-fast` + +When in `video` mode, the generation step requests video via OpenRouter, stores the generated media in Supabase Storage, and publish flow sends video payloads to connected Postiz channels. + +### One-time Postiz setup + +1. In Postiz: connect your Instagram channel. +2. Generate API key in **Settings > Developers > Public API**. +3. Set env vars: + - `POSTIZ_API_KEY` + - `POSTIZ_BASE_URL=https://api.postiz.com/public/v1` + - `POSTIZ_INSTAGRAM_INTEGRATION_ID` (optional override) +4. Optional: manually pin integration ID: + +```bash +curl -H "Authorization: $POSTIZ_API_KEY" https://api.postiz.com/public/v1/integrations +``` + +If not set, autogtm auto-detects the first Instagram integration from `GET /integrations`. + ## Deployment diff --git a/apps/autogtm/.env.example b/apps/autogtm/.env.example index 7f9b35c..bae6e6d 100644 --- a/apps/autogtm/.env.example +++ b/apps/autogtm/.env.example @@ -2,6 +2,7 @@ NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_STORAGE_BUCKET_SOCIAL=social-images # Exa API (https://exa.ai) EXA_API_KEY= @@ -12,6 +13,19 @@ INSTANTLY_API_KEY= # OpenAI OPENAI_API_KEY= +# OpenRouter (video generation) +OPENROUTER_API_KEY= +# Switch socials media mode: image | video +SOCIAL_MEDIA_ASSET_MODE=image +# Video model switch when SOCIAL_MEDIA_ASSET_MODE=video: +# - google/veo-3.1-fast +# - bytedance/seedance-2.0-fast +OPENROUTER_VIDEO_MODEL=google/veo-3.1-fast + +# Postiz (https://docs.postiz.com/public-api/introduction) +POSTIZ_API_KEY= +POSTIZ_BASE_URL=https://api.postiz.com/public/v1 + # Inngest (https://inngest.com) INNGEST_SIGNING_KEY= INNGEST_EVENT_KEY= diff --git a/apps/autogtm/src/app/api/companies/[id]/social/_lib.ts b/apps/autogtm/src/app/api/companies/[id]/social/_lib.ts new file mode 100644 index 0000000..7b85a60 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/_lib.ts @@ -0,0 +1,20 @@ +import { createClient } from '@supabase/supabase-js'; + +export function getServiceSupabase() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); +} + +export async function getCompanyIdFromParams(params: Promise<{ id: string }>): Promise { + const { id } = await params; + return id; +} + +export function badRequest(message: string, status = 400) { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/dumps/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/dumps/route.ts new file mode 100644 index 0000000..27d574f --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/dumps/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSocialDataDump } from '@autogtm/core/db/socialsDbCalls'; +import { inngest } from '@/inngest/client'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const body = await request.json() as { + raw_content?: string; + source?: 'csv' | 'paste' | 'url'; + }; + + if (!body.raw_content || !body.raw_content.trim()) { + return NextResponse.json({ error: 'raw_content is required' }, { status: 400 }); + } + + const dump = await createSocialDataDump(companyId, { + raw_content: body.raw_content, + source: body.source || 'paste', + }); + + await inngest.send({ + name: 'autogtm/social.dump-created', + data: { + companyId, + dumpId: dump.id, + }, + }); + + return NextResponse.json({ dump }); + } catch (error) { + console.error('Error creating social dump:', error); + return NextResponse.json({ error: 'Failed to create social dump' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/items/[itemId]/archive/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/items/[itemId]/archive/route.ts new file mode 100644 index 0000000..8b49bf8 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/items/[itemId]/archive/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { updateSocialDataItem } from '@autogtm/core/db/socialsDbCalls'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; itemId: string }> } +) { + try { + const { id: companyId, itemId } = await params; + const item = await updateSocialDataItem(companyId, itemId, { status: 'archived' }); + return NextResponse.json({ item }); + } catch (error) { + console.error('Error archiving social item:', error); + return NextResponse.json({ error: 'Failed to archive social item' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/items/[itemId]/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/items/[itemId]/route.ts new file mode 100644 index 0000000..b119d18 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/items/[itemId]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { updateSocialDataItem } from '@autogtm/core/db/socialsDbCalls'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string; itemId: string }> } +) { + try { + const { id: companyId, itemId } = await params; + const body = await request.json() as { + theme_id?: string | null; + structured?: Record; + status?: 'pending_classification' | 'classified' | 'reserved' | 'used' | 'archived'; + classification_confidence?: number | null; + classification_reason?: string | null; + }; + + const item = await updateSocialDataItem(companyId, itemId, { + theme_id: body.theme_id, + structured: body.structured, + status: body.status, + classification_confidence: body.classification_confidence, + classification_reason: body.classification_reason, + }); + return NextResponse.json({ item }); + } catch (error) { + console.error('Error updating social item:', error); + return NextResponse.json({ error: 'Failed to update social item' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/items/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/items/route.ts new file mode 100644 index 0000000..4520314 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/items/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { listSocialDataItems } from '@autogtm/core/db/socialsDbCalls'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const status = request.nextUrl.searchParams.get('status') as + | 'pending_classification' + | 'classified' + | 'reserved' + | 'used' + | 'archived' + | null; + const themeId = request.nextUrl.searchParams.get('theme_id'); + + const items = await listSocialDataItems(companyId, { + status: status || undefined, + themeId: themeId || undefined, + }); + + return NextResponse.json({ items }); + } catch (error) { + console.error('Error listing social items:', error); + return NextResponse.json({ error: 'Failed to fetch social items' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/approve/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/approve/route.ts new file mode 100644 index 0000000..8331d09 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/approve/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { updateSocialPost } from '@autogtm/core/db/socialsDbCalls'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + try { + const { id: companyId, postId } = await params; + const post = await updateSocialPost(companyId, postId, { status: 'approved' }); + return NextResponse.json({ post }); + } catch (error) { + console.error('Error approving social post:', error); + return NextResponse.json({ error: 'Failed to approve social post' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/cancel/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/cancel/route.ts new file mode 100644 index 0000000..246bbb0 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/cancel/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { updateSocialPost } from '@autogtm/core/db/socialsDbCalls'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + try { + const { id: companyId, postId } = await params; + const post = await updateSocialPost(companyId, postId, { status: 'cancelled' }); + return NextResponse.json({ post }); + } catch (error) { + console.error('Error cancelling social post:', error); + return NextResponse.json({ error: 'Failed to cancel social post' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/generate-image/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/generate-image/route.ts new file mode 100644 index 0000000..ba1b4ef --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/generate-image/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { inngest } from '@/inngest/client'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + try { + const { id: companyId, postId } = await params; + const body = await request.json().catch(() => ({})) as { mode?: 'image' | 'video' }; + const mode = body.mode === 'image' || body.mode === 'video' ? body.mode : undefined; + await inngest.send({ + name: 'autogtm/social.image-gen', + data: { companyId, postId, mode }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error triggering social image generation:', error); + return NextResponse.json({ error: 'Failed to trigger image generation' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/publish-now/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/publish-now/route.ts new file mode 100644 index 0000000..7afe5b7 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/publish-now/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { inngest } from '@/inngest/client'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + try { + const { id: companyId, postId } = await params; + await inngest.send({ + name: 'autogtm/social.publish', + data: { companyId, postId }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error triggering social publish:', error); + return NextResponse.json({ error: 'Failed to trigger social publish' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/regenerate/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/regenerate/route.ts new file mode 100644 index 0000000..78914c5 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/regenerate/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { inngest } from '@/inngest/client'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + try { + const { id: companyId, postId } = await params; + await inngest.send({ + name: 'autogtm/social.draft-post', + data: { companyId, postId }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error regenerating social post:', error); + return NextResponse.json({ error: 'Failed to regenerate social post' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/route.ts new file mode 100644 index 0000000..7606eea --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/posts/[postId]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { updateSocialPost } from '@autogtm/core/db/socialsDbCalls'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + try { + const { id: companyId, postId } = await params; + const body = await request.json() as { + caption?: string; + hashtags?: string[]; + image_prompt?: string; + scheduled_for?: string; + }; + const post = await updateSocialPost(companyId, postId, { + caption: body.caption, + hashtags: body.hashtags, + image_prompt: body.image_prompt, + scheduled_for: body.scheduled_for, + }); + return NextResponse.json({ post }); + } catch (error) { + console.error('Error updating social post:', error); + return NextResponse.json({ error: 'Failed to update social post' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/posts/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/posts/route.ts new file mode 100644 index 0000000..e7090ac --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/posts/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { listSocialPosts } from '@autogtm/core/db/socialsDbCalls'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const status = request.nextUrl.searchParams.get('status') as + | 'planned' + | 'pending_review' + | 'approved' + | 'image_ready' + | 'published' + | 'failed' + | 'cancelled' + | null; + const weekPlanId = request.nextUrl.searchParams.get('week_plan_id'); + + const posts = await listSocialPosts(companyId, { + status: status || undefined, + weekPlanId: weekPlanId || undefined, + }); + return NextResponse.json({ posts }); + } catch (error) { + console.error('Error fetching social posts:', error); + return NextResponse.json({ error: 'Failed to fetch social posts' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/schedule/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/schedule/route.ts new file mode 100644 index 0000000..49d9034 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/schedule/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSocialSchedule, upsertSocialSchedule } from '@autogtm/core/db/socialsDbCalls'; +import { materializeSchedulePreset, type SocialSchedulePreset, type SocialScheduleSlot } from '@autogtm/core/socials/schedulePresets'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const schedule = await getSocialSchedule(companyId); + return NextResponse.json({ schedule }); + } catch (error) { + console.error('Error fetching social schedule:', error); + return NextResponse.json({ error: 'Failed to fetch social schedule' }, { status: 500 }); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const body = await request.json() as { + preset?: SocialSchedulePreset; + timezone?: string; + slots?: SocialScheduleSlot[]; + }; + + const preset = body.preset || 'creator_mwf'; + const materialized = materializeSchedulePreset({ + preset, + timezone: body.timezone || 'America/New_York', + customSlots: body.slots || [], + }); + + const schedule = await upsertSocialSchedule(companyId, { + preset: materialized.preset, + timezone: materialized.timezone, + slots: materialized.slots, + }); + + return NextResponse.json({ schedule }); + } catch (error) { + console.error('Error upserting social schedule:', error); + return NextResponse.json({ error: 'Failed to update social schedule' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/schedule/slots/[slotIndex]/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/schedule/slots/[slotIndex]/route.ts new file mode 100644 index 0000000..82ff19b --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/schedule/slots/[slotIndex]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSocialSchedule, upsertSocialSchedule } from '@autogtm/core/db/socialsDbCalls'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string; slotIndex: string }> } +) { + try { + const { id: companyId, slotIndex } = await params; + const index = Number(slotIndex); + if (Number.isNaN(index)) { + return NextResponse.json({ error: 'Invalid slot index' }, { status: 400 }); + } + + const body = await request.json() as { theme_id?: string | null }; + const schedule = await getSocialSchedule(companyId); + if (!schedule) { + return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }); + } + + if (!schedule.slots[index]) { + return NextResponse.json({ error: 'Slot not found' }, { status: 404 }); + } + + const slots = [...schedule.slots]; + slots[index] = { + ...slots[index], + theme_id: body.theme_id || null, + }; + + const updated = await upsertSocialSchedule(companyId, { + preset: schedule.preset, + timezone: schedule.timezone, + slots, + is_active: schedule.is_active, + }); + + return NextResponse.json({ schedule: updated }); + } catch (error) { + console.error('Error patching social schedule slot:', error); + return NextResponse.json({ error: 'Failed to patch social schedule slot' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/draft-one/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/draft-one/route.ts new file mode 100644 index 0000000..b654cd7 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/draft-one/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSocialPosts, listSocialDataItems, listSocialThemes } from '@autogtm/core/db/socialsDbCalls'; +import { inngest } from '@/inngest/client'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; themeId: string }> } +) { + try { + const { id: companyId, themeId } = await params; + const body = await request.json().catch(() => ({})) as { scheduled_for?: string }; + + const themes = await listSocialThemes(companyId); + const theme = themes.find((entry) => entry.id === themeId); + if (!theme) { + return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); + } + + const items = await listSocialDataItems(companyId, { + status: 'classified', + themeId, + limit: 1, + }); + const item = items[0]; + if (!item) { + return NextResponse.json({ error: 'No ready ideas available for this theme' }, { status: 400 }); + } + + const scheduledFor = body.scheduled_for || new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + const posts = await createSocialPosts([ + { + company_id: companyId, + theme_id: themeId, + data_item_id: item.id, + week_plan_id: null, + slot_index: null, + scheduled_for: scheduledFor, + status: 'pending_review', + }, + ]); + + const post = posts[0]; + if (!post) { + return NextResponse.json({ error: 'Failed to create post' }, { status: 500 }); + } + + await inngest.send({ + name: 'autogtm/social.draft-post', + data: { companyId, postId: post.id }, + }); + + return NextResponse.json({ + success: true, + post_id: post.id, + data_item_id: item.id, + scheduled_for: scheduledFor, + }); + } catch (error) { + console.error('Error generating one draft from theme:', error); + return NextResponse.json({ error: 'Failed to generate draft from theme' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/items/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/items/route.ts new file mode 100644 index 0000000..f932303 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/items/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSocialDataDump, createSocialDataItems, updateSocialDataDump } from '@autogtm/core/db/socialsDbCalls'; + +function splitRawInput(raw: string): string[] { + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; themeId: string }> } +) { + try { + const { id: companyId, themeId } = await params; + const body = await request.json() as { raw_content?: string; source?: 'csv' | 'paste' | 'url' }; + + if (!body.raw_content || !body.raw_content.trim()) { + return NextResponse.json({ error: 'raw_content is required' }, { status: 400 }); + } + + const items = splitRawInput(body.raw_content); + if (items.length === 0) { + return NextResponse.json({ error: 'No valid lines found' }, { status: 400 }); + } + + const dump = await createSocialDataDump(companyId, { + raw_content: body.raw_content, + source: body.source || 'paste', + }); + + await createSocialDataItems( + companyId, + dump.id, + items.map((rawText) => ({ + raw_text: rawText, + suggested_theme_id: themeId, + theme_id: themeId, + classification_confidence: 1, + classification_reason: 'Manually added to theme', + status: 'classified', + })) + ); + + await updateSocialDataDump(dump.id, { + parse_status: 'completed', + items_extracted: items.length, + error: null, + }); + + return NextResponse.json({ success: true, items_added: items.length, theme_id: themeId }); + } catch (error) { + console.error('Error adding items to social theme:', error); + return NextResponse.json({ error: 'Failed to add data to theme' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/route.ts new file mode 100644 index 0000000..aa48924 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/themes/[themeId]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { archiveSocialTheme, updateSocialTheme } from '@autogtm/core/db/socialsDbCalls'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string; themeId: string }> } +) { + try { + const { id: companyId, themeId } = await params; + const body = await request.json() as Record; + + const updates: Record = {}; + if (typeof body.name === 'string') updates.name = body.name.trim(); + if (typeof body.purpose === 'string') updates.purpose = body.purpose; + if (typeof body.caption_prompt === 'string') updates.caption_prompt = body.caption_prompt; + if (typeof body.image_prompt_template === 'string') updates.image_prompt_template = body.image_prompt_template; + if (typeof body.brand_voice === 'string') updates.brand_voice = body.brand_voice; + if (typeof body.priority === 'number') updates.priority = Math.max(1, Math.min(10, body.priority)); + if (typeof body.is_active === 'boolean') updates.is_active = body.is_active; + + const theme = await updateSocialTheme(companyId, themeId, updates); + return NextResponse.json({ theme }); + } catch (error) { + console.error('Error updating social theme:', error); + return NextResponse.json({ error: 'Failed to update social theme' }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; themeId: string }> } +) { + try { + const { id: companyId, themeId } = await params; + await archiveSocialTheme(companyId, themeId); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting social theme:', error); + return NextResponse.json({ error: 'Failed to delete social theme' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/themes/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/themes/route.ts new file mode 100644 index 0000000..4c4834e --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/themes/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createSocialTheme, listSocialThemes } from '@autogtm/core/db/socialsDbCalls'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const themes = await listSocialThemes(companyId); + return NextResponse.json({ themes }); + } catch (error) { + console.error('Error fetching social themes:', error); + return NextResponse.json({ error: 'Failed to fetch social themes' }, { status: 500 }); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const body = await request.json() as { + name?: string; + purpose?: string; + caption_prompt?: string; + image_prompt_template?: string; + brand_voice?: string; + priority?: number; + is_active?: boolean; + }; + + if (!body.name?.trim()) { + return NextResponse.json({ error: 'Theme name is required' }, { status: 400 }); + } + + const theme = await createSocialTheme(companyId, { + name: body.name.trim(), + purpose: body.purpose?.trim() || '', + caption_prompt: body.caption_prompt?.trim() || '', + image_prompt_template: body.image_prompt_template?.trim() || '', + brand_voice: body.brand_voice?.trim() || '', + priority: Math.max(1, Math.min(10, body.priority || 1)), + is_active: body.is_active ?? true, + }); + + return NextResponse.json({ theme }); + } catch (error) { + console.error('Error creating social theme:', error); + return NextResponse.json({ error: 'Failed to create social theme' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/approve/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/approve/route.ts new file mode 100644 index 0000000..58d648a --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/approve/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { inngest } from '@/inngest/client'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; planId: string }> } +) { + try { + const { id: companyId, planId } = await params; + await inngest.send({ + name: 'autogtm/social.plan-approved', + data: { + companyId, + weekPlanId: planId, + trigger: 'manual', + }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error approving social week plan:', error); + return NextResponse.json({ error: 'Failed to approve social week plan' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/posts/[postId]/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/posts/[postId]/route.ts new file mode 100644 index 0000000..abdff14 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/posts/[postId]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { updateSocialPost } from '@autogtm/core/db/socialsDbCalls'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string; planId: string; postId: string }> } +) { + try { + const { id: companyId, postId } = await params; + const body = await request.json() as { + theme_id?: string | null; + data_item_id?: string | null; + scheduled_for?: string; + }; + + const post = await updateSocialPost(companyId, postId, { + theme_id: body.theme_id, + data_item_id: body.data_item_id, + scheduled_for: body.scheduled_for, + }); + return NextResponse.json({ post }); + } catch (error) { + console.error('Error updating social week-plan post:', error); + return NextResponse.json({ error: 'Failed to update social week-plan post' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/route.ts new file mode 100644 index 0000000..9c3cd41 --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/week-plans/[planId]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServiceSupabase } from '../../_lib'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; planId: string }> } +) { + try { + const { id: companyId, planId } = await params; + const supabase = getServiceSupabase(); + + const { data: plan, error: planError } = await supabase + .from('social_week_plans') + .select('*') + .eq('id', planId) + .eq('company_id', companyId) + .single(); + if (planError) throw planError; + + const { data: posts, error: postsError } = await supabase + .from('social_posts') + .select('*') + .eq('company_id', companyId) + .eq('week_plan_id', planId) + .order('scheduled_for', { ascending: true }); + if (postsError) throw postsError; + + return NextResponse.json({ plan, posts: posts || [] }); + } catch (error) { + console.error('Error fetching social week plan:', error); + return NextResponse.json({ error: 'Failed to fetch social week plan' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/app/api/companies/[id]/social/week-plans/route.ts b/apps/autogtm/src/app/api/companies/[id]/social/week-plans/route.ts new file mode 100644 index 0000000..6f2953f --- /dev/null +++ b/apps/autogtm/src/app/api/companies/[id]/social/week-plans/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + createSocialPosts, + deletePlannedPostsForWeekPlan, + getSocialSchedule, + getSocialWeekPlan, + listSocialThemes, + listSocialWeekPlans, + updateSocialDataItem, + upsertSocialWeekPlan, + type SocialDataItem, +} from '@autogtm/core/db/socialsDbCalls'; +import { allocateWeek } from '@autogtm/core/socials/weeklyPlanner'; + +function toDateOnly(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const weekStartDate = request.nextUrl.searchParams.get('week_start_date'); + if (weekStartDate) { + const plan = await getSocialWeekPlan(companyId, weekStartDate); + return NextResponse.json({ plan }); + } + const plans = await listSocialWeekPlans(companyId, 12); + return NextResponse.json({ plans }); + } catch (error) { + console.error('Error fetching social week plans:', error); + return NextResponse.json({ error: 'Failed to fetch social week plans' }, { status: 500 }); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: companyId } = await params; + const body = await request.json().catch(() => ({})) as { week_start_date?: string }; + + const weekStartDate = body.week_start_date || toDateOnly(new Date()); + const schedule = await getSocialSchedule(companyId); + if (!schedule || !schedule.is_active || !Array.isArray(schedule.slots) || schedule.slots.length === 0) { + return NextResponse.json({ error: 'Schedule is not configured' }, { status: 400 }); + } + + const themes = (await listSocialThemes(companyId)).filter((theme) => theme.is_active); + if (themes.length === 0) { + return NextResponse.json({ error: 'No active themes found' }, { status: 400 }); + } + + const supabase = (await import('@supabase/supabase-js')).createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + const { data: classifiedItems, error: itemsError } = await supabase + .from('social_data_items') + .select('*') + .eq('company_id', companyId) + .eq('status', 'classified') + .order('created_at', { ascending: true }); + if (itemsError) throw itemsError; + const items = (classifiedItems || []) as SocialDataItem[]; + + const weekStart = new Date(`${weekStartDate}T00:00:00.000Z`); + const slots = (schedule.slots || []).flatMap((slot, idx) => { + const out: Array<{ slot_index: number; day_of_week: number; hour_utc: number; theme_id?: string | null; scheduled_for: string }> = []; + for (let dayOffset = 0; dayOffset < 7; dayOffset++) { + const dayDate = new Date(weekStart); + dayDate.setUTCDate(weekStart.getUTCDate() + dayOffset); + if (dayDate.getUTCDay() !== slot.day_of_week) continue; + dayDate.setUTCHours(slot.hour_utc, 0, 0, 0); + out.push({ + slot_index: idx + dayOffset * 100, + day_of_week: slot.day_of_week, + hour_utc: slot.hour_utc, + theme_id: slot.theme_id || null, + scheduled_for: dayDate.toISOString(), + }); + } + return out; + }).sort((a, b) => a.scheduled_for.localeCompare(b.scheduled_for)); + + const inventoryByThemeId: Record = {}; + const itemsByThemeId: Record = {}; + for (const theme of themes) { + const matching = items.filter((item) => item.theme_id === theme.id); + inventoryByThemeId[theme.id] = matching.length; + itemsByThemeId[theme.id] = matching; + } + + const allocation = allocateWeek({ + slots, + themes: themes.map((theme) => ({ + id: theme.id, + name: theme.name, + priority: theme.priority, + is_active: theme.is_active, + })), + inventoryByThemeId, + }); + + const plan = await upsertSocialWeekPlan(companyId, weekStartDate, { + status: 'draft', + planner_summary: allocation.summary as unknown as Record, + }); + + await deletePlannedPostsForWeekPlan(companyId, plan.id); + + const postsToCreate: Array<{ + company_id: string; + theme_id: string | null; + data_item_id: string | null; + week_plan_id: string; + slot_index: number; + scheduled_for: string; + status: 'planned'; + }> = []; + + for (const assignment of allocation.slotAssignments) { + if ('skipped' in assignment && assignment.skipped) continue; + const pool = itemsByThemeId[assignment.theme_id] || []; + const item = pool.shift(); + if (!item) continue; + postsToCreate.push({ + company_id: companyId, + theme_id: assignment.theme_id, + data_item_id: item.id, + week_plan_id: plan.id, + slot_index: assignment.slot_index, + scheduled_for: assignment.scheduled_for, + status: 'planned', + }); + await updateSocialDataItem(companyId, item.id, { status: 'reserved' }); + } + + const createdPosts = await createSocialPosts(postsToCreate); + return NextResponse.json({ plan, postsCreated: createdPosts.length }); + } catch (error) { + console.error('Error replanning social week:', error); + return NextResponse.json({ error: 'Failed to replan social week' }, { status: 500 }); + } +} diff --git a/apps/autogtm/src/components/Dashboard.tsx b/apps/autogtm/src/components/Dashboard.tsx index f102189..217e6c3 100644 --- a/apps/autogtm/src/components/Dashboard.tsx +++ b/apps/autogtm/src/components/Dashboard.tsx @@ -33,6 +33,7 @@ import { ArrowDownWideNarrow, } from 'lucide-react'; import { AutopilotTab } from '@/components/AutopilotTab'; +import { SocialsPanel } from '@/components/SocialsPanel'; interface DashboardProps { userEmail: string; @@ -153,7 +154,7 @@ interface InstantlyAccount { warmup_status: number; // 0=Paused, 1=Active, -1=Banned } -type Tab = 'context' | 'searches' | 'leads' | 'campaigns' | 'autopilot'; +type Tab = 'context' | 'searches' | 'leads' | 'campaigns' | 'socials' | 'autopilot'; export function Dashboard({ userEmail }: DashboardProps) { const { toast } = useToast(); @@ -976,6 +977,7 @@ export function Dashboard({ userEmail }: DashboardProps) { { id: 'searches', label: 'Searches', count: stats.queries, icon: Search }, { id: 'leads', label: 'Leads', count: stats.leads, icon: Users }, { id: 'campaigns', label: 'Campaigns', count: liveCampaigns.length, icon: Send }, + { id: 'socials', label: 'Socials', icon: Sparkles }, { id: 'autopilot', label: 'Autopilot', icon: Zap, showBlip: !!company?.auto_add_enabled }, ]; @@ -2131,6 +2133,11 @@ export function Dashboard({ userEmail }: DashboardProps) { onCompanyUpdated={(updates) => setCompany((prev) => (prev ? { ...prev, ...updates } : prev))} /> )} + + {/* Socials Tab */} + {activeTab === 'socials' && companyId && ( + + )} )} diff --git a/apps/autogtm/src/components/SocialsPanel.tsx b/apps/autogtm/src/components/SocialsPanel.tsx new file mode 100644 index 0000000..cd4da83 --- /dev/null +++ b/apps/autogtm/src/components/SocialsPanel.tsx @@ -0,0 +1,677 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Loader2 } from 'lucide-react'; + +type SocialSubTab = 'themes' | 'schedule' | 'plan' | 'queue' | 'calendar'; + +interface SocialTheme { + id: string; + name: string; + purpose: string; + caption_prompt: string; + image_prompt_template: string; + brand_voice: string; + priority: number; + is_active: boolean; +} + +interface SocialSchedule { + preset: string; + timezone: string; + slots: Array<{ day_of_week: number; hour_utc: number; theme_id?: string | null }>; +} + +interface SocialWeekPlan { + id: string; + week_start_date: string; + status: string; + planner_summary: Record; +} + +interface SocialPost { + id: string; + status: string; + scheduled_for: string; + caption: string | null; + theme_id: string | null; + image_status?: string | null; + image_url?: string | null; + error?: string | null; +} + +interface SocialDataItem { + id: string; + raw_text: string; + status: string; + theme_id: string | null; + classification_confidence: number | null; +} + +const schedulePresetOptions = [ + { id: 'creator_daily', label: 'Creator Daily' }, + { id: 'creator_mwf', label: 'Creator MWF' }, + { id: 'brand_weekday', label: 'Brand Weekday' }, + { id: 'brand_heavy', label: 'Brand Heavy' }, + { id: 'weekly_pulse', label: 'Weekly Pulse' }, + { id: 'custom', label: 'Custom' }, +]; + +export function SocialsPanel({ companyId }: { companyId: string }) { + const [activeTab, setActiveTab] = useState('queue'); + const [loading, setLoading] = useState(false); + + const [themes, setThemes] = useState([]); + const [schedule, setSchedule] = useState(null); + const [weekPlans, setWeekPlans] = useState([]); + const [planPosts, setPlanPosts] = useState([]); + const [queuePosts, setQueuePosts] = useState([]); + const [dataItems, setDataItems] = useState([]); + const [creatingTheme, setCreatingTheme] = useState(false); + const [archivingThemes, setArchivingThemes] = useState(false); + const [showCreateThemeForm, setShowCreateThemeForm] = useState(false); + const [themeDataDrafts, setThemeDataDrafts] = useState>({}); + const [addingDataThemeId, setAddingDataThemeId] = useState(null); + const [generatingDraftThemeId, setGeneratingDraftThemeId] = useState(null); + const [newTheme, setNewTheme] = useState({ + name: '', + purpose: '', + caption_prompt: '', + image_prompt_template: '', + brand_voice: '', + priority: 5, + }); + + async function fetchSocialsData() { + setLoading(true); + try { + const [themesRes, scheduleRes, plansRes, queueRes, itemsRes] = await Promise.all([ + fetch(`/api/companies/${companyId}/social/themes`), + fetch(`/api/companies/${companyId}/social/schedule`), + fetch(`/api/companies/${companyId}/social/week-plans`), + fetch(`/api/companies/${companyId}/social/posts`), + fetch(`/api/companies/${companyId}/social/items`), + ]); + + let fetchedThemes: SocialTheme[] = []; + if (themesRes.ok) { + const data = await themesRes.json(); + fetchedThemes = data.themes || []; + setThemes(fetchedThemes); + } + if (scheduleRes.ok) { + const data = await scheduleRes.json(); + setSchedule(data.schedule || null); + } + if (plansRes.ok) { + const data = await plansRes.json(); + setWeekPlans(data.plans || []); + } + if (queueRes.ok) { + const data = await queueRes.json(); + let posts = data.posts || []; + + // Auto-clean orphan posts (theme archived/removed) so queue never shows stale entries. + if (fetchedThemes.length > 0 && posts.length > 0) { + const activeThemeIds = new Set(fetchedThemes.map((theme) => theme.id)); + const orphanPosts = posts.filter( + (post: SocialPost) => + post.theme_id && + !activeThemeIds.has(post.theme_id) && + post.status !== 'published' && + post.status !== 'cancelled' + ); + + if (orphanPosts.length > 0) { + await Promise.all( + orphanPosts.map((post: SocialPost) => + fetch(`/api/companies/${companyId}/social/posts/${post.id}/cancel`, { method: 'POST' }) + ) + ); + const refreshedQueueRes = await fetch(`/api/companies/${companyId}/social/posts`); + if (refreshedQueueRes.ok) { + const refreshedQueueData = await refreshedQueueRes.json(); + posts = refreshedQueueData.posts || []; + } + } + } + + setQueuePosts(posts); + } + if (itemsRes.ok) { + const data = await itemsRes.json(); + setDataItems(data.items || []); + } + } finally { + setLoading(false); + } + } + + async function loadPlanPosts(planId: string) { + const res = await fetch(`/api/companies/${companyId}/social/week-plans/${planId}`); + if (!res.ok) return; + const data = await res.json(); + setPlanPosts(data.posts || []); + } + + useEffect(() => { + void fetchSocialsData(); + }, [companyId]); + + async function handleCreateTheme() { + if (!newTheme.name.trim()) return; + setCreatingTheme(true); + try { + const res = await fetch(`/api/companies/${companyId}/social/themes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newTheme), + }); + if (res.ok) { + setNewTheme({ + name: '', + purpose: '', + caption_prompt: '', + image_prompt_template: '', + brand_voice: '', + priority: 5, + }); + await fetchSocialsData(); + } + } finally { + setCreatingTheme(false); + } + } + + async function handleSchedulePresetChange(preset: string) { + await fetch(`/api/companies/${companyId}/social/schedule`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preset }), + }); + await fetchSocialsData(); + } + + async function handleReplanWeek(weekStartDate?: string) { + await fetch(`/api/companies/${companyId}/social/week-plans`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ week_start_date: weekStartDate }), + }); + await fetchSocialsData(); + } + + async function handleApprovePlan(planId: string) { + await fetch(`/api/companies/${companyId}/social/week-plans/${planId}/approve`, { + method: 'POST', + }); + await fetchSocialsData(); + await loadPlanPosts(planId); + } + + async function handleAddDataToTheme(themeId: string) { + const rawContent = (themeDataDrafts[themeId] || '').trim(); + if (!rawContent) return; + setAddingDataThemeId(themeId); + try { + await fetch(`/api/companies/${companyId}/social/themes/${themeId}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ raw_content: rawContent, source: 'paste' }), + }); + setThemeDataDrafts((prev) => ({ ...prev, [themeId]: '' })); + await fetchSocialsData(); + } finally { + setAddingDataThemeId(null); + } + } + + async function handleGenerateOneDraft(themeId: string) { + setGeneratingDraftThemeId(themeId); + try { + await fetch(`/api/companies/${companyId}/social/themes/${themeId}/draft-one`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + await fetchSocialsData(); + } finally { + setGeneratingDraftThemeId(null); + } + } + + async function handleApproveAndGenerate(postId: string, mode: 'video' | 'image') { + await fetch(`/api/companies/${companyId}/social/posts/${postId}/approve`, { method: 'POST' }); + await fetch(`/api/companies/${companyId}/social/posts/${postId}/generate-image`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }); + await fetchSocialsData(); + } + + async function handleDeleteTheme(themeId: string) { + await fetch(`/api/companies/${companyId}/social/themes/${themeId}`, { method: 'DELETE' }); + await fetchSocialsData(); + } + + async function handleArchiveAllThemes() { + if (themes.length === 0) return; + if (!window.confirm('Archive all existing themes? You can create fresh ones right after this.')) return; + setArchivingThemes(true); + try { + await Promise.all( + themes.map((theme) => fetch(`/api/companies/${companyId}/social/themes/${theme.id}`, { method: 'DELETE' })) + ); + await fetchSocialsData(); + } finally { + setArchivingThemes(false); + } + } + + const themesById = Object.fromEntries(themes.map((theme) => [theme.id, theme.name])); + const queueReview = queuePosts.filter((post) => post.status === 'pending_review'); + const queueApproved = queuePosts.filter((post) => post.status === 'approved' || post.status === 'image_ready'); + + function getThemeLabel(themeId: string | null): string { + if (!themeId) return 'Unassigned'; + return themesById[themeId] || 'Archived/removed theme'; + } + + const activePlan = weekPlans[0] || null; + + return ( +
+
+ {([ + ['queue', 'Queue'], + ['plan', 'Plan'], + ['themes', 'Themes'], + ['schedule', 'Schedule'], + ['calendar', 'Calendar'], + ] as Array<[SocialSubTab, string]>).map(([id, label]) => ( + + ))} +
+ +
+
+ + {activeTab === 'themes' && ( +
+
+

Existing Themes ({themes.length})

+
+ + +
+
+ + {showCreateThemeForm && ( +
+
+

+ Raw instructions for writing: use Caption prompt template. +

+

+ Raw instructions for visuals: use Image prompt template. +

+
+
+
+

Theme name

+ setNewTheme((prev) => ({ ...prev, name: e.target.value }))} + /> +
+
+

Purpose

+ setNewTheme((prev) => ({ ...prev, purpose: e.target.value }))} + /> +
+
+

Caption prompt template

+