Introduced 1 production defect in 180 days, median 94 days to fix.
import type { CustomFieldDefinitions } from '@/modules/crm/types';
import type { SidebarConversation } from '@/modules/conversationsPage/hooks/use-conversations-sidebar-conversations';
import type { SidebarGroupBy } from '../slice/conversationsSidebarSlice';
export interface SidebarGroup {
key=[redacted];
label: string;
conversations: SidebarConversation[];
defaultCollapsed?: boolean;
}
export interface GroupContext {
currentUserId: string | null;
/** AOP status options used when groupBy === 'status'. */
statusOptions?: Array<{ value: string; label: string }>;
/** AOP priority options used when groupBy === 'priority'. */
priorityOptions?: Array<{ value: string; label: string }>;
/** Map of aopId → AOP display name; used for groupBy === 'aop'. */
aopNamesById?: Record<string, string>;
/** Map of userId → display name; used for groupBy === 'owner'. */
userNamesById?: Record<string, string>;
/** Custom field definitions; used for grouping by a wm_* column. */
customFieldDefinitions?: CustomFieldDefinitions;
}
/**
* Return true when the conversation's most-recent event was inbound — i.e. someone
* else spoke last and we owe a response. Considers email_inbound, slack_message_inbound,
* and slack messages where direction is 'inbound'.
*/
type LegacyGroupMode =
| 'none'
| 'starredAndResponse'
| 'awaitingResponse'
| 'status'
| 'priority'
| 'owner'
| 'aop'
| 'unknown';
/**
* Collapse the structured `SidebarGroupBy` shape down to the legacy switch key
* used by the original 8-case grouping logic. Phase 4 will extend this to
* dispatch directly on custom field column ids (select / boolean).
*/
function sidebarGroupByToMode(groupBy: SidebarGroupBy): LegacyGroupMode {
if (groupBy.kind === 'sentinel') return groupBy.value;
switch (groupBy.columnId) {
case 'status':
return 'status';
case 'priority':
return 'priority';
case 'primaryUser':
return 'owner';
case 'aopId':
return 'aop';
default:
return 'unknown';
}
}
export function isAwaitingResponse(conv: SidebarConversation): boolean {
if (!conv?.conversation) return false;
const events = conv.conversation.events ?? [];
if (events.length === 0) return false;
const sorted = [...events].sort(
(a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
const latest = sorted[0];
if (!latest) return false;
if (latest.eventType === 'email_inbound') return true;
if (latest.eventType === 'slack_message_inbound') return true;
if (latest.direction === 'inbound') return true;
return false;
}
export function groupSidebarConversations(
rawConvs: SidebarConversation[],
groupBy: SidebarGroupBy,
ctx: GroupContext = { currentUserId: null },
): SidebarGroup[] {
// Defensively skip malformed entries so a partial cache row can never crash
// the whole sidebar.
const convs = rawConvs.filter((c) => !!c?.conversation);
if (convs.length === 0) return [];
// Custom field grouping (wm_* column ids) is dispatched here, before the
// legacy-mode switch — these are not enum sentinels.
if (groupBy.kind === 'column' && groupBy.columnId.startsWith('wm_')) {
return groupByCustomField(convs, groupBy.columnId, ctx);
}
// Normalize structured groupBy into the legacy string mode used by the
// switch below.
const mode = sidebarGroupByToMode(groupBy);
switch (mode) {
case 'none':
return [{ key: 'all', label: 'All', conversations: convs }];
case 'starredAndResponse': {
const starred: SidebarConversation[] = [];
const response: SidebarConversation[] = [];
const other: SidebarConversation[] = [];
for (const c of convs) {
if (c.conversation.important) starred.push(c);
else if (isAwaitingResponse(c)) response.push(c);
else other.push(c);
}
return [
{ key=[redacted], label: 'Starred', conversations: starred },
{ key=[redacted], label: 'Response', conversations: response },
{ key=[redacted], label: 'Other', conversations: other, defaultCollapsed: true },
].filter((g) => g.conversations.length > 0);
}
case 'awaitingResponse': {
const awaiting: SidebarConversation[] = [];
const upToDate: SidebarConversation[] = [];
for (const c of convs) {
if (isAwaitingResponse(c)) awaiting.push(c);
else upToDate.push(c);
}
return [
{ key=[redacted], label: 'Awaiting response', conversations: awaiting },
{ key=[redacted], label: 'Up to date', conversations: upToDate },
].filter((g) => g.conversations.length > 0);
}
case 'status': {
const opts = ctx.statusOptions ?? [];
const bucketByValue = new Map<string, SidebarConversation[]>();
const unset: SidebarConversation[] = [];
for (const opt of opts) bucketByValue.set(opt.value, []);
for (const c of convs) {
const s = c.conversation.status;
if (s && bucketByValue.has(s)) {
bucketByValue.get(s)!.push(c);
} else if (s) {
// Status not in AOP options — bucket under its raw value.
bucketByValue.set(s, []);
bucketByValue.get(s)!.push(c);
} else {
unset.push(c);
}
}
const groups: SidebarGroup[] = [];
for (const opt of opts) {
const list = bucketByValue.get(opt.value) ?? [];
if (list.length > 0) groups.push({ key=[redacted], label: opt.label, conversations: list });
}
// Extra statuses not enumerated by AOP appear after the configured ones.
for (const [value, list] of bucketByValue) {
if (opts.some((o) => o.value === value)) continue;
if (list.length > 0) groups.push({ key=[redacted], label: value, conversations: list });
}
if (unset.length > 0) groups.push({ key=[redacted], label: 'Unset', conversations: unset });
return groups;
}
case 'priority': {
const opts = ctx.priorityOptions ?? [];
const bucketByValue = new Map<string, SidebarConversation[]>();
const unset: SidebarConversation[] = [];
for (const opt of opts) bucketByValue.set(opt.value, []);
for (const c of convs) {
const p = c.conversation.priority;
if (p && bucketByValue.has(p)) {
bucketByValue.get(p)!.push(c);
} else if (p) {
if (!bucketByValue.has(p)) bucketByValue.set(p, []);
bucketByValue.get(p)!.push(c);
} else {
unset.push(c);
}
}
const groups: SidebarGroup[] = [];
for (const opt of opts) {
const list = bucketByValue.get(opt.value) ?? [];
if (list.length > 0) groups.push({ key=[redacted], label: opt.label, conversations: list });
}
for (const [value, list] of bucketByValue) {
if (opts.some((o) => o.value === value)) continue;
if (list.length > 0) groups.push({ key=[redacted], label: value, conversations: list });
}
if (unset.length > 0) groups.push({ key=[redacted], label: 'Unset', conversations: unset });
return groups;
}
case 'owner': {
const buckets = new Map<string, SidebarConversation[]>();
for (const c of convs) {
const u = c.conversation.userId ?? 'unset';
if (!buckets.has(u)) buckets.set(u, []);
buckets.get(u)!.push(c);
}
const groups: SidebarGroup[] = [];
if (ctx.currentUserId && buckets.has(ctx.currentUserId)) {
groups.push({
key=[redacted],
label: 'Me',
conversations: buckets.get(ctx.currentUserId)!,
});
buckets.delete(ctx.currentUserId);
}
for (const [userId, list] of buckets) {
const label = userId === 'unset' ? 'Unassigned' : ctx.userNamesById?.[userId] ?? userId;
groups.push({ key=[redacted], label, conversations: list });
}
return groups;
}
case 'aop': {
const buckets = new Map<string, SidebarConversation[]>();
const noAop: SidebarConversation[] = [];
for (const c of convs) {
const id = c.conversation.aopId;
if (!id) noAop.push(c);
else {
if (!buckets.has(id)) buckets.set(id, []);
buckets.get(id)!.push(c);
}
}
const groups: SidebarGroup[] = [];
for (const [aopId, list] of buckets) {
groups.push({ key=[redacted], label: ctx.aopNamesById?.[aopId] ?? aopId, conversations: list });
}
if (noAop.length > 0) groups.push({ key=[redacted], label: 'No AOP', conversations: noAop });
return groups;
}
case 'unknown':
default:
return [{ key: 'all', label: 'All', conversations: convs }];
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Custom field (wm_*) grouping
// ──────────────────────────────────────────────────────────────────────────────
function readCustomFieldValue(
conv: SidebarConversation,
fieldId: string,
): unknown {
if (!conv.customFields) return undefined;
// `name` is the only identifier both projections carry — the list ships no working-memory
// id, and the `fieldId`/`id` arms of the old duck-typed lookup could therefore never match
// on a sidebar row anyway. Matching on the one real field says so out loud.
return conv.customFields.find((f) => f.name === fieldId)?.value;
}
function groupByCustomField(
convs: SidebarConversation[],
columnId: string,
ctx: GroupContext,
): SidebarGroup[] {
const fieldId = columnId.slice(3); // strip 'wm_'
const def = ctx.customFieldDefinitions?.[fieldId];
if (!def) return [{ key: 'all', label: 'All', conversations: convs }];
if (def.type === 'boolean') {
const yes: SidebarConversation[] = [];
const no: SidebarConversation[] = [];
const unset: SidebarConversation[] = [];
for (const c of convs) {
const v = readCustomFieldValue(c, fieldId);
if (v === true || v === 'true') yes.push(c);
else if (v === false || v === 'false') no.push(c);
else unset.push(c);
}
const out: SidebarGroup[] = [];
if (yes.length) out.push({ key: 'yes', label: 'Yes', conversations: yes });
if (no.length) out.push({ key: 'no', label: 'No', conversations: no });
if (unset.length) out.push({ key=[redacted], label: 'Unset', conversations: unset });
return out;
}
if (def.type === 'select') {
const buckets = new Map<string, SidebarConversation[]>();
const opts = def.options ?? [];
for (const opt of opts) {
if (opt.value !== null) buckets.set(opt.value, []);
}
const unset: SidebarConversation[] = [];
for (const c of convs) {
const v = readCustomFieldValue(c, fieldId);
if (typeof v !== 'string' || v.length === 0) {
unset.push(c);
continue;
}
if (!buckets.has(v)) buckets.set(v, []);
buckets.get(v)!.push(c);
}
const groups: SidebarGroup[] = [];
// Follow option order first
for (const opt of opts) {
if (opt.value === null) continue;
const list = buckets.get(opt.value) ?? [];
if (list.length > 0) {
groups.push({ key=[redacted], label: opt.label || opt.value, conversations: list });
}
}
// Then any unexpected values (legacy data)
for (const [value, list] of buckets) {
if (opts.some((o) => o.value === value)) continue;
if (list.length > 0) groups.push({ key=[redacted], label: value, conversations: list });
}
if (unset.length > 0) groups.push({ key=[redacted], label: 'Unset', conversations: unset });
return groups;
}
// Non-enum types fall back to "all" — sidebar's group picker only offers
// select / boolean fields, but be defensive.
return [{ key: 'all', label: 'All', conversations: convs }];
}