mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-09 16:01:56 -07:00
Implement RFID tag dump PWA with REST API, auth, and CI
Add Next.js app with SQLite/Drizzle storage for sites and LF/HF tag dumps, multi-user Auth.js (credentials + optional OIDC), personal API tokens in Settings, versioned /api/v1 for UI and future CLI use, MCT/Proxmark/JSON import-export, PWA offline shell, Vitest tests, and GitHub Actions CI.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { z } from "zod";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
import { FREQUENCIES, PROTOCOLS } from "@/lib/rfid/protocols";
|
||||
import { dumpDataSchema, keysSchema } from "@/lib/validation/rfid";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const db = getDb();
|
||||
const allSites = db.select().from(sites).all();
|
||||
const allTags = db.select().from(tagRecords).all();
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
sites: allSites.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
code: s.code,
|
||||
notes: s.notes,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
})),
|
||||
tags: allTags.map((t) => ({
|
||||
id: t.id,
|
||||
siteId: t.siteId,
|
||||
label: t.label,
|
||||
frequency: t.frequency,
|
||||
protocol: t.protocol,
|
||||
uid: t.uid,
|
||||
dumpData: t.dumpData,
|
||||
keys: t.keys,
|
||||
notes: t.notes,
|
||||
lastWrittenAt: t.lastWrittenAt?.toISOString() ?? null,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
updatedAt: t.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
const download = request.nextUrl.searchParams.get("download") === "1";
|
||||
if (download) {
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": 'attachment; filename="rfid-backup.json"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return jsonOk(payload);
|
||||
}
|
||||
|
||||
const backupSchema = z.object({
|
||||
version: z.number().optional(),
|
||||
mode: z.enum(["merge", "replace"]).default("merge"),
|
||||
sites: z.array(
|
||||
z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().min(1),
|
||||
code: z.string().min(1),
|
||||
notes: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
tags: z.array(
|
||||
z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
siteId: z.string().optional(),
|
||||
siteCode: z.string().optional(),
|
||||
label: z.string().min(1),
|
||||
frequency: z.enum(FREQUENCIES),
|
||||
protocol: z.enum(PROTOCOLS),
|
||||
uid: z.string().min(1),
|
||||
dumpData: dumpDataSchema,
|
||||
keys: keysSchema,
|
||||
notes: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = backupSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
if (parsed.data.mode === "replace") {
|
||||
db.delete(tagRecords).run();
|
||||
db.delete(sites).run();
|
||||
}
|
||||
|
||||
const codeToId = new Map<string, string>();
|
||||
let sitesCreated = 0;
|
||||
let tagsCreated = 0;
|
||||
|
||||
for (const s of parsed.data.sites) {
|
||||
const code = s.code.toUpperCase();
|
||||
const existing = db.select().from(sites).where(eq(sites.code, code)).get();
|
||||
if (existing) {
|
||||
codeToId.set(code, existing.id);
|
||||
if (s.id) codeToId.set(s.id, existing.id);
|
||||
continue;
|
||||
}
|
||||
const id = s.id ?? crypto.randomUUID();
|
||||
db.insert(sites)
|
||||
.values({
|
||||
id,
|
||||
name: s.name,
|
||||
code,
|
||||
notes: s.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
createdAt: now,
|
||||
})
|
||||
.run();
|
||||
codeToId.set(code, id);
|
||||
codeToId.set(id, id);
|
||||
sitesCreated += 1;
|
||||
}
|
||||
|
||||
// refresh map from DB
|
||||
for (const s of db.select().from(sites).all()) {
|
||||
codeToId.set(s.code, s.id);
|
||||
codeToId.set(s.id, s.id);
|
||||
}
|
||||
|
||||
for (const t of parsed.data.tags) {
|
||||
let siteId = t.siteId ? codeToId.get(t.siteId) : undefined;
|
||||
if (!siteId && t.siteCode) {
|
||||
siteId = codeToId.get(t.siteCode.toUpperCase());
|
||||
}
|
||||
if (!siteId) continue;
|
||||
|
||||
let uid: string;
|
||||
try {
|
||||
uid = normalizeUid(t.uid);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = t.id ?? crypto.randomUUID();
|
||||
const existingById = t.id
|
||||
? db.select().from(tagRecords).where(eq(tagRecords.id, t.id)).get()
|
||||
: undefined;
|
||||
|
||||
if (existingById) {
|
||||
db.update(tagRecords)
|
||||
.set({
|
||||
siteId,
|
||||
label: t.label,
|
||||
frequency: t.frequency,
|
||||
protocol: t.protocol,
|
||||
uid,
|
||||
dumpData: t.dumpData,
|
||||
keys: t.keys ?? null,
|
||||
notes: t.notes ?? null,
|
||||
updatedById: userOrRes.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(tagRecords.id, existingById.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(tagRecords)
|
||||
.values({
|
||||
id,
|
||||
siteId,
|
||||
label: t.label,
|
||||
frequency: t.frequency,
|
||||
protocol: t.protocol,
|
||||
uid,
|
||||
dumpData: t.dumpData,
|
||||
keys: t.keys ?? null,
|
||||
notes: t.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
updatedById: userOrRes.id,
|
||||
lastWrittenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
tagsCreated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return jsonOk({ ok: true, sitesCreated, tagsCreated, mode: parsed.data.mode });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { isOidcConfigured } from "@/lib/auth/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const db = getDb();
|
||||
const row = db.select().from(users).where(eq(users.id, userOrRes.id)).get();
|
||||
if (!row) return jsonError(404, "NOT_FOUND", "User not found");
|
||||
|
||||
return jsonOk({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
oidcAvailable: isOidcConfigured(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { desc, like, or, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const q = request.nextUrl.searchParams.get("q")?.trim();
|
||||
if (!q) {
|
||||
return jsonError(400, "VALIDATION_ERROR", "Query parameter q is required");
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(request.nextUrl.searchParams.get("limit") ?? 50), 200);
|
||||
const pattern = `%${q}%`;
|
||||
const db = getDb();
|
||||
|
||||
const matchingSites = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(or(like(sites.code, pattern), like(sites.name, pattern), like(sites.notes, pattern)))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
const matchingTags = db
|
||||
.select({
|
||||
tag: tagRecords,
|
||||
siteCode: sites.code,
|
||||
siteName: sites.name,
|
||||
})
|
||||
.from(tagRecords)
|
||||
.innerJoin(sites, eq(sites.id, tagRecords.siteId))
|
||||
.where(
|
||||
or(
|
||||
like(tagRecords.uid, pattern),
|
||||
like(tagRecords.label, pattern),
|
||||
like(tagRecords.notes, pattern),
|
||||
like(tagRecords.protocol, pattern),
|
||||
like(sites.code, pattern)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(tagRecords.updatedAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
return jsonOk({
|
||||
q,
|
||||
sites: matchingSites.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
code: s.code,
|
||||
notes: s.notes,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
})),
|
||||
tags: matchingTags.map((r) => ({
|
||||
id: r.tag.id,
|
||||
siteId: r.tag.siteId,
|
||||
siteCode: r.siteCode,
|
||||
siteName: r.siteName,
|
||||
label: r.tag.label,
|
||||
frequency: r.tag.frequency,
|
||||
protocol: r.tag.protocol,
|
||||
uid: r.tag.uid,
|
||||
notes: r.tag.notes,
|
||||
updatedAt: r.tag.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { siteUpdateSchema } from "@/lib/validation/rfid";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const site = db.select().from(sites).where(eq(sites.id, id)).get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const tagCount =
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(tagRecords)
|
||||
.where(eq(tagRecords.siteId, id))
|
||||
.get()?.c ?? 0;
|
||||
|
||||
return jsonOk({
|
||||
...site,
|
||||
createdAt: site.createdAt.toISOString(),
|
||||
tagCount: Number(tagCount),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = siteUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const existing = db.select().from(sites).where(eq(sites.id, id)).get();
|
||||
if (!existing) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const updates: Partial<typeof sites.$inferInsert> = {};
|
||||
if (parsed.data.name !== undefined) updates.name = parsed.data.name;
|
||||
if (parsed.data.code !== undefined) updates.code = parsed.data.code.toUpperCase();
|
||||
if (parsed.data.notes !== undefined) updates.notes = parsed.data.notes;
|
||||
|
||||
if (updates.code && updates.code !== existing.code) {
|
||||
const clash = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.code, updates.code))
|
||||
.get();
|
||||
if (clash) {
|
||||
return jsonError(409, "CONFLICT", `Site code ${updates.code} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
db.update(sites).set(updates).where(eq(sites.id, id)).run();
|
||||
const updated = db.select().from(sites).where(eq(sites.id, id)).get()!;
|
||||
return jsonOk({
|
||||
...updated,
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const existing = db.select().from(sites).where(eq(sites.id, id)).get();
|
||||
if (!existing) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
db.delete(sites).where(eq(sites.id, id)).run();
|
||||
return jsonOk({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { asc, count, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { siteCreateSchema } from "@/lib/validation/rfid";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.select({
|
||||
id: sites.id,
|
||||
name: sites.name,
|
||||
code: sites.code,
|
||||
notes: sites.notes,
|
||||
createdById: sites.createdById,
|
||||
createdAt: sites.createdAt,
|
||||
tagCount: count(tagRecords.id),
|
||||
})
|
||||
.from(sites)
|
||||
.leftJoin(tagRecords, eq(tagRecords.siteId, sites.id))
|
||||
.groupBy(sites.id)
|
||||
.orderBy(asc(sites.code))
|
||||
.all();
|
||||
|
||||
return jsonOk({
|
||||
sites: rows.map((s) => ({
|
||||
...s,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
tagCount: Number(s.tagCount),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = siteCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const code = parsed.data.code.toUpperCase();
|
||||
const existing = db.select().from(sites).where(eq(sites.code, code)).get();
|
||||
if (existing) {
|
||||
return jsonError(409, "CONFLICT", `Site code ${code} already exists`);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
db.insert(sites)
|
||||
.values({
|
||||
id,
|
||||
name: parsed.data.name,
|
||||
code,
|
||||
notes: parsed.data.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
createdAt,
|
||||
})
|
||||
.run();
|
||||
|
||||
return jsonOk(
|
||||
{
|
||||
id,
|
||||
name: parsed.data.name,
|
||||
code,
|
||||
notes: parsed.data.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
createdAt: createdAt.toISOString(),
|
||||
tagCount: 0,
|
||||
},
|
||||
201
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { tagRecords } from "@/db/schema";
|
||||
import { exportFormatSchema } from "@/lib/validation/rfid";
|
||||
import { exportTag, type ExportFormat } from "@/lib/rfid/exporters";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const formatRaw = request.nextUrl.searchParams.get("format") ?? "json";
|
||||
const download = request.nextUrl.searchParams.get("download") === "1";
|
||||
|
||||
const parsed = exportFormatSchema.safeParse(formatRaw);
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"format must be json|proxmark|mct|hex"
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get();
|
||||
if (!row) return jsonError(404, "NOT_FOUND", "Tag not found");
|
||||
|
||||
const exported = exportTag(
|
||||
{
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
},
|
||||
parsed.data as ExportFormat
|
||||
);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": exported.contentType,
|
||||
};
|
||||
if (download) {
|
||||
headers["Content-Disposition"] =
|
||||
`attachment; filename="${exported.filename}"`;
|
||||
}
|
||||
|
||||
return new Response(exported.body, { status: 200, headers });
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { tagUpdateSchema } from "@/lib/validation/rfid";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
function serializeTag(row: typeof tagRecords.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
createdById: row.createdById,
|
||||
updatedById: row.updatedById,
|
||||
lastWrittenAt: row.lastWrittenAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.select({
|
||||
tag: tagRecords,
|
||||
siteCode: sites.code,
|
||||
siteName: sites.name,
|
||||
})
|
||||
.from(tagRecords)
|
||||
.innerJoin(sites, eq(sites.id, tagRecords.siteId))
|
||||
.where(eq(tagRecords.id, id))
|
||||
.get();
|
||||
|
||||
if (!row) return jsonError(404, "NOT_FOUND", "Tag not found");
|
||||
return jsonOk({
|
||||
...serializeTag(row.tag),
|
||||
siteCode: row.siteCode,
|
||||
siteName: row.siteName,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = tagUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(tagRecords)
|
||||
.where(eq(tagRecords.id, id))
|
||||
.get();
|
||||
if (!existing) return jsonError(404, "NOT_FOUND", "Tag not found");
|
||||
|
||||
const updates: Partial<typeof tagRecords.$inferInsert> = {
|
||||
updatedById: userOrRes.id,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (parsed.data.siteId !== undefined) {
|
||||
const site = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.id, parsed.data.siteId))
|
||||
.get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
updates.siteId = parsed.data.siteId;
|
||||
}
|
||||
if (parsed.data.label !== undefined) updates.label = parsed.data.label;
|
||||
if (parsed.data.frequency !== undefined)
|
||||
updates.frequency = parsed.data.frequency;
|
||||
if (parsed.data.protocol !== undefined)
|
||||
updates.protocol = parsed.data.protocol;
|
||||
if (parsed.data.uid !== undefined) {
|
||||
try {
|
||||
updates.uid = normalizeUid(parsed.data.uid);
|
||||
} catch (e) {
|
||||
return jsonError(400, "VALIDATION_ERROR", (e as Error).message);
|
||||
}
|
||||
}
|
||||
if (parsed.data.dumpData !== undefined)
|
||||
updates.dumpData = parsed.data.dumpData;
|
||||
if (parsed.data.keys !== undefined) updates.keys = parsed.data.keys ?? null;
|
||||
if (parsed.data.notes !== undefined) updates.notes = parsed.data.notes;
|
||||
if (parsed.data.lastWrittenAt !== undefined) {
|
||||
updates.lastWrittenAt = parsed.data.lastWrittenAt
|
||||
? new Date(parsed.data.lastWrittenAt)
|
||||
: null;
|
||||
}
|
||||
|
||||
db.update(tagRecords).set(updates).where(eq(tagRecords.id, id)).run();
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get()!;
|
||||
return jsonOk(serializeTag(row));
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(tagRecords)
|
||||
.where(eq(tagRecords.id, id))
|
||||
.get();
|
||||
if (!existing) return jsonError(404, "NOT_FOUND", "Tag not found");
|
||||
|
||||
db.delete(tagRecords).where(eq(tagRecords.id, id)).run();
|
||||
return jsonOk({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { tagUpsertSchema } from "@/lib/validation/rfid";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
|
||||
type Ctx = { params: Promise<{ uid: string }> };
|
||||
|
||||
function serializeTag(row: typeof tagRecords.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
createdById: row.createdById,
|
||||
updatedById: row.updatedById,
|
||||
lastWrittenAt: row.lastWrittenAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { uid: uidParam } = await context.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const siteIdFromQuery = request.nextUrl.searchParams.get("siteId");
|
||||
const merged =
|
||||
typeof body === "object" && body !== null
|
||||
? { siteId: siteIdFromQuery, ...(body as Record<string, unknown>) }
|
||||
: body;
|
||||
|
||||
const parsed = tagUpsertSchema.safeParse(merged);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
let uid: string;
|
||||
try {
|
||||
uid = normalizeUid(uidParam);
|
||||
// body uid should match path if provided
|
||||
if (normalizeUid(parsed.data.uid) !== uid) {
|
||||
// prefer path uid
|
||||
uid = normalizeUid(uidParam);
|
||||
}
|
||||
} catch (e) {
|
||||
return jsonError(400, "VALIDATION_ERROR", (e as Error).message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const site = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.id, parsed.data.siteId))
|
||||
.get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(tagRecords)
|
||||
.where(and(eq(tagRecords.siteId, parsed.data.siteId), eq(tagRecords.uid, uid)))
|
||||
.get();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (existing) {
|
||||
db.update(tagRecords)
|
||||
.set({
|
||||
label: parsed.data.label,
|
||||
frequency: parsed.data.frequency,
|
||||
protocol: parsed.data.protocol,
|
||||
dumpData: parsed.data.dumpData,
|
||||
keys: parsed.data.keys ?? null,
|
||||
notes: parsed.data.notes ?? null,
|
||||
updatedById: userOrRes.id,
|
||||
updatedAt: now,
|
||||
lastWrittenAt: parsed.data.lastWrittenAt
|
||||
? new Date(parsed.data.lastWrittenAt)
|
||||
: existing.lastWrittenAt,
|
||||
})
|
||||
.where(eq(tagRecords.id, existing.id))
|
||||
.run();
|
||||
|
||||
const row = db
|
||||
.select()
|
||||
.from(tagRecords)
|
||||
.where(eq(tagRecords.id, existing.id))
|
||||
.get()!;
|
||||
return jsonOk({ ...serializeTag(row), created: false });
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(tagRecords)
|
||||
.values({
|
||||
id,
|
||||
siteId: parsed.data.siteId,
|
||||
label: parsed.data.label,
|
||||
frequency: parsed.data.frequency,
|
||||
protocol: parsed.data.protocol,
|
||||
uid,
|
||||
dumpData: parsed.data.dumpData,
|
||||
keys: parsed.data.keys ?? null,
|
||||
notes: parsed.data.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
updatedById: userOrRes.id,
|
||||
lastWrittenAt: parsed.data.lastWrittenAt
|
||||
? new Date(parsed.data.lastWrittenAt)
|
||||
: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get()!;
|
||||
return jsonOk({ ...serializeTag(row), created: true }, 201);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { parseImport } from "@/lib/rfid/parsers";
|
||||
import { z } from "zod";
|
||||
|
||||
const importMetaSchema = z.object({
|
||||
siteId: z.string().uuid(),
|
||||
label: z.string().min(1).max(200).optional(),
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
content: z.string().min(1),
|
||||
filename: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = importMetaSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
let imported;
|
||||
try {
|
||||
imported = parseImport(parsed.data.content, parsed.data.filename);
|
||||
} catch (e) {
|
||||
return jsonError(400, "PARSE_ERROR", (e as Error).message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const site = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.id, parsed.data.siteId))
|
||||
.get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const label =
|
||||
parsed.data.label || imported.label || `Imported ${imported.uid}`;
|
||||
|
||||
db.insert(tagRecords)
|
||||
.values({
|
||||
id,
|
||||
siteId: parsed.data.siteId,
|
||||
label,
|
||||
frequency: imported.frequency,
|
||||
protocol: imported.protocol,
|
||||
uid: imported.uid,
|
||||
dumpData: imported.dumpData,
|
||||
keys: imported.keys ?? null,
|
||||
notes: parsed.data.notes ?? imported.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
updatedById: userOrRes.id,
|
||||
lastWrittenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get()!;
|
||||
return jsonOk(
|
||||
{
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
201
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { and, desc, eq, like, or } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { tagCreateSchema } from "@/lib/validation/rfid";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
|
||||
function serializeTag(row: typeof tagRecords.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
createdById: row.createdById,
|
||||
updatedById: row.updatedById,
|
||||
lastWrittenAt: row.lastWrittenAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const siteId = searchParams.get("siteId");
|
||||
const frequency = searchParams.get("frequency");
|
||||
const protocol = searchParams.get("protocol");
|
||||
const q = searchParams.get("q");
|
||||
const limit = Math.min(Number(searchParams.get("limit") ?? 100), 500);
|
||||
|
||||
const db = getDb();
|
||||
const conditions = [];
|
||||
if (siteId) conditions.push(eq(tagRecords.siteId, siteId));
|
||||
if (frequency) conditions.push(eq(tagRecords.frequency, frequency));
|
||||
if (protocol) conditions.push(eq(tagRecords.protocol, protocol));
|
||||
if (q) {
|
||||
const pattern = `%${q}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
like(tagRecords.uid, pattern),
|
||||
like(tagRecords.label, pattern),
|
||||
like(tagRecords.notes, pattern),
|
||||
like(sites.code, pattern)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
tag: tagRecords,
|
||||
siteCode: sites.code,
|
||||
siteName: sites.name,
|
||||
})
|
||||
.from(tagRecords)
|
||||
.innerJoin(sites, eq(sites.id, tagRecords.siteId))
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(tagRecords.updatedAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
return jsonOk({
|
||||
tags: rows.map((r) => ({
|
||||
...serializeTag(r.tag),
|
||||
siteCode: r.siteCode,
|
||||
siteName: r.siteName,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = tagCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
let uid: string;
|
||||
try {
|
||||
uid = normalizeUid(parsed.data.uid);
|
||||
} catch (e) {
|
||||
return jsonError(400, "VALIDATION_ERROR", (e as Error).message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const site = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.id, parsed.data.siteId))
|
||||
.get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
db.insert(tagRecords)
|
||||
.values({
|
||||
id,
|
||||
siteId: parsed.data.siteId,
|
||||
label: parsed.data.label,
|
||||
frequency: parsed.data.frequency,
|
||||
protocol: parsed.data.protocol,
|
||||
uid,
|
||||
dumpData: parsed.data.dumpData,
|
||||
keys: parsed.data.keys ?? null,
|
||||
notes: parsed.data.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
updatedById: userOrRes.id,
|
||||
lastWrittenAt: parsed.data.lastWrittenAt
|
||||
? new Date(parsed.data.lastWrittenAt)
|
||||
: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get()!;
|
||||
return jsonOk(serializeTag(row), 201);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(apiTokens)
|
||||
.where(and(eq(apiTokens.id, id), eq(apiTokens.userId, userOrRes.id)))
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
return jsonError(404, "NOT_FOUND", "Token not found");
|
||||
}
|
||||
|
||||
db.delete(apiTokens).where(eq(apiTokens.id, id)).run();
|
||||
return jsonOk({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { tokenCreateSchema } from "@/lib/validation/rfid";
|
||||
import { generateApiToken } from "@/lib/auth/tokens";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.select({
|
||||
id: apiTokens.id,
|
||||
name: apiTokens.name,
|
||||
prefix: apiTokens.prefix,
|
||||
expiresAt: apiTokens.expiresAt,
|
||||
lastUsedAt: apiTokens.lastUsedAt,
|
||||
createdAt: apiTokens.createdAt,
|
||||
})
|
||||
.from(apiTokens)
|
||||
.where(eq(apiTokens.userId, userOrRes.id))
|
||||
.orderBy(desc(apiTokens.createdAt))
|
||||
.all();
|
||||
|
||||
return jsonOk({
|
||||
tokens: rows.map((t) => ({
|
||||
...t,
|
||||
expiresAt: t.expiresAt?.toISOString() ?? null,
|
||||
lastUsedAt: t.lastUsedAt?.toISOString() ?? null,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = tokenCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const { token, tokenHash, prefix } = generateApiToken();
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
const expiresAt = parsed.data.expiresAt
|
||||
? new Date(parsed.data.expiresAt)
|
||||
: null;
|
||||
|
||||
const db = getDb();
|
||||
db.insert(apiTokens)
|
||||
.values({
|
||||
id,
|
||||
userId: userOrRes.id,
|
||||
name: parsed.data.name,
|
||||
tokenHash,
|
||||
prefix,
|
||||
expiresAt,
|
||||
lastUsedAt: null,
|
||||
createdAt,
|
||||
})
|
||||
.run();
|
||||
|
||||
return jsonOk(
|
||||
{
|
||||
id,
|
||||
name: parsed.data.name,
|
||||
prefix,
|
||||
token,
|
||||
expiresAt: expiresAt?.toISOString() ?? null,
|
||||
createdAt: createdAt.toISOString(),
|
||||
warning: "Store this token now. It will not be shown again.",
|
||||
},
|
||||
201
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user