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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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|.*\\..*).*)",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user