Count the SMS digest cap by UTC calendar day so a send just before midnight does not block the next day’s text.

This commit is contained in:
Jose Olarte III
2026-09-09 21:19:49 +08:00
parent 65eb1bee1e
commit 657fd9c530
4 changed files with 80 additions and 19 deletions
+4 -4
View File
@@ -92,7 +92,7 @@ The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endo
`runDailyAlertSearch(userId, now?)` picks the unused delegated JWT for the current **UTC** day, runs `runAlertSearchCycle` with that JWT, and marks that specific JWT consumed only when both Endorser and Partner complete (`success` or `empty`, including both empty). Pagination or source failures leave the JWT unused so the same day can be retried. The result reports the day it used as `utcDay`. After a retrieve, the result includes `digest` from `buildAlertSearchDigest` (six bucket records and counts). `digest` is `null` when there is no batch or no unused JWT for today. Consumption does not depend on `digest.hasUpdates`.
`startAlertSearchScheduler()` (started from `src/index.ts` next to the FCM scheduler) is a **separate** user-level job. One query per pass asks for the users who hold an unused JWT for the current UTC day, flagged by whether their `notify_hour_min_utc` has arrived ([Scheduler selection](#scheduler-selection)); it calls `runDailyAlertSearch` on the due ones, up to `ALERT_SEARCH_USER_CONCURRENCY` at a time. After each run, if the digest is complete with updates **and** today's JWT was consumed, it sends a user-visible FCM message (`title: TimeSafari`, body `You have N new updates.`, data `type: alert_search`) to that user's registered tokens. It does not call `sendPushToDevice` or change `WAKEUP_PING`. Subsequent ticks the same local day see no unused JWT (`digest: null`) and do not resend. FCM send failures are logged and do not roll back cursors or JWT consumption. A process-local in-flight flag skips a tick if a pass is still running.
`startAlertSearchScheduler()` (started from `src/index.ts` next to the FCM scheduler) is a **separate** user-level job. One query per pass asks for the users who hold an unused JWT for the current UTC day, flagged by whether their `notify_hour_min_utc` has arrived ([Scheduler selection](#scheduler-selection)); it calls `runDailyAlertSearch` on the due ones, up to `ALERT_SEARCH_USER_CONCURRENCY` at a time. After each run, if the digest is complete with updates **and** today's JWT was consumed, it sends a user-visible FCM message (`title: TimeSafari`, body `You have N new updates.`, data `type: alert_search`) to that user's registered tokens. It does not call `sendPushToDevice` or change `WAKEUP_PING`. Subsequent ticks the same UTC day see no unused JWT (`digest: null`) and do not resend. FCM send failures are logged and do not roll back cursors or JWT consumption. A process-local in-flight flag skips a tick if a pass is still running.
`buildAlertSearchDigest` maps a retrieve result into structured payload data: per-bucket record arrays, counts, `totalCount`, `hasUpdates`, and Endorser/Partner completion status. It does not invent a notification string; FCM uses only `totalCount` for the short body. Incomplete outcomes (`pagination`, auth, network, etc.) yield `completed: false` and `hasUpdates: false`. Empty successful retrieves are complete with `hasUpdates: false` and do not send FCM.
@@ -101,7 +101,7 @@ The **delegated** JWT is sent as `Authorization: Bearer`. Pass independent `endo
`/notify-sms` delivers the daily alertSearch digest by text as well as by push.
A user registers a phone number, proves possession of it with a 6-digit code,
authorizes a batch of delegated alertSearch JWTs for the SMS channel, and
receives at most one text per local day when that day's retrieval finds updates.
receives at most one text per UTC day when that day's retrieval finds updates.
The whole surface is off unless `SMS_ENABLED` is `true`; every route answers
`503 SMS_DISABLED` otherwise, and the SMS scheduler does not start.
@@ -404,11 +404,11 @@ second replica double-texts.
Eligibility is the same predicate the FCM path uses: the run consumed today's
JWT and the digest is complete with updates. Consumption is what makes later
ticks on the same local day no-ops. The message is
ticks on the same UTC day no-ops. The message is
`Gift Economies: you have N new updates. https://giftopia.me Reply STOP to end.`,
kept inside one 160-character GSM-7 segment, since a second segment is a second
charge. Underneath the JWT rule, an `sms_phone_log` count caps sends at one per
handset per identity per day. Send failures are logged and do not roll back
handset per identity per UTC calendar day. Send failures are logged and do not roll back
cursor advancement or JWT consumption.
The SMS channel keeps its own JWT inventory and its own cursor table. A user on
+11 -6
View File
@@ -1,6 +1,7 @@
import { smsPhoneLogDb } from "../db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../db/smsRegistrationsSqlite.js";
import { smsConfig } from "../env.js";
import { utcCalendarDay } from "../services/alertAuthorization.js";
import {
TWILIO_UNSUBSCRIBED_CODE,
isPhoneNumberBlocked,
@@ -23,16 +24,17 @@ export const SMS_SINGLE_SEGMENT_LIMIT = 160;
/**
* Backstop on top of JWT consumption: at most one digest per handset per
* identity per day. Scoped to the pair rather than the identity alone, because
* a DID with two verified handsets legitimately receives two texts.
* identity per UTC calendar day. Scoped to the pair rather than the identity
* alone, because a DID with two verified handsets legitimately receives two
* texts.
*/
export const ALERT_SENDS_PER_PHONE_PER_DAY = 1;
const DAY_MS = 24 * 60 * 60 * 1000;
export type AlertSearchSmsNotifyDeps = {
listPhones?: (userId: string) => Promise<string[]>;
send?: SmsSender;
/** Cap window uses this instant's UTC day. Tests pass a fixed clock. */
now?: Date;
};
export type AlertSearchSmsNotifyResult = {
@@ -52,7 +54,7 @@ export function alertSearchSmsBody(totalCount: number): string {
/**
* The same predicate the FCM path uses. Consumption of the day's SMS JWT is what
* makes later ticks on the same local day no-ops, so no separate flag is needed.
* makes later ticks on the same UTC day no-ops, so no separate flag is needed.
*/
export function isAlertSearchSmsEligible(
result: DailyAlertSearchResult
@@ -130,6 +132,9 @@ export async function deliverAlertSearchSms(
const body = alertSearchSmsBody(digest.totalCount);
const listPhones = deps.listPhones ?? defaultListPhones;
const send = deps.send ?? sendSms;
const now = deps.now ?? new Date();
const utcDayStartIso =
utcCalendarDay(Math.floor(now.getTime() / 1000)) + "T00:00:00.000Z";
const phones = await listPhones(result.userId);
// The digest was worth sending; this instance is simply not allowed to send
@@ -184,7 +189,7 @@ export async function deliverAlertSearchSms(
result.userId,
phoneHash,
["alert-sent"],
new Date(Date.now() - DAY_MS).toISOString()
utcDayStartIso
);
if (alreadySent >= ALERT_SENDS_PER_PHONE_PER_DAY) {
log.info(
+1 -1
View File
@@ -132,7 +132,7 @@ export const smsPhoneLogDb = {
return row.n;
},
/** The backstop on daily alert sends: one handset, one identity, one window. */
/** The backstop on daily alert sends: one handset, one identity, one UTC day. */
async countByUserAndPhoneHashSince(
userId: string,
phoneHash: string,
+64 -8
View File
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, it } from "node:test";
import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import { closeDatabase, getDatabase } from "../../src/db/sqlite.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
import type { SmsSendResult } from "../../src/services/smsService.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
@@ -235,7 +235,24 @@ describe("deliverAlertSearchSms", () => {
assert.equal(result.failed, 1);
});
it("does not text the same number twice in one day", async () => {
it("does not text the same number twice on the same UTC day", async () => {
await verifyPhone(PHONE);
const now = new Date("2026-09-08T18:00:00.000Z");
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
sends += 1;
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), { send, now });
// A second eligible run the same UTC day: the JWT is what normally stops
// this, and the log-backed cap is the backstop underneath it.
const second = await deliverAlertSearchSms(daily(), { send, now });
assert.equal(sends, 1);
assert.equal(second.sent, 0);
});
it("does not suppress a send on the next UTC day after a late-evening send", async () => {
await verifyPhone(PHONE);
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
@@ -243,12 +260,51 @@ describe("deliverAlertSearchSms", () => {
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), { send });
// A second eligible run the same day: the JWT is what normally stops this,
// and the log-backed cap is the backstop underneath it.
const second = await deliverAlertSearchSms(daily(), { send });
assert.equal(sends, 1);
assert.equal(second.sent, 0);
await deliverAlertSearchSms(daily(), {
send,
now: new Date("2026-09-08T23:30:00.000Z"),
});
getDatabase()
.prepare(
`UPDATE sms_phone_log SET created_at = ? WHERE action = 'alert-sent'`
)
.run("2026-09-08T23:30:00.000Z");
const nextDay = await deliverAlertSearchSms(daily(), {
send,
now: new Date("2026-09-09T00:01:00.000Z"),
});
assert.equal(sends, 2);
assert.equal(nextDay.sent, 1);
});
it("does not let a failed send count toward the UTC-day cap", async () => {
await verifyPhone(PHONE);
const now = new Date("2026-09-08T18:00:00.000Z");
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
now,
send: async () => {
sends += 1;
if (sends === 1) {
return { status: "failed", error: "carrier down" };
}
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(result.sent, 0);
assert.equal(result.failed, 1);
const retry = await deliverAlertSearchSms(daily(), {
now,
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 2);
assert.equal(retry.sent, 1);
assert.equal(retry.failed, 0);
});
});