feat: route store account notices to DingTalk group

This commit is contained in:
WuYongJie 2026-09-02 13:55:00 +08:00
parent 9c459e7195
commit 7f078abb7b
7 changed files with 469 additions and 39 deletions

View File

@ -92,6 +92,12 @@ YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center
YYD_NOTIFICATION_PROJECT_CODE=furniture_platform
YYD_NOTIFICATION_PROJECT_SECRET=
YYD_NOTIFICATION_TIMEOUT_MS=10000
# 店铺账号新增时,梦汐/扬灵群使用的钉钉“由壹点”群机器人。
# webhook 地址和签名密钥只配置在本地/正式站运行环境,不要提交真实值。
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_URL=
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_SECRET=
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_TIMEOUT_MS=10000
STORE_ACCOUNT_NOTIFICATION_RETRY_SECRET=
# 产品开发待办定时任务(仅服务端/部署 cron 使用,须与其他任务密钥分开)

View File

@ -0,0 +1,73 @@
/** @jest-environment node */
import { DingTalkWebhookClient, DingTalkWebhookRequestError } from './client'
describe('DingTalkWebhookClient', () => {
it('sends plain text to the configured webhook without exposing the token in the body', async () => {
const fetchImpl = jest.fn().mockResolvedValue(new Response(
JSON.stringify({ errcode: 0, errmsg: 'ok' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
))
const client = new DingTalkWebhookClient({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test-token',
fetchImpl,
now: () => 1_700_000_000_000,
})
await client.sendText('店铺账号新增通知:测试店铺')
expect(fetchImpl).toHaveBeenCalledWith(
'https://oapi.dingtalk.com/robot/send?access_token=test-token',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
msgtype: 'text',
text: { content: '店铺账号新增通知:测试店铺' },
}),
}),
)
})
it('adds a DingTalk signature when a secret is configured', async () => {
const fetchImpl = jest.fn().mockResolvedValue(new Response(
JSON.stringify({ errcode: 0, errmsg: 'ok' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
))
const client = new DingTalkWebhookClient({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test-token',
secret: 'test-secret',
fetchImpl,
now: () => 1_700_000_000_000,
})
await client.sendText('测试')
const calledUrl = new URL(fetchImpl.mock.calls[0][0])
expect(calledUrl.searchParams.get('timestamp')).toBe('1700000000000')
expect(calledUrl.searchParams.get('sign')).toBeTruthy()
})
it('surfaces DingTalk business errors for retry handling', async () => {
const fetchImpl = jest.fn().mockResolvedValue(new Response(
JSON.stringify({ errcode: 310000, errmsg: 'token invalid' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
))
const client = new DingTalkWebhookClient({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test-token',
fetchImpl,
})
await expect(client.sendText('测试')).rejects.toMatchObject<DingTalkWebhookRequestError>({
code: '310000',
})
})
it.each([
'',
'http://oapi.dingtalk.com/robot/send?access_token=test-token',
'https://example.com/robot/send?access_token=test-token',
'https://oapi.dingtalk.com/robot/send',
])('rejects an invalid webhook URL: %s', (webhookUrl) => {
expect(() => new DingTalkWebhookClient({ webhookUrl })).toThrow()
})
})

View File

@ -0,0 +1,130 @@
import 'server-only'
import { createHmac } from 'node:crypto'
type DingTalkWebhookClientConfig = {
webhookUrl: string
secret?: string
timeoutMs?: number
fetchImpl?: typeof fetch
now?: () => number
}
type DingTalkWebhookResponse = {
errcode?: number
errmsg?: string
}
export class DingTalkWebhookRequestError extends Error {
constructor(
message: string,
readonly status?: number,
readonly code?: string,
) {
super(message)
this.name = 'DingTalkWebhookRequestError'
}
}
function normalizeWebhookUrl(value: string) {
let url: URL
try {
url = new URL(value.trim())
} catch {
throw new Error('钉钉群机器人 webhook 地址格式不正确')
}
if (
url.protocol !== 'https:'
|| url.hostname.toLowerCase() !== 'oapi.dingtalk.com'
|| url.pathname !== '/robot/send'
|| url.username
|| url.password
|| url.hash
|| !url.searchParams.get('access_token')?.trim()
) {
throw new Error('钉钉群机器人 webhook 地址必须是 oapi.dingtalk.com/robot/send 地址')
}
return url
}
function signWebhookUrl(url: URL, secret: string, timestamp: number) {
const stringToSign = `${timestamp}\n${secret}`
const sign = createHmac('sha256', secret).update(stringToSign).digest('base64')
url.searchParams.set('timestamp', String(timestamp))
url.searchParams.set('sign', sign)
return url
}
export class DingTalkWebhookClient {
private readonly webhookUrl: URL
private readonly secret: string
private readonly timeoutMs: number
private readonly fetchImpl: typeof fetch
private readonly now: () => number
constructor(config: DingTalkWebhookClientConfig) {
this.webhookUrl = normalizeWebhookUrl(config.webhookUrl)
this.secret = config.secret?.trim() ?? ''
this.timeoutMs = Math.min(60_000, Math.max(1_000, config.timeoutMs ?? 10_000))
this.fetchImpl = config.fetchImpl ?? fetch
this.now = config.now ?? Date.now
}
async sendText(content: string) {
const normalizedContent = content.trim()
if (!normalizedContent) throw new Error('钉钉群机器人消息不能为空')
if (normalizedContent.length > 20_000) throw new Error('钉钉群机器人消息不能超过 20000 个字符')
const timestamp = this.now()
const url = this.secret
? signWebhookUrl(new URL(this.webhookUrl.toString()), this.secret, timestamp)
: this.webhookUrl
let response: Response
try {
response = await this.fetchImpl(url.toString(), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
msgtype: 'text',
text: { content: normalizedContent },
}),
cache: 'no-store',
signal: AbortSignal.timeout(this.timeoutMs),
})
} catch (error) {
throw new DingTalkWebhookRequestError(
error instanceof Error ? error.message : '钉钉群机器人连接失败',
)
}
const payload = (await response.json().catch(() => null)) as DingTalkWebhookResponse | null
if (!response.ok) {
throw new DingTalkWebhookRequestError(
payload?.errmsg || `钉钉群机器人返回 HTTP ${response.status}`,
response.status,
payload?.errcode === undefined ? undefined : String(payload.errcode),
)
}
if (!payload || payload.errcode !== 0) {
throw new DingTalkWebhookRequestError(
payload?.errmsg || '钉钉群机器人返回了无效响应',
response.status,
payload?.errcode === undefined ? 'DINGTALK_WEBHOOK_INVALID_RESPONSE' : String(payload.errcode),
)
}
return payload
}
}
export function isDingTalkStoreAccountWebhookConfigured(env: NodeJS.ProcessEnv = process.env) {
return Boolean(env.DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_URL?.trim())
}
export function createDingTalkStoreAccountWebhookClientFromEnv(env: NodeJS.ProcessEnv = process.env) {
return new DingTalkWebhookClient({
webhookUrl: env.DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_URL?.trim() ?? '',
secret: env.DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_SECRET?.trim(),
timeoutMs: Number(env.DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_TIMEOUT_MS) || 10_000,
})
}

View File

@ -6,6 +6,7 @@ const mockCreate = jest.fn()
const mockUpdate = jest.fn()
const mockFindMany = jest.fn()
const mockSend = jest.fn()
const mockSendWebhookText = jest.fn()
jest.mock('@/server/repositories/notification-delivery.repository', () => ({
notificationDeliveryRepository: {
@ -23,6 +24,11 @@ jest.mock('@/server/integrations/notification-center/client', () => ({
NotificationCenterRequestError: class NotificationCenterRequestError extends Error {},
}))
jest.mock('@/server/integrations/dingtalk-webhook/client', () => ({
createDingTalkStoreAccountWebhookClientFromEnv: () => ({ sendText: (...args: unknown[]) => mockSendWebhookText(...args) }),
DingTalkWebhookRequestError: class DingTalkWebhookRequestError extends Error {},
}))
import { NotificationDeliveryError, NotificationDeliveryService } from '../notification-delivery.service'
const now = new Date('2026-08-26T07:00:00.000Z')
@ -104,9 +110,10 @@ describe('NotificationDeliveryService', () => {
mockUpdate.mockImplementation(async (_id, data) => storedRecord({
...data,
externalDeliveryId: 'delivery-1',
renderedTitle: '【家具业务系统】通知客户端测试',
status: 'pending',
renderedTitle: data.renderedTitle ?? '【家具业务系统】通知客户端测试',
status: data.status ?? 'pending',
}))
mockSendWebhookText.mockResolvedValue({ errcode: 0, errmsg: 'ok' })
})
it('sends to the bound DingTalk user and stores the full audit payload', async () => {
@ -246,4 +253,29 @@ describe('NotificationDeliveryService', () => {
expect(mockSend).not.toHaveBeenCalled()
expect(result.status).toBe('failed')
})
it('sends a webhook notification and audits it without a user recipient', async () => {
const service = new NotificationDeliveryService()
const result = await service.sendToWebhook({
idempotencyKey: 'store_account_created:account-1:mengxi-yangling-group',
businessEvent: 'store_account_created',
businessRecordId: 'account-1',
source: 'USER',
initiatedById: 'admin-1',
initiatedByName: '管理员',
recipientKey: 'mengxi-yangling-group',
recipientName: '梦汐/扬灵群(由壹点)',
title: '店铺账号新增通知:测试店铺',
content: '账号:demo\n\n查看店铺账号',
})
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({
recipientUserId: 'webhook:mengxi-yangling-group',
recipientName: '梦汐/扬灵群(由壹点)',
recipientDingtalkUserId: 'webhook:mengxi-yangling-group',
}))
expect(mockSendWebhookText).toHaveBeenCalledWith('店铺账号新增通知:测试店铺\n\n账号:demo\n\n查看店铺账号')
expect(mockUpdate).toHaveBeenCalledWith('local-log-1', expect.objectContaining({ status: 'sent' }))
expect(result.status).toBe('sent')
})
})

View File

@ -3,6 +3,7 @@
const mockFindUsersByNames = jest.fn()
const mockRecordUndeliverable = jest.fn()
const mockSendToEmployee = jest.fn()
const mockSendToWebhook = jest.fn()
const mockFindRetryable = jest.fn()
jest.mock('@/server/repositories/store-account-notification.repository', () => ({
@ -21,6 +22,7 @@ jest.mock('@/server/services/notification-delivery.service', () => ({
notificationDeliveryService: {
recordUndeliverable: (...args: unknown[]) => mockRecordUndeliverable(...args),
sendToEmployee: (...args: unknown[]) => mockSendToEmployee(...args),
sendToWebhook: (...args: unknown[]) => mockSendToWebhook(...args),
},
NotificationDeliveryError: class NotificationDeliveryError extends Error {
constructor(message: string, readonly code: string) {
@ -113,31 +115,36 @@ describe('store account notification formatting', () => {
})
it('routes 京喜 to 胖大海 and other stores to 如懿', () => {
expect(getStoreAccountNotificationTargets('京喜-测试店').map((item) => [item.name, item.includeAccount, item.storeNameOnly, item.includeCredentials])).toEqual([
['梦汐', true, undefined, false],
['扬灵', true, undefined, false],
['如懿', false, true, false],
['胖大海', true, undefined, true],
expect(getStoreAccountNotificationTargets('京喜-测试店').map((item) => [item.name, item.delivery, item.includeAccount, item.storeNameOnly, item.includeCredentials])).toEqual([
['梦汐/扬灵群(由壹点)', 'webhook', true, undefined, false],
['如懿', 'employee', false, true, false],
['胖大海', 'employee', true, undefined, true],
])
expect(getStoreAccountNotificationTargets('阿里巴巴-测试店').map((item) => [item.name, item.includeAccount, item.storeNameOnly, item.includeCredentials])).toEqual([
['梦汐', true, undefined, false],
['扬灵', true, undefined, false],
['如懿', true, undefined, true],
expect(getStoreAccountNotificationTargets('阿里巴巴-测试店').map((item) => [item.name, item.delivery, item.includeAccount, item.storeNameOnly, item.includeCredentials])).toEqual([
['梦汐/扬灵群(由壹点)', 'webhook', true, undefined, false],
['如懿', 'employee', true, undefined, true],
])
})
it('sends short notifications to 梦汐/扬灵 and full credentials to the platform owner', async () => {
it('sends one short group notification and full credentials to the platform owner', async () => {
mockFindUsersByNames.mockResolvedValue(users)
mockSendToEmployee.mockImplementation(async (input: { recipientUserId: string; content: string }) => ({
id: `delivery-${input.recipientUserId}`,
}))
mockSendToWebhook.mockResolvedValue({ id: 'group-delivery-1' })
const result = await storeAccountNotificationService.notifyCreated(record)
expect(mockFindUsersByNames).toHaveBeenCalledWith(['梦汐', '扬灵', '如懿'])
expect(mockSendToEmployee).toHaveBeenCalledTimes(3)
expect(mockSendToEmployee.mock.calls[0][0].content).toBe('账号:广州沃来家具\n\n查看店铺账号')
expect(mockSendToEmployee.mock.calls[2][0].content).toContain('密码:secret-password')
expect(mockFindUsersByNames).toHaveBeenCalledWith(['如懿'])
expect(mockSendToWebhook).toHaveBeenCalledTimes(1)
expect(mockSendToWebhook.mock.calls[0][0]).toMatchObject({
recipientKey: 'mengxi-yangling-group',
recipientName: '梦汐/扬灵群(由壹点)',
title: '店铺账号新增通知:阿里巴巴-广州沃来贸易有限公司',
content: '账号:广州沃来家具\n\n查看店铺账号',
})
expect(mockSendToEmployee).toHaveBeenCalledTimes(1)
expect(mockSendToEmployee.mock.calls[0][0].content).toContain('密码:secret-password')
for (const [notification] of mockSendToEmployee.mock.calls) {
expect(notification).not.toHaveProperty('actionTitle')
expect(notification).not.toHaveProperty('actionUrl')
@ -150,10 +157,12 @@ describe('store account notification formatting', () => {
mockSendToEmployee.mockImplementation(async (input: { recipientUserId: string }) => ({
id: `delivery-${input.recipientUserId}`,
}))
mockSendToWebhook.mockResolvedValue({ id: 'group-delivery-1' })
await storeAccountNotificationService.notifyCreated({ ...record, storeName: '京喜-测试店' })
expect(mockFindUsersByNames).toHaveBeenCalledWith(['梦汐', '扬灵', '如懿', '胖大海'])
expect(mockFindUsersByNames).toHaveBeenCalledWith(['如懿', '胖大海'])
expect(mockSendToWebhook).toHaveBeenCalledTimes(1)
const ruyiNotification = mockSendToEmployee.mock.calls.find(([input]) => input.recipientUserId === 'user-2')?.[0]
const pangdahaiNotification = mockSendToEmployee.mock.calls.find(([input]) => input.recipientUserId === 'user-3')?.[0]
expect(ruyiNotification).toMatchObject({
@ -203,4 +212,33 @@ describe('store account notification retry', () => {
}))
expect(result).toEqual({ eligible: 1, retried: 1, sent: 1, failed: 0 })
})
it('retries failed group webhook records through the webhook client', async () => {
mockFindRetryable.mockResolvedValue([{
id: 'log-group-1',
idempotencyKey: 'store_account_created:account-1:mengxi-yangling-group',
businessEvent: 'store_account_created',
businessRecordId: 'account-1',
source: 'SYSTEM',
initiatedById: null,
initiatedByName: '新增店铺账号通知',
recipientUserId: 'webhook:mengxi-yangling-group',
recipientName: '梦汐/扬灵群(由壹点)',
title: '店铺账号新增通知:测试店铺',
content: '账号:test\n\n查看店铺账号',
actionTitle: '',
actionUrl: '',
}])
mockSendToWebhook.mockResolvedValue({ id: 'group-delivery-2' })
const result = await storeAccountNotificationService.retryFailed(new Date('2026-08-31T00:10:00.000Z'))
expect(mockSendToWebhook).toHaveBeenCalledWith(expect.objectContaining({
recipientKey: 'mengxi-yangling-group',
recipientName: '梦汐/扬灵群(由壹点)',
retryExisting: true,
}))
expect(mockSendToEmployee).not.toHaveBeenCalled()
expect(result).toEqual({ eligible: 1, retried: 1, sent: 1, failed: 0 })
})
})

View File

@ -7,6 +7,10 @@ import {
isNotificationCenterConfigured,
NotificationCenterRequestError,
} from '@/server/integrations/notification-center/client'
import {
createDingTalkStoreAccountWebhookClientFromEnv,
DingTalkWebhookRequestError,
} from '@/server/integrations/dingtalk-webhook/client'
import {
NOTIFICATION_DELIVERY_STATUSES,
type NotificationCenterDelivery,
@ -43,6 +47,14 @@ export type SendEmployeeNotificationInput = {
retryExisting?: boolean
}
export type SendWebhookNotificationInput = Omit<
SendEmployeeNotificationInput,
'recipientUserId'
> & {
recipientKey: string
recipientName: string
}
export type RecordUndeliverableNotificationInput = Omit<
SendEmployeeNotificationInput,
'recipientUserId' | 'retryExisting'
@ -288,6 +300,85 @@ class NotificationDeliveryService {
}
}
async sendToWebhook(input: SendWebhookNotificationInput) {
const recipientKey = requiredText(input.recipientKey, '群机器人标识', 100)
const recipientName = requiredText(input.recipientName, '群机器人名称', 100)
const idempotencyKey = requiredText(input.idempotencyKey, '幂等键', 160)
const businessEvent = requiredText(input.businessEvent, '业务事件', 100)
const title = requiredText(input.title, '通知标题', 100)
const content = requiredText(normalizeNotificationLineBreaks(input.content), '通知正文', 4_600)
const { actionTitle, actionUrl } = normalizeNotificationAction(input)
if (/^【[^】]+】/.test(title)) {
throw new NotificationDeliveryError('通知标题不要重复添加项目名称前缀', 'INVALID_INPUT')
}
const recipientUserId = `webhook:${recipientKey}`
const payloadHash = createPayloadHash({
recipientDingtalkUserId: recipientUserId,
title,
content,
actionTitle,
actionUrl,
})
let record = await notificationDeliveryRepository.findByIdempotencyKey(idempotencyKey)
if (record && input.retryExisting === false) return toDto(record)
if (record && record.payloadHash !== payloadHash) {
throw new NotificationDeliveryError(
'同一幂等键对应了不同通知内容',
'IDEMPOTENCY_CONFLICT',
)
}
if (record && ACTIVE_DELIVERY_STATUSES.has(record.status as NotificationDeliveryStatus)) {
return toDto(record)
}
if (!record) {
record = await notificationDeliveryRepository.create({
idempotencyKey,
payloadHash,
businessEvent,
businessRecordId: input.businessRecordId?.trim() || null,
source: input.source ?? 'SYSTEM',
initiatedById: input.initiatedById?.trim() || null,
initiatedByName: input.initiatedByName?.trim() || '系统任务',
recipientUserId,
recipientName,
recipientEmail: '',
recipientDingtalkUserId: recipientUserId,
title,
content,
actionTitle,
actionUrl,
})
}
try {
const deliveryContent = [title, content].filter(Boolean).join('\n\n')
await createDingTalkStoreAccountWebhookClientFromEnv().sendText(deliveryContent)
const updated = await notificationDeliveryRepository.update(record.id, {
renderedTitle: title,
status: 'sent',
sentAt: new Date(),
lastErrorCode: null,
lastErrorMessage: null,
lastSyncedAt: new Date(),
})
return toDto(updated)
} catch (error) {
const requestError = error instanceof DingTalkWebhookRequestError ? error : null
await notificationDeliveryRepository.update(record.id, {
status: 'failed',
lastErrorCode: requestError?.code || 'DINGTALK_WEBHOOK_REQUEST_FAILED',
lastErrorMessage: error instanceof Error ? error.message : '钉钉群机器人调用失败',
lastSyncedAt: new Date(),
})
throw new NotificationDeliveryError(
'钉钉群机器人调用失败,请在通知记录中查看原因',
requestError?.code || 'DINGTALK_WEBHOOK_REQUEST_FAILED',
)
}
}
async recordUndeliverable(input: RecordUndeliverableNotificationInput) {
const idempotencyKey = requiredText(input.idempotencyKey, '幂等键', 160)
const existing = await notificationDeliveryRepository.findByIdempotencyKey(idempotencyKey)

View File

@ -25,6 +25,7 @@ const NON_JINGXI_OWNER_NAME = '如懿'
type NotificationTarget = {
key: string
name: string
delivery: 'employee' | 'webhook'
includeAccount: boolean
storeNameOnly?: boolean
includeCredentials: boolean
@ -44,14 +45,27 @@ function displayValue(value: string) {
export function getStoreAccountNotificationTargets(storeName: string): NotificationTarget[] {
const isJingxi = storeName.trim().startsWith('京喜-')
return [
{ key: 'mengxi', name: DREAMXI_NAME, includeAccount: true, includeCredentials: false },
{ key: 'yangling', name: YANGLING_NAME, includeAccount: true, includeCredentials: false },
{
key: 'mengxi-yangling-group',
name: `${DREAMXI_NAME}/${YANGLING_NAME}群(由壹点)`,
delivery: 'webhook',
includeAccount: true,
includeCredentials: false,
},
...(isJingxi
? [{ key: 'jingxi-ruyi', name: NON_JINGXI_OWNER_NAME, includeAccount: false, storeNameOnly: true, includeCredentials: false }]
? [{
key: 'jingxi-ruyi',
name: NON_JINGXI_OWNER_NAME,
delivery: 'employee' as const,
includeAccount: false,
storeNameOnly: true,
includeCredentials: false,
}]
: []),
{
key: isJingxi ? 'jingxi-owner' : 'non-jingxi-owner',
name: isJingxi ? JINGXI_OWNER_NAME : NON_JINGXI_OWNER_NAME,
delivery: 'employee',
includeAccount: true,
includeCredentials: true,
},
@ -122,8 +136,9 @@ function sendInput(input: {
class StoreAccountNotificationService {
async notifyCreated(record: StoreAccountRecord, initiatedBy?: AuthUser) {
const targets = getStoreAccountNotificationTargets(record.storeName)
const employeeTargets = targets.filter((target) => target.delivery === 'employee')
const recipients = await storeAccountNotificationRepository.findUsersByNames(
targets.map((target) => target.name),
employeeTargets.map((target) => target.name),
)
const recipientByName = new Map(recipients.map((recipient) => [recipient.name, recipient]))
const password = decryptStoreAccountPassword(record.passwordCiphertext)
@ -135,12 +150,39 @@ class StoreAccountNotificationService {
account: record.account,
password,
boundPhone: record.boundPhone,
verificationContact: record.verificationContact,
includeAccount: target.includeAccount,
storeNameOnly: target.storeNameOnly,
includeCredentials: target.includeCredentials,
verificationContact: record.verificationContact,
includeAccount: target.includeAccount,
storeNameOnly: target.storeNameOnly,
includeCredentials: target.includeCredentials,
})
const recipient = recipientByName.get(target.name)
if (target.delivery === 'webhook') {
try {
const delivery = await notificationDeliveryService.sendToWebhook({
idempotencyKey: `${STORE_ACCOUNT_CREATED_NOTIFICATION_EVENT}:${record.id}:${target.key}`,
businessEvent: STORE_ACCOUNT_CREATED_NOTIFICATION_EVENT,
businessRecordId: record.id,
source: initiatedBy ? 'USER' : 'SYSTEM',
initiatedById: initiatedBy?.id,
initiatedByName: initiatedBy?.name ?? '新增店铺账号通知',
recipientKey: target.key,
recipientName: target.name,
title: formatted.title,
content: formatted.content,
retryExisting: false,
})
results.push({ recipientName: target.name, status: 'sent', deliveryId: delivery.id, errorCode: null })
} catch (error) {
results.push({
recipientName: target.name,
status: 'failed',
deliveryId: null,
errorCode: error instanceof NotificationDeliveryError ? error.code : 'STORE_ACCOUNT_NOTIFICATION_FAILED',
})
}
continue
}
if (!recipient) {
try {
const audit = await notificationDeliveryService.recordUndeliverable({
@ -201,20 +243,38 @@ class StoreAccountNotificationService {
if (record.recipientUserId.startsWith('missing:')) continue
retried += 1
try {
await notificationDeliveryService.sendToEmployee({
idempotencyKey: record.idempotencyKey,
businessEvent: record.businessEvent,
businessRecordId: record.businessRecordId ?? undefined,
source: record.source === 'USER' ? 'USER' : 'SYSTEM',
initiatedById: record.initiatedById ?? undefined,
initiatedByName: record.initiatedByName,
recipientUserId: record.recipientUserId,
title: record.title,
content: record.content,
actionTitle: record.actionTitle,
actionUrl: record.actionUrl,
retryExisting: true,
})
if (record.recipientUserId.startsWith('webhook:')) {
await notificationDeliveryService.sendToWebhook({
idempotencyKey: record.idempotencyKey,
businessEvent: record.businessEvent,
businessRecordId: record.businessRecordId ?? undefined,
source: record.source === 'USER' ? 'USER' : 'SYSTEM',
initiatedById: record.initiatedById ?? undefined,
initiatedByName: record.initiatedByName,
recipientKey: record.recipientUserId.slice('webhook:'.length),
recipientName: record.recipientName,
title: record.title,
content: record.content,
actionTitle: record.actionTitle,
actionUrl: record.actionUrl,
retryExisting: true,
})
} else {
await notificationDeliveryService.sendToEmployee({
idempotencyKey: record.idempotencyKey,
businessEvent: record.businessEvent,
businessRecordId: record.businessRecordId ?? undefined,
source: record.source === 'USER' ? 'USER' : 'SYSTEM',
initiatedById: record.initiatedById ?? undefined,
initiatedByName: record.initiatedByName,
recipientUserId: record.recipientUserId,
title: record.title,
content: record.content,
actionTitle: record.actionTitle,
actionUrl: record.actionUrl,
retryExisting: true,
})
}
sent += 1
} catch {
failed += 1