Building a Manual Wi‑Fi Concierge Log with Next.js 13, Prisma, and a Dedicated Service Layer
TL;DR: I added a “concierge‑wifi” feature that lets the team record daily Wi‑Fi health reports manually, persisting them in a new Prisma model and exposing CRUD endpoints via the Next.js 13 app router. The change isolates UI, API, and data‑access concerns, making the log easy to extend with analytics later.
The Problem
Our internal dashboard needed a place to capture daily Wi‑Fi status reports from the on‑site concierge. The existing “DeviceHistory” model only stored automated sync logs, so the concierge had nowhere to write free‑form notes, who prepared the report, or a quick trend summary. The symptom was a missing UI page and no API to persist those manual entries, which forced the team to keep ad‑hoc spreadsheets that quickly fell out of sync.
Error messages that appeared when we tried to repurpose the DeviceHistory table for manual logs included:
PrismaClientValidationError: The field `reportDate` is missing in type `DeviceHistoryCreateInput`.
In short, the schema didn’t support the fields we needed, and the routing layer was not set up for a new resource.
What I Tried First
My first instinct was to overload the existing DeviceHistory model by adding nullable columns (notes, elaboradoPor) and then re‑using the existing /api/device-history endpoint. I added those columns in a quick migration and pointed the UI to the same service.
Result: The API started rejecting the new fields because the generated Prisma client still expected the original shape. Moreover, mixing automated sync logs with manual reports made queries confusing (WHERE syncLogId IS NULL everywhere). The UI also suffered from a cluttered form that displayed irrelevant fields.
I rolled back that attempt and decided to treat the manual log as a first‑class entity.
The Implementation
1. Extend the Prisma schema
I introduced a brand‑new model WifiConciergeLog. The diff added ~45 lines, but the core definition looks like this:
model WifiConciergeLog {
id Int @id @default(autoincrement())
reportDate DateTime @unique // ISO date, e.g. "2026-08-26"
elaboradoPor String? // optional name of the person who prepared the report
notes String? // free‑form observations
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([reportDate])
}
Why a separate model? It isolates manual entries from automated device logs, lets us enforce a unique reportDate, and gives us clean indexes for trend queries.
After updating schema.prisma, I ran:
npx prisma migrate dev --name add-wifi-concierge-log
npx prisma generate
2. Service layer (src/services/wifiConcierge.service.ts)
All data access lives in a thin service file. The diff added 113 lines; the essential functions are:
import { prisma } from "@/lib/prisma";
export interface WifiConciergeLogInput {
reportDate: string; // ISO date, e.g. "2026-08-26"
elaboradoPor?: string;
notes?: string;
}
/**
* Upsert a daily log – creates a new entry or updates the existing one.
*/
export async function upsertWifiConciergeLog(input: WifiConciergeLogInput) {
const { reportDate, ...rest } = input;
const date = new Date(reportDate);
return prisma.wifiConciergeLog.upsert({
where: { reportDate: date },
create: { reportDate: date, ...rest },
update: { ...rest },
});
}
/**
* List all logs, ordered newest first.
*/
export async function listWifiConciergeLogs() {
return prisma.wifiConciergeLog.findMany({
orderBy: { reportDate: "desc" },
});
}
/**
* Return a simple trend summary (count per month).
*/
export async function getWifiConciergeSummary() {
const raw = await prisma.$queryRaw<Array<{ month: string; count: number }>>`
SELECT DATE_TRUNC('month', "reportDate") AS month,
COUNT(*) AS count
FROM "WifiConciergeLog"
GROUP BY month
ORDER BY month DESC
`;
return raw;
}
Key points:
- Upsert guarantees idempotent daily submissions – the concierge can edit the same day’s report without creating duplicates.
- The summary uses a raw SQL query because Prisma’s aggregation API wasn’t expressive enough for the month‑bucket we needed.
3. API routes (App Router)
src/app/api/wifi-concierge/route.ts
import { NextResponse } from "next/server";
import {
upsertWifiConciergeLog,
listWifiConciergeLogs,
} from "@/services/wifiConcierge.service";
export async function GET() {
const logs = await listWifiConciergeLogs();
return NextResponse.json(logs);
}
export async function POST(request: Request) {
try {
const payload = await request.json();
const log = await upsertWifiConciergeLog(payload);
return NextResponse.json(log, { status: 201 });
} catch (err) {
console.error(err);
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
}
src/app/api/wifi-concierge/summary/route.ts
import { NextResponse } from "next/server";
import { getWifiConciergeSummary } from "@/services/wifiConcierge.service";
export async function GET() {
const summary = await getWifiConciergeSummary();
return NextResponse.json(summary);
}
Both routes are server‑only; they return plain JSON, which the client component can consume via fetch.
4. UI – a client component (src/features/wifi-concierge/WifiConciergeView.tsx)
The bulk of the diff (375 lines) is a self‑contained view that:
- Shows a table of existing logs.
- Provides a modal form to add or edit a report.
- Shows a small “trend” chart using
recharts(not shown in the diff but added later).
Key snippets:
tsx
"use client";
import { useEffect, useState, useCallback } from "react";
import {
Plus,
Trash2,
Loader2,
AlertTriangle,
RefreshCcw,
} from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
interface Log {
id: number;
reportDate: string;
elaboradoPor?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export function WifiConciergeView() {
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<Partial<Log>>({});
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/wifi-concierge");
const data = await res.json();
setLogs(data);
} catch (e) {
setError("Failed to load logs");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch("/api/wifi-concierge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error("Bad response");
await fetchLogs();
setForm({});
} catch {
setError
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/greenview` · 2026-08-27*
\#playadev #buildinpublic
Top comments (0)