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:
Nemo
2026-03-12 08:49:13 +00:00
parent bb3f8d0ef2
commit b273f1a573
53 changed files with 6000 additions and 2 deletions

54
lib/auth.ts Normal file
View File

@@ -0,0 +1,54 @@
import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null
const user = await prisma.user.findUnique({
where: { email: credentials.email },
})
if (!user) return null
const passwordMatch = await bcrypt.compare(credentials.password, user.password)
if (!passwordMatch) return null
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
},
}),
],
session: { strategy: 'jwt' },
pages: { signIn: '/login' },
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as any).role
}
return token
},
async session({ session, token }) {
if (session.user) {
(session.user as any).id = token.id as string
;(session.user as any).role = token.role as string
}
return session
},
},
secret: process.env.NEXTAUTH_SECRET,
}

15
lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma

107
lib/queue-processor.ts Normal file
View 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,
},
})
}

58
lib/semaphore.ts Normal file
View File

@@ -0,0 +1,58 @@
import axios from 'axios'
const SEMAPHORE_API_URL = 'https://api.semaphore.co/api/v4'
export interface SemaphoreResponse {
success: boolean
messageId?: string
error?: string
}
export async function sendSms(
apiKey: string,
senderName: string,
phone: string,
message: string
): Promise<SemaphoreResponse> {
try {
const response = await axios.post(
`${SEMAPHORE_API_URL}/messages`,
{
apikey: apiKey,
number: phone,
message,
sendername: senderName,
},
{ timeout: 15000 }
)
const data = response.data
if (Array.isArray(data) && data.length > 0) {
const msg = data[0]
if (msg.status === 'Queued' || msg.status === 'Sent') {
return { success: true, messageId: String(msg.message_id) }
}
return { success: false, error: msg.status || 'Unknown status' }
}
return { success: false, error: 'Empty response from Semaphore' }
} catch (err: any) {
const errorMsg =
err.response?.data?.message ||
err.response?.data ||
err.message ||
'SMS send failed'
return { success: false, error: String(errorMsg) }
}
}
export async function getBalance(apiKey: string): Promise<number | null> {
try {
const response = await axios.get(`${SEMAPHORE_API_URL}/account`, {
params: { apikey: apiKey },
timeout: 10000,
})
return response.data?.credit_balance ?? null
} catch {
return null
}
}

32
lib/utils.ts Normal file
View File

@@ -0,0 +1,32 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function generateApiKey(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = 'nfc_'
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
export function formatPhone(phone: string): string {
const cleaned = phone.replace(/\D/g, '')
if (cleaned.startsWith('0') && cleaned.length === 11) {
return '63' + cleaned.slice(1)
}
if (cleaned.startsWith('63') && cleaned.length === 12) return cleaned
if (cleaned.length === 10) return '63' + cleaned
return cleaned
}