Files
notification-wakeup-service/src/db/smsPhoneLogSqlite.ts
T

192 lines
5.2 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type {
SmsPhoneLogAction,
SmsPhoneLogEntry,
SmsPhoneLogResult,
} from "../models/smsRegistration.js";
import { getDatabase } from "./sqlite.js";
type DbRow = {
id: string;
user_id: string;
phone_e164: string | null;
phone_hash: string;
action: string;
result: string;
detail: string | null;
jwt_hash: string | null;
provider_message_id: string | null;
created_at: string;
};
const ROW_COLUMNS =
"id, user_id, phone_e164, phone_hash, action, result, detail, " +
"jwt_hash, provider_message_id, created_at";
function toRecord(row: DbRow): SmsPhoneLogEntry {
return {
id: row.id,
userId: row.user_id,
phoneE164: row.phone_e164 ?? undefined,
phoneHash: row.phone_hash,
action: row.action as SmsPhoneLogAction,
result: row.result as SmsPhoneLogResult,
detail: row.detail ?? undefined,
jwtHash: row.jwt_hash ?? undefined,
providerMessageId: row.provider_message_id ?? undefined,
createdAt: row.created_at,
};
}
export type SmsPhoneLogInput = {
userId: string;
phoneE164?: string;
phoneHash: string;
action: SmsPhoneLogAction;
result: SmsPhoneLogResult;
detail?: string;
jwtHash?: string;
providerMessageId?: string;
};
/** Append-only record of every phone action, plus the counts the throttles read. */
export const smsPhoneLogDb = {
async append(input: SmsPhoneLogInput): Promise<SmsPhoneLogEntry> {
const now = new Date().toISOString();
const id = randomUUID();
getDatabase()
.prepare(
`
INSERT INTO sms_phone_log (
id, user_id, phone_e164, phone_hash, action, result,
detail, jwt_hash, provider_message_id, created_at
) VALUES (
@id, @user_id, @phone_e164, @phone_hash, @action, @result,
@detail, @jwt_hash, @provider_message_id, @created_at
)
`
)
.run({
id,
user_id: input.userId,
phone_e164: input.phoneE164 ?? null,
phone_hash: input.phoneHash,
action: input.action,
result: input.result,
detail: input.detail ?? null,
jwt_hash: input.jwtHash ?? null,
provider_message_id: input.providerMessageId ?? null,
created_at: now,
});
return {
id,
userId: input.userId,
phoneE164: input.phoneE164,
phoneHash: input.phoneHash,
action: input.action,
result: input.result,
detail: input.detail,
jwtHash: input.jwtHash,
providerMessageId: input.providerMessageId,
createdAt: now,
};
},
async listByUserId(
userId: string,
limit = 100
): Promise<SmsPhoneLogEntry[]> {
const rows = getDatabase()
.prepare(
`
SELECT ${ROW_COLUMNS} FROM sms_phone_log
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?
`
)
.all(userId, limit) as DbRow[];
return rows.map(toRecord);
},
/**
* Counted from phone_hash, not user_id: a per-identity counter is defeated by
* minting more identities, so this throttle is deliberately cross-DID.
*/
async countByPhoneHashSince(
phoneHash: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE phone_hash = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(phoneHash, sinceIso, ...actions) as { n: number };
return row.n;
},
/** The backstop on daily alert sends: one handset, one identity, one window. */
async countByUserAndPhoneHashSince(
userId: string,
phoneHash: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE user_id = ? AND phone_hash = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(userId, phoneHash, sinceIso, ...actions) as { n: number };
return row.n;
},
async countByUserSince(
userId: string,
actions: SmsPhoneLogAction[],
sinceIso: string
): Promise<number> {
if (actions.length === 0) return 0;
const placeholders = actions.map(() => "?").join(", ");
const row = getDatabase()
.prepare(
`
SELECT COUNT(*) AS n FROM sms_phone_log
WHERE user_id = ? AND created_at >= ?
AND action IN (${placeholders})
`
)
.get(userId, sinceIso, ...actions) as { n: number };
return row.n;
},
/**
* Forget whose number it was, keep what happened. DELETE nulls phone_e164 for
* one DID's rows; phone_hash and the action history are left intact.
*/
async scrubPhoneNumber(userId: string, phoneE164: string): Promise<number> {
const result = getDatabase()
.prepare(
`
UPDATE sms_phone_log
SET phone_e164 = NULL
WHERE user_id = ? AND phone_e164 = ?
`
)
.run(userId, phoneE164);
return result.changes;
},
};