mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #324 from ArnasDon/feat/inbox-tag-company-filter
feat(inbox): filter conversations by contact tags and company
This commit is contained in:
commit
41815acc88
@ -3,6 +3,10 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import {
|
||||||
|
CONVERSATION_SELECT,
|
||||||
|
normalizeConversation,
|
||||||
|
} from "@/lib/inbox/conversations";
|
||||||
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
|
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { ConversationList } from "@/components/inbox/conversation-list";
|
import { ConversationList } from "@/components/inbox/conversation-list";
|
||||||
@ -118,7 +122,7 @@ export default function InboxPage() {
|
|||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("conversations")
|
.from("conversations")
|
||||||
.select("*, contact:contacts(*)")
|
.select(CONVERSATION_SELECT)
|
||||||
.eq("id", convId)
|
.eq("id", convId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -133,7 +137,7 @@ export default function InboxPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const fetched = data as Conversation;
|
const fetched = normalizeConversation(data);
|
||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
const existing = prev.find((c) => c.id === fetched.id);
|
const existing = prev.find((c) => c.id === fetched.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@ -2,18 +2,23 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import {
|
||||||
|
CONVERSATION_SELECT,
|
||||||
|
matchesContactFilters,
|
||||||
|
normalizeConversations,
|
||||||
|
} from "@/lib/inbox/conversations";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Conversation, ConversationStatus } from "@/types";
|
import type { Conversation, ConversationStatus, Tag } from "@/types";
|
||||||
import { Search, ChevronDown } from "lucide-react";
|
import { Search, ChevronDown, X } from "lucide-react";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
interface ConversationListProps {
|
interface ConversationListProps {
|
||||||
@ -56,6 +61,12 @@ export function ConversationList({
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filter, setFilter] = useState<InboxFilter>("all");
|
const [filter, setFilter] = useState<InboxFilter>("all");
|
||||||
const [loading, setLoading] = useState(true);
|
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<Tag[]>([]);
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keep the latest callback in a ref so the fetch effect below can
|
// Keep the latest callback in a ref so the fetch effect below can
|
||||||
// have a stable, empty-dep identity. Previously the fetch useCallback
|
// have a stable, empty-dep identity. Previously the fetch useCallback
|
||||||
@ -81,7 +92,7 @@ export function ConversationList({
|
|||||||
(async () => {
|
(async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("conversations")
|
.from("conversations")
|
||||||
.select("*, contact:contacts(*)")
|
.select(CONVERSATION_SELECT)
|
||||||
.order("last_message_at", { ascending: false });
|
.order("last_message_at", { ascending: false });
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -98,7 +109,7 @@ export function ConversationList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onConversationsLoadedRef.current(data ?? []);
|
onConversationsLoadedRef.current(normalizeConversations(data ?? []));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -110,6 +121,38 @@ export function ConversationList({
|
|||||||
// up on any events sent while the WS was disconnected or throttled.
|
// up on any events sent while the WS was disconnected or throttled.
|
||||||
}, [resyncToken]);
|
}, [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<string>();
|
||||||
|
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<string, Tag>();
|
||||||
|
for (const t of tags) m.set(t.id, t);
|
||||||
|
return m;
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = conversations;
|
let result = conversations;
|
||||||
|
|
||||||
@ -119,6 +162,16 @@ export function ConversationList({
|
|||||||
result = result.filter((c) => c.status === filter);
|
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()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
result = result.filter((c) => {
|
result = result.filter((c) => {
|
||||||
@ -130,7 +183,20 @@ export function ConversationList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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(
|
const handleSearchChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -165,31 +231,158 @@ export function ConversationList({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted">
|
<DropdownMenu>
|
||||||
{activeFilter?.label ?? "All"}
|
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted">
|
||||||
<ChevronDown className="h-3 w-3" />
|
{activeFilter?.label ?? "All"}
|
||||||
</DropdownMenuTrigger>
|
<ChevronDown className="h-3 w-3" />
|
||||||
<DropdownMenuContent
|
</DropdownMenuTrigger>
|
||||||
align="start"
|
<DropdownMenuContent
|
||||||
className="border-border bg-popover"
|
align="start"
|
||||||
>
|
className="border-border bg-popover"
|
||||||
{FILTER_OPTIONS.map((opt) => (
|
>
|
||||||
<DropdownMenuItem
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
key={opt.value}
|
<DropdownMenuItem
|
||||||
onClick={() => setFilter(opt.value)}
|
key={opt.value}
|
||||||
|
onClick={() => setFilter(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
filter === opt.value
|
||||||
|
? "text-primary"
|
||||||
|
: "text-popover-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm",
|
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
|
||||||
filter === opt.value
|
selectedTagIds.length > 0
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
: "text-popover-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{opt.label}
|
Tags
|
||||||
</DropdownMenuItem>
|
{selectedTagIds.length > 0 && (
|
||||||
))}
|
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
|
||||||
</DropdownMenuContent>
|
{selectedTagIds.length}
|
||||||
</DropdownMenu>
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="max-h-64 w-56 border-border bg-popover"
|
||||||
|
>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={t.id}
|
||||||
|
checked={selectedTagIds.includes(t.id)}
|
||||||
|
onCheckedChange={() => toggleTag(t.id)}
|
||||||
|
className="text-sm text-popover-foreground"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: t.color }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{t.name}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{companies.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className={cn(
|
||||||
|
"inline-flex max-w-40 items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
|
||||||
|
selectedCompany
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedCompany ?? "Company"}</span>
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="max-h-64 w-56 border-border bg-popover"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setSelectedCompany(null)}
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
selectedCompany === null
|
||||||
|
? "text-primary"
|
||||||
|
: "text-popover-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All companies
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{companies.map((co) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={co}
|
||||||
|
onClick={() => setSelectedCompany(co)}
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
selectedCompany === co
|
||||||
|
? "text-primary"
|
||||||
|
: "text-popover-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{co}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasContactFilters && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{selectedTagIds.map((id) => {
|
||||||
|
const tag = tagsById.get(id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => toggleTag(id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground hover:bg-muted/70"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: tag?.color ?? "var(--muted-foreground)" }}
|
||||||
|
/>
|
||||||
|
<span className="max-w-24 truncate">{tag?.name ?? "Tag"}</span>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedCompany && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCompany(null)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground hover:bg-muted/70"
|
||||||
|
>
|
||||||
|
<span className="max-w-24 truncate">{selectedCompany}</span>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={clearContactFilters}
|
||||||
|
className="px-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversation Items.
|
{/* Conversation Items.
|
||||||
|
|||||||
145
src/lib/inbox/conversations.test.ts
Normal file
145
src/lib/inbox/conversations.test.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
matchesContactFilters,
|
||||||
|
normalizeConversation,
|
||||||
|
} from "./conversations";
|
||||||
|
import type { Conversation } from "@/types";
|
||||||
|
|
||||||
|
function makeConversation(
|
||||||
|
contact: Partial<Conversation["contact"]> | 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<string, unknown>).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/lib/inbox/conversations.ts
Normal file
71
src/lib/inbox/conversations.ts
Normal file
@ -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<Conversation, "contact"> & {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -100,6 +100,9 @@ export interface Contact {
|
|||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 {
|
export interface Tag {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user