diff --git a/.gitignore b/.gitignore index 2a22ae6..f2ab9d2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts supabase/.temp +.env* diff --git a/app/(dashboard)/dashboard/inbox/inbox-view.tsx b/app/(dashboard)/dashboard/inbox/inbox-view.tsx index fca93af..5bc9c4f 100644 --- a/app/(dashboard)/dashboard/inbox/inbox-view.tsx +++ b/app/(dashboard)/dashboard/inbox/inbox-view.tsx @@ -6,7 +6,6 @@ import { MessageSquare, RefreshCw, User } from "lucide-react"; import { ConversationList } from "@/components/inbox/conversation-list"; import { MessageThread } from "@/components/inbox/message-thread"; import { ContactPanel } from "@/components/inbox/contact-panel"; -import { createClient } from "@/lib/supabase/client"; import { cn } from "@/lib/utils"; import type { Database } from "@/lib/types/database"; @@ -82,13 +81,15 @@ export function InboxView({ setLoadingMessages(false); } - // Mark as read + // Mark as read via API (not client-side Supabase) if (selected!.unread_count > 0) { - const supabase = createClient(); - await supabase - .from("conversations") - .update({ unread_count: 0 }) - .eq("id", selected!.id); + try { + await fetch(`/api/v1/conversations/${selected!.id}/read`, { + method: "POST", + }); + } catch (err) { + console.error("Failed to mark conversation as read:", err); + } } } diff --git a/app/api/v1/conversations/[conversationId]/read/route.ts b/app/api/v1/conversations/[conversationId]/read/route.ts new file mode 100644 index 0000000..4d46809 --- /dev/null +++ b/app/api/v1/conversations/[conversationId]/read/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +/** + * POST /api/v1/conversations/[conversationId]/read + * + * Marks a conversation as read (sets unread_count to 0). + * Replaces the old client-side Supabase call that exposed the service + * role client to the browser. + */ +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ conversationId: string }> } +) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { conversationId } = await params; + if (!conversationId) { + return NextResponse.json( + { error: "conversationId required" }, + { status: 400 } + ); + } + + const { error } = await supabase + .from("conversations") + .update({ unread_count: 0 }) + .eq("id", conversationId); + + if (error) { + console.error("Failed to mark conversation as read:", error); + return NextResponse.json( + { error: "Failed to mark as read" }, + { status: 500 } + ); + } + + return NextResponse.json({ success: true }); +} diff --git a/app/api/v1/messages/route.ts b/app/api/v1/messages/route.ts index 2f98e27..c6c9029 100644 --- a/app/api/v1/messages/route.ts +++ b/app/api/v1/messages/route.ts @@ -60,11 +60,13 @@ export async function GET(request: NextRequest) { (res.data as { data?: unknown[] })?.data ?? []; - // Map Zernio messages to the shape the inbox UI expects - const messages = zernioMessages.map((m: any) => ({ + // Map Zernio messages to the shape the inbox UI expects. + // Accept both "outgoing" (Zernio's actual enum value) and "outbound" + // (legacy spelling) so our own replies are correctly shown on our side. + const zernioMapped = zernioMessages.map((m: any) => ({ id: m.id, conversation_id: conversationId, - direction: m.direction === "outbound" ? "outbound" : "inbound", + direction: m.direction === "outgoing" || m.direction === "outbound" ? "outbound" : "inbound", text: m.text ?? m.message ?? null, attachments: m.attachments?.length ? m.attachments : null, quick_reply_payload: null, @@ -78,7 +80,79 @@ export async function GET(request: NextRequest) { created_at: m.sentAt ?? m.createdAt ?? new Date().toISOString(), })); - return NextResponse.json(messages); + // Merge with locally stored messages. Zernio is the source of truth but + // its API can lag or miss messages (especially outbound sends). The local + // table has messages the webhook stored (inbound) and flow engine stored + // (outbound). Dedup on platform_message_id so messages that appear in both + // don't render twice. + // + // IMPORTANT: The flow engine stores outbound DMs WITHOUT platform_message_id + // (it's NULL), while Zernio returns the same message with its own ID. So + // deduping on ID alone would show every DM twice. We additionally dedup on + // (direction, normalized_text, ±60s time window) to catch these. + const { data: localMessages } = await supabase + .from("messages") + .select("*") + .eq("conversation_id", conversationId) + .order("created_at", { ascending: true }); + + const seenIds = new Set(); + // Track outbound messages by content+time for content-based dedup + const outboundSignatures: Array<{ text: string; time: number }> = []; + const merged: Array> = []; + + const normalize = (s: string | null): string => + (s ?? "").trim().replace(/\s+/g, " ").toLowerCase(); + + for (const m of zernioMapped) { + const key = (m.platform_message_id || m.id) as string | null; + if (key && !seenIds.has(key)) { + seenIds.add(key); + merged.push(m as unknown as Record); + // Record outbound signatures for content-based dedup + if (m.direction === "outbound" && m.text) { + outboundSignatures.push({ + text: normalize(m.text as string), + time: new Date(m.created_at as string).getTime(), + }); + } + } + } + + if (localMessages) { + for (const m of localMessages) { + // First try ID-based dedup + const key = m.platform_message_id || m.id; + if (key && seenIds.has(key)) continue; + + // For outbound messages without platform_message_id, try content-based dedup. + // If a Zernio message has the same text within ±60 seconds, it's the same DM. + if (m.direction === "outbound" && !m.platform_message_id && m.text) { + const normText = normalize(m.text); + const localTime = new Date(m.created_at).getTime(); + const isDupe = outboundSignatures.some( + (sig) => + sig.text === normText && + Math.abs(sig.time - localTime) < 60_000, // 60-second window + ); + if (isDupe) continue; // Skip — Zernio already has this message + } + + if (key) { + seenIds.add(key); + } + merged.push(m as unknown as Record); + } + } + + // Sort merged messages by created_at for consistent chronological display + merged.sort((a, b) => { + const aTime = new Date(a.created_at as string).getTime(); + const bTime = new Date(b.created_at as string).getTime(); + return aTime - bTime; + }); + + return NextResponse.json(merged); } catch (error) { console.error("Failed to fetch messages from Zernio API:", error); return NextResponse.json( diff --git a/app/api/webhooks/late/route.ts b/app/api/webhooks/late/route.ts index df73226..dd9383c 100644 --- a/app/api/webhooks/late/route.ts +++ b/app/api/webhooks/late/route.ts @@ -6,6 +6,7 @@ import { matchTrigger } from "@/lib/flow-engine/trigger-matcher"; import { resolveWebhookSecret, verifyWebhookSignature } from "@/lib/zernio-webhook"; import { upsertContactForSender } from "@/lib/inbox-sync"; import { processComment } from "@/lib/comment-processor"; +import { createZernioClient } from "@/lib/zernio-client"; import type { Database } from "@/lib/types/database"; import { messagePreview } from "@/lib/message-preview"; @@ -137,8 +138,10 @@ async function handleWebhook(request: NextRequest) { const { message: msg, account } = payload; - // Ignore outbound messages (sent by the bot itself) to prevent loops - if (msg.direction === "outbound") { + // Ignore outbound messages (sent by the bot itself) to prevent loops. + // Accept both "outgoing" (Zernio's actual enum value) and the legacy + // "outbound" spelling for resilience. + if (msg.direction === "outgoing" || msg.direction === "outbound") { return NextResponse.json({ ok: true, skipped: true }); } @@ -159,6 +162,8 @@ async function handleWebhook(request: NextRequest) { // Prevent loops: if the sender is another connected account in this // workspace, skip. This happens when both sides of a DM conversation // are connected (e.g. during testing). + // NOTE: Facebook page DMs can arrive with sender.username=null (same bug + // as comments), so we also check by sender.id against all workspace channels. if (msg.sender.username) { const { data: senderChannel } = await supabase .from("channels") @@ -172,6 +177,22 @@ async function handleWebhook(request: NextRequest) { return NextResponse.json({ ok: true, skipped: true, reason: "sender_is_own_account" }); } } + // Defense-in-depth: also check by late_account_id in case username is null + // (Facebook page accounts). Any sender whose account ID matches a connected + // channel in this workspace is our own bot. + if (msg.sender.id) { + const { data: senderChannelById } = await supabase + .from("channels") + .select("id") + .eq("workspace_id", channel.workspace_id) + .eq("late_account_id", msg.sender.id) + .eq("is_active", true) + .maybeSingle(); + + if (senderChannelById) { + return NextResponse.json({ ok: true, skipped: true, reason: "sender_is_own_account" }); + } + } // Verify HMAC-SHA256 signature against the workspace-level secret // (falls back to the legacy per-channel secret during transition). @@ -231,24 +252,47 @@ async function processMessageEvent( const preview = messagePreview(msg.text); - const { data: conversation } = await supabase + // Two-step upsert to avoid overwriting late_conversation_id on existing + // conversations. The webhook receives Zernio's conversation ID (conv.id), + // but for comment-triggered flows we set late_conversation_id to the + // sender's PSID/IGA ID via sendFirstMessageAsPrivateReply. If we blindly + // overwrite it here on every incoming message, we'd break the inbox link. + // Step 1: try INSERT (only fires for new contacts). + const { data: newConversation } = await supabase .from("conversations") - .upsert( - { - workspace_id: channel.workspace_id, - channel_id: channel.id, - contact_id: contactId, - platform: channel.platform, - late_conversation_id: conv.id, + .insert({ + workspace_id: channel.workspace_id, + channel_id: channel.id, + contact_id: contactId, + platform: channel.platform, + late_conversation_id: conv.id, + status: "open", + last_message_at: new Date().toISOString(), + last_message_preview: preview, + unread_count: 1, + }) + .select("id, is_automation_paused") + .single(); + + let conversation = newConversation; + + // Step 2: if INSERT failed (contact already exists), UPDATE without + // touching late_conversation_id. + if (!conversation) { + const { data: existingConversation } = await supabase + .from("conversations") + .update({ status: "open", last_message_at: new Date().toISOString(), last_message_preview: preview, - unread_count: 1, - }, - { onConflict: "channel_id,contact_id" } - ) - .select("id, is_automation_paused") - .single(); + }) + .eq("channel_id", channel.id) + .eq("contact_id", contactId) + .select("id, is_automation_paused") + .single(); + + conversation = existingConversation; + } if (!conversation) { console.error("Failed to upsert conversation for webhook message"); @@ -264,7 +308,27 @@ async function processMessageEvent( .then(() => {}); } - // Messages are stored by Zernio (source of truth) — no local insert needed. + // Store the incoming DM locally so the inbox has a reliable history even + // when Zernio's API returns stale or incomplete data. Use platformMessageId + // (not msg.id which is Zernio's internal id) so it matches what the messages + // route returns and dedup works when both sources are merged. + const { data: existingMsg } = await supabase + .from("messages") + .select("id") + .eq("conversation_id", conversation.id) + .eq("platform_message_id", msg.platformMessageId || msg.id) + .maybeSingle(); + + if (!existingMsg) { + await supabase.from("messages").insert({ + conversation_id: conversation.id, + direction: "inbound", + text: msg.text, + attachments: msg.attachments?.length ? msg.attachments : null, + platform_message_id: msg.platformMessageId || msg.id, + status: "sent", + }); + } // ── Flow engine ─────────────────────────────────────────────────────────── @@ -281,12 +345,22 @@ async function processMessageEvent( }, }; - const handled = await handleGlobalKeywords( + // Look up workspace API key for keyword confirmation DMs + const { data: kwWorkspace } = await supabase + .from("workspaces") + .select("late_api_key_encrypted") + .eq("id", channel.workspace_id) + .single(); + + const handled = await handleGlobalKeywords({ supabase, - channel.workspace_id, + workspaceId: channel.workspace_id, + workspaceApiKey: kwWorkspace?.late_api_key_encrypted ?? null, + lateConversationId: conv.id, + lateAccountId: account.id, contactId, - msg.text || undefined - ); + text: msg.text || undefined, + }); if (!handled) { const trigger = await matchTrigger(supabase, { @@ -310,7 +384,30 @@ async function processMessageEvent( lateAccountId: account.id, }); } catch (err) { - console.error("Flow execution error:", err); + const errMsg = err instanceof Error ? err.message : String(err); + + // Distinguish 24h-window failures from other errors. Meta only + // allows businesses to message within 24h of the contact's last + // message; sending after the window fails with a specific error. + // Logging it distinctly surfaces this operational gap instead of + // collapsing it into a generic message_failed event. + const is24hWindowError = /24.*hour|window|outside.*allowed/i.test(errMsg); + + console.error("Flow execution error:", errMsg); + + await supabase.from("analytics_events").insert({ + workspace_id: channel.workspace_id, + contact_id: contactId, + flow_id: trigger.flow_id, + event_type: is24hWindowError + ? "message_failed_24h_window" + : "message_failed", + metadata: { + error: errMsg, + triggerId: trigger.id, + ...(is24hWindowError ? { reason: "24h_messaging_window" } : {}), + }, + }); } } } @@ -340,10 +437,24 @@ async function handleCommentWebhook( // Prevent loops: our own comments (e.g. the configured public reply) also // arrive as comment.received and must never re-trigger a flow. - if ( - payload.comment.author?.username && - payload.comment.author.username === channel.username - ) { + // Facebook page comments arrive with username=null (Meta strips author + // identity from page-owned comments by design — Graph API v26.0 doesn't + // even list a `from` field in the Comment reference). + // We check THREE identifiers for defense-in-depth: + // 1. username (works for Instagram, fails for Facebook) + // 2. display_name (works for both, but fragile — string match) + // 3. platform_page_id (works for Facebook — author.id = FB Page ID) + // NOTE: payload.account.id is the Zernio internal account ID, NOT the FB + // Page ID. author.id is the FB Page ID. They are DIFFERENT ID SYSTEMS and + // must never be compared to each other. + const authorName = payload.comment.author?.name?.trim(); + const authorUsername = payload.comment.author?.username?.trim(); + const authorId = payload.comment.author?.id; + const isOwnComment = + (authorUsername && authorUsername === channel.username) || + (authorName && channel.display_name && authorName === channel.display_name) || + (authorId && channel.platform_page_id && authorId === channel.platform_page_id); + if (isOwnComment) { return NextResponse.json({ ok: true, skipped: true, reason: "own_comment" }); } @@ -384,12 +495,39 @@ async function handleCommentWebhook( // ── Global keywords ───────────────────────────────────────────────────────── -async function handleGlobalKeywords( - supabase: Awaited>, - workspaceId: string, - contactId: string, - text: string | undefined -): Promise { +/** + * Context object for keyword confirmation DMs. Passing a single object avoids + * the "too many positional parameters" problem where a caller silently passes + * arguments in the wrong order. + */ +interface KeywordConfirmationParams { + supabase: Awaited>; + workspaceId: string; + workspaceApiKey: string | null; + lateConversationId: string | null; + lateAccountId: string | null; + contactId: string; + text: string | undefined; +} + +/** + * Checks the incoming text against the workspace's global keywords. When a + * STOP/START keyword matches, updates the contact's subscription and sends a + * confirmation DM so the contact knows their opt-in/opt-out was received. + * + * The confirmation DM is only sent when we have a late_conversation_id (i.e. + * a DM context). Comment-triggered flows do not have one at keyword-check + * time, so the confirmation is a no-op there — the early return below guards it. + */ +async function handleGlobalKeywords({ + supabase, + workspaceId, + workspaceApiKey, + lateConversationId, + lateAccountId, + contactId, + text, +}: KeywordConfirmationParams): Promise { if (!text) return false; const { data: workspace } = await supabase @@ -415,6 +553,17 @@ async function handleGlobalKeywords( .from("contacts") .update({ is_subscribed: false }) .eq("id", contactId); + + // Send confirmation DM. No-ops when there is no DM conversation yet + // (e.g. keyword sent on a comment-triggered flow) or when outside the + // 24h messaging window. + await sendKeywordConfirmation({ + workspaceApiKey, + lateConversationId, + lateAccountId, + message: "You've been unsubscribed from our messages. Text START to resubscribe anytime.", + }); + return true; } if (kw.action === "subscribe") { @@ -422,6 +571,14 @@ async function handleGlobalKeywords( .from("contacts") .update({ is_subscribed: true }) .eq("id", contactId); + + await sendKeywordConfirmation({ + workspaceApiKey, + lateConversationId, + lateAccountId, + message: "You're subscribed! Reply STOP anytime to opt out.", + }); + return true; } return false; @@ -430,3 +587,34 @@ async function handleGlobalKeywords( return false; } + +/** + * Sends a single confirmation DM. Silently no-ops when the prerequisites for + * sending (API key, conversation ID, account ID) are missing — this happens + * for comment-triggered flows where no DM conversation exists yet. + */ +async function sendKeywordConfirmation({ + workspaceApiKey, + lateConversationId, + lateAccountId, + message, +}: { + workspaceApiKey: string | null; + lateConversationId: string | null; + lateAccountId: string | null; + message: string; +}): Promise { + if (!workspaceApiKey || !lateConversationId || !lateAccountId) return; + + try { + const zernio = createZernioClient(workspaceApiKey); + await zernio.messages.sendInboxMessage({ + path: { conversationId: lateConversationId }, + body: { accountId: lateAccountId, message }, + }); + } catch (err) { + // Most likely cause is the 24h messaging window having elapsed; log and + // move on rather than surfacing this as a flow failure. + console.error("Failed to send keyword confirmation DM:", err); + } +} diff --git a/backup_system_prompt.txt b/backup_system_prompt.txt new file mode 100644 index 0000000..1ef403f --- /dev/null +++ b/backup_system_prompt.txt @@ -0,0 +1,92 @@ +You are Pallavi, the educator and creator behind Khilta. You have a B.Ed and NTT certification with 10+ years teaching children ages 2-8. + +You personally designed and created every worksheet and activity at Khilta. This is YOUR work. + +YOUR PERSONALITY: +- Warm, encouraging, genuine — like a friendly teacher +- Keep replies SHORT (2-3 sentences max) +- Use emojis naturally but don't overdo it (1-2 per message) +- You ARE Pallavi. Never say "I'm part of the team" — say "I'm Pallavi" or "I" + +YOUR GOAL: Help parents and teachers discover Khilta's worksheets and guide them to get the full collection. Every conversation should naturally move toward them experiencing the worksheets — starting with a free sample, then the full bundle. + +CRITICAL — LINKS IN DMs: +Instagram and Facebook DMs make links clickable ONLY when you include the full URL with https:// +- ✅ CORRECT: "https://khilta.com/go" +- ❌ WRONG: "khilta.com/go" (shows as plain text, NOT clickable) +ALWAYS write full URLs with https:// prefix. Never write bare domain names. + +THE SAMPLE-TO-BUNDLE FUNNEL (your core strategy): +When someone shows ANY interest, offer a free sample first: +"I'd love for you to try some of our worksheets! Just comment 'FREE' on our Instagram posts and I'll send you a sample pack. 🌸" + +Once they've seen or asked about the sample, explain: +"That's just a tiny glimpse — it's only about 2-5% of what we have! The full collection has 400+ worksheets across 28 categories covering almost every learning need. You can get it all at https://khilta.com/go 📚" + +WHAT KHILTA OFFERS: +1. FREE sample worksheets → comment "FREE" on Instagram posts +2. FOCUS & attention worksheets → comment "FOCUS" +3. Full bundle: 400+ worksheets, 10,000+ pages, 28 categories, ages 2-8 → https://khilta.com/go +4. 1-on-1 consultation with Pallavi → https://khilta.com/book + +NEVER mention specific prices. If they ask about cost, say: "You can check it all out at https://khilta.com/go! 😊" + +FAQ — COMMON QUESTIONS: + +Q: Is it a hardcopy or e-book? +A: All digital (PDF) — instant download, print at home! Get the full collection at https://khilta.com/go 📄 + +Q: Where can I get them? / How do I buy? +A: Head to https://khilta.com/go for instant access to everything! You can also comment "FREE" on our posts to try a sample first. 🌸 + +Q: What ages are these for? +A: Ages 2-8! From toddlers tracing lines to early elementary kids with math, reading, and more. Try a free sample — comment "FREE" on our posts! + +Q: Where are you based? +A: India 🇮🇳 But our worksheets are loved by parents and teachers worldwide! + +Q: Who created these? / Did you make them? +A: Yes! I'm Pallavi — I personally designed every worksheet. B.Ed + NTT certified, 10+ years classroom experience. 🌸 + +Q: Do you have worksheets for autism / SEN / special needs? +A: Absolutely! Many of our activities are great for diverse learning needs, especially our FOCUS worksheets. Comment "FOCUS" on our posts for a free sample! The full collection at https://khilta.com/go covers so many different needs. + +Q: I'm a teacher, can I use these? +A: Perfect! Many teachers use our worksheets in class. The full bundle at https://khilta.com/go has 400+ activities across 28 categories — great for classrooms! + +Q: What's in the bundle? +A: 400+ worksheets across 28 categories — tracing, counting, coloring, mazes, matching, patterns, phonics, and MUCH more. Ages 2-8. That's 10,000+ pages of activities! Get it at https://khilta.com/go 📚 + +Q: I already got the sample / I want more +A: The sample is just 2-5% of what we offer! The full collection covers almost every learning need. Get everything at https://khilta.com/go 🌟 + +CONVERSATION STRATEGY: +- If interested → offer FREE sample first → then explain it's just 2-5% → point to https://khilta.com/go for full bundle +- If they mention a specific need (autism, math, reading, etc.) → mention relevant worksheets → offer sample → full bundle covers much more +- Don't ask too many questions — provide information and solutions +- Every reply should gently move them toward trying a sample or visiting https://khilta.com/go +- Don't keep asking "what age?" repeatedly — answer their question and guide forward + +WHEN YOU DON'T KNOW: +- Never make up information about Khilta +- If asked about refunds, GST, shipping, payment methods: "You can find all the details at https://khilta.com/go! 🌸" + +OFF-TOPIC: +- Gently redirect: "I help with early learning activities! Comment 'FREE' on our posts to try some worksheets 😊" + +LANGUAGE: +- Reply in the same language the person uses +- Default to simple, friendly English + +CRITICAL — WHEN NOT TO REPLY (SKIP RULE): +If you do NOT clearly understand what the person is saying, or you don't have a genuinely useful answer, respond with EXACTLY one word: SKIP + +This includes: +- Messages with attachments you can't see (you'll see [Attachment: image] in the message) +- Messages that are just a name, "thanks", or a greeting with no question +- Messages where you're not confident you understand the meaning +- Messages in a language you can't fully understand +- Messages that are just business names, random text, or unclear + +Never guess. Never make up information. Never respond when confused. +When in doubt, SKIP — silence is always better than a wrong reply. \ No newline at end of file diff --git a/components/flow-builder/panels/AiResponsePanel.tsx b/components/flow-builder/panels/AiResponsePanel.tsx index 0386842..e6e9446 100644 --- a/components/flow-builder/panels/AiResponsePanel.tsx +++ b/components/flow-builder/panels/AiResponsePanel.tsx @@ -14,6 +14,8 @@ interface AiResponsePanelData { maxTokens?: number; contextMessages?: number; sendDirectly?: boolean; + maxRetries?: number; + fallbackMessage?: string; [key: string]: unknown; } @@ -161,6 +163,47 @@ export function AiResponsePanel({ data: rawData, onChange }: AiResponsePanelProp yourself with a Send Message node.

+ + {/* Resilience */} +
+

Resilience

+ + {/* Max Retries */} +
+ + onChange({ ...data, maxRetries: parseInt(e.target.value) || 0 })} + min={0} + max={10} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +

+ Retry attempts with exponential backoff on transient errors (rate + limits, timeouts). Set to 0 to disable. +

+
+ + {/* Fallback Message */} +
+ +