Feat/spam #5
No files matched your search
+12
-1
@@ -32,6 +32,7 @@ import { checkDirectory } from './sso/directory.ts';
|
||||
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { spamQuotaCheck } from './spam/check.ts';
|
||||
import { ftcSync } from './spam/ftc.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
import type { SendMail } from './services/mail.ts';
|
||||
import { getSetting, setSetting } from './services/settings.ts';
|
||||
@@ -44,7 +45,11 @@ export type JobDeps = {
|
||||
generator: Pick<Generator, 'apply'>;
|
||||
voicemail: { scan(ext: string): Promise<void> };
|
||||
send: SendMail;
|
||||
log: { info(o: object, m: string): void; error(o: object, m: string): void };
|
||||
log: {
|
||||
info(o: object, m: string): void;
|
||||
warn(o: object, m: string): void;
|
||||
error(o: object, m: string): void;
|
||||
};
|
||||
now?: () => Date;
|
||||
statfs?: typeof statfs;
|
||||
};
|
||||
@@ -279,6 +284,11 @@ export function createJobs(d: JobDeps) {
|
||||
|
||||
return {
|
||||
spamQuotaCheck: () => spamQuotaCheck(d.db),
|
||||
/** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */
|
||||
spamFtcSync: async () => {
|
||||
const reason = await ftcSync(d.db, { now: now() });
|
||||
if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed');
|
||||
},
|
||||
oidcCheck,
|
||||
directoryCheck,
|
||||
daily,
|
||||
@@ -317,6 +327,7 @@ export function startJobs(d: JobDeps): () => void {
|
||||
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
|
||||
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
|
||||
if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
|
||||
run('spamFtcSync', j.spamFtcSync);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
|
||||
@@ -576,6 +576,13 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return { items };
|
||||
});
|
||||
|
||||
/** Settings → Spam check → Download now (14 §2.2): due at the next minute, unless under way. */
|
||||
app.post('/api/settings/spam/ftc/sync', async () => {
|
||||
if (!(await getSetting(db, 'spam')).ftc) throw badRequest('Set up the FTC list first');
|
||||
await patchSpamState(db, { ftcLastSyncAt: null });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Hidden until the next time the credits run out (14 §3). */
|
||||
app.post('/api/system/warnings/spam_quota/dismiss', async () => {
|
||||
await patchSpamState(db, { warningDismissedAt: new Date().toISOString() });
|
||||
|
||||
+122
-6
@@ -1,9 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing
|
||||
// out. The copy is synced by spamFtcSync (SD3).
|
||||
import { sql } from 'drizzle-orm';
|
||||
// out. spamFtcSync keeps the copy.
|
||||
import { lt, sql } from 'drizzle-orm';
|
||||
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { ftcComplaints } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { isCheckable, patchSpamState } from './check.ts';
|
||||
import { SpamError, getJson } from './http.ts';
|
||||
|
||||
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
|
||||
@@ -41,15 +46,126 @@ export async function ftcLookup(
|
||||
return { risk: ftcRisk(info.complaints), info };
|
||||
}
|
||||
|
||||
/** Settings → Spam check → Test: one request with the api.data.gov key. */
|
||||
export async function ftcTest(key: string): Promise<string> {
|
||||
const OVER_LIMIT = 'api.data.gov says the key is over its hourly limit';
|
||||
|
||||
async function ftcGet(key: string, q: Record<string, string>) {
|
||||
try {
|
||||
await getJson(`${FTC_API}?items_per_page=1`, { 'X-Api-Key': key });
|
||||
return await getJson(`${FTC_API}?${new URLSearchParams(q)}`, { 'X-Api-Key': key });
|
||||
} catch (err) {
|
||||
const m = (err as Error).message;
|
||||
if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key');
|
||||
if (m === 'HTTP 429') throw new SpamError('api.data.gov says the key is over its hourly limit');
|
||||
if (m === 'HTTP 429') throw new SpamError(OVER_LIMIT);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Spam check → Test: one request with the api.data.gov key. */
|
||||
export async function ftcTest(key: string): Promise<string> {
|
||||
await ftcGet(key, { items_per_page: '1' });
|
||||
return 'The FTC answered.';
|
||||
}
|
||||
|
||||
// ponytail: the FTC's dates have no zone; taken as Eastern (it's in Washington). Hours off at
|
||||
// worst, against a window of weeks.
|
||||
const FTC_TZ = 'America/New_York';
|
||||
const PAGE = 50;
|
||||
const DATE_TIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
const nextDay = (day: string) =>
|
||||
new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
||||
const daysBefore = (day: string, n: number) =>
|
||||
new Date(Date.parse(`${day}T00:00:00Z`) - n * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
/** One complaint as stored, or null for one without a usable US number or date. */
|
||||
function complaint(c: unknown) {
|
||||
const { id, attributes: a } = (c ?? {}) as { id?: unknown; attributes?: Record<string, unknown> };
|
||||
const phone = `1${String(a?.['company-phone-number'] ?? '')}`;
|
||||
const at = a?.['created-date'];
|
||||
if (
|
||||
typeof id !== 'string' ||
|
||||
!isCheckable(phone) ||
|
||||
typeof at !== 'string' ||
|
||||
!DATE_TIME.test(at)
|
||||
)
|
||||
return null;
|
||||
const robo = a?.['recorded-message-or-robocall'];
|
||||
return {
|
||||
id,
|
||||
number: phone,
|
||||
reportedAt: sql`${at}::timestamp at time zone ${FTC_TZ}`,
|
||||
subject: typeof a?.subject === 'string' && a.subject ? a.subject : null,
|
||||
robocall: robo === 'Y' ? true : robo === 'N' ? false : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Job spamFtcSync (14 §2.2), every minute: when a sync is due or under way, read up to `budget`
|
||||
* pages (15 a minute is 900 an hour, under api.data.gov's 1,000) a day at a time, oldest first,
|
||||
* saving where it got to. A failure (a 429 among them) is saved for the Settings tab and the next
|
||||
* minute carries on from the same page. Returns the error, for the job's log line once.
|
||||
*/
|
||||
export async function ftcSync(
|
||||
db: Db,
|
||||
opts: { now?: Date; budget?: number } = {},
|
||||
): Promise<string | null> {
|
||||
const now = opts.now ?? new Date();
|
||||
const s = await getSetting(db, 'spam');
|
||||
if (!s.enabled || !s.ftc) return null;
|
||||
const st = await getSetting(db, 'spam.state');
|
||||
let cursor = st.ftcCursor;
|
||||
if (!cursor) {
|
||||
const due =
|
||||
!st.ftcLastSyncAt ||
|
||||
now.getTime() - Date.parse(st.ftcLastSyncAt) >= s.ftc.syncHours * 3_600_000;
|
||||
if (!due) return null;
|
||||
// From the newest complaint kept, less a day for ones posted late; at most the window.
|
||||
const r = await db.execute<{ day: string | null }>(
|
||||
sql`select to_char(max(reported_at) at time zone ${FTC_TZ}, 'YYYY-MM-DD') as day from ftc_complaints`,
|
||||
);
|
||||
const first = daysBefore(todayIn(FTC_TZ, now), s.ftc.windowDays);
|
||||
const last = r.rows[0]?.day ? daysBefore(r.rows[0].day, 1) : first;
|
||||
cursor = { day: last > first ? last : first, offset: 0 };
|
||||
}
|
||||
const key = decrypt(s.ftc.keyEnc);
|
||||
const today = todayIn(FTC_TZ, now);
|
||||
for (let n = 0; n < (opts.budget ?? 15); n++) {
|
||||
let page: unknown[];
|
||||
try {
|
||||
const j = await ftcGet(key, {
|
||||
created_date_from: `"${cursor.day} 00:00:00"`,
|
||||
created_date_to: `"${cursor.day} 23:59:59"`,
|
||||
sort_order: 'ASC',
|
||||
items_per_page: String(PAGE),
|
||||
offset: String(cursor.offset),
|
||||
});
|
||||
if (!Array.isArray(j.data)) throw new SpamError('the answer had no complaints list');
|
||||
page = j.data;
|
||||
} catch (err) {
|
||||
const error = err instanceof SpamError ? err.message : 'the sync failed';
|
||||
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: error });
|
||||
if (!(err instanceof SpamError)) throw err;
|
||||
return error === st.ftcLastError ? null : error;
|
||||
}
|
||||
const rows = page.map(complaint).filter((c) => c !== null);
|
||||
if (rows.length) await db.insert(ftcComplaints).values(rows).onConflictDoNothing();
|
||||
if (page.length === PAGE) cursor = { day: cursor.day, offset: cursor.offset + PAGE };
|
||||
else if (cursor.day < today) cursor = { day: nextDay(cursor.day), offset: 0 };
|
||||
else {
|
||||
await db
|
||||
.delete(ftcComplaints)
|
||||
.where(
|
||||
lt(
|
||||
ftcComplaints.reportedAt,
|
||||
sql`${now.toISOString()}::timestamptz - make_interval(days => ${s.ftc.windowDays})`,
|
||||
),
|
||||
);
|
||||
await patchSpamState(db, {
|
||||
ftcCursor: null,
|
||||
ftcLastSyncAt: now.toISOString(),
|
||||
ftcLastError: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: null });
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SPAM_DEFAULTS } from '@selfphoneng/shared';
|
||||
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
|
||||
import type { Db } from '../src/db/client.ts';
|
||||
import { ftcComplaints, settings } from '../src/db/schema.ts';
|
||||
import { ftcSync } from '../src/spam/ftc.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { createTestDb, hasTestDb } from './db.ts';
|
||||
|
||||
const KEY = 'datagov-secret-key-456';
|
||||
const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington
|
||||
const hour = 3_600_000;
|
||||
|
||||
const at = (day: string, i: number) =>
|
||||
`${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`;
|
||||
const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({
|
||||
type: 'dnc_complaint',
|
||||
id,
|
||||
attributes: {
|
||||
'company-phone-number': phone,
|
||||
'created-date': created,
|
||||
subject: 'Warranties',
|
||||
'recorded-message-or-robocall': robo,
|
||||
},
|
||||
});
|
||||
/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */
|
||||
const DATA = [
|
||||
...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))),
|
||||
...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')),
|
||||
ftc('c0', '3125550102', '2026-10-27 09:00:00', ''),
|
||||
ftc('c1', '', '2026-10-27 09:01:00'), // no number
|
||||
ftc('c2', '3125550102', '2026-10-27'), // no time
|
||||
];
|
||||
|
||||
describe.skipIf(!hasTestDb)('FTC complaint download', () => {
|
||||
let db: Db;
|
||||
let drop: () => Promise<void>;
|
||||
/** Each request's day and offset. */
|
||||
const asked: string[] = [];
|
||||
let fail: { at: number; status: number } | null = null;
|
||||
|
||||
const turnOn = (keyEnc = encrypt(KEY)) =>
|
||||
setSetting(db, 'spam', {
|
||||
...SPAM_DEFAULTS,
|
||||
enabled: true,
|
||||
ftc: { keyEnc, syncHours: 24, windowDays: 7 },
|
||||
});
|
||||
const stored = async () => (await db.select().from(ftcComplaints)).length;
|
||||
const reset = async () => {
|
||||
await db.delete(ftcComplaints);
|
||||
await db.delete(settings).where(eq(settings.key, 'spam.state'));
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, drop } = await createTestDb());
|
||||
setSecretKey(randomBytes(32).toString('base64'));
|
||||
});
|
||||
afterAll(() => drop?.());
|
||||
|
||||
beforeEach(() => {
|
||||
asked.length = 0;
|
||||
fail = null;
|
||||
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
|
||||
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
|
||||
if ((init?.headers as Record<string, string>)['X-Api-Key'] !== KEY) return json({}, 403);
|
||||
const q = new URL(url).searchParams;
|
||||
const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!;
|
||||
expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`);
|
||||
expect(q.get('sort_order')).toBe('ASC');
|
||||
const offset = Number(q.get('offset'));
|
||||
asked.push(`${day}+${offset}`);
|
||||
if (fail && asked.length === fail.at) return json({}, fail.status);
|
||||
const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day));
|
||||
return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } });
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await reset();
|
||||
});
|
||||
|
||||
it('downloads nothing while spam check or the FTC list is off', async () => {
|
||||
await setSetting(db, 'spam', SPAM_DEFAULTS);
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true });
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
expect(asked).toEqual([]);
|
||||
});
|
||||
|
||||
it('the first download fills the window a day at a time and drops old complaints', async () => {
|
||||
await turnOn();
|
||||
await db.insert(ftcComplaints).values({
|
||||
id: 'old',
|
||||
number: '13125550109',
|
||||
reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`,
|
||||
});
|
||||
expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull();
|
||||
expect(asked).toEqual([
|
||||
'2026-10-20+0',
|
||||
'2026-10-21+0',
|
||||
'2026-10-22+0',
|
||||
'2026-10-23+0',
|
||||
'2026-10-24+0',
|
||||
'2026-10-25+0',
|
||||
'2026-10-25+50',
|
||||
'2026-10-25+100',
|
||||
'2026-10-26+0',
|
||||
'2026-10-27+0',
|
||||
]);
|
||||
expect(await stored()).toBe(124);
|
||||
const row = async (id: string) =>
|
||||
(await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0];
|
||||
expect(await row('b0')).toMatchObject({
|
||||
number: '13125550101',
|
||||
subject: 'Warranties',
|
||||
robocall: false,
|
||||
reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington
|
||||
});
|
||||
expect((await row('c0'))?.robocall).toBeNull();
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: null,
|
||||
ftcLastSyncAt: NOW.toISOString(),
|
||||
ftcLastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('not again until syncHours; then from the newest day kept, less one', async () => {
|
||||
await turnOn();
|
||||
await ftcSync(db, { now: NOW, budget: 100 });
|
||||
asked.length = 0;
|
||||
await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) });
|
||||
expect(asked).toEqual([]);
|
||||
await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) });
|
||||
expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']);
|
||||
});
|
||||
|
||||
it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => {
|
||||
await turnOn();
|
||||
await ftcSync(db, { now: NOW });
|
||||
expect(asked).toHaveLength(10);
|
||||
await reset();
|
||||
asked.length = 0;
|
||||
|
||||
await ftcSync(db, { now: NOW, budget: 2 });
|
||||
expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({
|
||||
day: '2026-10-22',
|
||||
offset: 0,
|
||||
});
|
||||
fail = { at: 7, status: 429 }; // the 25th's second page
|
||||
const said = 'api.data.gov says the key is over its hourly limit';
|
||||
expect(await ftcSync(db, { now: NOW })).toBe(said);
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: { day: '2026-10-25', offset: 50 },
|
||||
ftcLastError: said,
|
||||
ftcLastSyncAt: null,
|
||||
});
|
||||
fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
fail = null;
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
expect(await stored()).toBe(124);
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: null,
|
||||
ftcLastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('a refused key is saved for the Settings tab', async () => {
|
||||
await turnOn(encrypt('wrong'));
|
||||
expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key');
|
||||
expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key');
|
||||
});
|
||||
});
|
||||
@@ -315,6 +315,20 @@ describe.skipIf(!hasTestDb)('spam check', () => {
|
||||
},
|
||||
});
|
||||
|
||||
it('Download now: due at the next minute; refused without the FTC list', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
const r = await admin('POST', '/api/settings/spam/ftc/sync');
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.body).toMatch('Set up the FTC list first');
|
||||
await settings(keys.withFtc);
|
||||
await setSetting(t.db, 'spam.state', {
|
||||
...(await getSetting(t.db, 'spam.state')),
|
||||
ftcLastSyncAt: new Date().toISOString(),
|
||||
});
|
||||
expect((await admin('POST', '/api/settings/spam/ftc/sync')).statusCode).toBe(200);
|
||||
expect((await getSetting(t.db, 'spam.state')).ftcLastSyncAt).toBeNull();
|
||||
});
|
||||
|
||||
it('keys are write-only, and empty keeps the saved one', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const r = await admin('PATCH', '/api/settings', body());
|
||||
|
||||
@@ -263,6 +263,7 @@ export const settingsApi = {
|
||||
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
|
||||
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
|
||||
/** 14 §7.1: the typed key, else the saved one. */
|
||||
syncFtc: () => api<{ ok: true }>('POST', '/api/settings/spam/ftc/sync'),
|
||||
testSpam: (provider: 'ipqs' | 'ftc', key?: string) =>
|
||||
api<SpamTestResult>('POST', `/api/settings/spam/${provider}/test`, { key }),
|
||||
};
|
||||
|
||||
@@ -1400,6 +1400,39 @@ function SpamTest({ provider, keyText }: { provider: 'ipqs' | 'ftc'; keyText: st
|
||||
|
||||
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : null);
|
||||
|
||||
/** Where the FTC download is, and Download now (14 §2.2). */
|
||||
function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) {
|
||||
const qc = useQueryClient();
|
||||
const sync = useMutation({
|
||||
mutationFn: settingsApi.syncFtc,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<p className="text-zinc-500">
|
||||
{st.ftcCursor
|
||||
? `Downloading: at the complaints of ${st.ftcCursor.day}.`
|
||||
: st.ftcLastSyncAt
|
||||
? `Last download ${when(st.ftcLastSyncAt)}.`
|
||||
: 'Not downloaded yet: it starts within a minute.'}
|
||||
</p>
|
||||
{st.ftcLastError && (
|
||||
<p className="text-amber-700 dark:text-amber-400">
|
||||
The last try failed: {st.ftcLastError}. It tries again every minute.
|
||||
</p>
|
||||
)}
|
||||
{!st.ftcCursor && (
|
||||
<div>
|
||||
<Button onClick={() => sync.mutate()} disabled={sync.isPending}>
|
||||
Download now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{sync.isError && <Alert>{sync.error.message}</Alert>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Spam check (spec 14 §7.1): IPQualityScore first, then the FTC list. Information only. */
|
||||
function SpamTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
@@ -1540,12 +1573,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
|
||||
{num('syncHours', 'Download again every (hours, 1–168)')}
|
||||
{num('windowDays', 'Keep complaints for (days, 7–365)')}
|
||||
</div>
|
||||
{st.ftcLastSyncAt && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Last download {when(st.ftcLastSyncAt)}
|
||||
{st.ftcLastError ? `: ${st.ftcLastError}` : '.'}
|
||||
</p>
|
||||
)}
|
||||
{sp.ftc && <FtcSyncStatus st={st} />}
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
@@ -433,6 +433,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
|
||||
| GET | `/api/audit-log` | |
|
||||
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
|
||||
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
|
||||
| POST | `/api/settings/spam/ftc/sync` | Download now: the FTC download starts at the next minute (14 §9.3) |
|
||||
| POST | `/api/settings/spam/ipqs/test`, `/api/settings/spam/ftc/test` | `{key?}`: the typed key, else the saved one; `{ok, detail}` or `{ok: false, error}` (14 §7.1) |
|
||||
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
|
||||
| GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
|
||||
@@ -476,6 +477,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
|
||||
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
|
||||
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
|
||||
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
|
||||
| `spamFtcSync` | every 60 s, when due or under way | the FTC complaint download, 15 pages a minute (14 §2.2, §9.3) |
|
||||
| `spamQuotaCheck` | every 6 hours, while IPQualityScore is paused | ask its Credit Usage API; credits back → resume (14 §3) |
|
||||
|
||||
Jobs must never overlap with themselves (use a running flag).
|
||||
|
||||
@@ -9,7 +9,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
|
||||
---
|
||||
|
||||
## Backlog (owner's order, 2026-10-21)
|
||||
0. **Spam check** (owner, 2026-10-27: SD1, SD2 done; SD3 next), tasks SD1–SD5
|
||||
0. **Spam check** (owner, 2026-10-27: SD1–SD3 done; SD4 next), tasks SD1–SD5
|
||||
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
|
||||
callers who aren't in anyone's contacts; information only, off by default.
|
||||
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
|
||||
|
||||
+31
-3
@@ -187,7 +187,7 @@ Settings (02 §3):
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `spam` | object | below | the admin's choices |
|
||||
| `spam.state` | object | `{}` | read-only to the API, like `system.*`: `ipqsPausedAt`, `warningDismissedAt`, `ftcSyncedTo`, `ftcCursor`, `ftcLastSyncAt`, `ftcLastError` |
|
||||
| `spam.state` | object | `{}` | read-only to the API, like `system.*`: `ipqsPausedAt`, `warningDismissedAt`, `ftcCursor` (`{day, offset}` while a download is under way), `ftcLastSyncAt`, `ftcLastError` |
|
||||
|
||||
```ts
|
||||
spam = {
|
||||
@@ -235,7 +235,7 @@ log masks it. Validated with zod (`packages/shared`).
|
||||
|---|---|---|
|
||||
| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP. Done, see §9.1 |
|
||||
| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free. Done but the real-key check, see §9.2 |
|
||||
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key |
|
||||
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key. Done but the real sync, see §9.3 |
|
||||
| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time |
|
||||
| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires |
|
||||
|
||||
@@ -245,7 +245,7 @@ log masks it. Validated with zod (`packages/shared`).
|
||||
can't wait (14 §5.1, §5.2) races it against its own timer, and the check carries on and fills
|
||||
the cache. The cache is only read while IPQualityScore is set up; turning it off leaves the
|
||||
rows to expire.
|
||||
- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is SD3), `spam/http.ts`
|
||||
- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is §9.3), `spam/http.ts`
|
||||
(`getJson`, 4 s timeout, and `SpamError`, the only error text that is logged or shown: never
|
||||
the URL or fetch's own error). A provider message is cut to 200 characters with the key taken
|
||||
out, in case it quotes it.
|
||||
@@ -286,6 +286,34 @@ log masks it. Validated with zod (`packages/shared`).
|
||||
0 credits and resuming above; the "are off" text without the FTC list; a new key; the job
|
||||
asking nothing unless paused and failing without the key).
|
||||
|
||||
### 9.3 As built: SD3
|
||||
|
||||
- `spam/ftc.ts` `ftcSync(db, { now, budget })`, job `spamFtcSync` **every minute**. It does
|
||||
nothing unless spam check and the FTC list are on and a download is due (`ftcLastSyncAt`
|
||||
older than `syncHours`, or never) or under way (`ftcCursor`).
|
||||
- One FTC date at a time (`created_date_from` / `_to` that day, `sort_order=ASC`, 50 a page,
|
||||
`offset`), so an offset never grows past one day's complaints. A short page moves on to the
|
||||
next day; today's short page ends the download: complaints older than `windowDays` are
|
||||
deleted and `ftcLastSyncAt` set.
|
||||
- Pacing: at most 15 pages a minute (900 an hour), and the place (`ftcCursor`) saved after each
|
||||
run, so a restart carries on. The first 30 days take about 7 hours.
|
||||
- Where a download starts: the newest complaint kept, less a day (for ones posted late, as on
|
||||
weekends), but no further back than `windowDays`. Ids make the overlap harmless.
|
||||
- The FTC's dates have no zone: taken as Eastern, so a day is midnight to midnight in
|
||||
Washington. Complaints without a 10-digit US number or a full date are skipped.
|
||||
- A failure (403 "api.data.gov refused the key", a 429 "…over its hourly limit", a timeout)
|
||||
keeps the place and is saved in `ftcLastError`; the next minute tries again from the same page.
|
||||
The job logs it (`warn`) only when it differs from the last one, so a long outage is one line.
|
||||
- Settings → Spam check: "Downloading: at the complaints of {day}", "Last download {time}", the
|
||||
last failure, and **Download now** (`POST /api/settings/spam/ftc/sync`, which clears
|
||||
`ftcLastSyncAt` so the next minute starts one; 400 without the FTC list).
|
||||
- Not done: one real download with the owner's api.data.gov key.
|
||||
- Tests: `test/ftc-sync.test.ts` against a fake api.ftc.gov (off; the first download a day at a
|
||||
time with three pages on one day, old complaints dropped, robocall and Eastern time; not again
|
||||
until `syncHours`, then from the newest day less one; 15 pages a minute with the place saved; a
|
||||
429 stopping and the next minute carrying on without duplicates; the same failure logged once;
|
||||
a refused key). `test/spam.test.ts`: Download now.
|
||||
|
||||
Later, not now:
|
||||
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
|
||||
number to a provider that takes reports (OpenSpam has `POST /api/report`).
|
||||
|
||||
@@ -73,8 +73,11 @@ export const SPAM_DEFAULTS: SpamSettings = {
|
||||
export const SpamStateSchema = z.object({
|
||||
ipqsPausedAt: z.string().nullable().default(null),
|
||||
warningDismissedAt: z.string().nullable().default(null),
|
||||
ftcSyncedTo: z.string().nullable().default(null),
|
||||
ftcCursor: z.number().int().nullable().default(null),
|
||||
/** A sync under way (14 §2.2): the FTC's date (Eastern) being read and how far into it. */
|
||||
ftcCursor: z
|
||||
.object({ day: z.iso.date(), offset: z.number().int().min(0) })
|
||||
.nullable()
|
||||
.default(null),
|
||||
ftcLastSyncAt: z.string().nullable().default(null),
|
||||
ftcLastError: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user