Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
afadcbe
fix: consolidated inbox reliability, AI resilience, and Meta compliance
khilta Aug 10, 2026
e0e9bb7
fix: prevent self-reply infinite loop on Facebook (author username is…
khilta Aug 11, 2026
6d98b16
fix: harden DM self-account guard for Facebook (sender.username=null)
khilta Aug 11, 2026
f8e06ef
feat: add circuit breaker — rate-limit replies per author per post
khilta Aug 11, 2026
39e5d52
fix: reply-once per user per post + correct FB page ID check
khilta Aug 11, 2026
ed7f332
feat: AI comment replies + SKIP guardrail + attachment awareness
khilta Aug 12, 2026
33ee2bd
fix: 3 critical bugs — phantom dm lockout, wrong API for returning us…
khilta Aug 12, 2026
4a6fa0c
fix: close race condition in dedup — block in-progress flows (<5min) …
khilta Aug 12, 2026
4fb6c99
fix: link late_conversation_id after private reply — inbox thread was…
khilta Aug 12, 2026
be66473
fix: content-based dedup for outbound messages (Zernio vs local DB)
khilta Aug 12, 2026
4bcad8a
fix: re-throw DM failures in executePrivateReply + protect late_conve…
khilta Aug 12, 2026
530a18a
fix: Next.js 15 params Promise type for read route
khilta Aug 12, 2026
da3b49e
fix: {{contact_name}} not interpolated in comment-triggered DMs
khilta Aug 13, 2026
5b2a8a6
fix: guard against malformed nodes/edges in flow engine
khilta Aug 13, 2026
81693bc
fix: 7 bug fixes from systematic codebase audit
khilta Aug 13, 2026
9893ba5
fix: validation fixes from cross-checking all audit changes
khilta Aug 13, 2026
7dbe14a
feat: RAG knowledge base search for AI responses
khilta Aug 13, 2026
0619f3e
feat: reply rotation + infinite loop prevention
khilta Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
supabase/.temp
.env*
15 changes: 8 additions & 7 deletions app/(dashboard)/dashboard/inbox/inbox-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
}
}
}

Expand Down
46 changes: 46 additions & 0 deletions app/api/v1/conversations/[conversationId]/read/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
82 changes: 78 additions & 4 deletions app/api/v1/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string>();
// Track outbound messages by content+time for content-based dedup
const outboundSignatures: Array<{ text: string; time: number }> = [];
const merged: Array<Record<string, unknown>> = [];

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<string, unknown>);
// 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<string, unknown>);
}
}

// 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(
Expand Down
Loading