feat(debug): expose nextEligibleAt and structured send-wakeup results

Extend authenticated debug endpoints for local iOS notification testing:
add nextEligibleAt (23h prod / 10m test) to device lookup, return success
and failureReason from send-wakeup with masked tokens only, reuse
resolveOwnedDevice for ownership checks, and standardize [DebugEndpoint] logs.
This commit is contained in:
Jose Olarte III
2026-05-21 18:23:50 +08:00
parent 9764b30aed
commit e82c3ae5bc
2 changed files with 62 additions and 16 deletions

View File

@@ -1,10 +1,13 @@
import { Router } from "express";
import { db } from "../db/fcmTokens.js";
import { requireAuth } from "../middleware/auth.js";
import { sendPushToDevice } from "../services/pushService.js";
import {
computeNextEligibleAt,
sendPushToDevice,
} from "../services/pushService.js";
import { maskToken } from "../util/maskToken.js";
// TODO: Restrict further before production deployment
// TODO: Protect this endpoint before production deployment
export const debugRouter = Router();
debugRouter.use(requireAuth);
@@ -23,15 +26,26 @@ function deviceDebugPayload(row: {
id: row.id,
deviceId: row.deviceId,
platform: row.platform,
testMode: row.testMode,
testMode: row.testMode ?? false,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastNotifiedAt: row.lastNotifiedAt,
nextEligibleAt: computeNextEligibleAt(row),
fcmTokenSuffix: maskToken(row.fcmToken),
};
}
// TODO: Restrict further before production deployment
function sendWakeupFailureReason(
result: "sent" | "skipped" | "failed"
): string | undefined {
if (result === "sent") return undefined;
if (result === "skipped") {
return "Device was notified within the eligibility threshold";
}
return "FCM send failed";
}
// TODO: Protect this endpoint before production deployment
debugRouter.get("/device/:token", async (req, res) => {
const userId = req.did;
if (userId === undefined) {
@@ -40,10 +54,10 @@ debugRouter.get("/device/:token", async (req, res) => {
}
const fcmToken = decodeURIComponent(req.params.token);
const row = await db.getByFcmTokenForUser(userId, fcmToken);
const row = await db.resolveOwnedDevice(userId, { fcmToken });
if (row === undefined) {
console.log(
"[Debug] Device lookup not found for user, token suffix:",
"[DebugEndpoint] Device lookup not found for user, token suffix:",
maskToken(fcmToken)
);
res.status(404).json({ error: "Device not found" });
@@ -51,13 +65,13 @@ debugRouter.get("/device/:token", async (req, res) => {
}
console.log(
"[Debug] Device lookup for user, token suffix:",
"[DebugEndpoint] Device lookup for user, token suffix:",
maskToken(fcmToken)
);
res.json(deviceDebugPayload(row));
});
// TODO: Restrict further before production deployment
// TODO: Protect this endpoint before production deployment
debugRouter.post("/send-wakeup", async (req, res) => {
const userId = req.did;
if (userId === undefined) {
@@ -67,25 +81,45 @@ debugRouter.post("/send-wakeup", async (req, res) => {
const { fcmToken } = req.body as { fcmToken?: unknown };
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
res.status(400).json({ error: "fcmToken is required" });
res.status(400).json({
success: false,
failureReason: "fcmToken is required",
});
return;
}
const row = await db.getByFcmTokenForUser(userId, fcmToken);
const row = await db.resolveOwnedDevice(userId, { fcmToken });
if (row === undefined) {
console.log(
"[Debug] Send-wakeup rejected, token suffix:",
"[DebugEndpoint] Send-wakeup rejected, token suffix:",
maskToken(fcmToken)
);
res.status(404).json({ error: "Device not found" });
res.status(404).json({
success: false,
failureReason: "Device not found",
fcmTokenSuffix: maskToken(fcmToken),
});
return;
}
console.log("[Debug] Send-wakeup for token suffix:", maskToken(fcmToken));
console.log(
"[DebugEndpoint] Send-wakeup for token suffix:",
maskToken(fcmToken)
);
const result = await sendPushToDevice(fcmToken);
const success = result === "sent";
const failureReason = sendWakeupFailureReason(result);
console.log(
"[DebugEndpoint] Send-wakeup result:",
success ? "success" : result,
"token suffix:",
maskToken(fcmToken)
);
res.json({
result,
success,
...(failureReason !== undefined ? { failureReason } : {}),
fcmTokenSuffix: maskToken(fcmToken),
deviceId: row.deviceId,
});
});

View File

@@ -5,10 +5,22 @@ import { messaging } from "./firebase.js";
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
const MS_TEST = 10 * 60 * 1000;
function notifyThresholdMs(testMode?: boolean): number {
export function notifyThresholdMs(testMode?: boolean): number {
return testMode === true ? MS_TEST : MS_PRODUCTION;
}
/** Epoch ms when the device may receive another push (diagnostics only). */
export function computeNextEligibleAt(row: {
lastNotifiedAt?: number;
testMode?: boolean;
}): number {
const threshold = notifyThresholdMs(row.testMode);
if (row.lastNotifiedAt === undefined) {
return Date.now();
}
return row.lastNotifiedAt + threshold;
}
function lastNotifiedMs(row: StoredRow | undefined): number | undefined {
const v = row?.lastNotifiedAt;
if (v === undefined) return undefined;