gongxiangp2/lib/performance-survey-notifications.ts

479 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from 'crypto'
import { prisma } from '@/lib/prisma'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { buildDingtalkEntryPath } from '@/lib/safe-redirect'
import { sendDingtalkWorkNotice } from '@/lib/dingtalk-client'
const STATUS = {
PENDING: 'pending',
PROCESSING: 'processing',
RETRYING: 'retrying',
SUCCEEDED: 'succeeded',
EXHAUSTED: 'exhausted',
SKIPPED: 'skipped',
} as const
const MAX_ATTEMPTS = 5
const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
const RECOVERY_INTERVAL_MS = 60_000
const REMINDER_INTERVAL_MS = 10 * 60_000
const STALE_PROCESSING_MS = 5 * 60_000
let workerStarted = false
let recoveryTimer: NodeJS.Timeout | null = null
let reminderTimer: NodeJS.Timeout | null = null
const deliveryTimers = new Map<string, NodeJS.Timeout>()
type QueueNotificationInput = {
surveyId?: string | null
targetId?: string | null
assignmentId?: string | null
recipientEmployeeId?: string | null
recipientUserId?: string | null
dingtalkUserId?: string | null
recipientName: string
eventType: string
businessKey: string
title: string
content: string
actionUrl?: string | null
}
function truncateBody(value: string | null | undefined, limit = 2000) {
const text = String(value || '')
return text.length > limit ? text.slice(0, limit) : text
}
function getSurveyUrl(path: string) {
return `${getPublicAppOrigin()}${buildDingtalkEntryPath(path)}`
}
function clean(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized.length > 0 ? normalized : null
}
async function resolveNotificationDingtalkUserId(notification: {
dingtalkUserId?: string | null
recipientUserId?: string | null
recipientEmployeeId?: string | null
}) {
const current = clean(notification.dingtalkUserId)
if (current) return current
const recipientUserId = clean(notification.recipientUserId)
if (recipientUserId) {
const user = await prisma.user.findUnique({
where: { id: recipientUserId },
select: { dingtalkUserId: true },
})
const userDingtalkUserId = clean(user?.dingtalkUserId)
if (userDingtalkUserId) return userDingtalkUserId
}
const recipientEmployeeId = clean(notification.recipientEmployeeId)
if (!recipientEmployeeId) return null
const employee = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { id: recipientEmployeeId },
select: { dingtalkUserId: true, dingEmail: true },
})
const employeeDingtalkUserId = clean(employee?.dingtalkUserId)
if (employeeDingtalkUserId) return employeeDingtalkUserId
const email = clean(employee?.dingEmail)
if (!email) return null
const user = await prisma.user.findFirst({
where: { email },
select: { dingtalkUserId: true },
})
return clean(user?.dingtalkUserId)
}
export function ensurePerformanceSurveyNotificationWorkerStarted() {
if (workerStarted) return
workerStarted = true
const bootstrap = setTimeout(() => {
void recoverDuePerformanceSurveyNotifications()
void queueDuePerformanceSurveyReminders()
}, 1000)
bootstrap.unref?.()
recoveryTimer = setInterval(() => {
void recoverDuePerformanceSurveyNotifications()
}, RECOVERY_INTERVAL_MS)
recoveryTimer.unref?.()
reminderTimer = setInterval(() => {
void queueDuePerformanceSurveyReminders()
}, REMINDER_INTERVAL_MS)
reminderTimer.unref?.()
}
function scheduleNotificationProcessing(notificationId: string, delayMs: number) {
const existing = deliveryTimers.get(notificationId)
if (existing) {
clearTimeout(existing)
}
const timer = setTimeout(() => {
deliveryTimers.delete(notificationId)
void processPerformanceSurveyNotification(notificationId)
}, Math.max(0, delayMs))
timer.unref?.()
deliveryTimers.set(notificationId, timer)
}
export async function queuePerformanceSurveyNotification(input: QueueNotificationInput) {
ensurePerformanceSurveyNotificationWorkerStarted()
const existing = await prisma.performanceSurveyNotification.findUnique({
where: {
eventType_businessKey: {
eventType: input.eventType,
businessKey: input.businessKey,
},
},
})
if (existing) {
return existing.id
}
const created = await prisma.performanceSurveyNotification.create({
data: {
id: randomUUID(),
surveyId: input.surveyId || null,
targetId: input.targetId || null,
assignmentId: input.assignmentId || null,
recipientEmployeeId: input.recipientEmployeeId || null,
recipientUserId: input.recipientUserId || null,
dingtalkUserId: input.dingtalkUserId || null,
recipientName: input.recipientName,
eventType: input.eventType,
businessKey: input.businessKey,
title: input.title,
content: input.content,
actionUrl: input.actionUrl || null,
status: STATUS.PENDING,
maxAttempts: MAX_ATTEMPTS,
nextRetryAt: new Date(),
},
select: { id: true },
})
scheduleNotificationProcessing(created.id, 0)
return created.id
}
export async function queueSurveyAssignmentNotifications(surveyId: string) {
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
include: {
assignments: {
where: { status: 'pending' },
include: { target: true, evaluatorEmployee: true },
},
},
})
if (!survey) return []
const ids: string[] = []
for (const assignment of survey.assignments) {
const targetName = assignment.target.targetName
ids.push(
await queuePerformanceSurveyNotification({
surveyId: survey.id,
targetId: assignment.targetId,
assignmentId: assignment.id,
recipientEmployeeId: assignment.evaluatorEmployeeId,
recipientUserId: assignment.evaluatorUserId,
dingtalkUserId: assignment.evaluatorDingtalkUserId,
recipientName: assignment.evaluatorName,
eventType: 'survey.assignment.created',
businessKey: `assignment:${assignment.id}`,
title: '你有新的考核调研待填写',
content: `你有一份新的考核调研需要填写。\n\n调研:${survey.title}\n对象:${targetName}${survey.dueAt ? `\n截止:${formatDateTime(survey.dueAt)}` : ''}`,
actionUrl: getSurveyUrl(`/performance-survey/survey/${survey.id}?targetId=${assignment.targetId}`),
}),
)
}
return ids
}
export async function queueSurveyResultNotifications(surveyId: string) {
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
include: {
targets: { include: { employeeProfile: true } },
},
})
if (!survey) return []
const ids: string[] = []
for (const target of survey.targets) {
if (target.targetType !== 'employee' || !target.employeeProfile) continue
ids.push(
await queuePerformanceSurveyNotification({
surveyId: survey.id,
targetId: target.id,
recipientEmployeeId: target.employeeProfileId,
dingtalkUserId: target.employeeProfile.dingtalkUserId,
recipientName: target.targetName,
eventType: 'survey.result.available',
businessKey: `result:${survey.id}:${target.id}`,
title: '考核调研结果已可查看',
content: `你参与的考核调研结果已经生成。\n\n调研:${survey.title}\n对象:${target.targetName}`,
actionUrl: getSurveyUrl(`/performance-survey/results/${survey.id}?targetId=${target.id}`),
}),
)
}
return ids
}
export async function queueSurveyCreatorNotification(surveyId: string, eventType = 'survey.creator.completed') {
const survey = await prisma.performanceSurvey.findUnique({
where: { id: surveyId },
include: {
assignments: true,
},
})
if (!survey) return null
const creator = await prisma.user.findUnique({
where: { id: survey.creatorUserId },
select: { id: true, username: true, realName: true, dingtalkUserId: true },
})
const total = survey.assignments.length
const submitted = survey.assignments.filter((assignment) => assignment.status === 'submitted').length
return queuePerformanceSurveyNotification({
surveyId: survey.id,
recipientUserId: creator?.id || survey.creatorUserId,
dingtalkUserId: creator?.dingtalkUserId || null,
recipientName: creator?.realName || creator?.username || survey.creatorName,
eventType,
businessKey: `${eventType}:${survey.id}`,
title: eventType === 'survey.creator.closed' ? '考核调研已关闭' : '考核调研已完成',
content: `你发起的考核调研有新的进度。\n\n调研:${survey.title}\n提交进度:${submitted}/${total}`,
actionUrl: getSurveyUrl(`/performance-survey/results/${survey.id}`),
})
}
export async function queueDuePerformanceSurveyReminders() {
ensurePerformanceSurveyNotificationWorkerStarted()
const now = new Date()
const before = new Date(Date.now() + 24 * 60 * 60 * 1000)
const surveys = await prisma.performanceSurvey.findMany({
where: {
status: 'open',
dueAt: {
gt: now,
lte: before,
},
},
include: {
assignments: {
where: { status: 'pending' },
include: { target: true },
},
},
})
const ids: string[] = []
for (const survey of surveys) {
const dueKey = survey.dueAt ? survey.dueAt.toISOString().slice(0, 10) : 'no-date'
for (const assignment of survey.assignments) {
ids.push(
await queuePerformanceSurveyNotification({
surveyId: survey.id,
targetId: assignment.targetId,
assignmentId: assignment.id,
recipientEmployeeId: assignment.evaluatorEmployeeId,
recipientUserId: assignment.evaluatorUserId,
dingtalkUserId: assignment.evaluatorDingtalkUserId,
recipientName: assignment.evaluatorName,
eventType: 'survey.assignment.due_reminder',
businessKey: `due:${dueKey}:${assignment.id}`,
title: '考核调研即将截止',
content: `你还有一份考核调研未填写。\n\n调研:${survey.title}\n对象:${assignment.target.targetName}\n截止:${formatDateTime(survey.dueAt)}`,
actionUrl: getSurveyUrl(`/performance-survey/survey/${survey.id}?targetId=${assignment.targetId}`),
}),
)
}
}
return ids
}
export async function recoverDuePerformanceSurveyNotifications(limit = 20) {
ensurePerformanceSurveyNotificationWorkerStarted()
await prisma.performanceSurveyNotification.updateMany({
where: {
status: STATUS.PROCESSING,
updatedAt: { lte: new Date(Date.now() - STALE_PROCESSING_MS) },
},
data: {
status: STATUS.RETRYING,
nextRetryAt: new Date(),
lastError: '上次投递处理中断,已重新入队',
},
})
const rows = await prisma.performanceSurveyNotification.findMany({
where: {
status: { in: [STATUS.PENDING, STATUS.RETRYING] },
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
},
select: { id: true },
orderBy: { createdAt: 'asc' },
take: limit,
})
rows.forEach((row) => scheduleNotificationProcessing(row.id, 0))
return rows.length
}
export async function retryPerformanceSurveyNotification(notificationId: string) {
ensurePerformanceSurveyNotificationWorkerStarted()
await prisma.performanceSurveyNotification.update({
where: { id: notificationId },
data: {
status: STATUS.PENDING,
nextRetryAt: new Date(),
completedAt: null,
attemptCount: 0,
maxAttempts: MAX_ATTEMPTS,
lastStatusCode: null,
lastResponseBody: null,
lastError: null,
providerMessageId: null,
sentAt: null,
},
})
scheduleNotificationProcessing(notificationId, 0)
}
export async function processPerformanceSurveyNotification(notificationId: string) {
ensurePerformanceSurveyNotificationWorkerStarted()
const notification = await prisma.performanceSurveyNotification.findUnique({
where: { id: notificationId },
})
if (!notification) return null
if (![STATUS.PENDING, STATUS.RETRYING, STATUS.PROCESSING].includes(notification.status as any)) {
return null
}
await prisma.performanceSurveyNotification.update({
where: { id: notificationId },
data: {
status: STATUS.PROCESSING,
updatedAt: new Date(),
},
})
const resolvedDingtalkUserId = await resolveNotificationDingtalkUserId(notification)
if (!resolvedDingtalkUserId) {
await prisma.performanceSurveyNotification.update({
where: { id: notificationId },
data: {
status: STATUS.SKIPPED,
lastError: '收件人缺少钉钉 userId',
completedAt: new Date(),
},
})
return { success: false, status: STATUS.SKIPPED }
}
if (resolvedDingtalkUserId !== notification.dingtalkUserId) {
await prisma.performanceSurveyNotification.update({
where: { id: notificationId },
data: {
dingtalkUserId: resolvedDingtalkUserId,
updatedAt: new Date(),
},
})
}
try {
const result = await sendDingtalkWorkNotice({
userIds: [resolvedDingtalkUserId],
title: notification.title,
text: notification.content,
actionUrl: notification.actionUrl,
})
if (result.skipped) {
await prisma.performanceSurveyNotification.update({
where: { id: notificationId },
data: {
status: STATUS.SKIPPED,
lastError: result.reason || '钉钉工作通知未发送',
completedAt: new Date(),
},
})
return { success: false, status: STATUS.SKIPPED }
}
await prisma.performanceSurveyNotification.update({
where: { id: notificationId },
data: {
status: STATUS.SUCCEEDED,
attemptCount: { increment: 1 },
providerMessageId: result.taskId || result.requestId || null,
lastStatusCode: 200,
lastResponseBody: truncateBody(JSON.stringify(result.raw || result)),
lastError: null,
sentAt: new Date(),
completedAt: new Date(),
nextRetryAt: null,
},
})
return { success: true, status: STATUS.SUCCEEDED }
} catch (error) {
const nextAttempt = notification.attemptCount + 1
const exhausted = nextAttempt >= notification.maxAttempts
const retryDelayMs = RETRY_DELAYS_MS[Math.min(nextAttempt, RETRY_DELAYS_MS.length - 1)]
const nextRetryAt = new Date(Date.now() + retryDelayMs)
await prisma.performanceSurveyNotification.update({
where: { id: notificationId },
data: {
status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING,
attemptCount: { increment: 1 },
lastError: error instanceof Error ? error.message : '钉钉工作通知发送失败',
nextRetryAt: exhausted ? null : nextRetryAt,
completedAt: exhausted ? new Date() : null,
},
})
if (!exhausted) {
scheduleNotificationProcessing(notificationId, retryDelayMs)
}
return { success: false, status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING }
}
}
function formatDateTime(value: Date | string | null | undefined) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString('zh-CN', { hour12: false })
}