fix(server): schedule Teams consent expiry from saved deadlines

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 06:01:32 -05:00
1 parent 9a06e4a6ba
commit 5ddb4543ed
12 files changed
+457 -68

No files matched your search

+2 -1
View File
@@ -1255,7 +1255,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
stageTerminalReceiptReactionRemovals, runtimeContextForRecord, scheduleMessageProcessing, processReceiptReaction,
});
const { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority } = createTeamsFileAuthority({ runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds });
const { teamsTransferProtocol, handleTeamsFileConsent, tryProcessTeamsFilePublication, scheduleTeamsFileMaintenance } = createTeamsFileDelivery(db, {
const { registerTeamsConsentExpiryWork, teamsTransferProtocol, handleTeamsFileConsent, tryProcessTeamsFilePublication, scheduleTeamsFileMaintenance } = createTeamsFileDelivery(db, {
endpointRecord, runtimeContextForRecord, runtimeFor, runtimeContexts, localRuntimeEpoch, runtime,
deriveTeamsFileTransferAuthority, publicationFiles, scheduleMessageProcessing, processPendingPublications,
withCredentialMutationLease, teamsFileRetryAfter, isShuttingDown: () => shuttingDown,
@@ -3501,6 +3501,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
const { prepareTeamsBoardSend } = createTeamsBoardSendPreparation(db, { endpointRecord, runtimeFor });
const { getPublicationBatchStatus, publishComment, publishBoardMessage } = createChatPublicationRequests(db, { endpointRecord, stageTeamsBoardFileIntent, processPendingSlackBoardMessages, processPendingPublications, prepareTeamsBoardSend });
return {
registerTeamsConsentExpiryWork,
registerCommandWork,
registerReceiverWork,
voice,
@@ -0,0 +1,53 @@
import { and, eq, gt, inArray, sql } from "drizzle-orm";
import { chatTeamsFileTransfers, type Db } from "@paperclipai/db";
import type { createDeliveryWorkCoordinator } from "../../delivery-work-coordinator.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../delivery-work-notifications.js";
import { registerScopedDeadlineWork } from "../../scoped-deadline-work.js";
export const unusedTeamsConsentPhases = ["consent_pending", "awaiting_consent", "upload_pending"];
type Entry = { id: string; companyId: string; dueAt: number };
export function notifyTeamsConsentExpiry(tx: object, id: string) {
return notifyDeliveryWork(tx, DELIVERY_QUEUES.chatTeamsConsentExpiry, id);
}
/** Owns only unused consent deadlines, not transport attempts or unknown outcomes. */
export function teamsConsentExpiryWork(db: Db, expire: (companyId: string, id: string) => Promise<boolean>) {
async function load(id?: string, signal?: AbortSignal): Promise<Entry[]> {
try {
const entries: Entry[] = []; let cursor: string | undefined;
for (;;) {
signal?.throwIfAborted();
const rows = await db.select({ id: chatTeamsFileTransfers.id, companyId: chatTeamsFileTransfers.companyId,
dueAt: sql<string>`ceil(extract(epoch from ${chatTeamsFileTransfers.expiresAt}) * 1000)`,
}).from(chatTeamsFileTransfers).where(and(inArray(chatTeamsFileTransfers.phase, unusedTeamsConsentPhases),
id ? eq(chatTeamsFileTransfers.id, id) : undefined,
cursor ? gt(chatTeamsFileTransfers.id, cursor) : undefined)).orderBy(chatTeamsFileTransfers.id).limit(100);
entries.push(...rows.map(row => ({ ...row, dueAt: Number(row.dueAt) })));
if (rows.length < 100) return entries;
cursor = rows[rows.length - 1].id;
}
} catch {
// Never expose private transfer envelopes or SQL parameter errors.
throw new Error("Teams consent expiry selection is temporarily unavailable");
}
}
async function check(entry: Entry, signal?: AbortSignal): Promise<Entry | null> {
const current = (await load(entry.id, signal))[0];
if (!current) return null;
if (current.dueAt > Date.now()) return current;
signal?.throwIfAborted();
try { await expire(current.companyId, current.id); }
catch { throw new Error("Teams consent expiry could not settle"); }
return (await load(entry.id, signal))[0] ?? null;
}
function register(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.chatTeamsConsentExpiry,
decodeScope: (hint: string) => hint === "*" ? undefined : hint,
matches: (entry: Entry, id?: string) => !id || entry.id === id,
load, check,
}, canRun);
}
return { load, check, register };
}
@@ -1,3 +1,5 @@
import { teamsConsentExpiryWork } from "./consent-expiry-work.js";
import type { createDeliveryWorkCoordinator } from "../../delivery-work-coordinator.js";
import { createOutboundAttachments } from "../outbound-attachments.js";
import { createTeamsFileAuthority } from "./file-transfer-authority.js";
@@ -14,7 +16,6 @@ import {
eq,
inArray,
lte,
or,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -392,24 +393,11 @@ export function createTeamsFileDelivery(db: Db, options: TeamsFileDeliveryOption
.selectDistinct({ companyId: chatTeamsFileTransfers.companyId })
.from(chatTeamsFileTransfers)
.where(
or(
and(
inArray(chatTeamsFileTransfers.phase, [
"consent_pending",
"awaiting_consent",
"upload_pending",
]),
lte(chatTeamsFileTransfers.expiresAt, new Date()),
),
and(
inArray(chatTeamsFileTransfers.phase, [
"consent_sending",
"uploading",
"file_info_sending",
"conflict",
]),
lte(chatTeamsFileTransfers.attemptExpiresAt, new Date()),
),
and(
inArray(chatTeamsFileTransfers.phase, [
"consent_sending", "uploading", "file_info_sending", "conflict",
]),
lte(chatTeamsFileTransfers.attemptExpiresAt, new Date()),
),
);
const protocol = teamsTransferProtocol();
@@ -417,7 +405,7 @@ export function createTeamsFileDelivery(db: Db, options: TeamsFileDeliveryOption
// its row count and lock waits and performs no provider I/O.
for (const scope of scopes) {
if (isShuttingDown()) break;
await protocol.expireAndRecover(scope.companyId, 100);
await protocol.recoverAbandonedAttempts(scope.companyId, 100);
}
} catch {
// Selection errors are closed here too; private transfer envelopes and
@@ -430,5 +418,9 @@ export function createTeamsFileDelivery(db: Db, options: TeamsFileDeliveryOption
}
});
}
return { teamsTransferProtocol, handleTeamsFileConsent, tryProcessTeamsFilePublication, scheduleTeamsFileMaintenance };
function registerTeamsConsentExpiryWork(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
return teamsConsentExpiryWork(db, (companyId, id) => teamsTransferProtocol().expireUnusedConsent(companyId, id))
.register(coordinator, () => !isShuttingDown() && canRun());
}
return { registerTeamsConsentExpiryWork, teamsTransferProtocol, handleTeamsFileConsent, tryProcessTeamsFilePublication, scheduleTeamsFileMaintenance };
}
@@ -1,3 +1,6 @@
import { teamsConsentExpiryWork } from "./chat-channels/microsoft-teams/consent-expiry-work.js";
import { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { idleWorkSnapshot } from "./task-admission.js";
import { createHash, randomUUID } from "node:crypto";
import {
afterAll,
@@ -329,6 +332,188 @@ suite(
};
}
describe("scheduled unused consent expiry", () => {
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
const transferIds: string[] = [], errors: unknown[] = [];
let enabled = true;
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
if (transferIds.length) await db.delete(chatTeamsFileTransfers).where(inArray(chatTeamsFileTransfers.id, transferIds.splice(0)));
errors.length = 0; enabled = true;
});
async function transfer() {
const f = await fixture(); transferIds.push(f.created.id);
f.opts.now = () => new Date();
return f;
}
function worker(expire: (companyId: string, id: string) => Promise<boolean>) {
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => enabled, canReconcile: () => true, onError: error => errors.push(error) });
const work = teamsConsentExpiryWork(db, expire);
return { work, ready: work.register(coordinator, () => enabled).ready };
}
async function settled() {
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); }, { timeout: 5000 });
}
async function quiet(ms = 10_000) {
const select = vi.spyOn(db, "select"), transaction = vi.spyOn(db, "transaction"), execute = vi.spyOn(db, "execute");
try { await vi.advanceTimersByTimeAsync(ms); expect(select).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); }
finally { select.mockRestore(); transaction.mockRestore(); execute.mockRestore(); }
}
it("is quiet with configured endpoints and cancelled history", async () => {
const f = await transfer(); await f.service().cancel(f.a.companyId, f.created.id);
const expire = vi.fn(f.service().expireUnusedConsent);
await worker(expire).ready; await settled();
expect(coordinator!.nextWakeAt()).toBeNull(); await quiet(180_000);
expect(expire).not.toHaveBeenCalled();
});
it.each(["consent_pending", "awaiting_consent", "upload_pending"])("restores the saved %s expiry without an early scan or provider effect", async phase => {
const f = await transfer();
if (phase !== "consent_pending") await f.service().process(f.a.companyId, f.created.id);
if (phase === "upload_pending") await f.service().recordConsent(await f.event());
expect((await f.read()).phase).toBe(phase);
const providerCalls = vi.mocked(f.opts.postConsent).mock.calls.length;
const due = (await f.read()).expiresAt.getTime();
await worker(f.service().expireUnusedConsent).ready; await settled();
expect(coordinator!.nextWakeAt()).toBe(due); await quiet();
await coordinator!.stop();
await worker(f.service().expireUnusedConsent).ready;
expect(coordinator!.nextWakeAt()).toBe(due); await quiet();
await vi.advanceTimersByTimeAsync(due - Date.now() + 1000); await settled();
expect((await f.read()).phase).toBe("expired"); expect(coordinator!.nextWakeAt()).toBeNull();
await quiet(180_000); expect(f.opts.postConsent).toHaveBeenCalledTimes(providerCalls);
expect(f.opts.uploadRequest).not.toHaveBeenCalled(); expect(f.opts.postFileInfo).not.toHaveBeenCalled(); expect(errors).toEqual([]);
});
it("reactivates from real issuance and disarms on cancellation", async () => {
let f: Awaited<ReturnType<typeof transfer>>;
await worker((companyId, id) => f.service().expireUnusedConsent(companyId, id)).ready;
await settled(); expect(coordinator!.nextWakeAt()).toBeNull(); await quiet();
f = await transfer(); await settled();
expect(coordinator!.nextWakeAt()).toBe((await f.read()).expiresAt.getTime());
await f.service().cancel(f.a.companyId, f.created.id); await settled();
expect(coordinator!.nextWakeAt()).toBeNull(); await quiet(180_000);
expect(f.opts.postConsent).not.toHaveBeenCalled();
});
it("keeps first committed work through drain without passive SQL", async () => {
enabled = false;
let f: Awaited<ReturnType<typeof transfer>>;
await worker((companyId, id) => f.service().expireUnusedConsent(companyId, id)).ready;
f = await transfer(); await quiet(90_000);
expect((await f.read()).phase).toBe("consent_pending");
enabled = true; await vi.advanceTimersByTimeAsync(30_000); await settled();
expect((await f.read()).phase).toBe("expired"); expect(coordinator!.nextWakeAt()).toBeNull();
await quiet(180_000); expect(errors).toEqual([]);
});
it("retains expiry after atomic projection failure and redacts its private error", async () => {
const f = await transfer(); let fail = true;
f.opts.project = async tx => {
await tx.update(chatPublications).set({ state: "cancelled" }).where(eq(chatPublications.id, f.a.publicationId));
if (fail) throw new Error("PRIVATE-TEAMS-UPLOAD-CANARY");
};
await worker(f.service().expireUnusedConsent).ready;
await vi.advanceTimersByTimeAsync((await f.read()).expiresAt.getTime() - Date.now() + 1000); await settled();
expect((await f.read()).phase).toBe("consent_pending");
expect((await db.select().from(chatPublications).where(eq(chatPublications.id, f.a.publicationId)))[0].state).toBe("pending");
expect(errors).toHaveLength(1);
expect(String(errors[0])).not.toContain("CANARY");
expect(String((errors[0] as AggregateError).errors[0])).not.toContain("CANARY");
fail = false; await vi.advanceTimersByTimeAsync(30_000); await settled();
expect((await f.read()).phase).toBe("expired");
expect((await db.select().from(chatPublications).where(eq(chatPublications.id, f.a.publicationId)))[0].state).toBe("cancelled");
expect(coordinator!.nextWakeAt()).toBeNull(); await quiet();
});
it("waits for outer commit and ignores nested rollback", async () => {
const f = await transfer(); await db.delete(chatTeamsFileTransfers).where(eq(chatTeamsFileTransfers.id, f.created.id));
await worker(f.service().expireUnusedConsent).ready; await settled();
const due = new Date(Date.now() + 300_000);
await expect(db.transaction(async tx => {
await tx.transaction(async nested => teamsFileTransferService(nested as unknown as typeof db, f.opts).issue(f.a, due));
await vi.advanceTimersByTimeAsync(10_000); expect(coordinator!.nextWakeAt()).not.toBe(due.getTime());
throw new Error("rollback");
})).rejects.toThrow("rollback");
await settled(); expect(coordinator!.nextWakeAt()).toBeNull(); await quiet();
let release!: () => void, inserted!: () => void;
const hold = new Promise<void>(resolve => { release = resolve; }), ready = new Promise<void>(resolve => { inserted = resolve; });
const write = db.transaction(async tx => {
const row = await tx.transaction(async nested => teamsFileTransferService(nested as unknown as typeof db, f.opts).issue(f.a, due));
inserted(); await hold; return row;
});
try {
await ready; await vi.advanceTimersByTimeAsync(10_000);
expect(coordinator!.nextWakeAt()).not.toBe(due.getTime());
} finally { release(); }
const row = await write; transferIds.push(row.id); await settled();
expect(coordinator!.nextWakeAt()).toBe(due.getTime()); await quiet();
});
it("rounds PostgreSQL expiry up and leaves active or unknown attempts alone", async () => {
const f = await transfer();
const whole = Date.now() + 60_000;
await db.update(chatTeamsFileTransfers).set({ expiresAt: sql`${new Date(whole).toISOString()}::timestamptz + interval '0.0005 second'` }).where(eq(chatTeamsFileTransfers.id, f.created.id));
vi.useFakeTimers({ toFake: ["Date"] });
const work = teamsConsentExpiryWork(db, f.service().expireUnusedConsent);
const [entry] = await work.load(); expect(entry.dueAt).toBe(whole + 1);
vi.setSystemTime(whole); await work.check(entry);
expect((await f.read()).phase).toBe("consent_pending");
vi.setSystemTime(whole + 1); expect(await work.check(entry)).toBeNull();
expect((await f.read()).phase).toBe("expired");
for (const phase of ["consent_sending", "uploading", "file_info_sending", "consent_unknown", "upload_unknown", "file_info_unknown", "conflict"]) {
await db.update(chatTeamsFileTransfers).set({ phase, attemptId: randomUUID(), attemptExpiresAt: new Date(0) }).where(eq(chatTeamsFileTransfers.id, f.created.id));
const work = teamsConsentExpiryWork(db, f.service().expireUnusedConsent);
expect(await work.load()).toEqual([]);
}
});
it("pages and drains more than 100 obligations without starving another company behind a corrupt row", async () => {
const f = await transfer(), other = await transfer();
const due = new Date(Date.now() + 60_000);
for (let index = 0; index < 100; index++) {
const publicationId = randomUUID();
await db.insert(chatPublications).values({ id: publicationId, companyId: f.a.companyId,
endpointId: f.a.endpointId, conversationId: f.a.conversationId, issueId: f.a.issueId,
commentId: f.a.commentId, idempotencyKey: `expiry-page:${publicationId}`,
payload: { text: "Same authorized file", attachmentIds: [f.a.attachmentId] } });
const a = { ...f.a, publicationId };
const created = await teamsFileTransferService(db, { ...f.opts, authorize: async () => a }).issue(a, due);
transferIds.push(created.id);
}
await db.update(chatTeamsFileTransfers).set({ authorityDigest: "0".repeat(64) }).where(eq(chatTeamsFileTransfers.id, f.created.id));
const { work, ready } = worker(f.service().expireUnusedConsent); await ready;
expect(await work.load()).toHaveLength(102);
await vi.advanceTimersByTimeAsync(due.getTime() - Date.now() + 1000);
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
expect((await other.read()).phase).toBe("expired");
const remaining = await work.load(); expect(remaining.map(row => row.id)).toEqual([f.created.id]);
}, { timeout: 10_000 });
expect((await f.read()).phase).toBe("consent_pending");
expect(errors.length).toBeGreaterThan(0); expect(f.opts.postConsent).not.toHaveBeenCalled();
}, 30_000);
it("joins an in-flight expiry projection during shutdown", async () => {
const f = await transfer(); let release!: () => void, entered!: () => void;
const hold = new Promise<void>(resolve => { release = resolve; });
const ready = new Promise<void>(resolve => { entered = resolve; });
f.opts.project = async () => { entered(); await hold; };
await worker(f.service().expireUnusedConsent).ready;
try {
await vi.advanceTimersByTimeAsync((await f.read()).expiresAt.getTime() - Date.now() + 1000);
await ready;
let stopped = false; const stop = coordinator!.stop().then(() => { stopped = true; });
await Promise.resolve(); expect(stopped).toBe(false);
release(); await stop;
expect((await f.read()).phase).toBe("expired");
} finally { release(); }
});
it("keeps the compatibility attempt sweep from expiring unused consent", async () => {
const f = await transfer();
// Restore fixture time so the explicit maintenance operation sees it due.
f.opts.now = () => new Date(Date.parse(f.created.expiresAt) + 1);
expect(await f.service().recoverAbandonedAttempts(f.a.companyId)).toMatchObject({ scanned: 0, recovered: 0 });
expect((await f.read()).phase).toBe("consent_pending");
expect(await f.service().expireUnusedConsent(f.a.companyId, f.created.id)).toBe(true);
expect((await f.read()).phase).toBe("expired");
});
});
it("uses the exact attachment-derived filename for an unnamed source across issuance and restart", async () => {
const f = await fixture(60_000, null);
const expected = `attachment-${f.a.attachmentId}`;
@@ -1,3 +1,4 @@
import { notifyTeamsConsentExpiry, unusedTeamsConsentPhases } from "./chat-channels/microsoft-teams/consent-expiry-work.js";
import { createHash, randomUUID } from "node:crypto";
import { and, eq, gt, inArray, lte, or, sql } from "drizzle-orm";
import { z } from "zod";
@@ -388,6 +389,7 @@ export function teamsFileTransferService(
row: Row,
patch: Partial<typeof chatTeamsFileTransfers.$inferInsert>,
): Promise<Row> {
await notifyTeamsConsentExpiry(tx, row.id);
const [next] = await tx
.update(chatTeamsFileTransfers)
.set({ ...patch, version: row.version + 1, updatedAt: now() })
@@ -533,6 +535,7 @@ export function teamsFileTransferService(
binding,
),
};
await notifyTeamsConsentExpiry(tx, id);
const [row] = await tx
.insert(chatTeamsFileTransfers)
.values({
@@ -859,9 +862,32 @@ export function teamsFileTransferService(
if (!row) fail();
return summary(row);
}
async function recoverTransfer(companyId: string, id: string, reason: "all" | "consent" | "attempts") {
return db.transaction(async (tx) => {
// Bound maintenance waits, including locks taken by atomic projection.
await tx.execute(sql`set local lock_timeout = '250ms'`);
const row = await locked(tx, companyId, id, true);
if (reason !== "consent" && (sending.includes(row.phase) || row.phase === "conflict") &&
row.attemptExpiresAt && row.attemptExpiresAt <= now()) {
await update(tx, row, {
phase: row.phase === "conflict" ? "conflict" : unknownPhase[row.phase],
attemptId: null,
attemptExpiresAt: null,
reason: row.phase === "conflict" ? row.reason : "worker_outcome_unknown",
});
return true;
}
if (reason !== "attempts" && unusedTeamsConsentPhases.includes(row.phase) && row.expiresAt <= now()) {
await update(tx, row, { phase: "expired", reason: "unused_consent_expired" });
return true;
}
return false;
});
}
async function expireAndRecover(
companyId: string,
limit = 25,
reason: "all" | "attempts" = "all",
): Promise<TeamsFileRecoverySweepSummary> {
if (!z.uuid().safeParse(companyId).success) fail();
const size = Number.isSafeInteger(limit)
@@ -872,7 +898,8 @@ export function teamsFileTransferService(
cursors = new Map();
expiryRecoveryCursors.set(db, cursors);
}
const cursor = cursors.get(companyId);
const cursorKey = `${companyId}:${reason}`;
const cursor = cursors.get(cursorKey);
const cutoff = now();
const eligible = and(
eq(chatTeamsFileTransfers.companyId, companyId),
@@ -881,14 +908,10 @@ export function teamsFileTransferService(
inArray(chatTeamsFileTransfers.phase, [...sending, "conflict"]),
lte(chatTeamsFileTransfers.attemptExpiresAt, cutoff),
),
and(
inArray(chatTeamsFileTransfers.phase, [
"consent_pending",
"awaiting_consent",
"upload_pending",
]),
reason === "all" ? and(
inArray(chatTeamsFileTransfers.phase, unusedTeamsConsentPhases),
lte(chatTeamsFileTransfers.expiresAt, cutoff),
),
) : undefined,
),
);
let rows: Array<{ id: string }>;
@@ -927,45 +950,11 @@ export function teamsFileTransferService(
for (const candidate of rows) {
// Progress over rejected rows too. Neither corrupt evidence nor a held
// publication lock can pin every later scan to the same oldest page.
cursors.set(companyId, candidate.id);
cursors.set(cursorKey, candidate.id);
result.cursor = candidate.id;
result.scanned++;
try {
const recovered = await db.transaction(async (tx) => {
// The projection may need endpoint/conversation rows too. Bound
// only maintenance lock waits, including locks inside that callback.
await tx.execute(sql`set local lock_timeout = '250ms'`);
const row = await locked(tx, companyId, candidate.id, true);
if (
(sending.includes(row.phase) || row.phase === "conflict") &&
row.attemptExpiresAt &&
row.attemptExpiresAt <= now()
) {
await update(tx, row, {
phase:
row.phase === "conflict" ? "conflict" : unknownPhase[row.phase],
attemptId: null,
attemptExpiresAt: null,
reason:
row.phase === "conflict"
? row.reason
: "worker_outcome_unknown",
});
return true;
} else if (
["consent_pending", "awaiting_consent", "upload_pending"].includes(
row.phase,
) &&
row.expiresAt <= now()
) {
await update(tx, row, {
phase: "expired",
reason: "unused_consent_expired",
});
return true;
}
return false;
});
const recovered = await recoverTransfer(companyId, candidate.id, reason);
if (recovered) result.recovered++;
} catch {
// Keep the exact evidence and rollback any paired projection. Only
@@ -1168,7 +1157,9 @@ export function teamsFileTransferService(
recordConsent,
process,
get,
expireAndRecover,
expireAndRecover: (companyId: string, limit = 25) => expireAndRecover(companyId, limit),
recoverAbandonedAttempts: (companyId: string, limit = 25) => expireAndRecover(companyId, limit, "attempts"),
expireUnusedConsent: (companyId: string, id: string) => recoverTransfer(companyId, id, "consent"),
cancel,
canCancelConflict,
resolveInTransaction,
@@ -46,6 +46,7 @@ export const DELIVERY_QUEUES = {
voiceInboundRetention: "voice-inbound-retention",
voiceInboundCleanup: "voice-inbound-cleanup",
voiceReplies: "voice-reply-delivery",
chatTeamsConsentExpiry: "chat-teams-consent-expiry",
chatCommandRegistration: "chat-command-registration",
chatTelegramSubscriptions: "chat-telegram-subscriptions",
chatSessionSyncs: "chat-session-syncs",