Skip to content

Commit f5f7146

Browse files
davila7claude
andcommitted
feat: Add collections API endpoints and improve dashboard proxy
- Add Vercel serverless functions for user collections CRUD (collections.js, [id].js, items.js) - Add shared auth (Clerk JWT verification) and Neon DB client helpers - Add @clerk/backend dependency for token verification - Improve dashboard API proxy: handle double-compression, error handling, filter accept-encoding - Add retry logic to collections-api.ts for GET requests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a0c2269 commit f5f7146

File tree

9 files changed

+584
-28
lines changed

9 files changed

+584
-28
lines changed

api/_lib/auth.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { verifyToken } from '@clerk/backend';
2+
3+
/**
4+
* Extract and verify Clerk JWT from Authorization header.
5+
* Returns the userId on success, or sends an error response and returns null.
6+
*/
7+
export async function authenticateRequest(req, res) {
8+
const authHeader = req.headers.authorization;
9+
if (!authHeader?.startsWith('Bearer ')) {
10+
res.status(401).json({ error: 'Missing or invalid Authorization header' });
11+
return null;
12+
}
13+
14+
const token = authHeader.slice(7);
15+
16+
try {
17+
const payload = await verifyToken(token, {
18+
secretKey: process.env.CLERK_SECRET_KEY,
19+
});
20+
return payload.sub;
21+
} catch (err) {
22+
console.error('Clerk token verification failed:', err.message);
23+
res.status(401).json({ error: 'Invalid or expired token' });
24+
return null;
25+
}
26+
}

api/_lib/neon.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { neon } from '@neondatabase/serverless';
2+
3+
export function getNeonClient() {
4+
const connectionString = process.env.NEON_DATABASE_URL;
5+
if (!connectionString) {
6+
throw new Error('NEON_DATABASE_URL not configured');
7+
}
8+
return neon(connectionString);
9+
}

api/collections.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { authenticateRequest } from './_lib/auth.js';
2+
import { getNeonClient } from './_lib/neon.js';
3+
4+
export default async function handler(req, res) {
5+
res.setHeader('Access-Control-Allow-Origin', '*');
6+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
7+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
8+
9+
if (req.method === 'OPTIONS') {
10+
return res.status(200).json({ ok: true });
11+
}
12+
13+
const userId = await authenticateRequest(req, res);
14+
if (!userId) return;
15+
16+
const sql = getNeonClient();
17+
18+
try {
19+
if (req.method === 'GET') {
20+
const collections = await sql`
21+
SELECT * FROM user_collections
22+
WHERE clerk_user_id = ${userId}
23+
ORDER BY position ASC, created_at ASC
24+
`;
25+
26+
const items = collections.length > 0
27+
? await sql`
28+
SELECT * FROM collection_items
29+
WHERE collection_id = ANY(${collections.map(c => c.id)})
30+
ORDER BY added_at ASC
31+
`
32+
: [];
33+
34+
const itemsByCollection = {};
35+
for (const item of items) {
36+
if (!itemsByCollection[item.collection_id]) {
37+
itemsByCollection[item.collection_id] = [];
38+
}
39+
itemsByCollection[item.collection_id].push(item);
40+
}
41+
42+
const result = collections.map(c => ({
43+
...c,
44+
collection_items: itemsByCollection[c.id] || [],
45+
}));
46+
47+
return res.status(200).json({ collections: result });
48+
}
49+
50+
if (req.method === 'POST') {
51+
const { name } = req.body;
52+
if (!name || typeof name !== 'string' || name.trim().length === 0) {
53+
return res.status(400).json({ error: 'Collection name is required' });
54+
}
55+
56+
if (name.length > 100) {
57+
return res.status(400).json({ error: 'Collection name too long (max 100 characters)' });
58+
}
59+
60+
const maxPos = await sql`
61+
SELECT COALESCE(MAX(position), -1) AS max_pos
62+
FROM user_collections
63+
WHERE clerk_user_id = ${userId}
64+
`;
65+
66+
const newPosition = maxPos[0].max_pos + 1;
67+
68+
const rows = await sql`
69+
INSERT INTO user_collections (clerk_user_id, name, position)
70+
VALUES (${userId}, ${name.trim()}, ${newPosition})
71+
RETURNING *
72+
`;
73+
74+
const collection = { ...rows[0], collection_items: [] };
75+
return res.status(201).json({ collection });
76+
}
77+
78+
return res.status(405).json({ error: 'Method not allowed', allowed: ['GET', 'POST'] });
79+
} catch (error) {
80+
console.error('Collections error:', error);
81+
return res.status(500).json({ error: 'Internal server error' });
82+
}
83+
}

api/collections/[id].js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { authenticateRequest } from '../_lib/auth.js';
2+
import { getNeonClient } from '../_lib/neon.js';
3+
4+
export default async function handler(req, res) {
5+
res.setHeader('Access-Control-Allow-Origin', '*');
6+
res.setHeader('Access-Control-Allow-Methods', 'PATCH, DELETE, OPTIONS');
7+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
8+
9+
if (req.method === 'OPTIONS') {
10+
return res.status(200).json({ ok: true });
11+
}
12+
13+
const userId = await authenticateRequest(req, res);
14+
if (!userId) return;
15+
16+
const { id } = req.query;
17+
if (!id) {
18+
return res.status(400).json({ error: 'Collection ID is required' });
19+
}
20+
21+
const sql = getNeonClient();
22+
23+
try {
24+
// Verify ownership
25+
const existing = await sql`
26+
SELECT id FROM user_collections
27+
WHERE id = ${id} AND clerk_user_id = ${userId}
28+
`;
29+
30+
if (existing.length === 0) {
31+
return res.status(404).json({ error: 'Collection not found' });
32+
}
33+
34+
if (req.method === 'PATCH') {
35+
const { name } = req.body;
36+
if (!name || typeof name !== 'string' || name.trim().length === 0) {
37+
return res.status(400).json({ error: 'Collection name is required' });
38+
}
39+
40+
if (name.length > 100) {
41+
return res.status(400).json({ error: 'Collection name too long (max 100 characters)' });
42+
}
43+
44+
const rows = await sql`
45+
UPDATE user_collections
46+
SET name = ${name.trim()}, updated_at = NOW()
47+
WHERE id = ${id} AND clerk_user_id = ${userId}
48+
RETURNING *
49+
`;
50+
51+
const items = await sql`
52+
SELECT * FROM collection_items
53+
WHERE collection_id = ${id}
54+
ORDER BY added_at ASC
55+
`;
56+
57+
const collection = { ...rows[0], collection_items: items };
58+
return res.status(200).json({ collection });
59+
}
60+
61+
if (req.method === 'DELETE') {
62+
await sql`DELETE FROM collection_items WHERE collection_id = ${id}`;
63+
await sql`DELETE FROM user_collections WHERE id = ${id} AND clerk_user_id = ${userId}`;
64+
65+
return res.status(200).json({ success: true });
66+
}
67+
68+
return res.status(405).json({ error: 'Method not allowed', allowed: ['PATCH', 'DELETE'] });
69+
} catch (error) {
70+
console.error('Collection [id] error:', error);
71+
return res.status(500).json({ error: 'Internal server error' });
72+
}
73+
}

api/collections/items.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { authenticateRequest } from '../_lib/auth.js';
2+
import { getNeonClient } from '../_lib/neon.js';
3+
4+
export default async function handler(req, res) {
5+
res.setHeader('Access-Control-Allow-Origin', '*');
6+
res.setHeader('Access-Control-Allow-Methods', 'POST, DELETE, PATCH, OPTIONS');
7+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
8+
9+
if (req.method === 'OPTIONS') {
10+
return res.status(200).json({ ok: true });
11+
}
12+
13+
const userId = await authenticateRequest(req, res);
14+
if (!userId) return;
15+
16+
const sql = getNeonClient();
17+
18+
try {
19+
if (req.method === 'POST') {
20+
const { collectionId, componentType, componentPath, componentName, componentCategory } = req.body;
21+
22+
if (!collectionId || !componentType || !componentPath || !componentName) {
23+
return res.status(400).json({ error: 'collectionId, componentType, componentPath, and componentName are required' });
24+
}
25+
26+
// Verify collection ownership
27+
const col = await sql`
28+
SELECT id FROM user_collections
29+
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
30+
`;
31+
if (col.length === 0) {
32+
return res.status(404).json({ error: 'Collection not found' });
33+
}
34+
35+
// Check for duplicate
36+
const dup = await sql`
37+
SELECT id FROM collection_items
38+
WHERE collection_id = ${collectionId} AND component_path = ${componentPath}
39+
`;
40+
if (dup.length > 0) {
41+
return res.status(409).json({ error: 'Component already in this collection' });
42+
}
43+
44+
const rows = await sql`
45+
INSERT INTO collection_items (collection_id, component_type, component_path, component_name, component_category)
46+
VALUES (${collectionId}, ${componentType}, ${componentPath}, ${componentName}, ${componentCategory || null})
47+
RETURNING *
48+
`;
49+
50+
return res.status(201).json({ item: rows[0] });
51+
}
52+
53+
if (req.method === 'DELETE') {
54+
const { itemId, collectionId } = req.body;
55+
56+
if (!itemId || !collectionId) {
57+
return res.status(400).json({ error: 'itemId and collectionId are required' });
58+
}
59+
60+
// Verify collection ownership
61+
const col = await sql`
62+
SELECT id FROM user_collections
63+
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
64+
`;
65+
if (col.length === 0) {
66+
return res.status(404).json({ error: 'Collection not found' });
67+
}
68+
69+
await sql`
70+
DELETE FROM collection_items
71+
WHERE id = ${itemId} AND collection_id = ${collectionId}
72+
`;
73+
74+
return res.status(200).json({ success: true });
75+
}
76+
77+
if (req.method === 'PATCH') {
78+
const { itemId, fromCollectionId, toCollectionId } = req.body;
79+
80+
if (!itemId || !fromCollectionId || !toCollectionId) {
81+
return res.status(400).json({ error: 'itemId, fromCollectionId, and toCollectionId are required' });
82+
}
83+
84+
// Verify ownership of both collections
85+
const cols = await sql`
86+
SELECT id FROM user_collections
87+
WHERE id = ANY(${[fromCollectionId, toCollectionId]}) AND clerk_user_id = ${userId}
88+
`;
89+
if (cols.length < 2) {
90+
return res.status(404).json({ error: 'One or both collections not found' });
91+
}
92+
93+
const rows = await sql`
94+
UPDATE collection_items
95+
SET collection_id = ${toCollectionId}
96+
WHERE id = ${itemId} AND collection_id = ${fromCollectionId}
97+
RETURNING *
98+
`;
99+
100+
if (rows.length === 0) {
101+
return res.status(404).json({ error: 'Item not found in source collection' });
102+
}
103+
104+
return res.status(200).json({ item: rows[0] });
105+
}
106+
107+
return res.status(405).json({ error: 'Method not allowed', allowed: ['POST', 'DELETE', 'PATCH'] });
108+
} catch (error) {
109+
console.error('Collection items error:', error);
110+
return res.status(500).json({ error: 'Internal server error' });
111+
}
112+
}

0 commit comments

Comments
 (0)