Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 25 additions & 6 deletions server/services/tribe.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,26 @@ export const personCadenceStatus = cadenceStatus;
// tribe (external excluded) is overdue or has no touchpoint yet, most-overdue
// first. `missing` (never contacted) sorts above any dated-overdue person.
export async function getCareSummary(limit = 5) {
const people = await listPeople();
const tribe = people.filter((person) => person.ring !== 'external');
await ensureReady();
// Deliberately NOT listPeople(): this runs on every Dashboard mount and on the
// 2-minute proactive-alerts poll, and listPeople() hydrates every column
// (notes, next_move, tags/emails/phones arrays) for the whole table just to
// date-check a handful of people. Project only what cadenceStatus() reads
// (ring, cadence_days, last_contact_on) plus the response fields (id, name,
// channel), and push the `external` exclusion into SQL (#6024).
const result = await query(
`SELECT id, name, ring, cadence_days, last_contact_on, channel
FROM tribe_people
WHERE deleted = FALSE AND ring <> 'external'`,
);
const tribe = result.rows.map((row) => ({
id: row.id,
name: row.name,
ring: row.ring || 'tribe',
cadenceDays: row.cadence_days,
lastContact: isoDate(row.last_contact_on),
channel: row.channel || '',
}));
const overdue = [];
for (const person of tribe) {
const status = personCadenceStatus(person);
Expand Down Expand Up @@ -206,11 +224,12 @@ export async function listPeople(options = {}) {
idx++;
}

// No touchpoint/memory-link counts here: they cost 2N correlated subqueries
// per list, no client view reads them, and `rowToPerson` already defaults both
// to 0 when the column is absent. Detail views get them from getPerson() (#6024).
const result = await query(
`SELECT p.*,
(SELECT COUNT(*) FROM tribe_touchpoints t WHERE t.person_id = p.id) AS touchpoint_count,
(SELECT COUNT(*) FROM tribe_memory_links ml WHERE ml.person_id = p.id) AS linked_memory_count
FROM tribe_people p
`SELECT *
FROM tribe_people
WHERE ${conditions.join(' AND ')}
ORDER BY
CASE ring WHEN 'support' THEN 1 WHEN 'core' THEN 2 WHEN 'tribe' THEN 3 WHEN 'village' THEN 4 WHEN 'external' THEN 5 ELSE 6 END,
Expand Down
79 changes: 74 additions & 5 deletions server/services/tribe.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock('../lib/db.js', () => ({
import { query, withTransaction } from '../lib/db.js';
import {
listPeople,
getPerson,
createPerson,
createTouchpoint,
normalizeTags,
Expand Down Expand Up @@ -138,6 +139,48 @@ describe('tribe service — listPeople query builder', () => {
query.mockResolvedValue({ rows: [] });
});

// The list view renders contact status/tags/energy/next move — never the
// touchpoint or memory-link counts — so the per-row correlated counts were
// pure overhead (2N index scans per request). getPerson() still carries them.
it('does not run correlated touchpoint/memory-link count subqueries', async () => {
await listPeople();
const [sql] = query.mock.calls[0];
expect(sql).not.toContain('COUNT(*)');
expect(sql).not.toContain('tribe_touchpoints');
expect(sql).not.toContain('tribe_memory_links');
expect(sql).toContain('FROM tribe_people');
});

it('keeps the ring-rank, oldest-contact, name ordering', async () => {
await listPeople();
const [sql] = query.mock.calls[0];
expect(sql).toContain("CASE ring WHEN 'support' THEN 1");
expect(sql).toContain("COALESCE(last_contact_on, DATE '1900-01-01') ASC");
expect(sql).toContain('name ASC');
});

it('maps rows to full person objects with counts defaulted to 0', async () => {
query.mockResolvedValue({ rows: [
{ id: 'p1', name: 'Ada', ring: 'core', cadence_days: 21, tags: ['x'], notes: 'n' },
] });
const [person] = await listPeople();
expect(person).toMatchObject({ id: 'p1', name: 'Ada', ring: 'core', tags: ['x'], notes: 'n' });
expect(person.touchpointCount).toBe(0);
expect(person.linkedMemoryCount).toBe(0);
});

it('getPerson still hydrates the touchpoint and memory-link counts', async () => {
query.mockResolvedValue({ rows: [
{ id: 'p1', name: 'Ada', ring: 'core', cadence_days: 21, touchpoint_count: '3', linked_memory_count: '2' },
] });
const person = await getPerson('p1');
const [sql, params] = query.mock.calls[0];
expect(sql).toContain('FROM tribe_touchpoints t');
expect(sql).toContain('FROM tribe_memory_links ml');
expect(params).toEqual(['p1']);
expect(person).toMatchObject({ id: 'p1', touchpointCount: 3, linkedMemoryCount: 2 });
});

it('filters by ring with a single $1 parameter', async () => {
await listPeople({ ring: 'core' });
const [sql, params] = query.mock.calls[0];
Expand Down Expand Up @@ -226,27 +269,53 @@ describe('tribe service — getCareSummary', () => {
query.mockReset();
});

// listPeople maps DB rows via rowToPerson, so mock rows use snake_case columns.
// The care query projects only the cadence + response columns, so mock rows
// carry exactly those snake_case columns and no full person entity.
const row = (over) => ({
id: over.id, name: over.name, ring: over.ring,
cadence_days: over.cadence_days, last_contact_on: over.last_contact_on ?? null,
channel: '', tags: [], touchpoint_count: '0', linked_memory_count: '0',
channel: '',
});

it('projects only the cadence columns and excludes external people in SQL', async () => {
query.mockResolvedValue({ rows: [] });
await getCareSummary();
const [sql] = query.mock.calls[0];
expect(sql).toContain('SELECT id, name, ring, cadence_days, last_contact_on, channel');
expect(sql).toContain("ring <> 'external'");
expect(sql).toContain('deleted = FALSE');
// Never the full entity, and never the 2N correlated counts (#6024).
expect(sql).not.toContain('notes');
expect(sql).not.toContain('COUNT(*)');
});

it('excludes external people and sorts missing first, then most-overdue', async () => {
it('sorts missing first, then most-overdue', async () => {
query.mockResolvedValue({ rows: [
row({ id: 'a', name: 'Overdue Small', ring: 'support', cadence_days: 7, last_contact_on: daysAgo(10) }), // 3d overdue
row({ id: 'b', name: 'Never', ring: 'core', cadence_days: 21, last_contact_on: null }), // missing
row({ id: 'c', name: 'Overdue Big', ring: 'core', cadence_days: 21, last_contact_on: daysAgo(60) }), // 39d overdue
row({ id: 'd', name: 'Steady', ring: 'village', cadence_days: 90, last_contact_on: daysAgo(1) }), // steady
row({ id: 'e', name: 'Nemesis', ring: 'external', cadence_days: 7, last_contact_on: daysAgo(999) }), // excluded
] });

const summary = await getCareSummary();
expect(summary.hasPeople).toBe(true);
expect(summary.peopleCount).toBe(4); // external excluded
expect(summary.peopleCount).toBe(4);
expect(summary.overdueCount).toBe(3); // missing + 2 overdue
expect(summary.overdue.map((p) => p.id)).toEqual(['b', 'c', 'a']);
// Response shape the dashboard widget + proactive alert consume.
expect(Object.keys(summary.overdue[1]).sort()).toEqual(
['channel', 'daysOverdue', 'id', 'lastContact', 'name', 'ring', 'state'],
);
expect(summary.overdue[1]).toMatchObject({ ring: 'core', state: 'overdue', daysOverdue: 39 });
});

it('renders a Date last_contact_on as an ISO date string', async () => {
query.mockResolvedValue({ rows: [
{ id: 'a', name: 'A', ring: 'core', cadence_days: 21, last_contact_on: new Date('2020-01-02T00:00:00.000Z'), channel: 'sms' },
] });
const summary = await getCareSummary();
expect(summary.overdue[0].lastContact).toBe('2020-01-02');
expect(summary.overdue[0].channel).toBe('sms');
});

it('respects the limit while still reporting the full overdueCount', async () => {
Expand Down