Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"check-i18n": "node scripts/check-i18n.cjs",
"check-locales": "node scripts/check-locales.mjs",
"prebuild": "pnpm run check-locales && pnpm run check-i18n",
"generate:sitemap": "npx tsx scripts/generate-sitemap.ts"
"generate:sitemap": "npx tsx scripts/generate-sitemap.ts",
"migrate": "npx tsx src/lib/db/migrate.ts"
},
"dependencies": {
"@apollo/client": "^3.8.0",
Expand Down
178 changes: 105 additions & 73 deletions src/app/api/approvals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,10 @@ import { z } from 'zod';
import { withRateLimit } from '@/lib/ratelimit';
import { logAuditMutation } from '@/middleware/audit';
import { validateBody, validateQuery } from '@/lib/validation';
import { query } from '@/lib/db/pool';
import type { ApprovalItem } from '@/types/api';

export const runtime = 'edge';

// ---------------------------------------------------------------------------
// In-memory store (replace with DB in production)
// ---------------------------------------------------------------------------

const approvalsStore = new Map<string, ApprovalItem>();
export const runtime = 'nodejs';

// ---------------------------------------------------------------------------
// Schemas
Expand All @@ -35,6 +30,19 @@ const ListQuerySchema = z.object({
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
});

const COLUMNS = `
id::text,
content_id AS "contentId",
content_type AS "contentType",
title,
submitted_by AS "submittedBy",
submitted_at AS "submittedAt",
status,
reviewed_by AS "reviewedBy",
reviewed_at AS "reviewedAt",
review_note AS "reviewNote"
` as const;

// ---------------------------------------------------------------------------
// GET /api/approvals — list submissions (admin: all; others: filtered by submittedBy)
// ---------------------------------------------------------------------------
Expand All @@ -44,15 +52,22 @@ export async function GET(request: Request): Promise<NextResponse> {
if (rateLimitResponse) return rateLimitResponse;

const { searchParams } = new URL(request.url);
const result = validateQuery(ListQuerySchema, searchParams);
if (!result.ok) return addHeaders(result.error);
const validation = validateQuery(ListQuerySchema, searchParams);
if (!validation.ok) return addHeaders(validation.error);

let items = Array.from(approvalsStore.values());
if (result.data.status) {
items = items.filter((item) => item.status === result.data.status);
}
try {
const dbResult = await query(
`SELECT ${COLUMNS} FROM content_approvals WHERE ($1::text IS NULL OR status = $1) ORDER BY submitted_at DESC`,
[validation.data.status ?? null],
);

return addHeaders(NextResponse.json({ success: true, data: items }));
return addHeaders(NextResponse.json({ success: true, data: dbResult.rows }));
} catch (error) {
console.error('[approvals] GET error:', error);
return addHeaders(
NextResponse.json({ success: false, message: 'Database error' }, { status: 500 }),
);
}
}

// ---------------------------------------------------------------------------
Expand All @@ -63,31 +78,38 @@ export async function POST(request: Request): Promise<NextResponse> {
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse;

const result = validateBody(SubmitSchema, await request.json());
if (!result.ok) return addHeaders(result.error);

const item: ApprovalItem = {
id: `approval-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
contentId: result.data.contentId,
contentType: result.data.contentType,
title: result.data.title,
submittedBy: result.data.submittedBy,
submittedAt: new Date().toISOString(),
status: 'PENDING',
};

approvalsStore.set(item.id, item);

const response = addHeaders(NextResponse.json({ success: true, data: item }, { status: 201 }));
logAuditMutation(request, {
action: 'create',
targetType: 'approval',
targetId: item.id,
statusCode: response.status,
metadata: { contentId: item.contentId, contentType: item.contentType },
});

return response;
const validation = validateBody(SubmitSchema, await request.json());
if (!validation.ok) return addHeaders(validation.error);

try {
const dbResult = await query(
`INSERT INTO content_approvals (content_id, content_type, title, submitted_by) VALUES ($1, $2, $3, $4) RETURNING ${COLUMNS}`,
[
validation.data.contentId,
validation.data.contentType,
validation.data.title,
validation.data.submittedBy,
],
);

const item = dbResult.rows[0] as ApprovalItem;

const response = addHeaders(NextResponse.json({ success: true, data: item }, { status: 201 }));
logAuditMutation(request, {
action: 'create',
targetType: 'approval',
targetId: item.id,
statusCode: response.status,
metadata: { contentId: item.contentId, contentType: item.contentType },
});

return response;
} catch (error) {
console.error('[approvals] POST error:', error);
return addHeaders(
NextResponse.json({ success: false, message: 'Database error' }, { status: 500 }),
);
}
}

// ---------------------------------------------------------------------------
Expand All @@ -98,43 +120,53 @@ export async function PATCH(request: Request): Promise<NextResponse> {
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse;

const result = validateBody(ReviewSchema, await request.json());
if (!result.ok) return addHeaders(result.error);

const existing = approvalsStore.get(result.data.id);
if (!existing) {
return addHeaders(
NextResponse.json({ success: false, message: 'Approval not found' }, { status: 404 }),
const validation = validateBody(ReviewSchema, await request.json());
if (!validation.ok) return addHeaders(validation.error);

try {
const dbResult = await query(
`UPDATE content_approvals SET status = $2, reviewed_by = $3, reviewed_at = NOW(), review_note = $4 WHERE id = $1::uuid AND status = 'PENDING' RETURNING ${COLUMNS}`,
[
validation.data.id,
validation.data.status,
validation.data.reviewedBy,
validation.data.reviewNote ?? null,
],
);
}

if (existing.status !== 'PENDING') {
if (dbResult.rows.length === 0) {
const exists = await query('SELECT id FROM content_approvals WHERE id = $1::uuid', [
validation.data.id,
]);
if (exists.rows.length === 0) {
return addHeaders(
NextResponse.json({ success: false, message: 'Approval not found' }, { status: 404 }),
);
}
return addHeaders(
NextResponse.json(
{ success: false, message: 'Only PENDING approvals can be reviewed' },
{ status: 409 },
),
);
}

const updated = dbResult.rows[0] as ApprovalItem;

const response = addHeaders(NextResponse.json({ success: true, data: updated }));
logAuditMutation(request, {
action: 'update',
targetType: 'approval',
targetId: updated.id,
statusCode: response.status,
metadata: { status: updated.status, reviewedBy: updated.reviewedBy },
});

return response;
} catch (error) {
console.error('[approvals] PATCH error:', error);
return addHeaders(
NextResponse.json(
{ success: false, message: 'Only PENDING approvals can be reviewed' },
{ status: 409 },
),
NextResponse.json({ success: false, message: 'Database error' }, { status: 500 }),
);
}

const updated: ApprovalItem = {
...existing,
status: result.data.status,
reviewedBy: result.data.reviewedBy,
reviewedAt: new Date().toISOString(),
reviewNote: result.data.reviewNote,
};

approvalsStore.set(updated.id, updated);

const response = addHeaders(NextResponse.json({ success: true, data: updated }));
logAuditMutation(request, {
action: 'update',
targetType: 'approval',
targetId: updated.id,
statusCode: response.status,
metadata: { status: updated.status, reviewedBy: updated.reviewedBy },
});

return response;
}
Loading
Loading