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
This commit is contained in:
107
lib/queue-processor.ts
Normal file
107
lib/queue-processor.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user