From aab38d909cfc7d8b56a7e7b7c6028aa3f8bd28a9 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 21 Mar 2026 22:33:00 +0200 Subject: [PATCH] feat: add dedicated /api/address-book REST endpoint for inter-service access Bearer token auth (ADDRESSBOOK_API_KEY) for external tools like avizare. Supports GET (list/search/filter/by-id), POST (create), PUT (update), DELETE. Middleware exclusion so it bypasses NextAuth session requirement. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 2 + src/app/api/address-book/route.ts | 236 ++++++++++++++++++++++++++++++ src/middleware.ts | 2 +- 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/app/api/address-book/route.ts diff --git a/docker-compose.yml b/docker-compose.yml index 2d86a09..405f881 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,8 @@ services: - NOTIFICATION_FROM_EMAIL=noreply@beletage.ro - NOTIFICATION_FROM_NAME=Alerte Termene - NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987 + # Address Book API (inter-service auth for external tools) + - ADDRESSBOOK_API_KEY=abook-7f3e9a2b4c1d8e5f6a0b3c7d9e2f4a1b5c8d0e3f6a9b2c5d8e1f4a7b0c3d6e depends_on: dwg2dxf: condition: service_healthy diff --git a/src/app/api/address-book/route.ts b/src/app/api/address-book/route.ts new file mode 100644 index 0000000..7ba9527 --- /dev/null +++ b/src/app/api/address-book/route.ts @@ -0,0 +1,236 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/core/storage/prisma"; +import { v4 as uuid } from "uuid"; + +const NAMESPACE = "address-book"; +const PREFIX = "contact:"; + +// ─── Auth: Bearer token OR NextAuth session ───────────────────────── +// External tools use: Authorization: Bearer +// Browser users fall through middleware (NextAuth session) + +function checkBearerAuth(req: NextRequest): boolean { + const secret = process.env.ADDRESSBOOK_API_KEY; + if (!secret) return false; + const authHeader = req.headers.get("Authorization"); + const token = authHeader?.replace("Bearer ", ""); + return token === secret; +} + +// ─── GET /api/address-book ────────────────────────────────────────── +// Query params: +// ?id= → single contact +// ?q= → search by name/company/email/phone +// ?type= → filter by type +// (no params) → all contacts + +export async function GET(req: NextRequest) { + if (!checkBearerAuth(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const params = req.nextUrl.searchParams; + const id = params.get("id"); + const q = params.get("q")?.toLowerCase(); + const type = params.get("type"); + + try { + // Single contact by ID + if (id) { + const item = await prisma.keyValueStore.findUnique({ + where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } }, + }); + if (!item) { + return NextResponse.json({ error: "Contact not found" }, { status: 404 }); + } + return NextResponse.json({ contact: item.value }); + } + + // All contacts (with optional filtering) + const items = await prisma.keyValueStore.findMany({ + where: { namespace: NAMESPACE }, + select: { key: true, value: true }, + }); + + let contacts: Record[] = []; + for (const item of items) { + if (!item.key.startsWith(PREFIX)) continue; + const val = item.value as Record; + if (!val) continue; + + // Type filter + if (type && val.type !== type) continue; + + // Search filter + if (q) { + const name = String(val.name ?? "").toLowerCase(); + const company = String(val.company ?? "").toLowerCase(); + const email = String(val.email ?? "").toLowerCase(); + const phone = String(val.phone ?? ""); + if ( + !name.includes(q) && + !company.includes(q) && + !email.includes(q) && + !phone.includes(q) + ) continue; + } + + contacts.push(val); + } + + // Sort by name/company + contacts.sort((a, b) => { + const aLabel = String(a.name || a.company || ""); + const bLabel = String(b.name || b.company || ""); + return aLabel.localeCompare(bLabel, "ro"); + }); + + return NextResponse.json({ contacts, total: contacts.length }); + } catch (error) { + console.error("Address book GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// ─── POST /api/address-book ───────────────────────────────────────── +// Body: { name?, company?, type?, email?, phone?, ... } +// Returns: { contact: AddressContact } +// Validation: at least name OR company required + +export async function POST(req: NextRequest) { + if (!checkBearerAuth(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + + const name = String(body.name ?? "").trim(); + const company = String(body.company ?? "").trim(); + + if (!name && !company) { + return NextResponse.json( + { error: "Cel puțin name sau company este obligatoriu" }, + { status: 400 }, + ); + } + + // Auto-detect type: if only company → institution + const autoType = !name && company ? "institution" : "client"; + + const now = new Date().toISOString(); + const id = body.id ?? uuid(); + + const contact = { + id, + name, + company, + type: body.type ?? autoType, + email: String(body.email ?? "").trim(), + email2: String(body.email2 ?? "").trim(), + phone: String(body.phone ?? "").trim(), + phone2: String(body.phone2 ?? "").trim(), + address: String(body.address ?? "").trim(), + department: String(body.department ?? "").trim(), + role: String(body.role ?? "").trim(), + website: String(body.website ?? "").trim(), + projectIds: Array.isArray(body.projectIds) ? body.projectIds : [], + contactPersons: Array.isArray(body.contactPersons) ? body.contactPersons : [], + tags: Array.isArray(body.tags) ? body.tags : [], + notes: String(body.notes ?? "").trim(), + visibility: body.visibility ?? "company", + createdAt: now, + updatedAt: now, + }; + + await prisma.keyValueStore.upsert({ + where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } }, + update: { value: contact as unknown as Prisma.InputJsonValue }, + create: { namespace: NAMESPACE, key: `${PREFIX}${id}`, value: contact as unknown as Prisma.InputJsonValue }, + }); + + return NextResponse.json({ contact }, { status: 201 }); + } catch (error) { + console.error("Address book POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// ─── PUT /api/address-book ────────────────────────────────────────── +// Body: { id: "", ...fields to update } +// Merges with existing contact, updates updatedAt + +export async function PUT(req: NextRequest) { + if (!checkBearerAuth(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const id = body.id; + + if (!id) { + return NextResponse.json({ error: "id is required" }, { status: 400 }); + } + + const existing = await prisma.keyValueStore.findUnique({ + where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } }, + }); + + if (!existing) { + return NextResponse.json({ error: "Contact not found" }, { status: 404 }); + } + + const prev = existing.value as Record; + const updated = { + ...prev, + ...body, + id: prev.id, // never overwrite + createdAt: prev.createdAt, // never overwrite + updatedAt: new Date().toISOString(), + }; + + // Re-validate: name OR company + if (!String(updated.name ?? "").trim() && !String(updated.company ?? "").trim()) { + return NextResponse.json( + { error: "Cel puțin name sau company este obligatoriu" }, + { status: 400 }, + ); + } + + await prisma.keyValueStore.update({ + where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } }, + data: { value: updated as unknown as Prisma.InputJsonValue }, + }); + + return NextResponse.json({ contact: updated }); + } catch (error) { + console.error("Address book PUT error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// ─── DELETE /api/address-book?id= ───────────────────────────── + +export async function DELETE(req: NextRequest) { + if (!checkBearerAuth(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const id = req.nextUrl.searchParams.get("id"); + if (!id) { + return NextResponse.json({ error: "id is required" }, { status: 400 }); + } + + try { + await prisma.keyValueStore.delete({ + where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } }, + }).catch(() => { /* ignore if not found */ }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Address book DELETE error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 6450bfb..6d7ce1c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -46,6 +46,6 @@ export const config = { * - /favicon.ico, /robots.txt, /sitemap.xml * - Files with extensions (images, fonts, etc.) */ - "/((?!api/auth|api/notifications/digest|api/compress-pdf|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)", + "/((?!api/auth|api/notifications/digest|api/compress-pdf|api/address-book|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)", ], };