diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 7ec3895..70480ec 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -3,6 +3,10 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; +import { + CONVERSATION_SELECT, + normalizeConversation, +} from "@/lib/inbox/conversations"; import type { Conversation, Message, Contact, ConversationStatus } from "@/types"; import { useRealtime } from "@/hooks/use-realtime"; import { ConversationList } from "@/components/inbox/conversation-list"; @@ -118,7 +122,7 @@ export default function InboxPage() { const supabase = createClient(); const { data, error } = await supabase .from("conversations") - .select("*, contact:contacts(*)") + .select(CONVERSATION_SELECT) .eq("id", convId) .maybeSingle(); if (error) { @@ -133,7 +137,7 @@ export default function InboxPage() { return; } if (!data) return; - const fetched = data as Conversation; + const fetched = normalizeConversation(data); setConversations((prev) => { const existing = prev.find((c) => c.id === fetched.id); if (existing) { diff --git a/src/components/inbox/conversation-list.tsx b/src/components/inbox/conversation-list.tsx index 69f51dd..d3dafd2 100644 --- a/src/components/inbox/conversation-list.tsx +++ b/src/components/inbox/conversation-list.tsx @@ -2,18 +2,23 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { createClient } from "@/lib/supabase/client"; +import { + CONVERSATION_SELECT, + matchesContactFilters, + normalizeConversations, +} from "@/lib/inbox/conversations"; import { cn } from "@/lib/utils"; -import type { Conversation, ConversationStatus } from "@/types"; -import { Search, ChevronDown } from "lucide-react"; +import type { Conversation, ConversationStatus, Tag } from "@/types"; +import { Search, ChevronDown, X } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import { Input } from "@/components/ui/input"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; interface ConversationListProps { @@ -56,6 +61,12 @@ export function ConversationList({ const [search, setSearch] = useState(""); const [filter, setFilter] = useState("all"); const [loading, setLoading] = useState(true); + // Contact-based filters (issue #272). Tags use OR logic (a conversation + // matches if its contact carries any selected tag), consistent with + // Broadcast audience filtering. Company is an exact match on the field. + const [tags, setTags] = useState([]); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [selectedCompany, setSelectedCompany] = useState(null); // Keep the latest callback in a ref so the fetch effect below can // have a stable, empty-dep identity. Previously the fetch useCallback @@ -81,7 +92,7 @@ export function ConversationList({ (async () => { const { data, error } = await supabase .from("conversations") - .select("*, contact:contacts(*)") + .select(CONVERSATION_SELECT) .order("last_message_at", { ascending: false }); if (cancelled) return; @@ -98,7 +109,7 @@ export function ConversationList({ return; } - onConversationsLoadedRef.current(data ?? []); + onConversationsLoadedRef.current(normalizeConversations(data ?? [])); setLoading(false); })(); @@ -110,6 +121,38 @@ export function ConversationList({ // up on any events sent while the WS was disconnected or throttled. }, [resyncToken]); + // Tag definitions for the filter picker — loaded once so labels/colours + // stay stable regardless of which conversations happen to be loaded. + useEffect(() => { + const supabase = createClient(); + let cancelled = false; + (async () => { + const { data } = await supabase.from("tags").select("*").order("name"); + if (!cancelled && data) setTags(data as Tag[]); + })(); + return () => { + cancelled = true; + }; + }, []); + + // Company options are derived from the loaded conversations — there's no + // separate companies table, and only companies with a live conversation + // are worth offering as an inbox filter. + const companies = useMemo(() => { + const set = new Set(); + for (const c of conversations) { + const co = c.contact?.company?.trim(); + if (co) set.add(co); + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); + }, [conversations]); + + const tagsById = useMemo(() => { + const m = new Map(); + for (const t of tags) m.set(t.id, t); + return m; + }, [tags]); + const filtered = useMemo(() => { let result = conversations; @@ -119,6 +162,16 @@ export function ConversationList({ result = result.filter((c) => c.status === filter); } + // Contact-based filters (tags via OR logic, exact company match). + if (selectedTagIds.length > 0 || selectedCompany !== null) { + result = result.filter((c) => + matchesContactFilters(c, { + tagIds: selectedTagIds, + company: selectedCompany, + }) + ); + } + if (search.trim()) { const q = search.toLowerCase(); result = result.filter((c) => { @@ -130,7 +183,20 @@ export function ConversationList({ } return result; - }, [conversations, filter, search]); + }, [conversations, filter, search, selectedTagIds, selectedCompany]); + + const toggleTag = useCallback((id: string) => { + setSelectedTagIds((prev) => + prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id] + ); + }, []); + + const clearContactFilters = useCallback(() => { + setSelectedTagIds([]); + setSelectedCompany(null); + }, []); + + const hasContactFilters = selectedTagIds.length > 0 || selectedCompany !== null; const handleSearchChange = useCallback( (e: React.ChangeEvent) => { @@ -165,31 +231,158 @@ export function ConversationList({ /> - - - {activeFilter?.label ?? "All"} - - - - {FILTER_OPTIONS.map((opt) => ( - setFilter(opt.value)} +
+ + + {activeFilter?.label ?? "All"} + + + + {FILTER_OPTIONS.map((opt) => ( + setFilter(opt.value)} + className={cn( + "text-sm", + filter === opt.value + ? "text-primary" + : "text-popover-foreground" + )} + > + {opt.label} + + ))} + + + + {tags.length > 0 && ( + + 0 ? "text-primary" - : "text-popover-foreground" + : "text-muted-foreground hover:text-foreground" )} > - {opt.label} - - ))} - - + Tags + {selectedTagIds.length > 0 && ( + + {selectedTagIds.length} + + )} + + + + {tags.map((t) => ( + toggleTag(t.id)} + className="text-sm text-popover-foreground" + > + + + {t.name} + + + ))} + + + )} + + {companies.length > 0 && ( + + + {selectedCompany ?? "Company"} + + + + setSelectedCompany(null)} + className={cn( + "text-sm", + selectedCompany === null + ? "text-primary" + : "text-popover-foreground" + )} + > + All companies + + {companies.map((co) => ( + setSelectedCompany(co)} + className={cn( + "text-sm", + selectedCompany === co + ? "text-primary" + : "text-popover-foreground" + )} + > + {co} + + ))} + + + )} +
+ + {hasContactFilters && ( +
+ {selectedTagIds.map((id) => { + const tag = tagsById.get(id); + return ( + + ); + })} + {selectedCompany && ( + + )} + +
+ )} {/* Conversation Items. diff --git a/src/lib/inbox/conversations.test.ts b/src/lib/inbox/conversations.test.ts new file mode 100644 index 0000000..7f76970 --- /dev/null +++ b/src/lib/inbox/conversations.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; +import { + matchesContactFilters, + normalizeConversation, +} from "./conversations"; +import type { Conversation } from "@/types"; + +function makeConversation( + contact: Partial | null, +): Conversation { + return { + id: "c1", + user_id: "u1", + contact_id: "ct1", + status: "open", + unread_count: 0, + created_at: "", + updated_at: "", + contact: contact + ? { + id: "ct1", + user_id: "u1", + account_id: "a1", + phone: "123", + created_at: "", + updated_at: "", + ...contact, + } + : undefined, + }; +} + +const tag = (id: string, name = id) => ({ + id, + user_id: "u1", + name, + color: "#fff", + created_at: "", +}); + +describe("matchesContactFilters", () => { + it("matches everything when no filters are set", () => { + const conv = makeConversation({ company: "Acme", tags: [tag("t1")] }); + expect(matchesContactFilters(conv, { tagIds: [], company: null })).toBe( + true, + ); + expect(makeConversation(null)).toBeDefined(); + expect( + matchesContactFilters(makeConversation(null), { + tagIds: [], + company: null, + }), + ).toBe(true); + }); + + it("uses OR logic across tags", () => { + const conv = makeConversation({ tags: [tag("t1"), tag("t2")] }); + expect( + matchesContactFilters(conv, { tagIds: ["t2", "t9"], company: null }), + ).toBe(true); + expect( + matchesContactFilters(conv, { tagIds: ["t9"], company: null }), + ).toBe(false); + }); + + it("excludes conversations whose contact has no tags when a tag filter is active", () => { + const conv = makeConversation({ tags: [] }); + expect( + matchesContactFilters(conv, { tagIds: ["t1"], company: null }), + ).toBe(false); + expect( + matchesContactFilters(makeConversation(null), { + tagIds: ["t1"], + company: null, + }), + ).toBe(false); + }); + + it("matches company exactly, trimming whitespace", () => { + const conv = makeConversation({ company: " Acme " }); + expect( + matchesContactFilters(conv, { tagIds: [], company: "Acme" }), + ).toBe(true); + expect( + matchesContactFilters(conv, { tagIds: [], company: "Other" }), + ).toBe(false); + }); + + it("requires both tag and company to match when both are set (AND across facets)", () => { + const conv = makeConversation({ company: "Acme", tags: [tag("t1")] }); + expect( + matchesContactFilters(conv, { tagIds: ["t1"], company: "Acme" }), + ).toBe(true); + expect( + matchesContactFilters(conv, { tagIds: ["t1"], company: "Other" }), + ).toBe(false); + expect( + matchesContactFilters(conv, { tagIds: ["tX"], company: "Acme" }), + ).toBe(false); + }); +}); + +describe("normalizeConversation", () => { + it("flattens embedded contact_tags into contact.tags", () => { + const raw = { + id: "c1", + user_id: "u1", + contact_id: "ct1", + status: "open" as const, + unread_count: 0, + created_at: "", + updated_at: "", + contact: { + id: "ct1", + user_id: "u1", + account_id: "a1", + phone: "123", + created_at: "", + updated_at: "", + contact_tags: [{ tags: tag("t1", "VIP") }, { tags: null }], + }, + }; + const normalized = normalizeConversation(raw); + expect(normalized.contact?.tags).toEqual([tag("t1", "VIP")]); + // The raw join key is dropped from the flattened contact. + expect( + (normalized.contact as unknown as Record).contact_tags, + ).toBeUndefined(); + }); + + it("passes through a conversation with no contact", () => { + const raw = { + id: "c1", + user_id: "u1", + contact_id: "ct1", + status: "open" as const, + unread_count: 0, + created_at: "", + updated_at: "", + contact: null, + }; + // A contactless row passes through untouched (consumers use `?.`). + expect(normalizeConversation(raw).contact).toBeNull(); + }); +}); diff --git a/src/lib/inbox/conversations.ts b/src/lib/inbox/conversations.ts new file mode 100644 index 0000000..b185cb1 --- /dev/null +++ b/src/lib/inbox/conversations.ts @@ -0,0 +1,71 @@ +import type { Conversation, Contact, Tag } from "@/types"; + +/** + * Conversation select that embeds the contact plus its tags, so the Inbox + * can filter conversations by contact tag without a second round-trip. + * `contact_tags(tags(*))` returns the join rows; {@link normalizeConversation} + * flattens them onto `contact.tags`. + */ +export const CONVERSATION_SELECT = + "*, contact:contacts(*, contact_tags(tags(*)))"; + +/** Raw shape returned by {@link CONVERSATION_SELECT} before flattening. */ +type RawContact = Contact & { contact_tags?: { tags: Tag | null }[] }; +type RawConversation = Omit & { + contact?: RawContact | null; +}; + +/** + * Flatten the embedded `contact_tags(tags(*))` join into `contact.tags`. + * Safe to call on rows fetched with {@link CONVERSATION_SELECT}; a row with + * no contact (e.g. a freshly-inserted conversation) passes through untouched. + */ +export function normalizeConversation(raw: RawConversation): Conversation { + const rawContact = raw.contact; + if (!rawContact) return raw as Conversation; + + const { contact_tags, ...contact } = rawContact; + return { + ...raw, + contact: { + ...contact, + tags: (contact_tags ?? []) + .map((ct) => ct.tags) + .filter((t): t is Tag => t != null), + }, + }; +} + +export function normalizeConversations( + rows: RawConversation[], +): Conversation[] { + return rows.map(normalizeConversation); +} + +export interface ContactFilters { + /** Tag ids; a conversation matches if its contact has ANY of them (OR). */ + tagIds: string[]; + /** Exact company match, or null for no company filter. */ + company: string | null; +} + +/** + * Whether a conversation passes the contact-based Inbox filters (issue #272). + * Empty `tagIds` and null `company` are no-ops, so the default (no filters) + * always matches. Tags use OR logic, consistent with Broadcast audiences. + */ +export function matchesContactFilters( + conversation: Conversation, + { tagIds, company }: ContactFilters, +): boolean { + if (tagIds.length > 0) { + const contactTagIds = conversation.contact?.tags ?? []; + if (!contactTagIds.some((t) => tagIds.includes(t.id))) return false; + } + + if (company !== null && conversation.contact?.company?.trim() !== company) { + return false; + } + + return true; +} diff --git a/src/types/index.ts b/src/types/index.ts index 440da21..ea94769 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -100,6 +100,9 @@ export interface Contact { avatar_url?: string; created_at: string; updated_at: string; + /** Hydrated by queries that embed `contact_tags(tags(*))` (e.g. the + * Inbox conversation list, for tag filtering). Absent otherwise. */ + tags?: Tag[]; } export interface Tag {