diff --git a/db.js b/db.js index 31d91e0..9ecee90 100644 --- a/db.js +++ b/db.js @@ -39,7 +39,7 @@ async function getConversation(sessionId) { if (!p) return null; const result = await p.query( - 'SELECT messages, user_id, todo_markdown, name FROM conversations WHERE session_id = $1', + 'SELECT messages, user_id, todo_markdown, name, collapsed_sections FROM conversations WHERE session_id = $1', [sessionId] ); if (result.rows.length === 0) return null; @@ -47,7 +47,8 @@ async function getConversation(sessionId) { messages: result.rows[0].messages, userId: result.rows[0].user_id, todoMarkdown: result.rows[0].todo_markdown || '', - name: result.rows[0].name || null + name: result.rows[0].name || null, + collapsedSections: result.rows[0].collapsed_sections || {} }; } @@ -107,6 +108,21 @@ async function updateTodoMarkdown(sessionId, userId, todoMarkdown) { return result.rowCount; } +// Persist a per-section collapsed/expanded state map so the same chat looks the +// same on every device. Same ownership rule as updateTodoMarkdown. +async function updateCollapsedSections(sessionId, userId, collapsedSections) { + const p = getPool(); + if (!p) return 0; + + const result = await p.query( + `UPDATE conversations + SET collapsed_sections = $1, updated_at = NOW() + WHERE session_id = $2 AND (user_id IS NULL OR user_id = $3)`, + [JSON.stringify(collapsedSections || {}), sessionId, userId] + ); + return result.rowCount; +} + // Explicit user-initiated rename. Scoped to the owner so one user can't rename // another user's chats by guessing sessionIds. async function renameConversation(sessionId, userId, name) { @@ -135,7 +151,7 @@ async function listConversationsByUser(userId) { if (!p) return []; const result = await p.query( - `SELECT session_id, messages, todo_markdown, name, updated_at + `SELECT session_id, messages, todo_markdown, name, collapsed_sections, updated_at FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC`, @@ -146,6 +162,7 @@ async function listConversationsByUser(userId) { messages: r.messages, todoMarkdown: r.todo_markdown || '', name: r.name || null, + collapsedSections: r.collapsed_sections || {}, updatedAt: r.updated_at })); } @@ -181,6 +198,7 @@ module.exports = { getConversation, saveConversation, updateTodoMarkdown, + updateCollapsedSections, renameConversation, deleteConversation, listConversationsByUser, diff --git a/index.html b/index.html index 70016b1..0149c44 100644 --- a/index.html +++ b/index.html @@ -1186,6 +1186,9 @@

No tasks yet

if (sc.name) { existing.name = sc.name; } + if (sc.collapsedSections && typeof sc.collapsedSections === 'object') { + existing.collapsedSections = sc.collapsedSections; + } if (existing.id === currentSessionId) { currentChatUpdated = true; } @@ -1201,7 +1204,8 @@

No tasks yet

id: sc.sessionId, name: sc.name || derived, todoMarkdown: sc.todoMarkdown || '', - messages: sc.messages || [] + messages: sc.messages || [], + collapsedSections: sc.collapsedSections || {} }); addedNewChat = true; } @@ -1429,6 +1433,7 @@

No tasks yet

if (!collapseChat.collapsedSections) collapseChat.collapsedSections = {}; collapseChat.collapsedSections[sectionName] = isCollapsed; saveChats(); + pushCollapsedSections(collapseChat.id, collapseChat.collapsedSections); } }); }); @@ -1526,6 +1531,24 @@

No tasks yet

} catch (e) { /* best-effort */ } } + async function pushCollapsedSections(sessionId, collapsedSections) { + if (!sessionId) return; + const headers = { 'Content-Type': 'application/json' }; + try { + if (window.Clerk && window.Clerk.session) { + const token = await window.Clerk.session.getToken(); + if (token) headers['Authorization'] = `Bearer ${token}`; + } + } catch (e) { /* fall through as guest */ } + try { + await fetch(`${API_BASE}/api/collapsed-sections`, { + method: 'POST', + headers, + body: JSON.stringify({ sessionId, collapsedSections: collapsedSections || {} }) + }); + } catch (e) { /* best-effort */ } + } + // API base URL - use Railway in production, relative URL in development const API_BASE = window.location.hostname === 'localhost' ? '' diff --git a/migrations/005_add-collapsed-sections-to-conversations.cjs b/migrations/005_add-collapsed-sections-to-conversations.cjs new file mode 100644 index 0000000..24df407 --- /dev/null +++ b/migrations/005_add-collapsed-sections-to-conversations.cjs @@ -0,0 +1,17 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.addColumn('conversations', { + collapsed_sections: { + type: 'jsonb', + notNull: true, + default: pgm.func("'{}'::jsonb") + } + }); +}; + +exports.down = (pgm) => { + pgm.dropColumn('conversations', 'collapsed_sections'); +}; diff --git a/server.js b/server.js index d252f93..a20f64f 100644 --- a/server.js +++ b/server.js @@ -216,6 +216,21 @@ app.post('/api/tasks', clerkAuth, async (req, res) => { res.json({ success: true }); }); +// Persist the per-section collapsed/expanded state map for a chat so the +// section toggle state syncs across devices. +app.post('/api/collapsed-sections', clerkAuth, async (req, res) => { + const { sessionId, collapsedSections } = req.body; + if (!sessionId || typeof collapsedSections !== 'object' || collapsedSections === null || Array.isArray(collapsedSections)) { + return res.status(400).json({ error: 'sessionId and collapsedSections object are required' }); + } + const row = await db.getConversation(sessionId); + if (row !== null && !canAccess(row.userId, req.userId)) { + return res.status(403).json({ error: 'forbidden' }); + } + await db.updateCollapsedSections(sessionId, req.userId, collapsedSections).catch(() => {}); + res.json({ success: true }); +}); + // Rename a conversation — persists the title so other devices see it on sync. // Scoped to the authenticated user so renames can't be spoofed across accounts. app.post('/api/rename', clerkAuth, requireAuth, async (req, res) => { diff --git a/server.test.js b/server.test.js index 4c9cdec..a9320b2 100644 --- a/server.test.js +++ b/server.test.js @@ -5,6 +5,7 @@ jest.mock('./db', () => ({ getConversation: jest.fn().mockResolvedValue(null), saveConversation: jest.fn().mockResolvedValue(false), updateTodoMarkdown: jest.fn().mockResolvedValue(0), + updateCollapsedSections: jest.fn().mockResolvedValue(0), renameConversation: jest.fn().mockResolvedValue(0), deleteConversation: jest.fn().mockResolvedValue(false), listConversationsByUser: jest.fn().mockResolvedValue([]), @@ -40,6 +41,7 @@ describe('PadTask Server', () => { db.getConversation.mockReset().mockResolvedValue(null); db.saveConversation.mockReset().mockResolvedValue(false); db.updateTodoMarkdown.mockReset().mockResolvedValue(0); + db.updateCollapsedSections.mockReset().mockResolvedValue(0); db.renameConversation.mockReset().mockResolvedValue(0); db.deleteConversation.mockReset().mockResolvedValue(false); db.listConversationsByUser.mockReset().mockResolvedValue([]); @@ -703,6 +705,105 @@ describe('PadTask Server', () => { }); }); + describe('POST /api/collapsed-sections', () => { + it('should return 400 when sessionId is missing', async () => { + const response = await request(app) + .post('/api/collapsed-sections') + .send({ collapsedSections: { 'Today': true } }); + expect(response.status).toBe(400); + expect(db.updateCollapsedSections).not.toHaveBeenCalled(); + }); + + it('should return 400 when collapsedSections is not an object', async () => { + const response = await request(app) + .post('/api/collapsed-sections') + .send({ sessionId: 's1', collapsedSections: 'oops' }); + expect(response.status).toBe(400); + expect(db.updateCollapsedSections).not.toHaveBeenCalled(); + }); + + it('should return 400 when collapsedSections is an array', async () => { + const response = await request(app) + .post('/api/collapsed-sections') + .send({ sessionId: 's1', collapsedSections: ['Today'] }); + expect(response.status).toBe(400); + expect(db.updateCollapsedSections).not.toHaveBeenCalled(); + }); + + it('should return 400 when collapsedSections is null', async () => { + const response = await request(app) + .post('/api/collapsed-sections') + .send({ sessionId: 's1', collapsedSections: null }); + expect(response.status).toBe(400); + expect(db.updateCollapsedSections).not.toHaveBeenCalled(); + }); + + it('should update collapsed state for a guest on an unowned row', async () => { + db.getConversation.mockResolvedValue({ messages: [], userId: null, todoMarkdown: '', name: null }); + db.updateCollapsedSections.mockResolvedValue(1); + + const response = await request(app) + .post('/api/collapsed-sections') + .send({ sessionId: 's1', collapsedSections: { 'Today': true } }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(db.updateCollapsedSections).toHaveBeenCalledWith('s1', null, { 'Today': true }); + }); + + it('should update collapsed state for the owner', async () => { + process.env.CLERK_SECRET_KEY = 'sk_test_dummy'; + verifyToken.mockResolvedValue({ sub: 'user_a' }); + db.getConversation.mockResolvedValue({ messages: [], userId: 'user_a', todoMarkdown: '', name: null }); + db.updateCollapsedSections.mockResolvedValue(1); + + const response = await request(app) + .post('/api/collapsed-sections') + .set('Authorization', 'Bearer tok') + .send({ sessionId: 's1', collapsedSections: { 'Today': true, 'Later': false } }); + + expect(response.status).toBe(200); + expect(db.updateCollapsedSections).toHaveBeenCalledWith('s1', 'user_a', { 'Today': true, 'Later': false }); + }); + + it('should reject when the row is owned by another user', async () => { + process.env.CLERK_SECRET_KEY = 'sk_test_dummy'; + verifyToken.mockResolvedValue({ sub: 'user_b' }); + db.getConversation.mockResolvedValue({ messages: [], userId: 'user_a', todoMarkdown: '', name: null }); + + const response = await request(app) + .post('/api/collapsed-sections') + .set('Authorization', 'Bearer tok') + .send({ sessionId: 's1', collapsedSections: { 'Today': true } }); + + expect(response.status).toBe(403); + expect(db.updateCollapsedSections).not.toHaveBeenCalled(); + }); + + it('should succeed when no DB row exists yet', async () => { + db.getConversation.mockResolvedValue(null); + db.updateCollapsedSections.mockResolvedValue(0); + + const response = await request(app) + .post('/api/collapsed-sections') + .send({ sessionId: 's-new', collapsedSections: {} }); + + expect(response.status).toBe(200); + expect(db.updateCollapsedSections).toHaveBeenCalledWith('s-new', null, {}); + }); + + it('should still respond 200 when the DB write rejects', async () => { + db.getConversation.mockResolvedValue(null); + db.updateCollapsedSections.mockRejectedValue(new Error('db down')); + + const response = await request(app) + .post('/api/collapsed-sections') + .send({ sessionId: 's1', collapsedSections: { 'Today': true } }); + + expect(response.status).toBe(200); + }); + }); + describe('POST /api/rename', () => { it('should return 401 when not authenticated', async () => { const response = await request(app)