diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9d8d9f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Database +DATABASE_URL="postgresql://nfchub:strongpassword@postgres:5432/nfchub" + +# NextAuth +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="change-me-with-openssl-rand-base64-32" + +# Internal API security +INTERNAL_API_KEY="change-me-internal-key" + +# Seed defaults (optional) +SEED_EMAIL="admin@nfchub.local" +SEED_PASSWORD="admin123" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cbbb62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# prisma +prisma/migrations/migration_lock.toml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f96a6ee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat openssl +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +RUN apk add --no-cache openssl +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED 1 +ENV DATABASE_URL="postgresql://placeholder:placeholder@placeholder:5432/placeholder" + +RUN npx prisma generate +RUN npm run build + +# Production image +FROM base AS runner +RUN apk add --no-cache openssl +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma + +# Entrypoint script +COPY --chown=nextjs:nodejs docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh + +USER nextjs + +EXPOSE 3000 +ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/README.md b/README.md index ef057e7..1234c62 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,92 @@ -# nfc-attendance-hub +# NFC Attendance Hub -NFC Student Attendance SMS Notification Hub - Admin Control Center \ No newline at end of file +A multi-tenant admin web app that acts as middleware between on-premise NFC student attendance apps and the [Semaphore](https://semaphore.co) SMS API. + +## Features + +- **Multi-tenant Client Management** — Register schools/orgs, each gets a unique API key +- **Semaphore SMS Integration** — Per-client Semaphore API key + sender name, credit balance display +- **DB-based SMS Queue** — Retry up to 3 attempts with exponential backoff, tracks failure reasons +- **Failed SMS Dashboard** — View failed messages with error details, manual retry button +- **Reports** — SMS stats per client, date filter, CSV export +- **Admin Auth** — Email/password login with NextAuth + +## Tech Stack + +- Next.js 14 (App Router) + TypeScript +- Tailwind CSS + shadcn/ui-compatible components +- Prisma ORM (v5) + PostgreSQL +- NextAuth.js (credentials provider) + +## Quick Start (Docker) + +```bash +cp .env.example .env +# Edit .env with your values + +docker compose up -d +``` + +App available at: http://localhost:3000 +Default admin: `admin@nfchub.local` / `admin123` + +## Development + +```bash +npm install +cp .env.example .env +# Edit .env to point to a local PostgreSQL + +npx prisma generate +npx prisma db push +npm run db:seed +npm run dev +``` + +## SMS API Reference + +On-premise NFC apps submit attendance events to: + +``` +POST /api/v1/sms/send +Header: X-API-Key: +Content-Type: application/json + +{ + "student_name": "Juan Dela Cruz", + "student_id": "2024-001", + "parent_phone": "09171234567", + "event": "time_in", // "time_in" or "time_out" + "timestamp": "2024-01-15T08:30:00+08:00", + "message": "Optional custom message" // optional +} +``` + +### Response + +```json +{ "success": true, "queueId": "...", "message": "SMS queued for delivery" } +``` + +### SMS Queue Behavior + +1. Submission is accepted immediately (202 Accepted) +2. Processing is triggered asynchronously +3. Failed sends are retried up to 3 times with exponential backoff (2min, 4min, 8min) +4. After 3 failures, SMS is marked FAILED and visible in the Failed SMS dashboard + +## Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `DATABASE_URL` | PostgreSQL connection string | Yes | +| `NEXTAUTH_URL` | Public app URL | Yes | +| `NEXTAUTH_SECRET` | Random secret for JWT | Yes | +| `INTERNAL_API_KEY` | Internal queue trigger key | Yes | + +## Docker Services + +| Service | Port | Description | +|---------|------|-------------| +| `app` | 3000 | Next.js application | +| `postgres` | 5433 (host) | PostgreSQL 16 | diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..59177ba --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,5 @@ +import NextAuth from 'next-auth' +import { authOptions } from '@/lib/auth' + +const handler = NextAuth(authOptions) +export { handler as GET, handler as POST } diff --git a/app/api/clients/[id]/route.ts b/app/api/clients/[id]/route.ts new file mode 100644 index 0000000..14348e1 --- /dev/null +++ b/app/api/clients/[id]/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { getBalance } from '@/lib/semaphore' + +export async function GET(req: NextRequest, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const client = await prisma.client.findUnique({ + where: { id: params.id }, + include: { _count: { select: { smsQueue: true, smsLogs: true } } }, + }) + if (!client) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + let balance: number | null = null + if (client.semaphoreKey) { + balance = await getBalance(client.semaphoreKey) + } + + return NextResponse.json({ ...client, balance }) +} + +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await req.json() + const { name, semaphoreKey, senderName, isActive } = body + + const client = await prisma.client.update({ + where: { id: params.id }, + data: { + ...(name !== undefined && { name }), + ...(semaphoreKey !== undefined && { semaphoreKey }), + ...(senderName !== undefined && { senderName }), + ...(isActive !== undefined && { isActive }), + }, + }) + + return NextResponse.json(client) +} + +export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + await prisma.client.delete({ where: { id: params.id } }) + return NextResponse.json({ success: true }) +} diff --git a/app/api/clients/route.ts b/app/api/clients/route.ts new file mode 100644 index 0000000..27889dd --- /dev/null +++ b/app/api/clients/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { generateApiKey, slugify } from '@/lib/utils' + +export async function GET() { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const clients = await prisma.client.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + _count: { select: { smsQueue: true, smsLogs: true } }, + }, + }) + return NextResponse.json(clients) +} + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await req.json() + const { name, semaphoreKey, senderName } = body + + if (!name) return NextResponse.json({ error: 'Name is required' }, { status: 400 }) + + const slug = slugify(name) + const apiKey = generateApiKey() + + const client = await prisma.client.create({ + data: { name, slug, apiKey, semaphoreKey, senderName: senderName || 'NFC-HUB' }, + }) + + return NextResponse.json(client, { status: 201 }) +} diff --git a/app/api/queue/process/route.ts b/app/api/queue/process/route.ts new file mode 100644 index 0000000..b5f9140 --- /dev/null +++ b/app/api/queue/process/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server' +import { processQueue } from '@/lib/queue-processor' + +export async function POST(req: NextRequest) { + const internalKey = req.headers.get('x-internal-key') + if (internalKey !== (process.env.INTERNAL_API_KEY || '')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const result = await processQueue() + return NextResponse.json({ success: true, ...result }) + } catch (err: any) { + console.error('[Queue Process]', err) + return NextResponse.json({ error: 'Processing failed' }, { status: 500 }) + } +} diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts new file mode 100644 index 0000000..f160a74 --- /dev/null +++ b/app/api/reports/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const clientId = searchParams.get('clientId') + const from = searchParams.get('from') + const to = searchParams.get('to') + const format = searchParams.get('format') + + const where: any = {} + if (clientId) where.clientId = clientId + if (from || to) { + where.sentAt = {} + if (from) where.sentAt.gte = new Date(from) + if (to) where.sentAt.lte = new Date(to + 'T23:59:59Z') + } + + const logs = await prisma.smsLog.findMany({ + where, + include: { client: { select: { name: true } }, queue: true }, + orderBy: { sentAt: 'desc' }, + take: format === 'csv' ? 10000 : 500, + }) + + if (format === 'csv') { + const headers = ['Date', 'Client', 'Student Name', 'Student ID', 'Phone', 'Event', 'Status', 'Error'] + const rows = logs.map(l => [ + new Date(l.sentAt).toISOString(), + l.client.name, + l.queue.studentName, + l.queue.studentId, + l.phone, + l.queue.event, + l.status, + l.errorReason || '', + ]) + const csv = [headers, ...rows].map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n') + return new NextResponse(csv, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="sms-report-${Date.now()}.csv"`, + }, + }) + } + + // Stats summary + const stats = { + total: logs.length, + success: logs.filter(l => l.status === 'SUCCESS').length, + failed: logs.filter(l => l.status === 'FAILED').length, + logs, + } + + return NextResponse.json(stats) +} diff --git a/app/api/sms/[id]/retry/route.ts b/app/api/sms/[id]/retry/route.ts new file mode 100644 index 0000000..9b0c312 --- /dev/null +++ b/app/api/sms/[id]/retry/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const queue = await prisma.smsQueue.findUnique({ where: { id: params.id } }) + if (!queue) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + await prisma.smsQueue.update({ + where: { id: params.id }, + data: { status: 'PENDING', attempts: 0, lastError: null, scheduledAt: new Date() }, + }) + + // Trigger processing + fetch(`${process.env.NEXTAUTH_URL}/api/queue/process`, { + method: 'POST', + headers: { 'x-internal-key': process.env.INTERNAL_API_KEY || '' }, + }).catch(() => {}) + + return NextResponse.json({ success: true, message: 'Queued for retry' }) +} diff --git a/app/api/sms/failed/route.ts b/app/api/sms/failed/route.ts new file mode 100644 index 0000000..03399f8 --- /dev/null +++ b/app/api/sms/failed/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const clientId = searchParams.get('clientId') + + const failed = await prisma.smsQueue.findMany({ + where: { + status: 'FAILED', + ...(clientId ? { clientId } : {}), + }, + include: { client: { select: { name: true } } }, + orderBy: { updatedAt: 'desc' }, + take: 200, + }) + + return NextResponse.json(failed) +} diff --git a/app/api/v1/sms/send/route.ts b/app/api/v1/sms/send/route.ts new file mode 100644 index 0000000..a3dcc50 --- /dev/null +++ b/app/api/v1/sms/send/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function POST(req: NextRequest) { + try { + const apiKey = req.headers.get('x-api-key') + if (!apiKey) { + return NextResponse.json({ error: 'Missing X-API-Key header' }, { status: 401 }) + } + + const client = await prisma.client.findUnique({ where: { apiKey } }) + if (!client || !client.isActive) { + return NextResponse.json({ error: 'Invalid or inactive API key' }, { status: 403 }) + } + + const body = await req.json() + const { student_name, student_id, parent_phone, event, timestamp, message } = body + + if (!student_name || !student_id || !parent_phone || !event || !timestamp) { + return NextResponse.json( + { error: 'Missing required fields: student_name, student_id, parent_phone, event, timestamp' }, + { status: 400 } + ) + } + + if (!['time_in', 'time_out'].includes(event)) { + return NextResponse.json({ error: 'event must be "time_in" or "time_out"' }, { status: 400 }) + } + + const queueEntry = await prisma.smsQueue.create({ + data: { + clientId: client.id, + studentName: student_name, + studentId: student_id, + parentPhone: parent_phone, + event, + timestamp: new Date(timestamp), + message: message || null, + status: 'PENDING', + }, + }) + + // Trigger queue processing in background (fire and forget) + fetch(`${process.env.NEXTAUTH_URL}/api/queue/process`, { + method: 'POST', + headers: { 'x-internal-key': process.env.INTERNAL_API_KEY || '' }, + }).catch(() => {}) + + return NextResponse.json({ + success: true, + queueId: queueEntry.id, + message: 'SMS queued for delivery', + }, { status: 202 }) + } catch (err: any) { + console.error('[SMS Send]', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/dashboard/clients/[id]/page.tsx b/app/dashboard/clients/[id]/page.tsx new file mode 100644 index 0000000..66fdbfc --- /dev/null +++ b/app/dashboard/clients/[id]/page.tsx @@ -0,0 +1,164 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import Link from 'next/link' +import { ArrowLeft, RefreshCw, Copy, Check } from 'lucide-react' + +export default function ClientSettingsPage() { + const params = useParams() + const router = useRouter() + const [client, setClient] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [copied, setCopied] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [form, setForm] = useState({ name: '', semaphoreKey: '', senderName: '', isActive: true }) + + useEffect(() => { + fetch(`/api/clients/${params.id}`) + .then(r => r.json()) + .then(data => { + setClient(data) + setForm({ + name: data.name, + semaphoreKey: data.semaphoreKey || '', + senderName: data.senderName || 'NFC-HUB', + isActive: data.isActive, + }) + setLoading(false) + }) + }, [params.id]) + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + setError('') + setSuccess('') + + const res = await fetch(`/api/clients/${params.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }) + + if (res.ok) { + setSuccess('Settings saved successfully') + const updated = await res.json() + setClient((prev: any) => ({ ...prev, ...updated })) + } else { + const data = await res.json() + setError(data.error || 'Save failed') + } + setSaving(false) + } + + async function copyApiKey() { + await navigator.clipboard.writeText(client.apiKey) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + if (loading) return
Loading...
+ if (!client) return
Client not found
+ + return ( +
+
+ + + +
+

{client.name}

+

Client settings and API configuration

+
+
+ + {/* API Key Card */} + + + API Key + Use this key in the X-API-Key header for SMS submissions. + + +
+ {client.apiKey} + +
+
+
+ + {/* Credit Balance */} + {client.semaphoreKey && ( + + + Semaphore Credits + + +
+ {client.balance !== null ? client.balance.toLocaleString() : 'N/A'} +
+

Available SMS credits

+
+
+ )} + + {/* Settings Form */} + + + Settings + + +
+ {error &&
{error}
} + {success &&
{success}
} +
+ + setForm(f => ({ ...f, name: e.target.value }))} required /> +
+
+ + setForm(f => ({ ...f, semaphoreKey: e.target.value }))} + placeholder="Enter Semaphore API key" + type="password" + /> +
+
+ + setForm(f => ({ ...f, senderName: e.target.value }))} + maxLength={11} + /> +
+
+ setForm(f => ({ ...f, isActive: e.target.checked }))} + className="h-4 w-4" + /> + +
+ +
+
+
+
+ ) +} diff --git a/app/dashboard/clients/new/page.tsx b/app/dashboard/clients/new/page.tsx new file mode 100644 index 0000000..1f2a0ca --- /dev/null +++ b/app/dashboard/clients/new/page.tsx @@ -0,0 +1,112 @@ +'use client' +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import Link from 'next/link' +import { ArrowLeft } from 'lucide-react' + +export default function NewClientPage() { + const router = useRouter() + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [form, setForm] = useState({ + name: '', + semaphoreKey: '', + senderName: 'NFC-HUB', + }) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + + const res = await fetch('/api/clients', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }) + + if (res.ok) { + router.push('/dashboard/clients') + } else { + const data = await res.json() + setError(data.error || 'Failed to create client') + setLoading(false) + } + } + + return ( +
+
+ + + +
+

Add Client

+

Register a new school or organization

+
+
+ + + + Client Details + + Each client gets a unique API key for their on-premise NFC attendance app. + + + +
+ {error && ( +
{error}
+ )} +
+ + setForm(f => ({ ...f, name: e.target.value }))} + placeholder="e.g. San Jose Elementary School" + required + /> +
+
+ + setForm(f => ({ ...f, semaphoreKey: e.target.value }))} + placeholder="Your Semaphore API key" + /> +

You can add this later in client settings.

+
+
+ + setForm(f => ({ ...f, senderName: e.target.value }))} + placeholder="NFC-HUB" + maxLength={11} + /> +

Max 11 characters. Must be registered with Semaphore.

+
+
+ + + + +
+
+
+
+
+ ) +} diff --git a/app/dashboard/clients/page.tsx b/app/dashboard/clients/page.tsx new file mode 100644 index 0000000..16b42ea --- /dev/null +++ b/app/dashboard/clients/page.tsx @@ -0,0 +1,86 @@ +import Link from 'next/link' +import { prisma } from '@/lib/prisma' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Plus, Settings } from 'lucide-react' + +export default async function ClientsPage() { + const clients = await prisma.client.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + _count: { select: { smsQueue: true, smsLogs: true } }, + }, + }) + + return ( +
+
+
+

Clients

+

Manage schools and organizations

+
+ + + +
+ + {clients.length === 0 ? ( + + +

No clients yet

+ + + +
+
+ ) : ( +
+ {clients.map(client => ( + + +
+
+
+

{client.name}

+ + {client.isActive ? 'Active' : 'Inactive'} + + {client.semaphoreKey ? ( + SMS Configured + ) : ( + ⚠ No SMS Key + )} +
+
+

+ API Key:{' '} + {client.apiKey} +

+

+ Sender: {client.senderName || 'NFC-HUB'} ·{' '} + SMS sent: {client._count.smsLogs} +

+
+
+ + + +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..894bd9f --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,18 @@ +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { Sidebar } from '@/components/sidebar' + +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { + const session = await getServerSession(authOptions) + if (!session) redirect('/login') + + return ( +
+ +
+
{children}
+
+
+ ) +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..026a996 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,92 @@ +import { prisma } from '@/lib/prisma' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Building2, MessageSquare, CheckCircle, XCircle, Clock } from 'lucide-react' + +export default async function DashboardPage() { + const [totalClients, activeClients, pendingCount, sentCount, failedCount] = await Promise.all([ + prisma.client.count(), + prisma.client.count({ where: { isActive: true } }), + prisma.smsQueue.count({ where: { status: { in: ['PENDING', 'RETRYING'] } } }), + prisma.smsQueue.count({ where: { status: 'SENT' } }), + prisma.smsQueue.count({ where: { status: 'FAILED' } }), + ]) + + const recentLogs = await prisma.smsLog.findMany({ + take: 10, + orderBy: { sentAt: 'desc' }, + include: { client: { select: { name: true } }, queue: true }, + }) + + const stats = [ + { title: 'Total Clients', value: totalClients, sub: `${activeClients} active`, icon: Building2, color: 'text-blue-600' }, + { title: 'Pending SMS', value: pendingCount, sub: 'In queue', icon: Clock, color: 'text-yellow-600' }, + { title: 'SMS Sent', value: sentCount, sub: 'All time', icon: CheckCircle, color: 'text-green-600' }, + { title: 'Failed SMS', value: failedCount, sub: 'Needs attention', icon: XCircle, color: 'text-red-600' }, + ] + + return ( +
+
+

Dashboard

+

Overview of your NFC Attendance Hub

+
+ +
+ {stats.map(stat => { + const Icon = stat.icon + return ( + + + {stat.title} + + + +
{stat.value}
+

{stat.sub}

+
+
+ ) + })} +
+ + + + Recent SMS Activity + + + {recentLogs.length === 0 ? ( +

No SMS activity yet

+ ) : ( +
+ {recentLogs.map(log => ( +
+
+ {log.queue.studentName} + ({log.queue.studentId}) + via {log.client.name} +
+
+ + {log.queue.event === 'time_in' ? '✅ Time In' : '🔴 Time Out'} + + + {log.status} + +
+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/app/dashboard/reports/page.tsx b/app/dashboard/reports/page.tsx new file mode 100644 index 0000000..3bbb1fc --- /dev/null +++ b/app/dashboard/reports/page.tsx @@ -0,0 +1,191 @@ +'use client' +import { useState, useEffect } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Download, Search, BarChart3 } from 'lucide-react' + +export default function ReportsPage() { + const [clients, setClients] = useState([]) + const [logs, setLogs] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(false) + const [filters, setFilters] = useState({ clientId: '', from: '', to: '' }) + + useEffect(() => { + fetch('/api/clients').then(r => r.json()).then(setClients) + loadReports() + }, []) + + async function loadReports() { + setLoading(true) + const params = new URLSearchParams() + if (filters.clientId) params.append('clientId', filters.clientId) + if (filters.from) params.append('from', filters.from) + if (filters.to) params.append('to', filters.to) + const res = await fetch(`/api/reports?${params}`) + const data = await res.json() + setStats(data) + setLogs(data.logs || []) + setLoading(false) + } + + async function handleExportCsv() { + const params = new URLSearchParams({ format: 'csv' }) + if (filters.clientId) params.append('clientId', filters.clientId) + if (filters.from) params.append('from', filters.from) + if (filters.to) params.append('to', filters.to) + const res = await fetch(`/api/reports?${params}`) + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `sms-report-${Date.now()}.csv` + a.click() + URL.revokeObjectURL(url) + } + + return ( +
+
+
+

Reports

+

SMS delivery statistics and logs

+
+ +
+ + {/* Filters */} + + Filters + +
+
+ + +
+
+ + setFilters(f => ({ ...f, from: e.target.value }))} + /> +
+
+ + setFilters(f => ({ ...f, to: e.target.value }))} + /> +
+
+ +
+
+
+
+ + {/* Stats */} + {stats && ( +
+ + +
{stats.total}
+

Total SMS

+
+
+ + +
{stats.success}
+

Delivered

+
+
+ + +
{stats.failed}
+

Failed

+
+
+
+ )} + + {/* Logs table */} + + SMS Logs + + {loading ? ( +

Loading...

+ ) : logs.length === 0 ? ( +

No records found

+ ) : ( +
+ + + + + + + + + + + + + + {logs.map((log: any) => ( + + + + + + + + + + ))} + +
DateClientStudentPhoneEventStatusError
+ {new Date(log.sentAt).toLocaleString('en-PH', { timeZone: 'Asia/Manila' })} + {log.client.name} +
{log.queue.studentName}
+
{log.queue.studentId}
+
{log.phone} + + {log.queue.event === 'time_in' ? '✅ In' : '🔴 Out'} + + + + {log.status} + + {log.errorReason || '-'}
+
+ )} +
+
+
+ ) +} diff --git a/app/dashboard/sms/failed/page.tsx b/app/dashboard/sms/failed/page.tsx new file mode 100644 index 0000000..84909e2 --- /dev/null +++ b/app/dashboard/sms/failed/page.tsx @@ -0,0 +1,125 @@ +'use client' +import { useState, useEffect } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { RefreshCw, AlertTriangle } from 'lucide-react' + +export default function FailedSmsPage() { + const [failed, setFailed] = useState([]) + const [loading, setLoading] = useState(true) + const [retrying, setRetrying] = useState(null) + const [message, setMessage] = useState('') + + async function loadFailed() { + setLoading(true) + const res = await fetch('/api/sms/failed') + const data = await res.json() + setFailed(data) + setLoading(false) + } + + useEffect(() => { loadFailed() }, []) + + async function handleRetry(id: string) { + setRetrying(id) + setMessage('') + const res = await fetch(`/api/sms/${id}/retry`, { method: 'POST' }) + const data = await res.json() + setMessage(data.message || (res.ok ? 'Queued for retry' : 'Retry failed')) + setRetrying(null) + if (res.ok) { + setTimeout(() => loadFailed(), 2000) + } + } + + return ( +
+
+
+

Failed SMS

+

SMS messages that failed after 3 attempts

+
+ +
+ + {message && ( +
{message}
+ )} + + + + + + Failed Messages ({failed.length}) + + + + {loading ? ( +

Loading...

+ ) : failed.length === 0 ? ( +

🎉 No failed messages!

+ ) : ( +
+ + + + + + + + + + + + + + {failed.map(item => ( + + + + + + + + + + ))} + +
StudentClientPhoneEventErrorFailed AtAction
+
{item.studentName}
+
{item.studentId}
+
{item.client.name}{item.parentPhone} + + {item.event === 'time_in' ? '✅ In' : '🔴 Out'} + + + + {item.lastError || 'Unknown error'} + + + {new Date(item.updatedAt).toLocaleString('en-PH', { timeZone: 'Asia/Manila' })} + + +
+
+ )} +
+
+
+ ) +} diff --git a/app/dashboard/sms/page.tsx b/app/dashboard/sms/page.tsx new file mode 100644 index 0000000..3a96235 --- /dev/null +++ b/app/dashboard/sms/page.tsx @@ -0,0 +1,101 @@ +import { prisma } from '@/lib/prisma' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' + +const statusColors: Record = { + PENDING: 'bg-yellow-100 text-yellow-700', + PROCESSING: 'bg-blue-100 text-blue-700', + SENT: 'bg-green-100 text-green-700', + FAILED: 'bg-red-100 text-red-700', + RETRYING: 'bg-orange-100 text-orange-700', +} + +export default async function SmsQueuePage() { + const queue = await prisma.smsQueue.findMany({ + orderBy: { createdAt: 'desc' }, + take: 100, + include: { client: { select: { name: true } } }, + }) + + const counts = { + PENDING: queue.filter(q => q.status === 'PENDING').length, + PROCESSING: queue.filter(q => q.status === 'PROCESSING').length, + SENT: queue.filter(q => q.status === 'SENT').length, + FAILED: queue.filter(q => q.status === 'FAILED').length, + RETRYING: queue.filter(q => q.status === 'RETRYING').length, + } + + return ( +
+
+

SMS Queue

+

Monitor SMS delivery status

+
+ +
+ {Object.entries(counts).map(([status, count]) => ( +
+ {status}: {count} +
+ ))} +
+ + + + Recent Queue (last 100) + + + {queue.length === 0 ? ( +

No SMS in queue

+ ) : ( +
+ + + + + + + + + + + + + + {queue.map(item => ( + + + + + + + + + + ))} + +
StudentClientPhoneEventStatusAttemptsCreated
+
{item.studentName}
+
{item.studentId}
+
{item.client.name}{item.parentPhone} + + {item.event === 'time_in' ? '✅ In' : '🔴 Out'} + + + + {item.status} + + {item.attempts}/3 + {new Date(item.createdAt).toLocaleString('en-PH', { timeZone: 'Asia/Manila' })} +
+
+ )} +
+
+
+ ) +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/fonts/GeistMonoVF.woff b/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/app/fonts/GeistMonoVF.woff differ diff --git a/app/fonts/GeistVF.woff b/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/app/fonts/GeistVF.woff differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..4e5670c --- /dev/null +++ b/app/globals.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..d7813e2 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' +import { Providers } from './providers' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'NFC Attendance Hub', + description: 'Multi-tenant SMS gateway for NFC student attendance systems', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..d084e55 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,81 @@ +'use client' +import { useState } from 'react' +import { signIn } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +export default function LoginPage() { + const router = useRouter() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + + const result = await signIn('credentials', { + email, + password, + redirect: false, + }) + + if (result?.error) { + setError('Invalid email or password') + setLoading(false) + } else { + router.push('/dashboard') + } + } + + return ( +
+ + +
📡
+ NFC Attendance Hub + Sign in to your admin account +
+ +
+ {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + required + /> +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + /> +
+ +
+
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..b113520 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from 'next/navigation' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +export default async function Home() { + const session = await getServerSession(authOptions) + if (session) redirect('/dashboard') + redirect('/login') +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..a565a5a --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,6 @@ +'use client' +import { SessionProvider } from 'next-auth/react' + +export function Providers({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx new file mode 100644 index 0000000..1ba7ad8 --- /dev/null +++ b/components/sidebar.tsx @@ -0,0 +1,69 @@ +'use client' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { signOut } from 'next-auth/react' +import { cn } from '@/lib/utils' +import { + LayoutDashboard, + Building2, + MessageSquare, + AlertTriangle, + BarChart3, + LogOut, +} from 'lucide-react' + +const navItems = [ + { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/dashboard/clients', label: 'Clients', icon: Building2 }, + { href: '/dashboard/sms', label: 'SMS Queue', icon: MessageSquare }, + { href: '/dashboard/sms/failed', label: 'Failed SMS', icon: AlertTriangle }, + { href: '/dashboard/reports', label: 'Reports', icon: BarChart3 }, +] + +export function Sidebar() { + const pathname = usePathname() + + return ( + + ) +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..1038f71 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + success: "border-transparent bg-green-100 text-green-800", + warning: "border-transparent bg-yellow-100 text-yellow-800", + }, + }, + defaultVariants: { variant: "default" }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
+} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..4d69750 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,45 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..f04975f --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +Card.displayName = "Card" + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..5063f6a --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => ( + + ) +) +Input.displayName = "Input" + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..8d4834a --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,19 @@ +"use client" +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..bb81674 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,85 @@ +"use client" +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root +const SelectGroup = SelectPrimitive.Group +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + {children} + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem, SelectLabel } diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..2d97917 --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,24 @@ +"use client" +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..995ce30 --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +) +Table.displayName = "Table" + +const TableHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableBody.displayName = "TableBody" + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableCell.displayName = "TableCell" + +export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx new file mode 100644 index 0000000..aa8919e --- /dev/null +++ b/components/ui/textarea.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +export interface TextareaProps extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => ( +