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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user