feat: add read-only /api/projects endpoint for external tools

Returns project tags from tag-manager (category=project).
Supports search (?q=), company filter (?company=), single by ID (?id=).
Same Bearer token auth as address-book API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-22 01:08:59 +02:00
parent aab38d909c
commit a3ab539197
2 changed files with 90 additions and 1 deletions
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
const NAMESPACE = "tags";
// ─── Auth: same Bearer token as address-book ────────────────────────
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/projects ──────────────────────────────────────────────
// Read-only. Returns all tags with category = "project".
//
// Query params:
// ?q=<search> → search in label / projectCode
// ?company=<companyId> → filter by companyId (beletage, urban-switch, studii-de-teren)
// ?id=<uuid> → single project by tag ID
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 company = params.get("company");
try {
// Single project by ID
if (id) {
const item = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: id } },
});
if (!item) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const val = item.value as Record<string, unknown>;
if (val.category !== "project") {
return NextResponse.json({ error: "Not a project tag" }, { status: 404 });
}
return NextResponse.json({ project: val });
}
// All project tags
const items = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
select: { key: true, value: true },
});
let projects: Record<string, unknown>[] = [];
for (const item of items) {
const val = item.value as Record<string, unknown>;
if (!val || val.category !== "project") continue;
// Company filter
if (company && val.companyId !== company) continue;
// Search filter
if (q) {
const label = String(val.label ?? "").toLowerCase();
const code = String(val.projectCode ?? "").toLowerCase();
if (!label.includes(q) && !code.includes(q)) continue;
}
projects.push(val);
}
// Sort by projectCode (B-001, B-002, US-001...) then label
projects.sort((a, b) => {
const aCode = String(a.projectCode ?? "");
const bCode = String(b.projectCode ?? "");
if (aCode && bCode) return aCode.localeCompare(bCode);
if (aCode) return -1;
if (bCode) return 1;
return String(a.label ?? "").localeCompare(String(b.label ?? ""), "ro");
});
return NextResponse.json({ projects, total: projects.length });
} catch (error) {
console.error("Projects GET 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|api/address-book|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
"/((?!api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
],
};