web: report spam from my calls, reports on Blocked numbers
ci / check (push) Successful in 1m24s
ci / images (push) Successful in 25s
ci / check (pull_request) Successful in 1m15s
ci / images (pull_request) Successful in 7s

Users report an outside call that rang them: the spam check's line when it
said medium or high, else a reason they type. Admins block or dismiss each
report on Blocked numbers, and the Dashboard counts them. Migration 0015 adds
spam_reports.
This commit is contained in:
iamdoubz committed 2026-10-28 15:07:05 -05:00
1 parent 2bc7d5cde5
commit a75113b6ac
16 files changed
+5212 -24

No files matched your search

+69 -4
View File
@@ -1,25 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5).
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5); users' Report spam (05 §4).
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { callExtensions, calls, extensions } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { syncAstDb } from '../src/services/astdb.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let req: (
method: string,
url: string,
payload?: unknown,
as?: string,
) => Promise<LightMyRequestResponse>;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
req = (method, url, payload, as = cookie) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
headers: { ...H, cookie: as },
});
});
afterAll(() => t?.close());
@@ -59,4 +66,62 @@ describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
});
expect((await req('GET', '/api/blocked-numbers')).json().items).toEqual([]);
});
it('Report spam: only on an outside call that rang me; Block clears it, Dismiss drops it', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
await t.addUser('pat@example.com', 'pat-password-123');
const [ext] = await t.db
.insert(extensions)
.values({
number: '103',
name: 'Sam',
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: sam.id,
})
.returning();
const call = async (linkedid: string, from: string | null, role: string) => {
const [c] = await t.db
.insert(calls)
.values({
linkedid,
direction: 'inbound',
status: 'answered',
fromNumber: from,
startedAt: new Date(),
endedAt: new Date(),
})
.returning();
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: ext!.id, role });
return c!.id;
};
const robo = await call('r1', '13125550142', 'rang');
const hidden = await call('r2', null, 'answered');
const samC = await t.login('sam@example.com', 'sam-password-123');
const patC = await t.login('pat@example.com', 'pat-password-123');
const report = (callId: string, as: string, reason = 'Car warranty robocall') =>
req('POST', '/api/me/spam-reports', { callId, reason }, as);
expect((await report(robo, patC)).statusCode).toBe(404); // not a call that rang Pat
expect((await report(robo, samC, ' ')).statusCode).toBe(400); // no reason
expect((await report(hidden, samC)).statusCode).toBe(400); // no number to block
expect((await report(robo, samC)).statusCode).toBe(201);
expect((await report(robo, samC)).statusCode).toBe(409); // once each
expect((await req('GET', '/api/spam-reports', undefined, samC)).statusCode).toBe(403);
const [r] = (await req('GET', '/api/spam-reports')).json().items;
expect(r).toMatchObject({
number: '13125550142',
reason: 'Car warranty robocall',
reportedBy: 'sam@example.com',
});
expect((await req('DELETE', `/api/spam-reports/${r.id}`)).statusCode).toBe(200);
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
expect((await report(robo, samC)).statusCode).toBe(201); // dismissed: may report again
const b = await req('POST', '/api/blocked-numbers', { number: r.number, reason: r.reason });
expect(b.statusCode).toBe(201);
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
expect((await report(robo, samC)).statusCode).toBe(409); // already blocked
});
});
+2 -2
View File
@@ -117,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 47 tables', async () => {
it('creates all 48 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 47 });
expect(r.rows[0]).toEqual({ n: 48 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {