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
20 changes: 20 additions & 0 deletions src/db/migrations/1781267945750_add-chat-scope-muted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate";

export const shorthands: ColumnDefinitions | undefined = undefined;

/**
* Per-chat MUTE — the third scope state (§7). A muted chat stays `included` (it
* keeps appearing in Updates/catch-up and is still summarized), but the daily
* suggestion engine skips it: no proactive suggestions or notifications. Default
* false so existing scopes are unchanged. Tenant isolation is inherited from the
* table's existing RLS policy — the column needs no policy of its own.
*/
export async function up(pgm: MigrationBuilder): Promise<void> {
pgm.addColumn("chat_scopes", {
muted: { type: "boolean", notNull: true, default: false },
});
}

export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.dropColumn("chat_scopes", "muted");
}
46 changes: 43 additions & 3 deletions src/db/repositories/chat-scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { NormalizedMessage } from "../../importer/types.js";
import { createTestDatabase } from "../../test/db.js";
import { listIncludedGroupIds, listScopes, upsertScope } from "./chat-scopes.js";
import {
listIncludedGroupIds,
listScopes,
listSuggestibleGroupIds,
upsertScope,
} from "./chat-scopes.js";
import { upsertGroup } from "./groups.js";
import { insertMessages } from "./messages.js";
import { upsertParticipant } from "./participants.js";
Expand Down Expand Up @@ -80,11 +85,46 @@ describe("chat-scopes repository", () => {
});

describe("listScopes", () => {
it("projects an un-scoped group as excluded/uncategorized/not-removed (default-off)", async () => {
it("projects an un-scoped group as excluded/uncategorized/not-removed/not-muted (default-off)", async () => {
await seedGroupWithMessage("cs-projection");
const row = (await listScopes(pool)).find((r) => r.group === "cs-projection")!;
expect(row).toMatchObject({ included: false, categoryId: null, removed: false });
expect(row).toMatchObject({
included: false,
categoryId: null,
removed: false,
muted: false,
});
expect(row.messageCount).toBe(1);
});
});

describe("per-chat mute (§7 third state)", () => {
it("upsertScope round-trips muted and listScopes projects it", async () => {
const g = await seedGroupWithMessage("cs-mute");
await upsertScope(pool, { groupId: g, included: true });
expect((await listScopes(pool)).find((r) => r.group === "cs-mute")!.muted).toBe(false);
await upsertScope(pool, { groupId: g, muted: true });
const row = (await listScopes(pool)).find((r) => r.group === "cs-mute")!;
// muting must not change inclusion — it still gets summarized.
expect(row).toMatchObject({ included: true, muted: true });
await upsertScope(pool, { groupId: g, muted: false });
expect((await listScopes(pool)).find((r) => r.group === "cs-mute")!.muted).toBe(false);
});

it("listSuggestibleGroupIds excludes muted chats but listIncludedGroupIds keeps them", async () => {
const plain = await seedGroupWithMessage("cs-sugg-plain");
const muted = await seedGroupWithMessage("cs-sugg-muted");
await upsertScope(pool, { groupId: plain, included: true });
await upsertScope(pool, { groupId: muted, included: true, muted: true });

const suggestible = await listSuggestibleGroupIds(pool);
const included = await listIncludedGroupIds(pool);

// muted chat is still summarized (included) ...
expect(included).toEqual(expect.arrayContaining([plain, muted]));
// ... but never produces suggestions.
expect(suggestible).toContain(plain);
expect(suggestible).not.toContain(muted);
});
});
});
34 changes: 32 additions & 2 deletions src/db/repositories/chat-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type ScopeRow = {
included: boolean;
categoryId: number | null;
removed: boolean;
/** Muted chats stay in catch-up/Updates but generate no proactive suggestions. */
muted: boolean;
};

/** A single scope change (one chat). `removed` true→soft-remove, false→restore. */
Expand All @@ -17,6 +19,7 @@ export type ScopeUpdate = {
included?: boolean;
categoryId?: number | null;
removed?: boolean;
muted?: boolean;
};

/**
Expand All @@ -33,6 +36,7 @@ export async function listScopes(client: pg.Pool | pg.PoolClient): Promise<Scope
included: boolean;
category_id: string | null;
removed: boolean;
muted: boolean;
}>(
`
SELECT g.name,
Expand All @@ -41,11 +45,12 @@ export async function listScopes(client: pg.Pool | pg.PoolClient): Promise<Scope
MAX(m.sent_at) AS last_message_at,
COALESCE(cs.included, false) AS included,
cs.category_id,
(cs.removed_at IS NOT NULL) AS removed
(cs.removed_at IS NOT NULL) AS removed,
COALESCE(cs.muted, false) AS muted
FROM groups g
LEFT JOIN chat_scopes cs ON cs.group_id = g.id
LEFT JOIN messages m ON m.group_id = g.id
GROUP BY g.id, g.name, g.source, cs.included, cs.category_id, cs.removed_at
GROUP BY g.id, g.name, g.source, cs.included, cs.category_id, cs.removed_at, cs.muted
ORDER BY last_message_at DESC NULLS LAST, g.name ASC
`,
);
Expand All @@ -57,6 +62,7 @@ export async function listScopes(client: pg.Pool | pg.PoolClient): Promise<Scope
included: r.included,
categoryId: r.category_id === null ? null : Number(r.category_id),
removed: r.removed,
muted: r.muted,
}));
}

Expand Down Expand Up @@ -92,6 +98,12 @@ export async function upsertScope(
vals.push(expr);
sets.push(`removed_at = ${expr}`);
}
if (update.muted !== undefined) {
params.push(update.muted);
cols.push("muted");
vals.push(`$${params.length}`);
sets.push("muted = EXCLUDED.muted");
}
sets.push("updated_at = now()");

await client.query(
Expand Down Expand Up @@ -127,3 +139,21 @@ export async function listIncludedGroupIds(client: pg.Pool | pg.PoolClient): Pro
);
return rows.map((r) => Number(r.id));
}

/**
* The suggestion filter: included group ids that are NOT muted. Muted chats are
* still summarized (they stay in `listIncludedGroupIds`) but produce no proactive
* suggestions — the §7 "third state". A strict subset of `listIncludedGroupIds`.
*/
export async function listSuggestibleGroupIds(client: pg.Pool | pg.PoolClient): Promise<number[]> {
const { rows } = await client.query<{ id: string }>(
`
SELECT g.id
FROM groups g
JOIN chat_scopes cs ON cs.group_id = g.id
WHERE cs.included AND cs.removed_at IS NULL AND NOT cs.muted
ORDER BY g.id ASC
`,
);
return rows.map((r) => Number(r.id));
}
26 changes: 22 additions & 4 deletions src/db/repositories/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,17 +168,34 @@ export async function upsertGroupByWhatsappId(
* messages (last_message_at IS NULL) sink to the bottom; name is the tiebreaker
* so equal-recency chats stay in a stable, predictable order.
*/
export async function listGroups(
client: pg.Pool | pg.PoolClient,
): Promise<{ name: string; source: string; messageCount: number; lastMessageAt: Date | null }[]> {
export async function listGroups(client: pg.Pool | pg.PoolClient): Promise<
{
name: string;
source: string;
messageCount: number;
lastMessageAt: Date | null;
newCount: number;
}[]
> {
const { rows } = await client.query<{
name: string;
source: string;
message_count: string;
last_message_at: Date | null;
new_count: string;
}>(
`
SELECT g.name, g.source, COUNT(m.id) AS message_count, MAX(m.sent_at) AS last_message_at
SELECT g.name, g.source,
COUNT(m.id) AS message_count,
MAX(m.sent_at) AS last_message_at,
-- "חדשות": messages that arrived since this chat was last summarized
-- (an un-summarized chat counts all its messages as new to catch up on).
COUNT(m.id) FILTER (
WHERE m.sent_at > COALESCE(
(SELECT MAX(s.created_at) FROM summaries s WHERE s.group_id = g.id),
'-infinity'::timestamptz
)
) AS new_count
FROM groups g
LEFT JOIN messages m ON m.group_id = g.id
GROUP BY g.id, g.name, g.source
Expand All @@ -190,6 +207,7 @@ export async function listGroups(
source: r.source,
messageCount: Number(r.message_count),
lastMessageAt: r.last_message_at ?? null,
newCount: Number(r.new_count),
}));
}

Expand Down
2 changes: 2 additions & 0 deletions src/web/handlers/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ async function getScopes(res: http.ServerResponse, deps: ServerDeps): Promise<vo
included: s.included,
categoryId: s.categoryId,
removed: s.removed,
muted: s.muted,
})),
),
);
Expand Down Expand Up @@ -100,6 +101,7 @@ async function putScopes(
update.categoryId = rec["categoryId"] as number | null;
}
if (typeof rec["removed"] === "boolean") update.removed = rec["removed"] as boolean;
if (typeof rec["muted"] === "boolean") update.muted = rec["muted"] as boolean;
updates.push(update);
}
await upsertScopes(deps.pool, updates);
Expand Down
Loading