groupSidebarConversations.ts11.8 KBView on GitHub

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 }];
}