Files
nfc-attendance-hub/lib/queue-processor.ts
Nemo b273f1a573 feat: initial NFC Attendance Hub implementation
- 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
2026-03-12 08:49:13 +00:00

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,
},
})
}