From 17c5882e86ffd94e30cd32d182328d580cdcad7e Mon Sep 17 00:00:00 2001 From: toanhoang Date: Wed, 19 Aug 2026 22:07:10 -0400 Subject: [PATCH] feat: add bot-facing API routes (catalog, areas, employees, asset-models, inventory-scans) Auth via BOT_INGEST_SECRET bearer header (src/lib/bot-auth.ts), not Auth.js session -- caller is the inventory-capture Telegram bot service, not a browser. Co-Authored-By: Claude Sonnet 5 --- src/app/api/bot/areas/route.ts | 42 ++++++++++++++++++ src/app/api/bot/asset-models/route.ts | 37 ++++++++++++++++ src/app/api/bot/catalog/route.ts | 35 +++++++++++++++ src/app/api/bot/employees/route.ts | 47 ++++++++++++++++++++ src/app/api/bot/inventory-scans/route.ts | 56 ++++++++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 src/app/api/bot/areas/route.ts create mode 100644 src/app/api/bot/asset-models/route.ts create mode 100644 src/app/api/bot/catalog/route.ts create mode 100644 src/app/api/bot/employees/route.ts create mode 100644 src/app/api/bot/inventory-scans/route.ts diff --git a/src/app/api/bot/areas/route.ts b/src/app/api/bot/areas/route.ts new file mode 100644 index 0000000..75ae334 --- /dev/null +++ b/src/app/api/bot/areas/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { requireBotAuth } from "@/lib/bot-auth"; +import { createOrGetArea, listAreas, validateCreateAreaInput } from "@/lib/areas"; + +// Bot-facing Area list/create — see src/lib/areas.ts's createOrGetArea() +// comment for why POST is idempotent-by-name rather than erroring on a +// name the bot's own keyboard just offered as an existing option. +export async function GET(request: Request): Promise { + if (!requireBotAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const areas = await listAreas(); + return NextResponse.json({ areas }); +} + +export async function POST(request: Request): Promise { + if (!requireBotAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); + } + + const { name, team } = body as Record; + const validation = validateCreateAreaInput({ name, team }); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const created = await createOrGetArea(validation.value); + if (!created.ok) { + return NextResponse.json({ error: created.error }, { status: 400 }); + } + return NextResponse.json(created.value, { status: 201 }); +} diff --git a/src/app/api/bot/asset-models/route.ts b/src/app/api/bot/asset-models/route.ts new file mode 100644 index 0000000..26c3f1e --- /dev/null +++ b/src/app/api/bot/asset-models/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { requireBotAuth } from "@/lib/bot-auth"; +import { createAssetModel, validateCreateAssetModelInput } from "@/lib/asset-models"; + +// Bot-facing AssetModel create — lets the bot's "➕ Model mới" option add a +// model to an existing category on the fly (e.g. a new Laptop model), +// mirroring src/app/api/bot/areas & employees' "reuse or create" pattern. +// Categories themselves are still admin-seeded, not bot-creatable — a new +// category is a bigger decision (may need AssetCategoryAttributeDef rows +// too) than a same-category model addition. +export async function POST(request: Request): Promise { + if (!requireBotAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); + } + + const { categoryId, manufacturer, name } = body as Record; + const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs: {} }); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const created = await createAssetModel(validation.value); + if (!created.ok) { + return NextResponse.json({ error: created.error }, { status: 400 }); + } + return NextResponse.json(created.value, { status: 201 }); +} diff --git a/src/app/api/bot/catalog/route.ts b/src/app/api/bot/catalog/route.ts new file mode 100644 index 0000000..97906b9 --- /dev/null +++ b/src/app/api/bot/catalog/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { requireBotAuth } from "@/lib/bot-auth"; +import { prisma } from "@/lib/prisma"; + +// One-shot catalog fetch for the bot's Category -> Model inline-keyboard +// step, plus each category's attribute defs (currently only Workstation +// has any) so the bot knows which extra questions (cpu/ram/gpu) to ask +// after a model in that category is picked. Bot-secret auth, not session — +// see src/lib/bot-auth.ts. +export async function GET(request: Request): Promise { + if (!requireBotAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const categories = await prisma.assetCategory.findMany({ + include: { + assetModels: { orderBy: [{ manufacturer: "asc" }, { name: "asc" }] }, + attributeDefs: { orderBy: [{ sortOrder: "asc" }] }, + }, + orderBy: [{ name: "asc" }], + }); + + return NextResponse.json({ + categories: categories.map((c) => ({ + id: c.id, + name: c.name, + models: c.assetModels.map((m) => ({ id: m.id, name: m.name, manufacturer: m.manufacturer })), + attribute_defs: c.attributeDefs.map((d) => ({ + key: d.key, + label: d.label, + data_type: d.dataType, + })), + })), + }); +} diff --git a/src/app/api/bot/employees/route.ts b/src/app/api/bot/employees/route.ts new file mode 100644 index 0000000..96db105 --- /dev/null +++ b/src/app/api/bot/employees/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { requireBotAuth } from "@/lib/bot-auth"; +import { createEmployee, listEmployees, validateCreateEmployeeInput } from "@/lib/employees"; + +// Bot-facing Employee list/create for the "assign to" step. `department` +// defaults to a placeholder here (not in src/lib/employees.ts itself, which +// keeps requiring it — this is a bot-flow-only convenience: the walk needs +// speed, department can be corrected later in the review UI) since the bot +// only asks for a name, not a full HR record. +export async function GET(request: Request): Promise { + if (!requireBotAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const employees = await listEmployees(); + return NextResponse.json({ employees }); +} + +export async function POST(request: Request): Promise { + if (!requireBotAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); + } + + const { name, department } = body as Record; + const validation = validateCreateEmployeeInput({ + name, + department: department || "Chưa cập nhật", + }); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const created = await createEmployee(validation.value); + if (!created.ok) { + return NextResponse.json({ error: created.error }, { status: 400 }); + } + return NextResponse.json(created.value, { status: 201 }); +} diff --git a/src/app/api/bot/inventory-scans/route.ts b/src/app/api/bot/inventory-scans/route.ts new file mode 100644 index 0000000..ccce32b --- /dev/null +++ b/src/app/api/bot/inventory-scans/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { requireBotAuth } from "@/lib/bot-auth"; +import { createInventoryScan, validateCreateInventoryScanInput } from "@/lib/inventory-scans"; + +// Bot-facing scan ingest — the actual "save this item" call at the end of +// the Telegram bot's Category -> Model -> [specs] -> Area -> assignee flow. +export async function POST(request: Request): Promise { + if (!requireBotAuth(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); + } + + const { + categoryId, + modelId, + areaId, + assignedEmployeeId, + assetTag, + serialNumber, + specs, + photoFileId, + telegramChatId, + telegramMessageId, + } = body as Record; + + const validation = validateCreateInventoryScanInput({ + categoryId, + modelId, + areaId, + assignedEmployeeId, + assetTag, + serialNumber, + specs, + photoFileId, + telegramChatId, + telegramMessageId, + }); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const created = await createInventoryScan(validation.value); + if (!created.ok) { + return NextResponse.json({ error: created.error }, { status: 400 }); + } + return NextResponse.json(created.value, { status: 201 }); +}