Merge pull request 'Chore/qol' (#7) from chore/qol into main
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
commit
8a1f17099d
20 files changed
+5430
-101
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,
|
||||
"tag": "0014_spam_check",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1793212840276,
|
||||
"tag": "0015_spam_reports",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -670,6 +670,19 @@ export const ftcComplaints = pgTable(
|
||||
(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(
|
||||
'call_extensions',
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /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 type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
formatNumber,
|
||||
type BlockedDto,
|
||||
type ListResponse,
|
||||
type SpamReportDto,
|
||||
} from '@selfphoneng/shared';
|
||||
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 { setBlock } from '../services/astdb.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
|
||||
throw e;
|
||||
}
|
||||
await mirror(b.number, true);
|
||||
await db.delete(spamReports).where(eq(spamReports.number, b.number));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
|
||||
});
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /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 { alias } from 'drizzle-orm/pg-core';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CallFilterSchema,
|
||||
SpamReportCreateSchema,
|
||||
formatNumber,
|
||||
normalizeNumber,
|
||||
type CallDetailDto,
|
||||
type CallDto,
|
||||
type ListResponse,
|
||||
@@ -14,9 +18,17 @@ import {
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.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 { badRequest, notFound } from '../errors.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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.
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
|
||||
// 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 {
|
||||
CALL_DIRECTIONS,
|
||||
@@ -10,12 +11,12 @@ import {
|
||||
type ProviderBalanceDto,
|
||||
type ReportSummary,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.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 { badRequest } from '../errors.ts';
|
||||
import { badRequest, notFound } from '../errors.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { queueStats } from '../services/queuestats.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
|
||||
|
||||
export async function reportRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
const admin = { preHandler: requireAdmin };
|
||||
|
||||
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
|
||||
const { tz, from, to } = await range(db, req.query);
|
||||
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
|
||||
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
|
||||
const { tz, from, to } = await range(db, query);
|
||||
const where = and(
|
||||
sql`${calls.startedAt} >= (${from}::date)::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 missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
||||
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
db
|
||||
.select({
|
||||
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`,
|
||||
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`,
|
||||
lineOut: sql<number>`${lineOut}::int`,
|
||||
avgTalk: sql<
|
||||
number | null
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where),
|
||||
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
},
|
||||
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. */
|
||||
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||
app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trunks)
|
||||
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
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 qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||
return {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
// 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 { inArray } from 'drizzle-orm';
|
||||
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 { hasTestDb } from './db.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');
|
||||
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/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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -26,6 +26,8 @@ import type {
|
||||
AnnouncementUpdate,
|
||||
BlockedCreate,
|
||||
BlockedDto,
|
||||
SpamReportCreate,
|
||||
SpamReportDto,
|
||||
CallDetailDto,
|
||||
CallDto,
|
||||
CallFilter,
|
||||
@@ -306,6 +308,9 @@ export const systemApi = {
|
||||
export const meApi = {
|
||||
calls: (cursor?: string) =>
|
||||
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'),
|
||||
/** 14 §5.1: only for a caller ringing me now. */
|
||||
callerCheck: (number: string) =>
|
||||
@@ -505,6 +510,8 @@ export const blockedApi = {
|
||||
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
|
||||
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
|
||||
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). */
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// 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 { useState } from 'react';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list });
|
||||
const reports = useQuery({ queryKey: ['blocked', 'reports'], queryFn: blockedApi.reports });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] });
|
||||
const [number, setNumber] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
@@ -24,6 +26,16 @@ export function BlockedPage() {
|
||||
mutationFn: (id: string) => blockedApi.remove(id),
|
||||
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 (
|
||||
<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
|
||||
never affected.
|
||||
</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
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
@@ -66,7 +110,7 @@ export function BlockedPage() {
|
||||
Block
|
||||
</Button>
|
||||
</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 && list.data.length > 0 && (
|
||||
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 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 { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
|
||||
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 { Alert, Button, inputClass } from '../../components/ui.tsx';
|
||||
import { fmtNumber } from './CallsPage.tsx';
|
||||
@@ -31,6 +31,11 @@ export function DashboardPage() {
|
||||
const onCall = list.filter((e) =>
|
||||
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
|
||||
).length;
|
||||
const reports = useQuery({
|
||||
queryKey: ['blocked', 'reports'],
|
||||
queryFn: blockedApi.reports,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
@@ -40,7 +45,7 @@ export function DashboardPage() {
|
||||
(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">
|
||||
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
|
||||
</Card>
|
||||
@@ -61,6 +66,12 @@ export function DashboardPage() {
|
||||
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<QueueTiles />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 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.
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
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 { 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 [range, setRange] = useState<ReportRange>({});
|
||||
const summary = useQuery({
|
||||
queryKey: ['reports', 'summary', range],
|
||||
queryFn: () => reportsApi.summary(range),
|
||||
queryKey: [mine ? 'me' : 'reports', 'summary', range],
|
||||
queryFn: () => (mine ? meApi.report(range) : reportsApi.summary(range)),
|
||||
});
|
||||
const queues = useQuery({
|
||||
queryKey: ['reports', '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 date = (k: 'from' | 'to', label: string) => (
|
||||
<Field label={label}>
|
||||
@@ -145,7 +151,8 @@ export function ReportsPage() {
|
||||
|
||||
return (
|
||||
<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
|
||||
aria-label="Dates"
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
@@ -174,19 +181,33 @@ export function ReportsPage() {
|
||||
{(
|
||||
[
|
||||
['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'],
|
||||
[
|
||||
'Average talk time',
|
||||
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
|
||||
null,
|
||||
],
|
||||
['Talk time', fmtMinutes(s.totals.talkSeconds), 'people talking, inside calls too'],
|
||||
[
|
||||
'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`,
|
||||
'Talk time',
|
||||
fmtMinutes(s.totals.talkSeconds),
|
||||
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(
|
||||
(b) =>
|
||||
[
|
||||
@@ -204,12 +225,18 @@ export function ReportsPage() {
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Bars label="Calls per day" days={s.days} segments={SERIES} />
|
||||
<Bars
|
||||
label="Outside minutes per day"
|
||||
label="Calls per day"
|
||||
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
|
||||
label="Missed per day"
|
||||
days={s.days}
|
||||
@@ -222,53 +249,55 @@ export function ReportsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-medium">Queues</h2>
|
||||
{queues.error && <Alert>{queues.error.message}</Alert>}
|
||||
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
|
||||
{!!queues.data?.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table aria-label="Queue report" className="w-full text-left text-sm">
|
||||
<thead className="text-muted">
|
||||
<tr>
|
||||
{[
|
||||
'Queue',
|
||||
'Calls',
|
||||
'Answered',
|
||||
'Hung up waiting',
|
||||
'Timed out',
|
||||
'Average wait',
|
||||
'Answered in time',
|
||||
].map((h) => (
|
||||
<th key={h} scope="col" className="px-2 py-1 font-normal">
|
||||
{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>
|
||||
{!mine && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-medium">Queues</h2>
|
||||
{queues.error && <Alert>{queues.error.message}</Alert>}
|
||||
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
|
||||
{!!queues.data?.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table aria-label="Queue report" className="w-full text-left text-sm">
|
||||
<thead className="text-muted">
|
||||
<tr>
|
||||
{[
|
||||
'Queue',
|
||||
'Calls',
|
||||
'Answered',
|
||||
'Hung up waiting',
|
||||
'Timed out',
|
||||
'Average wait',
|
||||
'Answered in time',
|
||||
].map((h) => (
|
||||
<th key={h} scope="col" className="px-2 py-1 font-normal">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// 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 {
|
||||
BookUser,
|
||||
ChartColumn,
|
||||
House,
|
||||
Info,
|
||||
LogOut,
|
||||
@@ -30,6 +32,7 @@ import { fmtNumber } from '../admin/CallsPage.tsx';
|
||||
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
||||
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
||||
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
||||
import { ReportsPage } from '../admin/ReportsPage.tsx';
|
||||
|
||||
const link = 'text-link underline';
|
||||
|
||||
@@ -124,6 +127,12 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
{user.extension && (
|
||||
<NavLink to="/me/reports" className={tab} onClick={close}>
|
||||
<ChartColumn className={icon} />
|
||||
Reports
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink to="/me/settings" className={tab} onClick={close}>
|
||||
<Settings className={icon} />
|
||||
Settings
|
||||
@@ -180,6 +189,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
<Route path="calls" element={<MyCallsPage />} />
|
||||
<Route path="voicemail" element={<VoicemailPage />} />
|
||||
<Route path="contacts" element={<ContactsPage />} />
|
||||
<Route path="reports" element={<ReportsPage mine />} />
|
||||
<Route path="settings" element={<MeSettingsPage />} />
|
||||
<Route path="people" element={<PresencePanel me={me} />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// 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
|
||||
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts".
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts", "Report
|
||||
// spam".
|
||||
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { MyCallDto } from '@selfphoneng/shared';
|
||||
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 { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.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. */
|
||||
export const otherParty = (c: MyCallDto) =>
|
||||
@@ -30,6 +31,64 @@ export function useCallBack() {
|
||||
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() {
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['me', 'calls'],
|
||||
@@ -98,6 +157,9 @@ export function MyCallsPage() {
|
||||
)}
|
||||
<StatusBadge status={c.status} />
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 (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)
|
||||
call_id uuid not null references calls(id) on delete cascade,
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
|
||||
@@ -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/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/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/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
|
||||
| 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/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/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/webhooks` | `POST /:id/test`, `GET /:id/deliveries` |
|
||||
| `/api/api-tokens` | POST returns the token once |
|
||||
|
||||
@@ -46,7 +46,7 @@ Left sidebar navigation, in this order:
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
|
||||
@@ -17,8 +17,27 @@ export const BlockedCreateSchema = z.object({
|
||||
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 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;
|
||||
};
|
||||
Reference in new issue
Block a user