Chore/qol #7

Merged
iamdoubz merged 2 commits from chore/qol into main 2026-10-28 15:18:49 -05:00
20 changed files with 5430 additions and 101 deletions

No files matched your search

@@ -0,0 +1,11 @@
CREATE TABLE "spam_reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"number" text NOT NULL,
"reason" text NOT NULL,
"reported_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_reports_number_reportedBy_unique" UNIQUE("number","reported_by")
);
--> statement-breakpoint
ALTER TABLE "spam_reports" ADD CONSTRAINT "spam_reports_reported_by_users_id_fk" FOREIGN KEY ("reported_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -106,6 +106,13 @@
"when": 1793127210386, "when": 1793127210386,
"tag": "0014_spam_check", "tag": "0014_spam_check",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1793212840276,
"tag": "0015_spam_reports",
"breakpoints": true
} }
] ]
} }
+13
View File
@@ -670,6 +670,19 @@ export const ftcComplaints = pgTable(
(t) => [index().on(t.number), index().on(t.reportedAt)], (t) => [index().on(t.number), index().on(t.reportedAt)],
); );
/** "Report spam" from a user's call history, waiting for an admin (05 §2 Blocked numbers). */
export const spamReports = pgTable(
'spam_reports',
{
id: id(),
number: text().notNull(),
reason: text().notNull(),
reportedBy: uuid().references(() => users.id, { onDelete: 'set null' }),
...timestamps,
},
(t) => [unique().on(t.number, t.reportedBy)],
);
export const callExtensions = pgTable( export const callExtensions = pgTable(
'call_extensions', 'call_extensions',
{ {
+28 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk // /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk
// entry checks (03 §6.4). Admin only. // entry checks (03 §6.4). /api/spam-reports: users' Report spam, to block or dismiss. Admin only.
import { desc, eq } from 'drizzle-orm'; import { desc, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
@@ -9,9 +9,10 @@ import {
formatNumber, formatNumber,
type BlockedDto, type BlockedDto,
type ListResponse, type ListResponse,
type SpamReportDto,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts'; import { requireAdmin } from '../auth/guards.ts';
import { blockedNumbers } from '../db/schema.ts'; import { blockedNumbers, spamReports, users } from '../db/schema.ts';
import { conflict, notFound } from '../errors.ts'; import { conflict, notFound } from '../errors.ts';
import { setBlock } from '../services/astdb.ts'; import { setBlock } from '../services/astdb.ts';
import { audit } from '../services/audit.ts'; import { audit } from '../services/audit.ts';
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
throw e; throw e;
} }
await mirror(b.number, true); await mirror(b.number, true);
await db.delete(spamReports).where(eq(spamReports.number, b.number));
await audit(db, { await audit(db, {
actorUserId: req.user!.id, actorUserId: req.user!.id,
actorIp: req.ip, actorIp: req.ip,
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
}); });
return { ok: true }; return { ok: true };
}); });
app.get('/api/spam-reports', async (): Promise<ListResponse<SpamReportDto>> => ({
items: (
await db
.select({ r: spamReports, name: users.displayName })
.from(spamReports)
.leftJoin(users, eq(users.id, spamReports.reportedBy))
.orderBy(desc(spamReports.createdAt))
).map(({ r, name }) => ({
id: r.id,
number: r.number,
reason: r.reason,
reportedBy: name,
createdAt: r.createdAt.toISOString(),
})),
nextCursor: null,
}));
app.delete('/api/spam-reports/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const [r] = await db.delete(spamReports).where(eq(spamReports.id, id)).returning();
if (!r) throw notFound('Already handled');
return { ok: true };
});
} }
+53 -3
View File
@@ -1,11 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging. // /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging.
import { and, asc, desc, eq, ilike, inArray, or, sql, type SQL } from 'drizzle-orm'; // /api/me/spam-reports: Report spam on a call that rang me (05 §4).
import { and, asc, desc, eq, ilike, inArray, ne, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { alias } from 'drizzle-orm/pg-core'; import { alias } from 'drizzle-orm/pg-core';
import { z } from 'zod'; import { z } from 'zod';
import { import {
CallFilterSchema, CallFilterSchema,
SpamReportCreateSchema,
formatNumber,
normalizeNumber,
type CallDetailDto, type CallDetailDto,
type CallDto, type CallDto,
type ListResponse, type ListResponse,
@@ -14,9 +18,17 @@ import {
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts'; import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts'; import {
blockedNumbers,
callExtensions,
calls,
cdr,
dids,
extensions,
spamReports,
} from '../db/schema.ts';
import { contactNames } from '../contacts/book.ts'; import { contactNames } from '../contacts/book.ts';
import { badRequest, notFound } from '../errors.ts'; import { badRequest, conflict, notFound } from '../errors.ts';
import { getSetting } from '../services/settings.ts'; import { getSetting } from '../services/settings.ts';
const answerer = alias(extensions, 'answerer'); const answerer = alias(extensions, 'answerer');
@@ -224,4 +236,42 @@ export async function callRoutes(app: FastifyInstance) {
}; };
}, },
); );
/** The admins see it on Blocked numbers and block or dismiss it. */
app.post('/api/me/spam-reports', { preHandler: requireAuth }, async (req, reply) => {
const body = SpamReportCreateSchema.parse(req.body);
const [c] = await db
.select({ number: calls.fromNumber })
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(
and(
eq(calls.id, body.callId),
eq(calls.direction, 'inbound'),
eq(extensions.userId, req.user!.id),
ne(callExtensions.role, 'caller'),
),
)
.limit(1);
if (!c) throw notFound('Not a call from outside that rang you');
let number: string;
try {
number = normalizeNumber(c.number ?? '');
} catch {
throw badRequest("This caller's number was hidden, so it can't be blocked");
}
const [blocked] = await db
.select({ id: blockedNumbers.id })
.from(blockedNumbers)
.where(eq(blockedNumbers.number, number));
if (blocked) throw conflict(`${formatNumber(number)} is already blocked`);
const [r] = await db
.insert(spamReports)
.values({ number, reason: body.reason, reportedBy: req.user!.id })
.onConflictDoNothing()
.returning();
if (!r) throw conflict(`You already reported ${formatNumber(number)}`);
return reply.code(201).send({ ok: true });
});
} }
+39 -13
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only. // /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
import { and, asc, eq, sql } from 'drizzle-orm'; // but /api/me/reports/summary: the same summary of the calls made to me.
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { import {
CALL_DIRECTIONS, CALL_DIRECTIONS,
@@ -10,12 +11,12 @@ import {
type ProviderBalanceDto, type ProviderBalanceDto,
type ReportSummary, type ReportSummary,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts'; import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { decrypt } from '../crypto/secrets.ts'; import { decrypt } from '../crypto/secrets.ts';
import { calls, queues, trunks } from '../db/schema.ts'; import { callExtensions, calls, extensions, queues, trunks } from '../db/schema.ts';
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts'; import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
import { badRequest } from '../errors.ts'; import { badRequest, notFound } from '../errors.ts';
import { todayIn } from '../generator/load.ts'; import { todayIn } from '../generator/load.ts';
import { queueStats } from '../services/queuestats.ts'; import { queueStats } from '../services/queuestats.ts';
import { getSetting } from '../services/settings.ts'; import { getSetting } from '../services/settings.ts';
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
export async function reportRoutes(app: FastifyInstance) { export async function reportRoutes(app: FastifyInstance) {
const { db } = app; const { db } = app;
app.addHook('preHandler', requireAdmin); const admin = { preHandler: requireAdmin };
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => { /** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
const { tz, from, to } = await range(db, req.query); async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
const { tz, from, to } = await range(db, query);
const where = and( const where = and(
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`, sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`, sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
toExt
? inArray(
calls.id,
db
.select({ id: callExtensions.callId })
.from(callExtensions)
.where(and(eq(callExtensions.extensionId, toExt), ne(callExtensions.role, 'caller'))),
)
: undefined,
); );
// Mine: answered and talk time are the calls I picked up, not a coworker in my ring group.
const answered = toExt
? sql`${calls.answeredByExtensionId} = ${toExt}`
: sql`${calls.status} = 'answered'`;
const local = sql`(${calls.startedAt} at time zone ${tz})`; const local = sql`(${calls.startedAt} at time zone ${tz})`;
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`; const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
// Outside line time: inbound from arrival (a receptionist or voicemail answers too), // Outside line time: inbound from arrival (a receptionist or voicemail answers too),
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
db db
.select({ .select({
calls: sql<number>`count(*)::int`, calls: sql<number>`count(*)::int`,
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`, answered: sql<number>`count(*) filter (where ${answered})::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`, missed: sql<number>`count(*) filter (where ${missed})::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`, talk: sql<number>`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 0)::int`,
lineIn: sql<number>`round(${lineIn})::int`, lineIn: sql<number>`round(${lineIn})::int`,
lineOut: sql<number>`${lineOut}::int`, lineOut: sql<number>`${lineOut}::int`,
avgTalk: sql< avgTalk: sql<
number | null number | null
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`, >`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
}) })
.from(calls) .from(calls)
.where(where), .where(where),
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
}, },
hours, hours,
}; };
}
app.get('/api/reports/summary', admin, (req) => summary(req.query));
app.get('/api/me/reports/summary', { preHandler: requireAuth }, async (req) => {
const [ext] = await db
.select({ id: extensions.id })
.from(extensions)
.where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
return summary(req.query, ext.id);
}); });
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */ /** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => { app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
const rows = await db const rows = await db
.select() .select()
.from(trunks) .from(trunks)
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
return { items, nextCursor: null }; return { items, nextCursor: null };
}); });
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => { app.get('/api/reports/queues', admin, async (req): Promise<ListResponse<QueueReportRow>> => {
const { from, to } = await range(db, req.query); const { from, to } = await range(db, req.query);
const qs = await db.select().from(queues).orderBy(asc(queues.number)); const qs = await db.select().from(queues).orderBy(asc(queues.number));
return { return {
+69 -4
View File
@@ -1,25 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { syncAstDb } from '../src/services/astdb.ts';
import { hasTestDb } from './db.ts'; import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts'; import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => { describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
let t: Awaited<ReturnType<typeof createTestApp>>; 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 () => { beforeAll(async () => {
t = await createTestApp(); t = await createTestApp();
await t.clearMustChange(ADMIN.email); await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password); const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) => req = (method, url, payload, as = cookie) =>
t.app.inject({ t.app.inject({
method: method as 'GET', method: method as 'GET',
url, url,
payload: payload as object, payload: payload as object,
headers: { ...H, cookie }, headers: { ...H, cookie: as },
}); });
}); });
afterAll(() => t?.close()); afterAll(() => t?.close());
@@ -59,4 +66,62 @@ describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
}); });
expect((await req('GET', '/api/blocked-numbers')).json().items).toEqual([]); 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
});
}); });
+74 -2
View File
@@ -1,8 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone. // M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone;
// /api/me/reports/summary, the calls made to me.
import type { LightMyRequestResponse } from 'fastify'; import type { LightMyRequestResponse } from 'fastify';
import { inArray } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, queueCalls, queues } from '../src/db/schema.ts'; import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { setSetting } from '../src/services/settings.ts'; import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts'; import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts'; import { ADMIN, H, createTestApp } from './app.ts';
@@ -125,5 +128,74 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
await t.addUser('pat@example.com', 'pat-password-123'); await t.addUser('pat@example.com', 'pat-password-123');
const pat = await t.login('pat@example.com', 'pat-password-123'); const pat = await t.login('pat@example.com', 'pat-password-123');
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403); expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
});
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
const [mine, other] = await t.db
.insert(extensions)
.values(
(['103', '104'] as const).map((number) => ({
number,
name: number,
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: number === '103' ? sam.id : null,
})),
)
.returning();
await t.db.insert(calls).values({
linkedid: 'g',
direction: 'internal',
status: 'answered',
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
endedAt: new Date('2026-05-05T15:00:45Z'),
talkSeconds: 40,
answeredByExtensionId: mine!.id,
});
const ids = Object.fromEntries(
(
await t.db
.select({ id: calls.id, linkedid: calls.linkedid })
.from(calls)
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
).map((c) => [c.linkedid, c.id]),
);
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
await t.db
.update(calls)
.set({ answeredByExtensionId: other!.id })
.where(inArray(calls.id, [ids.a!]));
await t.db.insert(callExtensions).values([
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
]);
const cookie = await t.login('sam@example.com', 'sam-password-123');
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
expect(r.statusCode, r.body).toBe(200);
const s = r.json();
expect(s.days).toEqual([
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
]);
expect(s.totals).toEqual({
calls: 3,
answered: 1,
missed: 1,
avgTalkSeconds: 40,
talkSeconds: 40,
outsideSeconds: { inbound: 95, outbound: 0 },
});
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
// The company summary is unchanged by who rang.
expect(
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
).toBe(2);
}); });
}); });
+2 -2
View File
@@ -117,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb())); beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.()); afterAll(() => drop?.());
it('creates all 47 tables', async () => { it('creates all 48 tables', async () => {
const r = await db.execute( const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`, 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) => { it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+7
View File
@@ -26,6 +26,8 @@ import type {
AnnouncementUpdate, AnnouncementUpdate,
BlockedCreate, BlockedCreate,
BlockedDto, BlockedDto,
SpamReportCreate,
SpamReportDto,
CallDetailDto, CallDetailDto,
CallDto, CallDto,
CallFilter, CallFilter,
@@ -306,6 +308,9 @@ export const systemApi = {
export const meApi = { export const meApi = {
calls: (cursor?: string) => calls: (cursor?: string) =>
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`), api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
reportSpam: (r: SpamReportCreate) => api<{ ok: true }>('POST', '/api/me/spam-reports', r),
/** The summary of the calls made to me. */
report: (r: ReportRange) => api<ReportSummary>('GET', `/api/me/reports/summary?${callQuery(r)}`),
get: () => api<MeDto>('GET', '/api/me'), get: () => api<MeDto>('GET', '/api/me'),
/** 14 §5.1: only for a caller ringing me now. */ /** 14 §5.1: only for a caller ringing me now. */
callerCheck: (number: string) => callerCheck: (number: string) =>
@@ -505,6 +510,8 @@ export const blockedApi = {
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items), list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b), create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/blocked-numbers/${id}`), remove: (id: string) => api<{ ok: true }>('DELETE', `/api/blocked-numbers/${id}`),
reports: () => api<ListResponse<SpamReportDto>>('GET', '/api/spam-reports').then((r) => r.items),
dismiss: (id: string) => api<{ ok: true }>('DELETE', `/api/spam-reports/${id}`),
}; };
/** Filters as a query string (empty values left out). */ /** Filters as a query string (empty values left out). */
+47 -3
View File
@@ -1,14 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings. // Blocked numbers (05 §2): calls from these numbers are rejected before anything rings. Users'
// Report spam waits at the top for Block or Dismiss.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { formatNumber } from '@selfphoneng/shared'; import { formatNumber } from '@selfphoneng/shared';
import { blockedApi } from '../../api/client.ts'; import { blockedApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; import { Alert, Button, Field, Panel, inputClass } from '../../components/ui.tsx';
export function BlockedPage() { export function BlockedPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list }); const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list });
const reports = useQuery({ queryKey: ['blocked', 'reports'], queryFn: blockedApi.reports });
const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] }); const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] });
const [number, setNumber] = useState(''); const [number, setNumber] = useState('');
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
@@ -24,6 +26,16 @@ export function BlockedPage() {
mutationFn: (id: string) => blockedApi.remove(id), mutationFn: (id: string) => blockedApi.remove(id),
onSuccess: refresh, onSuccess: refresh,
}); });
// Blocking a number clears every report of it (api).
const block = useMutation({
mutationFn: (r: { number: string; reason: string }) => blockedApi.create(r),
onSuccess: refresh,
});
const dismiss = useMutation({
mutationFn: (id: string) => blockedApi.dismiss(id),
onSuccess: refresh,
});
const error = add.error ?? remove.error ?? block.error ?? dismiss.error ?? reports.error;
return ( return (
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
@@ -32,6 +44,38 @@ export function BlockedPage() {
Calls from these numbers are rejected before any phone rings. Emergency calls you make are Calls from these numbers are rejected before any phone rings. Emergency calls you make are
never affected. never affected.
</p> </p>
{!!reports.data?.length && (
<Panel title="Reported as spam">
<ul className="divide-y divide-line">
{reports.data.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
<span className="font-mono">{formatNumber(r.number)}</span>
<span className="min-w-0 flex-1">
{r.reason}
<span className="block text-xs text-muted">
{r.reportedBy ?? 'A former user'}, {new Date(r.createdAt).toLocaleString()}
</span>
</span>
<Button
variant="primary"
aria-label={`Block ${formatNumber(r.number)}`}
disabled={block.isPending}
onClick={() => block.mutate({ number: r.number, reason: r.reason })}
>
Block
</Button>
<Button
variant="ghost"
aria-label={`Dismiss the report of ${formatNumber(r.number)}`}
onClick={() => dismiss.mutate(r.id)}
>
Dismiss
</Button>
</li>
))}
</ul>
</Panel>
)}
<form <form
className="flex flex-wrap items-end gap-2" className="flex flex-wrap items-end gap-2"
onSubmit={(e) => { onSubmit={(e) => {
@@ -66,7 +110,7 @@ export function BlockedPage() {
Block Block
</Button> </Button>
</form> </form>
{(add.error ?? remove.error) && <Alert>{(add.error ?? remove.error)!.message}</Alert>} {error && <Alert>{error.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>} {list.data?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>}
{list.data && list.data.length > 0 && ( {list.data && list.data.length > 0 && (
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"> <ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
+14 -3
View File
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer) // Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// queue tiles, parked calls and extensions online. // queue tiles, parked calls and extensions online, spam reports waiting.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared'; import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts'; import { blockedApi, liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx'; import { ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button, inputClass } from '../../components/ui.tsx'; import { Alert, Button, inputClass } from '../../components/ui.tsx';
import { fmtNumber } from './CallsPage.tsx'; import { fmtNumber } from './CallsPage.tsx';
@@ -31,6 +31,11 @@ export function DashboardPage() {
const onCall = list.filter((e) => const onCall = list.filter((e) =>
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state), ['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
).length; ).length;
const reports = useQuery({
queryKey: ['blocked', 'reports'],
queryFn: blockedApi.reports,
refetchInterval: 30_000,
});
return ( return (
<section className="flex flex-col gap-6"> <section className="flex flex-col gap-6">
@@ -40,7 +45,7 @@ export function DashboardPage() {
(t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'), (t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'),
)} )}
/> />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<Card title="Extensions online"> <Card title="Extensions online">
{status === 'connecting' ? '…' : `${online} / ${list.length}`} {status === 'connecting' ? '…' : `${online} / ${list.length}`}
</Card> </Card>
@@ -61,6 +66,12 @@ export function DashboardPage() {
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'} {status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
</span> </span>
</Card> </Card>
<Card title="Spam reports">
<Link to="/admin/blocked" className="hover:underline">
{reports.data?.length ?? '…'}
<span className="sr-only"> waiting on Blocked numbers</span>
</Link>
</Card>
</div> </div>
<QueueTiles /> <QueueTiles />
+90 -61
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per // Reports (05 §2): totals (calls, minutes, the voip.ms balance), calls and outside minutes per
// day, missed per day, busiest hours, queue table. // day, missed per day, busiest hours, queue table. `mine` (05 §4): the calls made to me, without
// the company's outside minutes, balance and queues.
// ponytail: bars and the heatmap are plain divs and a table, no chart library. // ponytail: bars and the heatmap are plain divs and a table, no chart library.
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import type { ReportRange, ReportSummary } from '@selfphoneng/shared'; import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
import { reportsApi } from '../../api/client.ts'; import { meApi, reportsApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { fmtDuration } from './CallsPage.tsx'; import { fmtDuration } from './CallsPage.tsx';
@@ -116,18 +117,23 @@ function Heatmap({ hours }: { hours: number[][] }) {
); );
} }
export function ReportsPage() { export function ReportsPage({ mine = false }: { mine?: boolean }) {
const [draft, setDraft] = useState<ReportRange>({}); const [draft, setDraft] = useState<ReportRange>({});
const [range, setRange] = useState<ReportRange>({}); const [range, setRange] = useState<ReportRange>({});
const summary = useQuery({ const summary = useQuery({
queryKey: ['reports', 'summary', range], queryKey: [mine ? 'me' : 'reports', 'summary', range],
queryFn: () => reportsApi.summary(range), queryFn: () => (mine ? meApi.report(range) : reportsApi.summary(range)),
}); });
const queues = useQuery({ const queues = useQuery({
queryKey: ['reports', 'queues', range], queryKey: ['reports', 'queues', range],
queryFn: () => reportsApi.queues(range), queryFn: () => reportsApi.queues(range),
enabled: !mine,
});
const balance = useQuery({
queryKey: ['reports', 'balance'],
queryFn: reportsApi.balance,
enabled: !mine,
}); });
const balance = useQuery({ queryKey: ['reports', 'balance'], queryFn: reportsApi.balance });
const s = summary.data; const s = summary.data;
const date = (k: 'from' | 'to', label: string) => ( const date = (k: 'from' | 'to', label: string) => (
<Field label={label}> <Field label={label}>
@@ -145,7 +151,8 @@ export function ReportsPage() {
return ( return (
<section className="flex flex-col gap-6"> <section className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">Reports</h1> <h1 className="text-xl font-semibold">{mine ? 'My reports' : 'Reports'}</h1>
{mine && <p className="text-sm text-muted">Calls that rang you, not the ones you made.</p>}
<form <form
aria-label="Dates" aria-label="Dates"
className="flex flex-wrap items-end gap-3" className="flex flex-wrap items-end gap-3"
@@ -174,19 +181,33 @@ export function ReportsPage() {
{( {(
[ [
['Calls', s.totals.calls, null], ['Calls', s.totals.calls, null],
['Answered', s.totals.answered, null], [
mine ? 'You answered' : 'Answered',
s.totals.answered,
mine ? 'not the ones a coworker picked up' : null,
],
['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'], ['Missed', s.totals.missed, 'missed, voicemail or hung up waiting'],
[ [
'Average talk time', 'Average talk time',
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds), s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
null, null,
], ],
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
[ [
'Outside minutes', 'Talk time',
fmtMinutes(s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound), fmtMinutes(s.totals.talkSeconds),
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`, mine ? null : 'people talking, inside calls too',
], ],
...(mine
? []
: ([
[
'Outside minutes',
fmtMinutes(
s.totals.outsideSeconds.inbound + s.totals.outsideSeconds.outbound,
),
`${fmtMinutes(s.totals.outsideSeconds.inbound)} in, ${fmtMinutes(s.totals.outsideSeconds.outbound)} out: roughly what voip.ms bills`,
],
] as const)),
...(balance.data ?? []).map( ...(balance.data ?? []).map(
(b) => (b) =>
[ [
@@ -204,12 +225,18 @@ export function ReportsPage() {
</div> </div>
))} ))}
</dl> </dl>
<Bars label="Calls per day" days={s.days} segments={SERIES} />
<Bars <Bars
label="Outside minutes per day" label="Calls per day"
days={s.days} days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]} segments={mine ? SERIES.filter(([k]) => k !== 'outbound') : SERIES}
/> />
{!mine && (
<Bars
label="Outside minutes per day"
days={s.days}
segments={[['outsideMinutes', 'Minutes', 'bg-emerald-500']]}
/>
)}
<Bars <Bars
label="Missed per day" label="Missed per day"
days={s.days} days={s.days}
@@ -222,53 +249,55 @@ export function ReportsPage() {
</> </>
)} )}
<div className="flex flex-col gap-2"> {!mine && (
<h2 className="font-medium">Queues</h2> <div className="flex flex-col gap-2">
{queues.error && <Alert>{queues.error.message}</Alert>} <h2 className="font-medium">Queues</h2>
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>} {queues.error && <Alert>{queues.error.message}</Alert>}
{!!queues.data?.length && ( {queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
<div className="overflow-x-auto"> {!!queues.data?.length && (
<table aria-label="Queue report" className="w-full text-left text-sm"> <div className="overflow-x-auto">
<thead className="text-muted"> <table aria-label="Queue report" className="w-full text-left text-sm">
<tr> <thead className="text-muted">
{[ <tr>
'Queue', {[
'Calls', 'Queue',
'Answered', 'Calls',
'Hung up waiting', 'Answered',
'Timed out', 'Hung up waiting',
'Average wait', 'Timed out',
'Answered in time', 'Average wait',
].map((h) => ( 'Answered in time',
<th key={h} scope="col" className="px-2 py-1 font-normal"> ].map((h) => (
{h} <th key={h} scope="col" className="px-2 py-1 font-normal">
</th> {h}
))} </th>
</tr> ))}
</thead>
<tbody className="divide-y divide-line">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
<span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody className="divide-y divide-line">
</table> {queues.data.map((q) => (
</div> <tr key={q.id} className="tabular-nums">
)} <td className="px-2 py-1">
</div> <span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</section> </section>
); );
} }
+11 -1
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone. // My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Reports, Settings, People, and the
// softphone.
import { import {
BookUser, BookUser,
ChartColumn,
House, House,
Info, Info,
LogOut, LogOut,
@@ -30,6 +32,7 @@ import { fmtNumber } from '../admin/CallsPage.tsx';
import { ParkedCalls } from '../../components/ParkedCalls.tsx'; import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx'; import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx'; import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
import { ReportsPage } from '../admin/ReportsPage.tsx';
const link = 'text-link underline'; const link = 'text-link underline';
@@ -124,6 +127,12 @@ export function MePage({ user }: { user: AuthUser }) {
</span> </span>
)} )}
</NavLink> </NavLink>
{user.extension && (
<NavLink to="/me/reports" className={tab} onClick={close}>
<ChartColumn className={icon} />
Reports
</NavLink>
)}
<NavLink to="/me/settings" className={tab} onClick={close}> <NavLink to="/me/settings" className={tab} onClick={close}>
<Settings className={icon} /> <Settings className={icon} />
Settings Settings
@@ -180,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) {
<Route path="calls" element={<MyCallsPage />} /> <Route path="calls" element={<MyCallsPage />} />
<Route path="voicemail" element={<VoicemailPage />} /> <Route path="voicemail" element={<VoicemailPage />} />
<Route path="contacts" element={<ContactsPage />} /> <Route path="contacts" element={<ContactsPage />} />
<Route path="reports" element={<ReportsPage mine />} />
<Route path="settings" element={<MeSettingsPage />} /> <Route path="settings" element={<MeSettingsPage />} />
<Route path="people" element={<PresencePanel me={me} />} /> <Route path="people" element={<PresencePanel me={me} />} />
</Routes> </Routes>
+66 -4
View File
@@ -1,15 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// My calls (05 §4): my call history; click the other number to call back (the softphone when it's // My calls (05 §4): my call history; click the other number to call back (the softphone when it's
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts". // on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts", "Report
import { useInfiniteQuery } from '@tanstack/react-query'; // spam".
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import type { MyCallDto } from '@selfphoneng/shared'; import type { MyCallDto } from '@selfphoneng/shared';
import { meApi, recordingsApi } from '../../api/client.ts'; import { meApi, recordingsApi } from '../../api/client.ts';
import { Alert, Button } from '../../components/ui.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { softphone } from '../../softphone/SoftphoneEngine.ts'; import { softphone } from '../../softphone/SoftphoneEngine.ts';
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx'; import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
import { AddToContacts, useContactName } from './ContactsPage.tsx'; import { AddToContacts, useContactName } from './ContactsPage.tsx';
import { SpamNote } from '../../components/SpamNote.tsx'; import { SpamNote, spamLine } from '../../components/SpamNote.tsx';
/** The other side of the call, from where I sit. */ /** The other side of the call, from where I sit. */
export const otherParty = (c: MyCallDto) => export const otherParty = (c: MyCallDto) =>
@@ -30,6 +31,64 @@ export function useCallBack() {
return { call, note }; return { call, note };
} }
/** To the admins' Blocked numbers: the spam check's line when it flagged the call, else my words. */
function ReportSpam({ c }: { c: MyCallDto }) {
const flagged = c.spam && c.spam.risk !== 'low' ? spamLine(c.spam) : null;
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const send = useMutation({
mutationFn: (r: string) => meApi.reportSpam({ callId: c.id, reason: r }),
});
if (send.isSuccess)
return (
<span role="status" className="text-muted">
Reported to your admin
</span>
);
return (
<>
{open ? (
<form
aria-label={`Report ${fmtNumber(c.fromNumber)} as spam`}
className="flex w-full flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
send.mutate(reason);
}}
>
<Field label="Why is it spam?">
{(p) => (
<input
{...p}
className={inputClass}
maxLength={200}
placeholder="Robocall about my car warranty"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
)}
</Field>
<Button type="submit" variant="primary" disabled={!reason.trim() || send.isPending}>
Report
</Button>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
</form>
) : (
<Button
variant="ghost"
disabled={send.isPending}
onClick={() => (flagged ? send.mutate(flagged) : setOpen(true))}
>
Report spam
</Button>
)}
{send.error && <Alert>{send.error.message}</Alert>}
</>
);
}
export function MyCallsPage() { export function MyCallsPage() {
const list = useInfiniteQuery({ const list = useInfiniteQuery({
queryKey: ['me', 'calls'], queryKey: ['me', 'calls'],
@@ -98,6 +157,9 @@ export function MyCallsPage() {
)} )}
<StatusBadge status={c.status} /> <StatusBadge status={c.status} />
<span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span> <span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span>
{c.myRole === 'in' && c.direction === 'inbound' && other.number && !known && (
<ReportSpam c={c} />
)}
</li> </li>
); );
})} })}
+8
View File
@@ -480,6 +480,14 @@ create table ftc_complaints ( -- the FTC copy (14 §2.2), kept windowDa
create index on ftc_complaints (number); create index on ftc_complaints (number);
create index on ftc_complaints (reported_at); create index on ftc_complaints (reported_at);
create table spam_reports ( -- a user's Report spam (05 §4), until an admin blocks or dismisses it
id uuid primary key default gen_random_uuid(),
number text not null, -- normalized, from the call
reason text not null, -- the spam check's line, or the user's words
reported_by uuid references users(id) on delete set null,
unique (number, reported_by)
);
create table call_extensions ( -- which extensions were involved (for per-user history) create table call_extensions ( -- which extensions were involved (for per-user history)
call_id uuid not null references calls(id) on delete cascade, call_id uuid not null references calls(id) on delete cascade,
extension_id uuid not null references extensions(id) on delete cascade, extension_id uuid not null references extensions(id) on delete cascade,
+3
View File
@@ -383,6 +383,8 @@ Auth & self:
| GET | `/api/me/softphone-qr` | user | `{url, qr, expiresAt}`: Linphone setup link and its QR (PNG data URL), 24 h (07 §5) | | GET | `/api/me/softphone-qr` | user | `{url, qr, expiresAt}`: Linphone setup link and its QR (PNG data URL), 24 h (07 §5) |
| GET | `/api/me/webrtc` | user | `{ wsUrl: "wss://<host>/ws", sipUri: "sip:101-web@<host>", authUser: "101-web", password, displayName, iceServers: [...] }` | | GET | `/api/me/webrtc` | user | `{ wsUrl: "wss://<host>/ws", sipUri: "sip:101-web@<host>", authUser: "101-web", password, displayName, iceServers: [...] }` |
| GET | `/api/me/calls` | user | own call history (via `call_extensions`) | | GET | `/api/me/calls` | user | own call history (via `call_extensions`) |
| GET | `/api/me/reports/summary?from&to` | user | `/api/reports/summary` of the calls that rang or were answered by me; `answered` and talk time are the ones I picked up |
| POST | `/api/me/spam-reports` {callId, reason} | user | Report spam on an outside call that rang me; the number comes from the call. 404 not my call, 400 hidden number, 409 already blocked or already reported by me |
| GET | `/api/me/voicemail` | user | list | | GET | `/api/me/voicemail` | user | list |
| GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) | | GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
| PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` | | PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` |
@@ -416,6 +418,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /
| `/api/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) | | `/api/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) |
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked). As built (M10-T1): body `{mac (any format), vendor, model, label, extensionId}`; the DTO carries `provisioningUrl` and the `provisioningPassword` (admins type it into the phone); MAC taken → 409; preview of a generic phone → 400 | | `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked). As built (M10-T1): body `{mac (any format), vendor, model, label, extensionId}`; the DTO carries `provisioningUrl` and the `provisioningPassword` (admins type it into the phone); MAC taken → 409; preview of a generic phone → 400 |
| `/api/blocked-numbers` | | | `/api/blocked-numbers` | |
| `/api/spam-reports` | GET (with the reporter's name) and DELETE (Dismiss). Blocking a number deletes its reports |
| `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) or vCard (11 §3.1) | | `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) or vCard (11 §3.1) |
| `/api/webhooks` | `POST /:id/test`, `GET /:id/deliveries` | | `/api/webhooks` | `POST /:id/test`, `GET /:id/deliveries` |
| `/api/api-tokens` | POST returns the token once | | `/api/api-tokens` | POST returns the token once |
+3 -2
View File
@@ -46,7 +46,7 @@ Left sidebar navigation, in this order:
| Recordings | `/admin/recordings` | Searchable list, play, download, delete | | Recordings | `/admin/recordings` | Searchable list, play, download, delete |
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page. Owner follow-up (2026-10-21, "total minutes, calltime, missed calls"): totals also have answered, talk time and outside minutes (in and out: inbound from arrival, since a receptionist or voicemail answers too; outbound from the answer; roughly what the provider bills), an outside-minutes-per-day chart, and one tile per voip.ms trunk with its account balance (`GET /api/reports/balance`, `getBalance`; a trunk without the API login says what to add) | | Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page. Owner follow-up (2026-10-21, "total minutes, calltime, missed calls"): totals also have answered, talk time and outside minutes (in and out: inbound from arrival, since a receptionist or voicemail answers too; outbound from the answer; roughly what the provider bills), an outside-minutes-per-day chart, and one tile per voip.ms trunk with its account balance (`GET /api/reports/balance`, `getBalance`; a trunk without the API login says what to add) |
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs | | SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | | | Blocked numbers | `/admin/blocked` | Users' Report spam on top (number, reason, who, when; Block or Dismiss); their count is a Dashboard tile |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS. Spam check (14 §7.1) | | Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS. Spam check (14 §7.1) |
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`, `spam.quotaExhausted`/`spam.quotaRestored` (14 §3). Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell | | System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button),, a **Diagnostics** panel (08 §2.6: the server's own checks, then a Quick or Complete network test through the probe, each line with how to fix it), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`, `spam.quotaExhausted`/`spam.quotaRestored` (14 §3). Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
@@ -102,9 +102,10 @@ Implementation notes (M3):
| Page | Path | Contents | | Page | Path | Contents |
|---|---|---| |---|---|---|
| Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list. M4 ships only new voicemails; the rest arrives with DND/forwarding (M6), call history (M7) and presence (M5-T3) | | Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list. M4 ships only new voicemails; the rest arrives with DND/forwarding (M6), call history (M7) and presence (M5-T3) |
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts | | Calls | `/me/calls` | Own call history, click a number to call back, add to contacts, Report spam on an outside call that rang me (the spam check's line when it said medium or high, else a reason I type) |
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name | | Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call | | Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
| Reports | `/me/reports` | The admin Reports' summary of the calls made to me (rang or answered me, not the ones I made): calls, answered by me, missed, talk time, calls and missed per day, busiest hours. No outside minutes, balance or queues. Shown only with an extension |
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) | | Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5), "Mobile app" (pair a phone, list and remove paired phones, 13 §2) | | Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5), "Mobile app" (pair a phone, list and remove paired phones, 13 §2) |
+20 -1
View File
@@ -17,8 +17,27 @@ export const BlockedCreateSchema = z.object({
return z.NEVER; return z.NEVER;
} }
}), }),
reason: z.string().trim().max(100).nullable().optional(), reason: z.string().trim().max(200).nullable().optional(),
}); });
export type BlockedCreate = z.input<typeof BlockedCreateSchema>; export type BlockedCreate = z.input<typeof BlockedCreateSchema>;
export type BlockedDto = { id: string; number: string; reason: string | null; createdAt: string }; export type BlockedDto = { id: string; number: string; reason: string | null; createdAt: string };
/**
* A user's "Report spam" on a call that rang them (05 §4). The reason is the spam check's line
* when it flagged the call, else what the user typed. An admin blocks or dismisses it.
*/
export const SpamReportCreateSchema = z.object({
callId: z.uuid(),
reason: z.string().trim().min(1, 'Say why').max(200),
});
export type SpamReportCreate = z.input<typeof SpamReportCreateSchema>;
export type SpamReportDto = {
id: string;
number: string;
reason: string;
/** The reporter's display name; null once their account is gone. */
reportedBy: string | null;
createdAt: string;
};