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_EMAIL=noreply@beletage.ro
|
||||||
- NOTIFICATION_FROM_NAME=Alerte Termene
|
- NOTIFICATION_FROM_NAME=Alerte Termene
|
||||||
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
|
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
|
||||||
|
# Address Book API (inter-service auth for external tools)
|
||||||
|
- ADDRESSBOOK_API_KEY=abook-7f3e9a2b4c1d8e5f6a0b3c7d9e2f4a1b5c8d0e3f6a9b2c5d8e1f4a7b0c3d6e
|
||||||
depends_on:
|
depends_on:
|
||||||
dwg2dxf:
|
dwg2dxf:
|
||||||
condition: service_healthy
|
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
|
* - /favicon.ico, /robots.txt, /sitemap.xml
|
||||||
* - Files with extensions (images, fonts, etc.)
|
* - 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