From f12c180282821f2f4244223b2a22a650d8533086 Mon Sep 17 00:00:00 2001 From: 0xjitsu <0xjitsu@radmafia.xyz> Date: Tue, 24 Mar 2026 05:16:44 +0800 Subject: [PATCH 1/2] feat: automated outreach with lifecycle state machine, Gmail API, DM queue, follow-up cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add outreach lifecycle module (outreach-lifecycle.ts) centralizing all status transitions: draft → sent → follow-up 1/2 → completed/replied - Integrate Gmail OAuth flow (route + callback) for server-side email sending - Build Gmail API client with token refresh, RFC 2822 MIME encoding, reply detection - Create /api/outreach/send endpoint with rate limiter integration - Add DM Focus Mode to queue page: one-at-a-time workflow with editable message, progress bar, rate limit display per platform - Add confirm-before-sent dialog for DMs (prevents false sent marks) - Create follow-up cron job (daily 9am UTC via Vercel Cron) that auto-sends email follow-ups and queues DM follow-ups for human review - Update settings page: Gmail now uses OAuth connect/disconnect flow - Add DB migration 002 for follow-up tracking columns and indexes Co-Authored-By: Claude Opus 4.6 --- apps/command-center/package.json | 1 + .../app/(dashboard)/creators/[id]/page.tsx | 41 ++- .../src/app/(dashboard)/settings/page.tsx | 31 +- .../src/app/api/auth/gmail/callback/route.ts | 90 +++++ .../src/app/api/auth/gmail/route.ts | 65 ++++ .../src/app/api/auth/status/route.ts | 11 +- .../src/app/api/cron/follow-ups/route.ts | 286 +++++++++++++++ .../src/app/api/outreach/approve/route.ts | 14 +- .../src/app/api/outreach/check-rate/route.ts | 19 + .../src/app/api/outreach/launch/route.ts | 2 + .../src/app/api/outreach/send/route.ts | 108 ++++++ .../src/components/draft-card.tsx | 344 +++++++++++------- .../src/components/outreach-queue.tsx | 315 +++++++++++++++- .../src/components/ui/alert-dialog.tsx | 141 +++++++ .../src/components/ui/button.tsx | 12 +- .../src/components/ui/textarea.tsx | 22 ++ apps/command-center/src/lib/gmail.ts | 199 ++++++++-- .../src/lib/outreach-lifecycle.ts | 142 ++++++++ apps/command-center/src/lib/types.ts | 12 + .../db/migrations/002_outreach_automation.sql | 24 ++ pnpm-lock.yaml | 30 ++ vercel.json | 9 +- 22 files changed, 1698 insertions(+), 220 deletions(-) create mode 100644 apps/command-center/src/app/api/auth/gmail/callback/route.ts create mode 100644 apps/command-center/src/app/api/auth/gmail/route.ts create mode 100644 apps/command-center/src/app/api/cron/follow-ups/route.ts create mode 100644 apps/command-center/src/app/api/outreach/check-rate/route.ts create mode 100644 apps/command-center/src/app/api/outreach/send/route.ts create mode 100644 apps/command-center/src/components/ui/alert-dialog.tsx create mode 100644 apps/command-center/src/components/ui/textarea.tsx create mode 100644 apps/command-center/src/lib/outreach-lifecycle.ts create mode 100644 packages/db/migrations/002_outreach_automation.sql diff --git a/apps/command-center/package.json b/apps/command-center/package.json index ea968b6..ca216c7 100644 --- a/apps/command-center/package.json +++ b/apps/command-center/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/apps/command-center/src/app/(dashboard)/creators/[id]/page.tsx b/apps/command-center/src/app/(dashboard)/creators/[id]/page.tsx index 89e0728..1ca191a 100644 --- a/apps/command-center/src/app/(dashboard)/creators/[id]/page.tsx +++ b/apps/command-center/src/app/(dashboard)/creators/[id]/page.tsx @@ -16,7 +16,7 @@ import { import { PlatformBadge } from "@/components/platform-icon" import { PipelineBadge } from "@/components/pipeline-badge" import type { Creator, PipelineStage, OutreachLog, OutreachChannel } from "@/lib/types" -import { sendViaGmail } from "@/lib/gmail" +import { generateMailtoUrl } from "@/lib/gmail" import { ArrowLeft, Rocket, @@ -155,19 +155,38 @@ export default function CreatorDetailPage({ const handleApproveAndSend = async (log: OutreachLog) => { if (!creator) return - if (log.channel === "email" && creator.email) { - const result = await sendViaGmail({ - outreachId: log.id, - to: creator.email, - subject: log.subject || "", - body: log.content, + if (log.channel === "email") { + // Try server-side Gmail API send first + const sendRes = await fetch("/api/outreach/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outreach_id: log.id }), }) - if (result.success) { - toast.success("Email opened in mail client and marked as sent!") + + if (sendRes.ok) { + toast.success(`Email sent to ${creator.email} via Gmail API!`) fetchCreator() - } else { - toast.error(result.error || "Failed to send") + return } + + // Fallback to mailto: if Gmail API not connected + if (creator.email) { + const url = generateMailtoUrl({ + to: creator.email, + subject: log.subject || "", + body: log.content, + }) + window.open(url, "_blank") + } + + // Mark as sent via approve endpoint + await fetch("/api/outreach/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outreach_id: log.id, action: "send" }), + }) + toast.success("Email opened in mail client and marked as sent!") + fetchCreator() } else { // For DMs, just mark as sent try { diff --git a/apps/command-center/src/app/(dashboard)/settings/page.tsx b/apps/command-center/src/app/(dashboard)/settings/page.tsx index 30ccf77..c152d92 100644 --- a/apps/command-center/src/app/(dashboard)/settings/page.tsx +++ b/apps/command-center/src/app/(dashboard)/settings/page.tsx @@ -133,9 +133,13 @@ function SettingsPageInner() { toast.success("Instagram connected successfully!") } else if (connected === "tiktok") { toast.success("TikTok connected successfully!") + } else if (connected === "gmail") { + toast.success("Gmail connected! Emails will now send via Gmail API.") } - if (error === "instagram_not_configured") { + if (error === "gmail_not_configured") { + toast.error("Set up GOOGLE_CLIENT_ID in Vercel environment variables first") + } else if (error === "instagram_not_configured") { toast.error("Set up META_APP_ID in Vercel environment variables first") } else if (error === "tiktok_not_configured") { toast.error("Set up TIKTOK_CLIENT_KEY in Vercel environment variables first") @@ -267,27 +271,8 @@ function SettingsPageInner() { ) } - // Gmail is always connected - if (platform === "gmail") { - return ( -
-
- {icon} -
-

{label}

-

{description}

-
-
- - - Connected - -
- ) - } + // Gmail: show OAuth connect/disconnect (same as other platforms) + // Falls through to the standard connected/not-connected logic below const status = authStatus?.[platform] @@ -420,7 +405,7 @@ function SettingsPageInner() { "gmail", , "Gmail", - "Send emails via mailto: protocol" + "Send emails via Gmail API (connect to automate sending)" )} {renderPlatformRow( "instagram", diff --git a/apps/command-center/src/app/api/auth/gmail/callback/route.ts b/apps/command-center/src/app/api/auth/gmail/callback/route.ts new file mode 100644 index 0000000..518dad3 --- /dev/null +++ b/apps/command-center/src/app/api/auth/gmail/callback/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server" +import { cookies } from "next/headers" +import { createServerClient } from "@/lib/supabase-server" + +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" + +export async function GET(request: NextRequest) { + const appUrl = process.env.NEXT_PUBLIC_APP_URL! + + try { + const { searchParams } = request.nextUrl + const code = searchParams.get("code") + const state = searchParams.get("state") + + if (!code || !state) { + return NextResponse.redirect(new URL("/settings?error=gmail_failed", appUrl)) + } + + // CSRF verification + const cookieStore = await cookies() + const savedState = cookieStore.get("oauth_state_gmail")?.value + + if (!savedState || savedState !== state) { + return NextResponse.redirect(new URL("/settings?error=gmail_csrf", appUrl)) + } + + // Exchange code for tokens + const redirectUri = `${appUrl}/api/auth/gmail/callback` + const tokenResponse = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: process.env.GOOGLE_CLIENT_ID!, + client_secret: process.env.GOOGLE_CLIENT_SECRET!, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }) + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text() + throw new Error(`Token exchange failed: ${errorText}`) + } + + const tokenData = await tokenResponse.json() + const { access_token, refresh_token, expires_in } = tokenData + + // Fetch user's email address for display + const profileResponse = await fetch( + "https://gmail.googleapis.com/gmail/v1/users/me/profile", + { headers: { Authorization: `Bearer ${access_token}` } } + ) + + let accountName = "Gmail" + if (profileResponse.ok) { + const profile = await profileResponse.json() + accountName = profile.emailAddress || "Gmail" + } + + // Store tokens in database + const supabase = createServerClient() + const expiresAt = expires_in + ? new Date(Date.now() + expires_in * 1000).toISOString() + : null + + const { error: dbError } = await supabase.from("oauth_tokens").upsert( + { + platform: "gmail", + access_token, + refresh_token: refresh_token || null, + expires_at: expiresAt, + account_name: accountName, + }, + { onConflict: "platform" } + ) + + if (dbError) { + throw new Error(`Database upsert failed: ${dbError.message}`) + } + + // Clear state cookie + cookieStore.delete("oauth_state_gmail") + + return NextResponse.redirect(new URL("/settings?connected=gmail", appUrl)) + } catch (error) { + console.error("[auth/gmail/callback] Error:", error) + return NextResponse.redirect(new URL("/settings?error=gmail_failed", appUrl)) + } +} diff --git a/apps/command-center/src/app/api/auth/gmail/route.ts b/apps/command-center/src/app/api/auth/gmail/route.ts new file mode 100644 index 0000000..d4925bf --- /dev/null +++ b/apps/command-center/src/app/api/auth/gmail/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server" +import { cookies } from "next/headers" + +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" + +export async function GET() { + try { + const clientId = process.env.GOOGLE_CLIENT_ID + if (!clientId) { + return NextResponse.redirect( + new URL("/settings?error=gmail_not_configured", process.env.NEXT_PUBLIC_APP_URL!) + ) + } + + const state = crypto.randomUUID() + + const cookieStore = await cookies() + cookieStore.set("oauth_state_gmail", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 600, // 10 minutes + path: "/", + }) + + const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/gmail/callback` + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.readonly", + access_type: "offline", + prompt: "consent", // Force consent to always get refresh_token + state, + }) + + return NextResponse.redirect(`${GOOGLE_AUTH_URL}?${params.toString()}`) + } catch (error) { + console.error("[auth/gmail] Error:", error) + return NextResponse.redirect( + new URL("/settings?error=gmail_failed", process.env.NEXT_PUBLIC_APP_URL!) + ) + } +} + +// DELETE /api/auth/gmail — Disconnect Gmail OAuth +export async function DELETE() { + try { + const { createServerClient } = await import("@/lib/supabase-server") + const supabase = createServerClient() + + const { error } = await supabase + .from("oauth_tokens") + .delete() + .eq("platform", "gmail") + + if (error) throw new Error(error.message) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[auth/gmail] DELETE error:", error) + return NextResponse.json({ error: "Failed to disconnect" }, { status: 500 }) + } +} diff --git a/apps/command-center/src/app/api/auth/status/route.ts b/apps/command-center/src/app/api/auth/status/route.ts index 6d4855e..9317cf4 100644 --- a/apps/command-center/src/app/api/auth/status/route.ts +++ b/apps/command-center/src/app/api/auth/status/route.ts @@ -22,6 +22,7 @@ export async function GET() { const instagramToken = tokenMap.get("instagram") const tiktokToken = tokenMap.get("tiktok") + const gmailToken = tokenMap.get("gmail") return NextResponse.json({ instagram: { @@ -37,10 +38,12 @@ export async function GET() { configured: !!process.env.TIKTOK_CLIENT_KEY, }, gmail: { - connected: true, - account_name: "mailto:", - expires_at: null, - configured: true, + connected: !!gmailToken, + account_name: gmailToken?.account_name || "mailto:", + expires_at: gmailToken?.expires_at || null, + configured: !!process.env.GOOGLE_CLIENT_ID, + // "api" if OAuth token exists, "mailto" if fallback + mode: gmailToken ? "api" : "mailto", }, }) } catch (error) { diff --git a/apps/command-center/src/app/api/cron/follow-ups/route.ts b/apps/command-center/src/app/api/cron/follow-ups/route.ts new file mode 100644 index 0000000..adfac97 --- /dev/null +++ b/apps/command-center/src/app/api/cron/follow-ups/route.ts @@ -0,0 +1,286 @@ +import { NextRequest, NextResponse } from "next/server" +import { createServerClient } from "@/lib/supabase-server" +import { sendEmailViaAPI, getGmailToken, checkForReply } from "@/lib/gmail" +import { canSend, recordSend } from "@/lib/rate-limiter" +import { advanceStatus } from "@/lib/outreach-lifecycle" +import fs from "fs" +import path from "path" + +// GET /api/cron/follow-ups — Daily cron job for follow-up automation +// Vercel Cron calls this via GET with Authorization header +export async function GET(request: NextRequest) { + // Verify cron secret (Vercel sends this automatically) + const authHeader = request.headers.get("authorization") + const cronSecret = process.env.CRON_SECRET + + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const supabase = createServerClient() + const now = new Date().toISOString() + const results = { + checked: 0, + repliesDetected: 0, + followUpsDrafted: 0, + emailsSent: 0, + dmsDrafted: 0, + completed: 0, + } + + // Step 1: Check for replies (Gmail only) before generating follow-ups + const gmailToken = await getGmailToken() + if (gmailToken) { + const { data: awaitingReply } = await supabase + .from("creators") + .select("id, email, dm_sent_date, dm_status") + .in("dm_status", ["Sent", "Follow Up 1", "Follow Up 2"]) + .not("email", "is", null) + + for (const creator of awaitingReply || []) { + if (!creator.email || !creator.dm_sent_date) continue + results.checked++ + + const hasReply = await checkForReply({ + fromEmail: creator.email, + afterDate: creator.dm_sent_date, + }) + + if (hasReply) { + await advanceStatus(creator.id, "reply_detected") + results.repliesDetected++ + } + } + } + + // Step 2: Auto-complete sequences that are done + // Creators with dm_status "Follow Up 2" and dm_sent_date > 7 days ago + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() + const { data: toComplete } = await supabase + .from("creators") + .select("id") + .eq("dm_status", "Follow Up 2") + .lt("dm_sent_date", sevenDaysAgo) + + for (const creator of toComplete || []) { + await advanceStatus(creator.id, "sequence_complete") + results.completed++ + } + + // Step 3: Generate follow-ups for creators due today + const { data: dueForFollowUp } = await supabase + .from("creators") + .select("*") + .lte("next_follow_up", now) + .in("dm_status", ["Sent", "Follow Up 1"]) + + if (!dueForFollowUp || dueForFollowUp.length === 0) { + return NextResponse.json({ + message: "Cron complete", + ...results, + }) + } + + // Load follow-up templates + const templatesDir = path.join(process.cwd(), "..", "..", "campaigns", "templates") + const templates: Record = {} + + for (const templateName of ["follow-up-1", "follow-up-2"]) { + try { + const raw = fs.readFileSync( + path.join(templatesDir, `${templateName}.md`), + "utf-8" + ) + const [emailPart, dmPart] = raw.split("---") + templates[templateName] = { + email: emailPart.trim(), + dm: (dmPart || "").replace(//g, "").trim(), + } + } catch { + // Fallback templates + templates[templateName] = { + email: `Hi {{name}}, following up on our previous message about the Anything Creator Program. Let me know if you're interested!\n\n{{sender_name}}`, + dm: `Hey {{name}}, following up about the Anything Creator Program. Let me know if you're curious!`, + } + } + } + + // Process each creator + for (const creator of dueForFollowUp) { + const templateKey = + creator.dm_status === "Sent" ? "follow-up-1" : "follow-up-2" + const template = templates[templateKey] + + const firstName = creator.name.split(" ")[0] + const vars: Record = { + name: creator.name, + first_name: firstName, + handle: creator.handle || "", + niche: creator.niche || "", + specific_hook: creator.specific_hook || "", + content_idea: creator.content_idea || "", + sender_name: "Berna", + product_name: "Anything", + program_name: "Anything Creator Program", + } + + const fillTemplate = (tpl: string) => { + let result = tpl + for (const [key, value] of Object.entries(vars)) { + result = result.replaceAll(`{{${key}}}`, value) + } + return result + } + + // Find the initial outreach to link as parent + const { data: parentOutreach } = await supabase + .from("outreach_log") + .select("id") + .eq("creator_id", creator.id) + .eq("message_type", "cold") + .eq("status", "Sent") + .order("sent_at", { ascending: false }) + .limit(1) + .single() + + const parentId = parentOutreach?.id || null + const drafts = [] + + // Email follow-up + if (creator.email) { + const filledEmail = fillTemplate(template.email) + let subject = `Re: ${creator.name} x Anything` + let body = filledEmail + + if (filledEmail.startsWith("Subject:")) { + const lines = filledEmail.split("\n") + subject = lines[0].replace("Subject: ", "").trim() + body = lines.slice(1).join("\n").trim() + } + + // Try to auto-send if Gmail API connected + if (gmailToken) { + const rateCheck = await canSend("gmail") + if (rateCheck.allowed) { + const sendResult = await sendEmailViaAPI({ + to: creator.email, + subject, + body, + }) + + if (sendResult.success) { + await recordSend("gmail") + + // Log the sent email + drafts.push({ + creator_id: creator.id, + creator_name: creator.name, + creator_handle: creator.handle, + channel: "email", + message_type: templateKey, + subject, + content: body, + status: "Sent", + sent_at: now, + gmail_message_id: sendResult.messageId || null, + parent_outreach_id: parentId, + }) + + results.emailsSent++ + } else { + // Gmail send failed — create as draft + drafts.push({ + creator_id: creator.id, + creator_name: creator.name, + creator_handle: creator.handle, + channel: "email", + message_type: templateKey, + subject, + content: body, + status: "Draft", + sent_at: null, + parent_outreach_id: parentId, + }) + results.followUpsDrafted++ + } + } else { + // Rate limit hit — create as draft + drafts.push({ + creator_id: creator.id, + creator_name: creator.name, + creator_handle: creator.handle, + channel: "email", + message_type: templateKey, + subject, + content: body, + status: "Draft", + sent_at: null, + parent_outreach_id: parentId, + }) + results.followUpsDrafted++ + } + } else { + // No Gmail API — create as draft for mailto: sending + drafts.push({ + creator_id: creator.id, + creator_name: creator.name, + creator_handle: creator.handle, + channel: "email", + message_type: templateKey, + subject, + content: body, + status: "Draft", + sent_at: null, + parent_outreach_id: parentId, + }) + results.followUpsDrafted++ + } + } + + // DM follow-up (always a draft — requires human sending) + const dmChannel = + creator.platform === "Instagram" + ? "instagram" + : creator.platform === "TikTok" + ? "tiktok" + : "twitter" + + const filledDm = fillTemplate(template.dm) + drafts.push({ + creator_id: creator.id, + creator_name: creator.name, + creator_handle: creator.handle, + channel: dmChannel, + message_type: templateKey, + subject: null, + content: filledDm, + status: "Draft", + sent_at: null, + parent_outreach_id: parentId, + }) + results.dmsDrafted++ + + // Insert all drafts + if (drafts.length > 0) { + await supabase.from("outreach_log").insert(drafts) + } + + // Advance lifecycle based on whether emails were sent + const emailWasSent = drafts.some( + (d) => d.channel === "email" && d.status === "Sent" + ) + + if (emailWasSent) { + const event = + templateKey === "follow-up-1" ? "follow_up_1_sent" : "follow_up_2_sent" + await advanceStatus(creator.id, event as "follow_up_1_sent" | "follow_up_2_sent") + } + // If only drafts were created, don't advance lifecycle yet — + // it will advance when the user sends them via the queue + } + + return NextResponse.json({ + message: `Cron complete: processed ${dueForFollowUp.length} creators`, + ...results, + }) +} diff --git a/apps/command-center/src/app/api/outreach/approve/route.ts b/apps/command-center/src/app/api/outreach/approve/route.ts index a699064..6edc8e6 100644 --- a/apps/command-center/src/app/api/outreach/approve/route.ts +++ b/apps/command-center/src/app/api/outreach/approve/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { createServerClient } from "@/lib/supabase-server" +import { advanceStatus, eventFromMessageType } from "@/lib/outreach-lifecycle" // POST /api/outreach/approve — Approve & send or skip an outreach draft export async function POST(request: NextRequest) { @@ -54,16 +55,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: updateError.message }, { status: 500 }) } - // Update creator pipeline stage and dm status - await supabase - .from("creators") - .update({ - stage: "Contacted", - dm_status: "sent", - dm_sent_date: now, - updated_at: now, - }) - .eq("id", outreach.creator_id) + // Advance creator status via lifecycle state machine + const event = eventFromMessageType(outreach.message_type) + await advanceStatus(outreach.creator_id, event) return NextResponse.json({ message: "Outreach marked as sent", diff --git a/apps/command-center/src/app/api/outreach/check-rate/route.ts b/apps/command-center/src/app/api/outreach/check-rate/route.ts new file mode 100644 index 0000000..0cb5bbb --- /dev/null +++ b/apps/command-center/src/app/api/outreach/check-rate/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server" +import { getStatus } from "@/lib/rate-limiter" + +// GET /api/outreach/check-rate?platform=instagram +export async function GET(request: NextRequest) { + const platform = request.nextUrl.searchParams.get("platform") + + if (!platform) { + return NextResponse.json({ error: "platform parameter required" }, { status: 400 }) + } + + try { + const status = await getStatus(platform) + return NextResponse.json(status) + } catch (error) { + console.error("[check-rate] Error:", error) + return NextResponse.json({ error: "Failed to check rate limit" }, { status: 500 }) + } +} diff --git a/apps/command-center/src/app/api/outreach/launch/route.ts b/apps/command-center/src/app/api/outreach/launch/route.ts index fd25e9d..5d9d4ef 100644 --- a/apps/command-center/src/app/api/outreach/launch/route.ts +++ b/apps/command-center/src/app/api/outreach/launch/route.ts @@ -139,6 +139,7 @@ Down to chat about a collab? creator_name: creator.name, creator_handle: creator.handle, channel: "email" as const, + message_type: "cold", subject: emailSubject, content: emailBody, status: "Draft" as const, @@ -156,6 +157,7 @@ Down to chat about a collab? creator_name: creator.name, creator_handle: creator.handle, channel: dmChannel, + message_type: "cold", subject: null, content: filledDm, status: "Draft" as const, diff --git a/apps/command-center/src/app/api/outreach/send/route.ts b/apps/command-center/src/app/api/outreach/send/route.ts new file mode 100644 index 0000000..ce487eb --- /dev/null +++ b/apps/command-center/src/app/api/outreach/send/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server" +import { createServerClient } from "@/lib/supabase-server" +import { sendEmailViaAPI } from "@/lib/gmail" +import { canSend, recordSend, recordFailure } from "@/lib/rate-limiter" +import { advanceStatus, eventFromMessageType } from "@/lib/outreach-lifecycle" + +// POST /api/outreach/send — Server-side send via Gmail API with rate limiting +export async function POST(request: NextRequest) { + const body = await request.json() + const { outreach_id } = body as { outreach_id: string } + + if (!outreach_id) { + return NextResponse.json({ error: "No outreach_id provided" }, { status: 400 }) + } + + const supabase = createServerClient() + + // Fetch the outreach entry + const { data: outreach, error: fetchError } = await supabase + .from("outreach_log") + .select("*") + .eq("id", outreach_id) + .single() + + if (fetchError || !outreach) { + return NextResponse.json({ error: "Outreach entry not found" }, { status: 404 }) + } + + if (outreach.status !== "Draft") { + return NextResponse.json({ error: "Outreach is not in Draft status" }, { status: 400 }) + } + + // Only email channel is supported for server-side send + if (outreach.channel !== "email") { + return NextResponse.json( + { error: "Server-side send only supports email. Use the DM queue for social." }, + { status: 400 } + ) + } + + // Fetch creator to get email address + const { data: creator } = await supabase + .from("creators") + .select("email, name") + .eq("id", outreach.creator_id) + .single() + + if (!creator?.email) { + return NextResponse.json({ error: "Creator has no email address" }, { status: 400 }) + } + + // Check rate limit + const rateCheck = await canSend("gmail") + if (!rateCheck.allowed) { + return NextResponse.json( + { + error: "Rate limit reached", + waitMs: rateCheck.waitMs, + dailySent: rateCheck.dailySent, + dailyCap: rateCheck.dailyCap, + }, + { status: 429 } + ) + } + + // Send via Gmail API + const result = await sendEmailViaAPI({ + to: creator.email, + subject: outreach.subject || `${creator.name} x Anything`, + body: outreach.content, + }) + + if (!result.success) { + await recordFailure("gmail") + + // Update outreach status to Failed + await supabase + .from("outreach_log") + .update({ status: "Failed" }) + .eq("id", outreach_id) + + return NextResponse.json( + { error: result.error || "Gmail send failed" }, + { status: 500 } + ) + } + + // Success — record send, update outreach, advance lifecycle + await recordSend("gmail") + + const now = new Date().toISOString() + await supabase + .from("outreach_log") + .update({ + status: "Sent", + sent_at: now, + gmail_message_id: result.messageId || null, + }) + .eq("id", outreach_id) + + const event = eventFromMessageType(outreach.message_type) + await advanceStatus(outreach.creator_id, event) + + return NextResponse.json({ + message: `Email sent to ${creator.email}`, + messageId: result.messageId, + }) +} diff --git a/apps/command-center/src/components/draft-card.tsx b/apps/command-center/src/components/draft-card.tsx index 1c42da7..afe3700 100644 --- a/apps/command-center/src/components/draft-card.tsx +++ b/apps/command-center/src/components/draft-card.tsx @@ -1,9 +1,20 @@ "use client" +import { useState } from "react" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" import type { OutreachLog, OutreachChannel } from "@/lib/types" import { generateMailtoUrl } from "@/lib/gmail" import { openInstagramDM, openTikTokDM } from "@/lib/playwright-dm" @@ -15,6 +26,7 @@ import { ExternalLink, SkipForward, Clock, + Loader2, } from "lucide-react" import { toast } from "sonner" @@ -50,6 +62,7 @@ function timeAgo(dateStr: string): string { interface DraftCardProps { log: OutreachLog + gmailConnected?: boolean selectable?: boolean selected?: boolean onSelectChange?: (checked: boolean) => void @@ -58,48 +71,78 @@ interface DraftCardProps { export function DraftCard({ log, + gmailConnected = false, selectable = false, selected = false, onSelectChange, onActionComplete, }: DraftCardProps) { + const [sending, setSending] = useState(false) + const [showDmConfirm, setShowDmConfirm] = useState(false) const ChannelIcon = channelIcons[log.channel] - const handleSendViaGmail = async () => { - // Open mailto link for emails - if (log.channel === "email") { + // Send email via Gmail API (server-side) or mailto: fallback + const handleSendEmail = async () => { + if (gmailConnected) { + setSending(true) + try { + const res = await fetch("/api/outreach/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outreach_id: log.id }), + }) + + if (res.status === 429) { + const data = await res.json() + toast.error(`Rate limit reached (${data.dailySent}/${data.dailyCap} today)`) + return + } + + if (res.ok) { + toast.success(`Email sent to ${log.creator_name} via Gmail API!`) + onActionComplete?.() + } else { + const data = await res.json() + toast.error(data.error || "Failed to send email") + } + } catch { + toast.error("Network error sending email") + } finally { + setSending(false) + } + } else { + // Fallback to mailto: const url = generateMailtoUrl({ - to: "", // We don't have the email on the outreach_log; the mailto will open without "to" + to: "", subject: log.subject || "", body: log.content, }) window.open(url, "_blank") - } - // Mark as sent in the database - try { - const res = await fetch("/api/outreach/approve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ outreach_id: log.id, action: "send" }), - }) - if (res.ok) { - toast.success(`Email sent to ${log.creator_name} via Gmail!`) - onActionComplete?.() - } else { - toast.error("Failed to update status") + // Mark as sent + try { + const res = await fetch("/api/outreach/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outreach_id: log.id, action: "send" }), + }) + if (res.ok) { + toast.success(`Email opened for ${log.creator_name}`) + onActionComplete?.() + } else { + toast.error("Failed to update status") + } + } catch { + toast.error("Network error") } - } catch { - toast.error("Network error") } } + // Copy DM to clipboard and open profile, then show confirm dialog const handleSendDM = async () => { try { - // 1. Copy message to clipboard await navigator.clipboard.writeText(log.content) - // 2. Open creator's profile in new tab const handle = log.creator_handle || "" let profileUrl = "" @@ -110,20 +153,34 @@ export function DraftCard({ const payload = openTikTokDM(handle, log.content) profileUrl = payload.url } else { - // Twitter or other — just open a search profileUrl = `https://twitter.com/${handle.replace(/^@/, "")}` } window.open(profileUrl, "_blank") - // 3. Show instructional toast - const platformName = log.channel === "instagram" ? "Instagram" : log.channel === "tiktok" ? "TikTok" : "Twitter" + const platformName = + log.channel === "instagram" + ? "Instagram" + : log.channel === "tiktok" + ? "TikTok" + : "Twitter" + toast.success(`Message copied! Paste it in ${platformName}'s DM window`, { description: `${log.creator_name}'s profile opened in a new tab`, duration: 5000, }) - // 4. Mark as sent in the database + // Show confirmation dialog instead of auto-marking as sent + setShowDmConfirm(true) + } catch { + toast.error("Failed to copy to clipboard") + } + } + + // Confirm the DM was actually sent + const handleConfirmDmSent = async () => { + setShowDmConfirm(false) + try { await fetch("/api/outreach/approve", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -131,7 +188,7 @@ export function DraftCard({ }) onActionComplete?.() } catch { - toast.error("Failed to copy to clipboard") + toast.error("Failed to mark as sent") } } @@ -152,127 +209,150 @@ export function DraftCard({ } return ( - - -
- {selectable && ( -
- - onSelectChange?.(checked === true) - } - /> -
- )} -
- {/* Header */} -
-
-
- {log.creator_name} - - {log.creator_handle} - -
-
- - - {channelLabels[log.channel]} - - {log.subject && ( - - {log.subject} + <> + + +
+ {selectable && ( +
+ + onSelectChange?.(checked === true) + } + /> +
+ )} +
+ {/* Header */} +
+
+
+ {log.creator_name} + + {log.creator_handle} - )} +
+
+ + + {channelLabels[log.channel]} + + {log.subject && ( + + {log.subject} + + )} +
+
+
+ + {timeAgo(log.created_at)}
-
- - {timeAgo(log.created_at)} -
-
- {/* Message Preview */} -

- {log.content} -

+ {/* Message Preview */} +

+ {log.content} +

- {/* Actions */} - {log.status === "Draft" && ( -
- - {log.channel === "email" ? ( - - ) : ( + {/* Actions */} + {log.status === "Draft" && ( +
- )} - -
- )} + {log.channel === "email" ? ( + + ) : ( + + )} + +
+ )} - {/* Sent status */} - {log.status === "Sent" && log.sent_at && ( -
+ {/* Sent status */} + {log.status === "Sent" && log.sent_at && ( +
+ + Sent + + + {new Date(log.sent_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })} + +
+ )} + + {log.status === "Replied" && ( - Sent + Replied - - {new Date(log.sent_at).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })} - -
- )} - - {log.status === "Replied" && ( - - Replied - - )} + )} - {log.status === "Failed" && ( - - Failed - - )} + {log.status === "Failed" && ( + + Failed + + )} +
-
- - + + + + {/* DM Send Confirmation Dialog */} + + + + Did you send the DM? + + Confirm that you pasted and sent the message to{" "} + {log.creator_name} ({log.creator_handle}). This will mark the + outreach as sent and advance the pipeline. + + + + Not yet + + Yes, I sent it + + + + + ) } diff --git a/apps/command-center/src/components/outreach-queue.tsx b/apps/command-center/src/components/outreach-queue.tsx index 34a0f91..dd3f8b2 100644 --- a/apps/command-center/src/components/outreach-queue.tsx +++ b/apps/command-center/src/components/outreach-queue.tsx @@ -4,10 +4,34 @@ import { useState, useEffect, useCallback } from "react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import { Textarea } from "@/components/ui/textarea" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" import { DraftCard } from "@/components/draft-card" -import { Send, CheckCircle, Inbox, Loader2 } from "lucide-react" +import { + Send, + CheckCircle, + Inbox, + Loader2, + ExternalLink, + SkipForward, + MessageSquare, + Clock, + Zap, +} from "lucide-react" import { toast } from "sonner" -import type { OutreachLog } from "@/lib/types" +import { openInstagramDM, openTikTokDM } from "@/lib/playwright-dm" +import type { OutreachLog, RateLimitStatus } from "@/lib/types" export function OutreachQueue() { const [selectedDrafts, setSelectedDrafts] = useState>(new Set()) @@ -15,25 +39,41 @@ export function OutreachQueue() { const [sentToday, setSentToday] = useState([]) const [failed, setFailed] = useState([]) const [loading, setLoading] = useState(true) + const [gmailConnected, setGmailConnected] = useState(false) + + // DM Focus Mode state + const [dmFocusIndex, setDmFocusIndex] = useState(0) + const [dmMessage, setDmMessage] = useState("") + const [showDmConfirm, setShowDmConfirm] = useState(false) + const [rateLimits, setRateLimits] = useState>({}) + + const dmDrafts = drafts.filter( + (d) => d.channel === "instagram" || d.channel === "tiktok" || d.channel === "twitter" + ) + const emailDrafts = drafts.filter((d) => d.channel === "email") + const currentDm = dmDrafts[dmFocusIndex] || null const fetchOutreach = useCallback(async () => { setLoading(true) try { - const [draftsRes, sentRes, failedRes] = await Promise.all([ + const [draftsRes, sentRes, failedRes, authRes] = await Promise.all([ fetch("/api/outreach?status=Draft"), fetch("/api/outreach?status=Sent"), fetch("/api/outreach?status=Failed"), + fetch("/api/auth/status"), ]) - const [draftsJson, sentJson, failedJson] = await Promise.all([ + const [draftsJson, sentJson, failedJson, authJson] = await Promise.all([ draftsRes.json(), sentRes.json(), failedRes.json(), + authRes.json(), ]) setDrafts(draftsJson.data || []) setSentToday(sentJson.data || []) setFailed(failedJson.data || []) + setGmailConnected(authJson.gmail?.connected && authJson.gmail?.mode === "api") } catch { toast.error("Failed to load outreach queue") } finally { @@ -41,11 +81,29 @@ export function OutreachQueue() { } }, []) + // Fetch rate limits for DM platforms + const fetchRateLimits = useCallback(async () => { + try { + const [igRes, ttRes] = await Promise.all([ + fetch("/api/outreach/check-rate?platform=instagram"), + fetch("/api/outreach/check-rate?platform=tiktok"), + ]) + const [igData, ttData] = await Promise.all([igRes.json(), ttRes.json()]) + setRateLimits({ instagram: igData, tiktok: ttData }) + } catch { + // Silently fail — rate limits are informational + } + }, []) + useEffect(() => { fetchOutreach() - }, [fetchOutreach]) + fetchRateLimits() + }, [fetchOutreach, fetchRateLimits]) - const emailDrafts = drafts.filter((d) => d.channel === "email") + // Update DM message when current DM changes + useEffect(() => { + if (currentDm) setDmMessage(currentDm.content) + }, [currentDm]) function toggleDraft(id: string, checked: boolean) { setSelectedDrafts((prev) => { @@ -69,6 +127,100 @@ export function OutreachQueue() { fetchOutreach() } + // DM Focus Mode: copy + open + const handleDmCopyAndOpen = async () => { + if (!currentDm) return + + const platform = currentDm.channel + const limit = rateLimits[platform] + if (limit && !limit.allowed) { + toast.error( + `${platform} rate limit reached (${limit.dailySent}/${limit.dailyCap} today)` + ) + return + } + + await navigator.clipboard.writeText(dmMessage) + + const handle = currentDm.creator_handle || "" + let profileUrl = "" + if (currentDm.channel === "instagram") { + profileUrl = openInstagramDM(handle, dmMessage).url + } else if (currentDm.channel === "tiktok") { + profileUrl = openTikTokDM(handle, dmMessage).url + } else { + profileUrl = `https://twitter.com/${handle.replace(/^@/, "")}` + } + + window.open(profileUrl, "_blank") + toast.success("Message copied! Paste it in the DM window", { duration: 4000 }) + setShowDmConfirm(true) + } + + const handleDmConfirmSent = async () => { + if (!currentDm) return + setShowDmConfirm(false) + + await fetch("/api/outreach/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outreach_id: currentDm.id, action: "send" }), + }) + + fetchRateLimits() + + // Move to next DM + if (dmFocusIndex < dmDrafts.length - 1) { + setDmFocusIndex(dmFocusIndex + 1) + } + fetchOutreach() + } + + const handleDmSkip = async () => { + if (!currentDm) return + + await fetch("/api/outreach/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outreach_id: currentDm.id, action: "skip" }), + }) + + toast.info(`Skipped ${currentDm.creator_name}`) + + if (dmFocusIndex < dmDrafts.length - 1) { + setDmFocusIndex(dmFocusIndex + 1) + } + fetchOutreach() + } + + // Rate limit display + const RateLimitBadge = ({ platform }: { platform: string }) => { + const limit = rateLimits[platform] + if (!limit) return null + + const percentage = limit.dailyCap > 0 ? (limit.dailySent / limit.dailyCap) * 100 : 0 + const isNearLimit = percentage > 80 + const isAtLimit = !limit.allowed + + return ( +
+ + + {limit.dailySent}/{limit.dailyCap} {platform} today + {limit.warmupDay <= 28 && ` (warm-up week ${Math.ceil(limit.warmupDay / 7)})`} + +
+ ) + } + return (
@@ -84,6 +236,18 @@ export function OutreachQueue() { )} + + + DM Focus + {dmDrafts.length > 0 && ( + + {dmDrafts.length} + + )} + Sent {sentToday.length > 0 && ( @@ -108,6 +272,7 @@ export function OutreachQueue() { + {/* Drafts Tab (list view) */} {loading ? (
@@ -115,7 +280,6 @@ export function OutreachQueue() {
) : ( <> - {/* Bulk Actions */} {drafts.length > 0 && (
{emailDrafts.length > 0 && ( @@ -127,7 +291,9 @@ export function OutreachQueue() { } > - Approve All Emails ({emailDrafts.length}) + {gmailConnected + ? `Send All Emails (${emailDrafts.length})` + : `Approve All Emails (${emailDrafts.length})`} )} {selectedDrafts.size > 0 && ( @@ -152,6 +318,7 @@ export function OutreachQueue() { toggleDraft(log.id, checked)} @@ -164,6 +331,113 @@ export function OutreachQueue() { )} + {/* DM Focus Mode Tab */} + + {loading ? ( +
+ +
+ ) : dmDrafts.length === 0 ? ( +
+ +

No DM drafts in queue

+

+ Generate drafts from the Creators page to start sending DMs. +

+
+ ) : ( +
+ {/* Progress bar */} +
+ 0 + ? ((dmFocusIndex + 1) / dmDrafts.length) * 100 + : 0 + } + className="flex-1" + /> + + {dmFocusIndex + 1} of {dmDrafts.length} + +
+ + {/* Rate limit status */} +
+ + +
+ + {/* Current DM card */} + {currentDm && ( + + + {/* Creator info */} +
+
+

+ {currentDm.creator_name} +

+

+ {currentDm.creator_handle} +

+
+ + {currentDm.channel === "instagram" + ? "Instagram" + : currentDm.channel === "tiktok" + ? "TikTok" + : "Twitter"} + +
+ + {/* Editable message */} +