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) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-21 22:33:00 +02:00
parent de52b5dced
commit aab38d909c
3 changed files with 239 additions and 1 deletions
+2
View File
@@ -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
+236
View File
@@ -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 <ADDRESSBOOK_API_KEY>
// 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=<uuid> → single contact
// ?q=<search> → search by name/company/email/phone
// ?type=<ContactType> → 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<string, unknown>[] = [];
for (const item of items) {
if (!item.key.startsWith(PREFIX)) continue;
const val = item.value as Record<string, unknown>;
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: "<uuid>", ...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<string, unknown>;
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=<uuid> ─────────────────────────────
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 });
}
}
+1 -1
View File
@@ -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|.*\\..*).*)",
],
};