← back to Norma
app/api/library/route.ts
140 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { getOrgId } from '@/lib/orgId';
/**
* GET /api/library
* List/search library items.
* Optional filters: ?item_type=, ?search=, ?tags=
* Ordered by is_pinned DESC, updated_at DESC.
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const { searchParams } = new URL(request.url);
const itemType = searchParams.get('item_type');
const search = searchParams.get('search');
const tags = searchParams.get('tags');
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
// Scope to active org
if (orgId) {
conditions.push(`org_id = $${paramIndex}`);
values.push(orgId);
paramIndex++;
}
if (itemType) {
conditions.push(`item_type = $${paramIndex}`);
values.push(itemType);
paramIndex++;
}
if (search) {
conditions.push(`(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
values.push(`%${search}%`);
paramIndex++;
}
if (tags) {
const tagArray = tags.split(',').map((t) => t.trim()).filter(Boolean);
conditions.push(`tags && $${paramIndex}`);
values.push(tagArray);
paramIndex++;
}
let limit = parseInt(searchParams.get('limit') || '50', 10);
if (isNaN(limit) || limit < 1) limit = 50;
if (limit > 200) limit = 200;
let offset = parseInt(searchParams.get('offset') || '0', 10);
if (isNaN(offset) || offset < 0) offset = 0;
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult = await query(
`SELECT COUNT(*)::int AS total FROM library_items ${whereClause}`,
values
);
const total = countResult.rows[0]?.total ?? 0;
const result = await query(
`SELECT * FROM library_items ${whereClause}
ORDER BY is_pinned DESC, updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...values, limit, offset]
);
return NextResponse.json({ items: result.rows, total, limit, offset });
} catch (err) {
console.error('[api/library] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch library items' }, { status: 500 });
}
}
/**
* POST /api/library
* Add a new library item.
* Body: { item_type, title, description?, content_html?, content_text?, tags?, email_type?, voice_mode? }
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
try {
const body = await request.json();
if (!body.item_type || !body.title) {
return NextResponse.json({ error: 'item_type and title are required' }, { status: 400 });
}
const validTypes = ['template', 'snippet', 'archived_draft'];
if (!validTypes.includes(body.item_type)) {
return NextResponse.json(
{ error: `item_type must be one of: ${validTypes.join(', ')}` },
{ status: 400 }
);
}
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const result = await query(
`INSERT INTO library_items (item_type, title, description, content_html, content_text, tags, email_type, voice_mode, org_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
body.item_type,
body.title.trim(),
body.description || null,
body.content_html || null,
body.content_text || null,
body.tags || [],
body.email_type || null,
body.voice_mode || null,
orgId || null,
]
);
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
await auditLog(
'library.created',
'library_item',
result.rows[0].id,
{ item_type: body.item_type, title: body.title },
ip
);
return NextResponse.json({ item: result.rows[0] }, { status: 201 });
} catch (err) {
console.error('[api/library] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to create library item' }, { status: 500 });
}
}