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:
54
lib/auth.ts
Normal file
54
lib/auth.ts
Normal 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
15
lib/prisma.ts
Normal 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
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
58
lib/semaphore.ts
Normal file
58
lib/semaphore.ts
Normal 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
32
lib/utils.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user