feat(api): absorb the Hub control plane into the local instance model
Add central notification channels, rules, queue and delivery logs, fleet organization groups and tags, device discovery, the device action catalog, instance module reads, the log centre, connection settings and system maintenance as native /api/v1 routes backed by the existing secret store, audit trail and pinned upstream transport.
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { Instance } from '@multi-simadmin/contracts';
|
||||
import type { InstanceService } from '../../application/instances/instance-service.js';
|
||||
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
|
||||
import type {
|
||||
ConnectionState,
|
||||
ConnectionProbe,
|
||||
} from '../../application/connections/connection-probe.js';
|
||||
import {
|
||||
MessageServiceError,
|
||||
type DeleteSmsMessageRequest,
|
||||
} from '../../application/messages/instance-message-service.js';
|
||||
import type { HubMessageService } from '../../application/messages/hub-message-service.js';
|
||||
import {
|
||||
type SmsOutboxItem,
|
||||
type SmsOutboxService,
|
||||
type SmsOutboxStatus,
|
||||
} from '../../application/messages/sms-outbox-service.js';
|
||||
import {
|
||||
NotificationServiceError,
|
||||
type InstanceNotificationService,
|
||||
} from '../../application/notifications/instance-notification-service.js';
|
||||
export interface FleetMessageDevice {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly availability: 'online' | 'unavailable';
|
||||
}
|
||||
|
||||
export interface FleetMessage {
|
||||
readonly id: string;
|
||||
readonly instanceId: string;
|
||||
readonly instanceName: string;
|
||||
readonly direction: string;
|
||||
readonly phoneNumber: string;
|
||||
readonly content: string;
|
||||
readonly timestamp: string;
|
||||
readonly status: string;
|
||||
readonly transport: string;
|
||||
}
|
||||
|
||||
export interface FleetMessagesResponse {
|
||||
readonly messages: readonly FleetMessage[];
|
||||
readonly devices: readonly FleetMessageDevice[];
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
export interface FleetMessageConversation {
|
||||
readonly instanceId: string;
|
||||
readonly instanceName: string;
|
||||
readonly phoneNumber: string;
|
||||
readonly messageCount: number;
|
||||
readonly incomingCount: number;
|
||||
readonly lastMessage: FleetMessage;
|
||||
}
|
||||
|
||||
export interface FleetMessageConversationsResponse {
|
||||
readonly conversations: readonly FleetMessageConversation[];
|
||||
readonly total: number;
|
||||
readonly stats: { readonly incoming: number; readonly outgoing: number; readonly total: number };
|
||||
}
|
||||
|
||||
export interface FleetRoutesOptions {
|
||||
readonly instances: InstanceService;
|
||||
readonly resources: InstanceResourceService;
|
||||
readonly messages: HubMessageService;
|
||||
readonly notifications: InstanceNotificationService;
|
||||
/** Reads the heartbeat journal; the overview never probes live so the page stays cheap. */
|
||||
readonly connections?: ConnectionProbe;
|
||||
/** Offline send queue; absent means sends fail fast instead of waiting for the device. */
|
||||
readonly outbox?: SmsOutboxService;
|
||||
}
|
||||
|
||||
/** One queued send, with the node label the queue table itself does not store. */
|
||||
export type FleetOutboxItem = SmsOutboxItem & { readonly instanceName: string };
|
||||
|
||||
function parseOutboxQuery(query: unknown): {
|
||||
readonly status: SmsOutboxStatus | 'open' | 'all';
|
||||
readonly instanceId?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
} {
|
||||
const value = record(query) ?? {};
|
||||
if (Object.keys(value).some((key) => !['status', 'instanceId', 'limit', 'offset'].includes(key)))
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const status = value.status;
|
||||
if (status !== undefined && typeof status !== 'string')
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const resolved = (status ?? 'open') as SmsOutboxStatus | 'open' | 'all';
|
||||
if (!OUTBOX_STATUSES.includes(resolved)) throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const instanceId = value.instanceId;
|
||||
if (
|
||||
instanceId !== undefined &&
|
||||
(typeof instanceId !== 'string' || instanceId.length > 256 || instanceId.trim() === '')
|
||||
)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const integer = (key: 'limit' | 'offset', fallback: number, maximum: number): number => {
|
||||
const raw = value[key];
|
||||
if (raw === undefined) return fallback;
|
||||
if (typeof raw !== 'string' || !/^\d+$/u.test(raw))
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > maximum)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
return parsed;
|
||||
};
|
||||
return {
|
||||
status: resolved,
|
||||
...(typeof instanceId === 'string' && instanceId.trim() !== ''
|
||||
? { instanceId: instanceId.trim() }
|
||||
: {}),
|
||||
limit: integer('limit', 25, MAX_FLEET_OUTBOX_LIMIT),
|
||||
offset: integer('offset', 0, MAX_FLEET_MESSAGE_OFFSET),
|
||||
};
|
||||
}
|
||||
|
||||
function outboxQueueId(value: unknown): string {
|
||||
const id = typeof value === 'string' ? value : '';
|
||||
if (!/^[A-Za-z0-9_.-]{1,64}$/u.test(id)) throw new MessageServiceError('VALIDATION_FAILED');
|
||||
return id;
|
||||
}
|
||||
|
||||
const OUTBOX_STATUSES: readonly (SmsOutboxStatus | 'open' | 'all')[] = [
|
||||
'open',
|
||||
'all',
|
||||
'queued',
|
||||
'sending',
|
||||
'sent',
|
||||
'failed',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
/**
|
||||
* Heartbeat view of one device. `probed: false` means the control plane has never reached it,
|
||||
* which the fleet table shows as 未知 rather than a misleading 离线.
|
||||
*/
|
||||
export interface FleetConnectionSummary {
|
||||
readonly probed: boolean;
|
||||
readonly reachable: boolean;
|
||||
readonly authenticated: boolean;
|
||||
readonly checkedAt: string | null;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_FLEET_DEVICES = 200;
|
||||
const MAX_FLEET_MESSAGE_LIMIT = 100;
|
||||
const MAX_FLEET_CONVERSATION_LIMIT = 200;
|
||||
const MAX_FLEET_MESSAGE_OFFSET = 1000;
|
||||
const MAX_FLEET_OUTBOX_LIMIT = 100;
|
||||
const EMPTY_OUTBOX_SUMMARY = Object.freeze({ queued: 0, sending: 0, failed: 0, sent: 0 });
|
||||
|
||||
interface QueryMessagesInput {
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
readonly search?: string;
|
||||
|
||||
readonly instanceId?: string;
|
||||
readonly phoneNumber?: string;
|
||||
}
|
||||
|
||||
/** Thread-level direction filter, accepted only by the conversation endpoint. */
|
||||
export interface QueryConversationsInput extends QueryMessagesInput {
|
||||
readonly direction?: 'incoming' | 'outgoing';
|
||||
}
|
||||
|
||||
async function listInstances(instances: InstanceService): Promise<readonly Instance[]> {
|
||||
const result: Instance[] = [];
|
||||
let page = 1;
|
||||
while (result.length < MAX_FLEET_DEVICES) {
|
||||
const current = await instances.list({ page, pageSize: PAGE_SIZE });
|
||||
result.push(...current.items.slice(0, MAX_FLEET_DEVICES - result.length));
|
||||
if (current.items.length < PAGE_SIZE) break;
|
||||
page += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function connectionSummary(state: ConnectionState | undefined): FleetConnectionSummary {
|
||||
if (!state) return { probed: false, reachable: false, authenticated: false, checkedAt: null };
|
||||
return {
|
||||
probed: true,
|
||||
reachable: state.reachable,
|
||||
authenticated: state.authenticated,
|
||||
checkedAt: state.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseMessagesQuery(
|
||||
query: unknown,
|
||||
maximumLimit = MAX_FLEET_MESSAGE_LIMIT,
|
||||
allowDirection = false,
|
||||
): QueryConversationsInput {
|
||||
const value = record(query) ?? {};
|
||||
if (
|
||||
Object.keys(value).some(
|
||||
(key) =>
|
||||
!['limit', 'offset', 'search', 'instanceId', 'phoneNumber'].includes(key) &&
|
||||
!(allowDirection && key === 'direction'),
|
||||
)
|
||||
)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const integer = (key: 'limit' | 'offset', fallback: number, maximum: number): number => {
|
||||
const raw = value[key];
|
||||
if (raw === undefined) return fallback;
|
||||
if (typeof raw !== 'string' || !/^\d+$/u.test(raw)) {
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > maximum)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
return parsed;
|
||||
};
|
||||
const search = value.search;
|
||||
if (search !== undefined && (typeof search !== 'string' || search.length > 200))
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const instanceId = value.instanceId;
|
||||
if (
|
||||
instanceId !== undefined &&
|
||||
(typeof instanceId !== 'string' || instanceId.length > 256 || instanceId.trim() === '')
|
||||
)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const phoneNumber = value.phoneNumber;
|
||||
if (
|
||||
phoneNumber !== undefined &&
|
||||
(typeof phoneNumber !== 'string' || phoneNumber.length > 32 || phoneNumber.trim() === '')
|
||||
)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
const direction = value.direction;
|
||||
if (
|
||||
direction !== undefined &&
|
||||
(!allowDirection || (direction !== 'incoming' && direction !== 'outgoing'))
|
||||
)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
return {
|
||||
limit: integer('limit', 24, maximumLimit),
|
||||
offset: integer('offset', 0, MAX_FLEET_MESSAGE_OFFSET),
|
||||
...(typeof search === 'string' && search.trim() !== '' ? { search: search.trim() } : {}),
|
||||
...(typeof instanceId === 'string' && instanceId.trim() !== ''
|
||||
? { instanceId: instanceId.trim() }
|
||||
: {}),
|
||||
...(typeof phoneNumber === 'string' && phoneNumber.trim() !== ''
|
||||
? { phoneNumber: phoneNumber.trim() }
|
||||
: {}),
|
||||
...(direction === 'incoming' || direction === 'outgoing' ? { direction } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMessageDeleteItems(value: unknown): readonly DeleteSmsMessageRequest[] {
|
||||
const body = record(value);
|
||||
const items = body?.items;
|
||||
if (!Array.isArray(items) || items.length < 1 || items.length > 500)
|
||||
throw new MessageServiceError('VALIDATION_FAILED');
|
||||
return items.map((item) => {
|
||||
const current = record(item);
|
||||
const instanceId = typeof current?.instanceId === 'string' ? current.instanceId : '';
|
||||
const id =
|
||||
typeof current?.id === 'string'
|
||||
? current.id
|
||||
: typeof current?.id === 'number' && Number.isSafeInteger(current.id) && current.id >= 0
|
||||
? String(current.id)
|
||||
: '';
|
||||
if (!instanceId || !id) throw new MessageServiceError('VALIDATION_FAILED');
|
||||
return { instanceId, id };
|
||||
});
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<Item, Result>(
|
||||
items: readonly Item[],
|
||||
concurrency: number,
|
||||
mapper: (item: Item, index: number) => Promise<Result>,
|
||||
): Promise<readonly Result[]> {
|
||||
const results: Result[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (next < items.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
results[index] = await mapper(items[index]!, index);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function wrapMessageAction<T>(
|
||||
handler: (request: FastifyRequest) => Promise<T>,
|
||||
): (request: FastifyRequest, reply: FastifyReply) => Promise<T> {
|
||||
return async (request, reply) => {
|
||||
try {
|
||||
return await handler(request);
|
||||
} catch (error) {
|
||||
if (error instanceof MessageServiceError) {
|
||||
const status =
|
||||
error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502;
|
||||
return reply
|
||||
.code(status)
|
||||
.type('application/problem+json')
|
||||
.send({
|
||||
type: 'about:blank',
|
||||
title: status === 400 ? 'Bad Request' : status === 404 ? 'Not Found' : 'Bad Gateway',
|
||||
status,
|
||||
code: error.code,
|
||||
detail: 'The requested Fleet message operation could not be completed.',
|
||||
requestId: request.id,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface FleetNotificationQueueItemParams {
|
||||
readonly instanceId: string;
|
||||
readonly queueId: string;
|
||||
}
|
||||
|
||||
function wrapNotificationAction<T>(
|
||||
handler: (
|
||||
request: FastifyRequest<{ readonly Params: FleetNotificationQueueItemParams }>,
|
||||
) => Promise<T>,
|
||||
): (request: FastifyRequest, reply: FastifyReply) => Promise<T> {
|
||||
return async (request, reply) => {
|
||||
try {
|
||||
return await handler(
|
||||
request as FastifyRequest<{ readonly Params: FleetNotificationQueueItemParams }>,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationServiceError) {
|
||||
const status =
|
||||
error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502;
|
||||
return reply
|
||||
.code(status)
|
||||
.type('application/problem+json')
|
||||
.send({
|
||||
type: 'about:blank',
|
||||
title: status === 400 ? 'Bad Request' : status === 404 ? 'Not Found' : 'Bad Gateway',
|
||||
status,
|
||||
code: error.code,
|
||||
detail: 'The requested Fleet notification queue operation could not be completed.',
|
||||
requestId: request.id,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function registerFleetRoutes(app: FastifyInstance, options: FleetRoutesOptions): void {
|
||||
app.get('/api/v1/fleet/overview', async () => {
|
||||
const instances = await listInstances(options.instances);
|
||||
const reachability = options.connections?.reachability();
|
||||
const items = await mapWithConcurrency(instances, 6, async (instance) => ({
|
||||
...instance,
|
||||
connection: connectionSummary(reachability?.get(instance.id)),
|
||||
resources: await options.resources.get(instance.id),
|
||||
}));
|
||||
return { items };
|
||||
});
|
||||
app.get('/api/v1/fleet/notifications', async () => options.notifications.summarize());
|
||||
app.post('/api/v1/fleet/notifications/queue/retry-all', async () =>
|
||||
options.notifications.retryAllQueue(),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/fleet/notifications/queue/:instanceId/items/:queueId/retry',
|
||||
wrapNotificationAction(async (request) =>
|
||||
options.notifications.retryQueueItem(request.params.instanceId, request.params.queueId),
|
||||
),
|
||||
);
|
||||
app.delete(
|
||||
'/api/v1/fleet/notifications/queue/:instanceId/items/:queueId',
|
||||
wrapNotificationAction(async (request) =>
|
||||
options.notifications.deleteQueueItem(request.params.instanceId, request.params.queueId),
|
||||
),
|
||||
);
|
||||
app.get(
|
||||
'/api/v1/fleet/messages',
|
||||
wrapMessageAction(async (request) =>
|
||||
options.messages.snapshot(parseMessagesQuery(request.query)),
|
||||
),
|
||||
);
|
||||
app.get(
|
||||
'/api/v1/fleet/messages/conversations',
|
||||
wrapMessageAction(async (request) => {
|
||||
const query = parseMessagesQuery(request.query, MAX_FLEET_CONVERSATION_LIMIT, true);
|
||||
await options.messages.refresh();
|
||||
const page = options.messages.conversations(query);
|
||||
return {
|
||||
conversations: page.items.map((item) => ({
|
||||
instanceId: item.instanceId,
|
||||
instanceName: item.instanceName,
|
||||
phoneNumber: item.phoneNumber,
|
||||
messageCount: item.messageCount,
|
||||
incomingCount: item.incomingCount,
|
||||
lastMessage: {
|
||||
id: item.lastMessage.id,
|
||||
instanceId: item.lastMessage.instanceId,
|
||||
instanceName: item.lastMessage.instanceName,
|
||||
direction: item.lastMessage.direction,
|
||||
phoneNumber: item.lastMessage.phoneNumber,
|
||||
content: item.lastMessage.content,
|
||||
timestamp: item.lastMessage.timestamp,
|
||||
status: item.lastMessage.status,
|
||||
transport: item.lastMessage.transport,
|
||||
},
|
||||
})),
|
||||
total: page.totalCount,
|
||||
stats: page.stats,
|
||||
} satisfies FleetMessageConversationsResponse;
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/fleet/messages/sync',
|
||||
{
|
||||
bodyLimit: 4_096,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
instanceId: { type: 'string', minLength: 1, maxLength: 256 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wrapMessageAction(async (request) => {
|
||||
const body = record(request.body) ?? {};
|
||||
const instanceId = typeof body.instanceId === 'string' ? body.instanceId : undefined;
|
||||
return instanceId
|
||||
? { synced: await options.messages.syncDevice(instanceId), instanceId }
|
||||
: await options.messages.syncAll();
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/fleet/messages/send',
|
||||
{
|
||||
bodyLimit: 8_192,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['instanceId', 'phoneNumber', 'content'],
|
||||
properties: {
|
||||
instanceId: { type: 'string', minLength: 1, maxLength: 256 },
|
||||
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
|
||||
content: { type: 'string', minLength: 1, maxLength: 2000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wrapMessageAction(async (request) => {
|
||||
const value = record(request.body);
|
||||
const instanceId = typeof value?.instanceId === 'string' ? value.instanceId : '';
|
||||
const phoneNumber = typeof value?.phoneNumber === 'string' ? value.phoneNumber : '';
|
||||
const content = typeof value?.content === 'string' ? value.content : '';
|
||||
if (!options.outbox) {
|
||||
await options.messages.send(instanceId, { phoneNumber, content });
|
||||
return { sent: true, queued: false, instanceId };
|
||||
}
|
||||
// The queue owns a foreign key onto instances, so an unknown node stays a 404.
|
||||
if (!(await options.instances.get(instanceId))) throw new MessageServiceError('NOT_FOUND');
|
||||
const result = await options.outbox.submit(instanceId, { phoneNumber, content });
|
||||
return {
|
||||
sent: result.status === 'sent',
|
||||
queued: result.status === 'queued',
|
||||
instanceId,
|
||||
...(result.item ? { queueId: result.item.id } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/fleet/messages/delete',
|
||||
{
|
||||
bodyLimit: 32_768,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['items'],
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 500,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['instanceId', 'id'],
|
||||
properties: {
|
||||
instanceId: { type: 'string', minLength: 1, maxLength: 256 },
|
||||
id: {
|
||||
anyOf: [
|
||||
{ type: 'string', minLength: 1, maxLength: 64 },
|
||||
{ type: 'integer', minimum: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wrapMessageAction(async (request) =>
|
||||
options.messages.deleteMany(parseMessageDeleteItems(request.body)),
|
||||
),
|
||||
);
|
||||
app.get(
|
||||
'/api/v1/fleet/messages/outbox',
|
||||
wrapMessageAction(async (request) => {
|
||||
if (!options.outbox) return { items: [], total: 0, summary: EMPTY_OUTBOX_SUMMARY };
|
||||
const query = parseOutboxQuery(request.query);
|
||||
const page = options.outbox.list(query);
|
||||
const names = new Map((await listInstances(options.instances)).map((i) => [i.id, i.name]));
|
||||
return {
|
||||
items: page.items.map(
|
||||
(item): FleetOutboxItem => ({
|
||||
...item,
|
||||
instanceName: names.get(item.instanceId) ?? item.instanceId,
|
||||
}),
|
||||
),
|
||||
total: page.total,
|
||||
summary: options.outbox.summary(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/fleet/messages/outbox/flush',
|
||||
wrapMessageAction(async () => {
|
||||
if (!options.outbox)
|
||||
return { attempted: 0, delivered: 0, deferred: 0, failed: 0, remaining: 0 };
|
||||
return await options.outbox.flush();
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/fleet/messages/outbox/:queueId/cancel',
|
||||
wrapMessageAction(async (request) => {
|
||||
if (!options.outbox) throw new MessageServiceError('NOT_FOUND');
|
||||
const params = record(request.params);
|
||||
return options.outbox.cancel(outboxQueueId(params?.queueId));
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/fleet/messages/outbox/:queueId/retry',
|
||||
wrapMessageAction(async (request) => {
|
||||
if (!options.outbox) throw new MessageServiceError('NOT_FOUND');
|
||||
const params = record(request.params);
|
||||
return options.outbox.retry(outboxQueueId(params?.queueId));
|
||||
}),
|
||||
);
|
||||
app.delete(
|
||||
'/api/v1/fleet/messages/outbox/:queueId',
|
||||
wrapMessageAction(async (request) => {
|
||||
if (!options.outbox) throw new MessageServiceError('NOT_FOUND');
|
||||
const params = record(request.params);
|
||||
const queueId = outboxQueueId(params?.queueId);
|
||||
options.outbox.remove(queueId);
|
||||
return { removed: true, queueId };
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user