- Multi-tenant client management with unique API keys - Semaphore SMS integration (per-client key + sender name + credit balance) - DB-based SMS queue with 3-attempt retry and exponential backoff - Failed SMS dashboard with manual retry button - Reports page with date/client filter and CSV export - Admin auth via NextAuth (email/password) - Docker Compose setup (app + PostgreSQL 16) - Prisma 5 schema with SmsQueue, SmsLog, Client, User models Tech: Next.js 14 App Router + TypeScript + Tailwind CSS + Prisma + PostgreSQL
108 lines
3.1 KiB
TypeScript
108 lines
3.1 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
|
import { sendSms } from '@/lib/semaphore'
|
|
import { formatPhone } from '@/lib/utils'
|
|
|
|
const MAX_ATTEMPTS = 3
|
|
|
|
export async function processQueue(): Promise<{ processed: number; failed: number }> {
|
|
let processed = 0
|
|
let failed = 0
|
|
|
|
const jobs = await prisma.smsQueue.findMany({
|
|
where: {
|
|
status: { in: ['PENDING', 'RETRYING'] },
|
|
attempts: { lt: MAX_ATTEMPTS },
|
|
scheduledAt: { lte: new Date() },
|
|
},
|
|
include: { client: true },
|
|
orderBy: { scheduledAt: 'asc' },
|
|
take: 50,
|
|
})
|
|
|
|
for (const job of jobs) {
|
|
await prisma.smsQueue.update({
|
|
where: { id: job.id },
|
|
data: { status: 'PROCESSING' },
|
|
})
|
|
|
|
const client = job.client
|
|
if (!client.semaphoreKey || !client.isActive) {
|
|
await prisma.smsQueue.update({
|
|
where: { id: job.id },
|
|
data: {
|
|
status: 'FAILED',
|
|
lastError: 'Client has no Semaphore API key configured',
|
|
processedAt: new Date(),
|
|
},
|
|
})
|
|
await createLog(job.id, client.id, job.parentPhone, job.message || '', 'FAILED', null, 'No Semaphore key')
|
|
failed++
|
|
continue
|
|
}
|
|
|
|
const message =
|
|
job.message ||
|
|
`${job.event === 'time_in' ? '✅ Time In' : '🔴 Time Out'}: ${job.studentName} (ID: ${job.studentId}) at ${new Date(job.timestamp).toLocaleString('en-PH', { timeZone: 'Asia/Manila' })}`
|
|
|
|
const phone = formatPhone(job.parentPhone)
|
|
const result = await sendSms(client.semaphoreKey, client.senderName || 'NFC-HUB', phone, message)
|
|
|
|
const newAttempts = job.attempts + 1
|
|
|
|
if (result.success) {
|
|
await prisma.smsQueue.update({
|
|
where: { id: job.id },
|
|
data: { status: 'SENT', attempts: newAttempts, processedAt: new Date(), lastError: null },
|
|
})
|
|
await createLog(job.id, client.id, phone, message, 'SUCCESS', result.messageId, null)
|
|
processed++
|
|
} else {
|
|
const nextStatus = newAttempts >= MAX_ATTEMPTS ? 'FAILED' : 'RETRYING'
|
|
const retryDelay = Math.pow(2, newAttempts) * 60 * 1000
|
|
const scheduledAt = nextStatus === 'RETRYING' ? new Date(Date.now() + retryDelay) : new Date()
|
|
|
|
await prisma.smsQueue.update({
|
|
where: { id: job.id },
|
|
data: {
|
|
status: nextStatus,
|
|
attempts: newAttempts,
|
|
lastError: result.error,
|
|
scheduledAt,
|
|
processedAt: nextStatus === 'FAILED' ? new Date() : undefined,
|
|
},
|
|
})
|
|
|
|
if (nextStatus === 'FAILED') {
|
|
await createLog(job.id, client.id, phone, message, 'FAILED', null, result.error || 'Max retries reached')
|
|
failed++
|
|
}
|
|
}
|
|
}
|
|
|
|
return { processed, failed }
|
|
}
|
|
|
|
async function createLog(
|
|
queueId: string,
|
|
clientId: string,
|
|
phone: string,
|
|
message: string,
|
|
status: 'SUCCESS' | 'FAILED',
|
|
semaphoreId: string | null | undefined,
|
|
errorReason: string | null | undefined
|
|
) {
|
|
const existing = await prisma.smsLog.findUnique({ where: { queueId } })
|
|
if (existing) return
|
|
await prisma.smsLog.create({
|
|
data: {
|
|
queueId,
|
|
clientId,
|
|
phone,
|
|
message,
|
|
status,
|
|
semaphoreId: semaphoreId ?? null,
|
|
errorReason: errorReason ?? null,
|
|
},
|
|
})
|
|
}
|