forked from wangziqi/gongxue-base
feat: add crm dead-letter operations and benchmark summary
This commit is contained in:
@@ -15,6 +15,9 @@ import {
|
||||
} from './commission.js';
|
||||
import {
|
||||
crmConfigRoute,
|
||||
crmDeadLettersRoute,
|
||||
crmQueueActionRoute,
|
||||
crmQueueLogsRoute,
|
||||
crmQueueRoute,
|
||||
referralBindRoute,
|
||||
referralInviteCodeRoute,
|
||||
@@ -45,6 +48,9 @@ export const referralRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/crm/config', crmConfigRoute],
|
||||
['PUT', '/api/crm/config', upsertCrmConfigRoute],
|
||||
['GET', '/api/crm/queue', crmQueueRoute],
|
||||
['GET', '/api/crm/dead-letters', crmDeadLettersRoute],
|
||||
['GET', '/api/crm/queue/logs', crmQueueLogsRoute],
|
||||
['POST', '/api/crm/queue/action', crmQueueActionRoute],
|
||||
['GET', '/api/commission/settings', commissionSettingsRoute],
|
||||
['PUT', '/api/commission/settings', updateCommissionSettingsRoute],
|
||||
['PUT', '/api/commission/member-rate', updateMemberCommissionRateRoute],
|
||||
|
||||
@@ -13,6 +13,10 @@ const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_b
|
||||
const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown'];
|
||||
const CRM_ASSIGNMENT_MODES = ['none', 'direct', 'round_robin', 'referrer'];
|
||||
const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
|
||||
const CRM_QUEUE_STATUSES = ['pending', 'processing', 'retrying', 'sent', 'failed', 'discarded'];
|
||||
const CRM_DEAD_LETTER_STATUSES = ['failed', 'discarded'];
|
||||
const CRM_QUEUE_ACTIONS = ['retry', 'ignore'];
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
@@ -30,6 +34,97 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function optionalUuidString(value: unknown, key: string) {
|
||||
const text = nullableString(value);
|
||||
if (!text) return '';
|
||||
if (!UUID_RE.test(text)) {
|
||||
throw new HttpError(400, `${key} must be a UUID`, 'INVALID_UUID');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function requiredUuidString(body: JsonBody, key: string) {
|
||||
const text = optionalUuidString(body[key], key);
|
||||
if (!text) throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
|
||||
return text;
|
||||
}
|
||||
|
||||
function limitedOptionalString(value: unknown, key: string, maxLength: number) {
|
||||
const text = nullableString(value);
|
||||
if (!text) return null;
|
||||
if (text.length > maxLength) {
|
||||
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function redactSensitiveString(value: string) {
|
||||
return value
|
||||
.replace(/("(?:access[_-]?token|token|secret|sign|signature|key|password)"\s*:\s*")[^"]+(")/gi, '$1[redacted]$2')
|
||||
.replace(/(access[_-]?token|token|secret|sign|signature|key|password)=([^&\s]+)/gi, '$1=[redacted]')
|
||||
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[redacted]')
|
||||
.replace(/(https?:\/\/[^/\s]+\/(?:robot\/send|bot|webhook|open-apis\/bot)\/)[^?\s/]+/gi, '$1[redacted]');
|
||||
}
|
||||
|
||||
function isSensitiveFieldKey(key: string) {
|
||||
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
return new Set([
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
'token',
|
||||
'secret',
|
||||
'clientsecret',
|
||||
'appsecret',
|
||||
'password',
|
||||
'credential',
|
||||
'credentials',
|
||||
'authorization',
|
||||
'signature',
|
||||
'sign',
|
||||
'key',
|
||||
'apikey',
|
||||
'secretkey',
|
||||
'accesskey',
|
||||
'privatekey',
|
||||
]).has(normalized);
|
||||
}
|
||||
|
||||
function redactSensitiveValue(value: unknown): unknown {
|
||||
if (typeof value === 'string') return redactSensitiveString(value);
|
||||
if (Array.isArray(value)) return value.map(item => redactSensitiveValue(item));
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (isSensitiveFieldKey(key)) {
|
||||
output[key] = '[redacted]';
|
||||
} else {
|
||||
output[key] = redactSensitiveValue(raw);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function safeUrlForResponse(value: unknown) {
|
||||
const text = nullableString(value);
|
||||
if (!text) return null;
|
||||
try {
|
||||
const url = new URL(text);
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
for (const key of Array.from(url.searchParams.keys())) {
|
||||
if (/token|secret|password|credential|authorization|signature|sign|key/i.test(key)) {
|
||||
url.searchParams.set(key, '[redacted]');
|
||||
}
|
||||
}
|
||||
if (/\/(robot\/send|bot|webhook|open-apis\/bot)\//i.test(url.pathname)) {
|
||||
url.pathname = url.pathname.replace(/(\/(?:robot\/send|bot|webhook|open-apis\/bot)\/)[^/]+/i, '$1[redacted]');
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return redactSensitiveString(text);
|
||||
}
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -543,6 +638,43 @@ function crmPermission(auth: TenantAdminAuth) {
|
||||
requireTenantPermission(auth, 'crm:read');
|
||||
}
|
||||
|
||||
async function recordCrmAudit(
|
||||
client: pg.PoolClient,
|
||||
auth: TenantAdminAuth,
|
||||
action: string,
|
||||
targetId: string | null,
|
||||
details: Record<string, unknown> = {},
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, $3, 'crm_webhook_queue', $4, $5::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, action, targetId, JSON.stringify(redactSensitiveValue(details))],
|
||||
);
|
||||
}
|
||||
|
||||
function crmQueueResponseItem<T extends Record<string, unknown>>(item: T) {
|
||||
return {
|
||||
...item,
|
||||
targetUrl: safeUrlForResponse(item.targetUrl),
|
||||
payload: redactSensitiveValue(item.payload || {}) as Record<string, unknown>,
|
||||
operatorMetadata: redactSensitiveValue(item.operatorMetadata || {}) as Record<string, unknown>,
|
||||
lastError: item.lastError ? redactSensitiveString(String(item.lastError)) : item.lastError,
|
||||
lastResponseSummary: item.lastResponseSummary ? redactSensitiveString(String(item.lastResponseSummary)) : item.lastResponseSummary,
|
||||
};
|
||||
}
|
||||
|
||||
function crmLogResponseItem<T extends Record<string, unknown>>(item: T) {
|
||||
return {
|
||||
...item,
|
||||
requestBody: item.requestBody ? redactSensitiveString(String(item.requestBody)) : item.requestBody,
|
||||
requestPayload: redactSensitiveValue(item.requestPayload || {}) as Record<string, unknown>,
|
||||
responseSummary: item.responseSummary ? redactSensitiveString(String(item.responseSummary)) : item.responseSummary,
|
||||
errorMessage: item.errorMessage ? redactSensitiveString(String(item.errorMessage)) : item.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export async function referralInviteCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
@@ -1146,12 +1278,20 @@ export async function crmQueueRoute(ctx: RequestContext) {
|
||||
crmPermission(auth);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const queueId = optionalUuidString(stringParam(ctx, 'queueId') || stringParam(ctx, 'id'), 'queueId');
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (status) {
|
||||
if (!CRM_QUEUE_STATUSES.includes(status)) {
|
||||
throw new HttpError(400, `Invalid CRM queue status: ${status}`, 'INVALID_CRM_QUEUE_STATUS');
|
||||
}
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
if (queueId) {
|
||||
params.push(queueId);
|
||||
filters.push(`id = $${params.length}::uuid`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
@@ -1160,6 +1300,11 @@ export async function crmQueueRoute(ctx: RequestContext) {
|
||||
attempts, next_attempt_at as "nextAttemptAt", last_error as "lastError",
|
||||
last_http_code as "lastHttpCode", lead_id as "leadId", sent_at as "sentAt",
|
||||
source, idempotency_key as "idempotencyKey", target_url as "targetUrl",
|
||||
provider, last_attempt_at as "lastAttemptAt", last_response_summary as "lastResponseSummary",
|
||||
dead_lettered_at as "deadLetteredAt", ignored_at as "ignoredAt",
|
||||
ignored_by as "ignoredBy", last_operator_user_id as "lastOperatorUserId",
|
||||
last_operator_action as "lastOperatorAction", last_operator_note as "lastOperatorNote",
|
||||
last_operator_at as "lastOperatorAt", operator_metadata as "operatorMetadata",
|
||||
payload, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.crm_webhook_queue
|
||||
where ${filters.join(' and ')}
|
||||
@@ -1168,5 +1313,297 @@ export async function crmQueueRoute(ctx: RequestContext) {
|
||||
`,
|
||||
params,
|
||||
);
|
||||
return { items };
|
||||
return { items: items.map(item => crmQueueResponseItem(item as Record<string, unknown>)) };
|
||||
}
|
||||
|
||||
export async function crmDeadLettersRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
crmPermission(auth);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const source = stringParam(ctx, 'source');
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = [`status = any($2::text[])`];
|
||||
params.push(CRM_DEAD_LETTER_STATUSES);
|
||||
if (status) {
|
||||
if (!CRM_DEAD_LETTER_STATUSES.includes(status)) {
|
||||
throw new HttpError(400, `Invalid CRM dead-letter status: ${status}`, 'INVALID_CRM_DEAD_LETTER_STATUS');
|
||||
}
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
if (source) {
|
||||
params.push(source);
|
||||
filters.push(`source = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, record_id as "recordId", status, scheduled_at as "scheduledAt",
|
||||
attempts, next_attempt_at as "nextAttemptAt", last_error as "lastError",
|
||||
last_http_code as "lastHttpCode", lead_id as "leadId", sent_at as "sentAt",
|
||||
source, idempotency_key as "idempotencyKey", target_url as "targetUrl",
|
||||
provider, last_attempt_at as "lastAttemptAt", last_response_summary as "lastResponseSummary",
|
||||
dead_lettered_at as "deadLetteredAt", ignored_at as "ignoredAt",
|
||||
ignored_by as "ignoredBy", last_operator_user_id as "lastOperatorUserId",
|
||||
last_operator_action as "lastOperatorAction", last_operator_note as "lastOperatorNote",
|
||||
last_operator_at as "lastOperatorAt", operator_metadata as "operatorMetadata",
|
||||
payload, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.crm_webhook_queue
|
||||
where tenant_id = $1 and ${filters.join(' and ')}
|
||||
order by coalesce(dead_lettered_at, updated_at, created_at) desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const summary = await queryOne<{
|
||||
failed: string;
|
||||
discarded: string;
|
||||
ignored: string;
|
||||
total: string;
|
||||
oldestOpenAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select count(*) filter (where status = 'failed')::text as failed,
|
||||
count(*) filter (where status = 'discarded')::text as discarded,
|
||||
count(*) filter (where ignored_at is not null)::text as ignored,
|
||||
count(*)::text as total,
|
||||
min(coalesce(dead_lettered_at, updated_at, created_at)) filter (where ignored_at is null) as "oldestOpenAt"
|
||||
from public.crm_webhook_queue
|
||||
where tenant_id = $1 and status in ('failed', 'discarded')
|
||||
`,
|
||||
[auth.tenantId],
|
||||
);
|
||||
return {
|
||||
summary: {
|
||||
failed: Number(summary?.failed || 0),
|
||||
discarded: Number(summary?.discarded || 0),
|
||||
ignored: Number(summary?.ignored || 0),
|
||||
total: Number(summary?.total || 0),
|
||||
oldestOpenAt: summary?.oldestOpenAt || null,
|
||||
},
|
||||
items: items.map(item => crmQueueResponseItem(item as Record<string, unknown>)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function crmQueueLogsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
crmPermission(auth);
|
||||
const queueId = optionalUuidString(stringParam(ctx, 'queueId') || stringParam(ctx, 'id'), 'queueId');
|
||||
if (!queueId) throw new HttpError(400, 'queueId is required', 'REQUIRED_FIELD');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const task = await queryOne<{
|
||||
id: string;
|
||||
recordId: string | null;
|
||||
leadId: string | null;
|
||||
idempotencyKey: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, record_id as "recordId", lead_id as "leadId", idempotency_key as "idempotencyKey"
|
||||
from public.crm_webhook_queue
|
||||
where tenant_id = $1 and id = $2::uuid
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, queueId],
|
||||
);
|
||||
if (!task) throw new HttpError(404, 'CRM queue task not found', 'CRM_QUEUE_TASK_NOT_FOUND');
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, queue_id as "queueId", record_id as "recordId", http_code as "httpCode",
|
||||
outcome, error_message as "errorMessage", lead_id as "leadId",
|
||||
request_body as "requestBody", request_payload as "requestPayload",
|
||||
response_summary as "responseSummary", signed_at as "signedAt",
|
||||
attempt, operator_user_id as "operatorUserId", operation,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.crm_webhook_log
|
||||
where tenant_id = $1
|
||||
and (
|
||||
queue_id = $2::uuid
|
||||
or (
|
||||
queue_id is null
|
||||
and (
|
||||
($3::text is not null and record_id = $3::text)
|
||||
or ($4::text is not null and lead_id = $4::text)
|
||||
)
|
||||
)
|
||||
)
|
||||
order by created_at desc
|
||||
limit $5
|
||||
`,
|
||||
[auth.tenantId, queueId, task.recordId, task.leadId, limit],
|
||||
);
|
||||
return { item: task, items: items.map(item => crmLogResponseItem(item as Record<string, unknown>)) };
|
||||
}
|
||||
|
||||
export async function crmQueueActionRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'crm:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const queueId = requiredUuidString(body, 'queueId');
|
||||
const action = optionalChoice(body.action, CRM_QUEUE_ACTIONS, 'retry');
|
||||
const note = limitedOptionalString(body.note ?? body.reason, 'note', 500);
|
||||
const metadata = objectValue(body.metadata);
|
||||
const safeMetadata = redactSensitiveValue(metadata) as Record<string, unknown>;
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const current = await client.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
recordId: string | null;
|
||||
leadId: string | null;
|
||||
source: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
select id, status, attempts, record_id as "recordId", lead_id as "leadId", source, payload
|
||||
from public.crm_webhook_queue
|
||||
where tenant_id = $1 and id = $2::uuid
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, queueId],
|
||||
);
|
||||
const task = current.rows[0];
|
||||
if (!task) throw new HttpError(404, 'CRM queue task not found', 'CRM_QUEUE_TASK_NOT_FOUND');
|
||||
|
||||
if (action === 'retry') {
|
||||
if (!['failed', 'discarded', 'retrying', 'pending'].includes(task.status)) {
|
||||
throw new HttpError(409, `CRM task cannot be retried from status ${task.status}`, 'CRM_QUEUE_STATUS_NOT_RETRYABLE');
|
||||
}
|
||||
const result = await client.query(
|
||||
`
|
||||
update public.crm_webhook_queue
|
||||
set status = 'pending',
|
||||
next_attempt_at = now(),
|
||||
scheduled_at = coalesce(scheduled_at, now()),
|
||||
dead_lettered_at = null,
|
||||
ignored_at = null,
|
||||
ignored_by = null,
|
||||
last_error = null,
|
||||
last_http_code = null,
|
||||
last_response_summary = null,
|
||||
last_operator_user_id = $3::uuid,
|
||||
last_operator_action = 'retry',
|
||||
last_operator_note = $4,
|
||||
last_operator_at = now(),
|
||||
operator_metadata = coalesce(operator_metadata, '{}'::jsonb) || $5::jsonb,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2::uuid
|
||||
returning id, record_id as "recordId", status, scheduled_at as "scheduledAt",
|
||||
attempts, next_attempt_at as "nextAttemptAt", last_error as "lastError",
|
||||
last_http_code as "lastHttpCode", lead_id as "leadId", sent_at as "sentAt",
|
||||
source, idempotency_key as "idempotencyKey", target_url as "targetUrl",
|
||||
provider, last_attempt_at as "lastAttemptAt", last_response_summary as "lastResponseSummary",
|
||||
dead_lettered_at as "deadLetteredAt", ignored_at as "ignoredAt",
|
||||
ignored_by as "ignoredBy", last_operator_user_id as "lastOperatorUserId",
|
||||
last_operator_action as "lastOperatorAction", last_operator_note as "lastOperatorNote",
|
||||
last_operator_at as "lastOperatorAt", operator_metadata as "operatorMetadata",
|
||||
payload, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
queueId,
|
||||
auth.userId,
|
||||
note,
|
||||
JSON.stringify({ lastRetry: { by: auth.userId, at: new Date().toISOString(), note, ...safeMetadata } }),
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
insert into public.crm_webhook_log (
|
||||
tenant_id, queue_id, record_id, http_code, outcome, error_message, lead_id,
|
||||
request_payload, response_summary, signed_at, attempt, operator_user_id, operation
|
||||
)
|
||||
values ($1, $2::uuid, $3, null, 'operator_retry', $4, $5, $6::jsonb, null, now(), $7, $8::uuid, 'retry')
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
queueId,
|
||||
task.recordId,
|
||||
note || 'Queued for manual retry',
|
||||
task.leadId,
|
||||
JSON.stringify({ source: 'tenant_operator', metadata: safeMetadata }),
|
||||
task.attempts,
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
await recordCrmAudit(client, auth, 'crm.queue.retried', queueId, {
|
||||
previousStatus: task.status,
|
||||
attempts: task.attempts,
|
||||
source: task.source,
|
||||
note,
|
||||
});
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
if (!CRM_DEAD_LETTER_STATUSES.includes(task.status)) {
|
||||
throw new HttpError(409, `CRM task cannot be ignored from status ${task.status}`, 'CRM_QUEUE_STATUS_NOT_IGNORABLE');
|
||||
}
|
||||
const result = await client.query(
|
||||
`
|
||||
update public.crm_webhook_queue
|
||||
set status = 'discarded',
|
||||
next_attempt_at = null,
|
||||
dead_lettered_at = coalesce(dead_lettered_at, now()),
|
||||
ignored_at = now(),
|
||||
ignored_by = $3::uuid,
|
||||
last_operator_user_id = $3::uuid,
|
||||
last_operator_action = 'ignore',
|
||||
last_operator_note = $4,
|
||||
last_operator_at = now(),
|
||||
operator_metadata = coalesce(operator_metadata, '{}'::jsonb) || $5::jsonb,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2::uuid
|
||||
returning id, record_id as "recordId", status, scheduled_at as "scheduledAt",
|
||||
attempts, next_attempt_at as "nextAttemptAt", last_error as "lastError",
|
||||
last_http_code as "lastHttpCode", lead_id as "leadId", sent_at as "sentAt",
|
||||
source, idempotency_key as "idempotencyKey", target_url as "targetUrl",
|
||||
provider, last_attempt_at as "lastAttemptAt", last_response_summary as "lastResponseSummary",
|
||||
dead_lettered_at as "deadLetteredAt", ignored_at as "ignoredAt",
|
||||
ignored_by as "ignoredBy", last_operator_user_id as "lastOperatorUserId",
|
||||
last_operator_action as "lastOperatorAction", last_operator_note as "lastOperatorNote",
|
||||
last_operator_at as "lastOperatorAt", operator_metadata as "operatorMetadata",
|
||||
payload, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
queueId,
|
||||
auth.userId,
|
||||
note,
|
||||
JSON.stringify({ lastIgnore: { by: auth.userId, at: new Date().toISOString(), note, ...safeMetadata } }),
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
insert into public.crm_webhook_log (
|
||||
tenant_id, queue_id, record_id, http_code, outcome, error_message, lead_id,
|
||||
request_payload, response_summary, signed_at, attempt, operator_user_id, operation
|
||||
)
|
||||
values ($1, $2::uuid, $3, null, 'operator_ignore', $4, $5, $6::jsonb, null, now(), $7, $8::uuid, 'ignore')
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
queueId,
|
||||
task.recordId,
|
||||
note || 'Ignored by tenant operator',
|
||||
task.leadId,
|
||||
JSON.stringify({ source: 'tenant_operator', metadata: safeMetadata }),
|
||||
task.attempts,
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
await recordCrmAudit(client, auth, 'crm.queue.ignored', queueId, {
|
||||
previousStatus: task.status,
|
||||
attempts: task.attempts,
|
||||
source: task.source,
|
||||
note,
|
||||
});
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item: crmQueueResponseItem(item as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user