From 3df7cc01ea3312d39351eb7ca6c1a0f2b3c24813 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 20:23:45 +0200 Subject: [PATCH] feat(P4B.T2): WhiteboardPayload + shared whiteboards CRUD wrappers + tests Adds WhiteboardPayload wire-format variant to MessagePayload/ParsedMessagePayload, creates the whiteboards.ts CRUD wrapper (createWhiteboard, listWhiteboardStrokes, insertWhiteboardStroke, clearWhiteboardStrokes), writes 5 unit tests (38/38 pass), re-exports from chat/index.ts, and registers conversation_whiteboards + whiteboard_strokes in packages/db-types so typecheck passes cleanly. Co-Authored-By: Claude Sonnet 4.6 --- packages/db-types/src/index.ts | 61 ++++++++++ packages/shared/src/chat/attachments.ts | 23 +++- packages/shared/src/chat/index.ts | 1 + packages/shared/src/chat/whiteboards.test.ts | 121 +++++++++++++++++++ packages/shared/src/chat/whiteboards.ts | 96 +++++++++++++++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/chat/whiteboards.test.ts create mode 100644 packages/shared/src/chat/whiteboards.ts diff --git a/packages/db-types/src/index.ts b/packages/db-types/src/index.ts index 854c061..b238a4f 100644 --- a/packages/db-types/src/index.ts +++ b/packages/db-types/src/index.ts @@ -477,6 +477,67 @@ export type Database = { }, ] } + conversation_whiteboards: { + Row: { + id: string + conversation_id: string + owner_user_id: string + created_at: string + } + Insert: { + id?: string + conversation_id: string + owner_user_id: string + created_at?: string + } + Update: { + id?: string + conversation_id?: string + owner_user_id?: string + created_at?: string + } + Relationships: [ + { + foreignKeyName: "conversation_whiteboards_conversation_id_fkey" + columns: ["conversation_id"] + isOneToOne: false + referencedRelation: "conversations" + referencedColumns: ["id"] + }, + ] + } + whiteboard_strokes: { + Row: { + id: string + whiteboard_id: string + author_user_id: string + stroke_json: Json + created_at: string + } + Insert: { + id?: string + whiteboard_id: string + author_user_id: string + stroke_json: Json + created_at?: string + } + Update: { + id?: string + whiteboard_id?: string + author_user_id?: string + stroke_json?: Json + created_at?: string + } + Relationships: [ + { + foreignKeyName: "whiteboard_strokes_whiteboard_id_fkey" + columns: ["whiteboard_id"] + isOneToOne: false + referencedRelation: "conversation_whiteboards" + referencedColumns: ["id"] + }, + ] + } } Views: { [_ in never]: never diff --git a/packages/shared/src/chat/attachments.ts b/packages/shared/src/chat/attachments.ts index 50623a7..816a875 100644 --- a/packages/shared/src/chat/attachments.ts +++ b/packages/shared/src/chat/attachments.ts @@ -76,7 +76,17 @@ export interface PollPayload { options: PollOption[]; } -export type MessagePayload = TextMessagePayload | CallEventPayload | PollPayload; +export interface WhiteboardPayload { + v: 1; + type: 'whiteboard'; + whiteboard_id: string; +} + +export type MessagePayload = + | TextMessagePayload + | CallEventPayload + | PollPayload + | WhiteboardPayload; export type ParsedMessagePayload = | { @@ -95,6 +105,10 @@ export type ParsedMessagePayload = kind: 'poll'; question: string; options: PollOption[]; + } + | { + kind: 'whiteboard'; + whiteboardId: string; }; export function serializeMessagePayload(payload: MessagePayload): string { @@ -151,6 +165,13 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload { options, }; } + if (obj.type === 'whiteboard') { + const p = obj as Partial; + const id = typeof p.whiteboard_id === 'string' && p.whiteboard_id.length > 0 + ? p.whiteboard_id + : ''; + return { kind: 'whiteboard', whiteboardId: id }; + } const t = obj as TextMessagePayload; return { kind: 'text', diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index 747b55e..cbaa0b1 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -10,6 +10,7 @@ export * from './userKeyMigration'; export * from './pinnedMessages'; export * from './mentions'; export * from './viewOnceAttachments'; +export * from './whiteboards'; // ----- RPC wrappers --------------------------------------------------------- diff --git a/packages/shared/src/chat/whiteboards.test.ts b/packages/shared/src/chat/whiteboards.test.ts new file mode 100644 index 0000000..d8fd5b9 --- /dev/null +++ b/packages/shared/src/chat/whiteboards.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + clearWhiteboardStrokes, + createWhiteboard, + insertWhiteboardStroke, + listWhiteboardStrokes, +} from './whiteboards'; + +function makeClient(opts: { + user?: { id: string } | null; + insertReturn?: { data: unknown; error: unknown }; + selectReturn?: { data: unknown[]; error: unknown }; + deleteReturn?: { error: unknown }; +}): any { + const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null }); + const order = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: [], error: null }); + const eqDelete = vi.fn().mockResolvedValue(opts.deleteReturn ?? { error: null }); + const insertSelect = vi.fn().mockReturnValue({ single }); + const insertChain = vi.fn().mockReturnValue({ select: insertSelect }); + const selectChain = vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ order }), + }); + const deleteChain = vi.fn().mockReturnValue({ eq: eqDelete }); + const from = vi.fn().mockReturnValue({ + insert: insertChain, + select: selectChain, + delete: deleteChain, + }); + return { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) }, + from, + }; +} + +describe('createWhiteboard', () => { + it('inserts with owner = current user', async () => { + const client = makeClient({ + insertReturn: { + data: { + id: 'w-1', + conversation_id: 'c-1', + owner_user_id: 'u-1', + created_at: '2026-05-16T00:00:00Z', + }, + error: null, + }, + }); + const out = await createWhiteboard(client, 'c-1'); + expect(out).toEqual({ + id: 'w-1', + conversationId: 'c-1', + ownerUserId: 'u-1', + createdAt: '2026-05-16T00:00:00Z', + }); + }); +}); + +describe('listWhiteboardStrokes', () => { + it('maps DB rows to camelCase + ascending order', async () => { + const client = makeClient({ + selectReturn: { + data: [ + { + id: 's-1', + whiteboard_id: 'w-1', + author_user_id: 'u-1', + stroke_json: { tool: 'pen' }, + created_at: '2026-05-16T00:00:00Z', + }, + ], + error: null, + }, + }); + const out = await listWhiteboardStrokes(client, 'w-1'); + expect(out).toEqual([ + { + id: 's-1', + whiteboardId: 'w-1', + authorUserId: 'u-1', + strokeJson: { tool: 'pen' }, + createdAt: '2026-05-16T00:00:00Z', + }, + ]); + }); +}); + +describe('insertWhiteboardStroke', () => { + it('writes author + stroke_json', async () => { + const client = makeClient({ + insertReturn: { + data: { + id: 's-2', + whiteboard_id: 'w-1', + author_user_id: 'u-1', + stroke_json: { tool: 'pen', points: [[0, 0, 0]] }, + created_at: '2026-05-16T00:00:01Z', + }, + error: null, + }, + }); + const out = await insertWhiteboardStroke(client, { + whiteboardId: 'w-1', + strokeJson: { tool: 'pen', points: [[0, 0, 0]] }, + }); + expect(out.id).toBe('s-2'); + expect((out.strokeJson as { tool: string }).tool).toBe('pen'); + }); +}); + +describe('clearWhiteboardStrokes', () => { + it('does not throw on a successful delete', async () => { + const client = makeClient({ deleteReturn: { error: null } }); + await expect(clearWhiteboardStrokes(client, 'w-1')).resolves.toBeUndefined(); + }); + + it('throws when delete returns an error', async () => { + const client = makeClient({ deleteReturn: { error: { message: 'rls denied' } as any } }); + await expect(clearWhiteboardStrokes(client, 'w-1')).rejects.toBeTruthy(); + }); +}); diff --git a/packages/shared/src/chat/whiteboards.ts b/packages/shared/src/chat/whiteboards.ts new file mode 100644 index 0000000..4458cf7 --- /dev/null +++ b/packages/shared/src/chat/whiteboards.ts @@ -0,0 +1,96 @@ +import type { Json } from '@chat-app/db-types'; + +import type { AppSupabaseClient } from '../supabase/client'; + +export interface Whiteboard { + id: string; + conversationId: string; + ownerUserId: string; + createdAt: string; +} + +export interface WhiteboardStroke { + id: string; + whiteboardId: string; + authorUserId: string; + strokeJson: unknown; + createdAt: string; +} + +export async function createWhiteboard( + client: AppSupabaseClient, + conversationId: string, +): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data, error } = await client + .from('conversation_whiteboards') + .insert({ + conversation_id: conversationId, + owner_user_id: session.user.id, + }) + .select('id, conversation_id, owner_user_id, created_at') + .single(); + if (error) throw error; + return { + id: data.id, + conversationId: data.conversation_id, + ownerUserId: data.owner_user_id, + createdAt: data.created_at, + }; +} + +export async function listWhiteboardStrokes( + client: AppSupabaseClient, + whiteboardId: string, +): Promise { + const { data, error } = await client + .from('whiteboard_strokes') + .select('id, whiteboard_id, author_user_id, stroke_json, created_at') + .eq('whiteboard_id', whiteboardId) + .order('created_at', { ascending: true }); + if (error) throw error; + return data.map((row) => ({ + id: row.id, + whiteboardId: row.whiteboard_id, + authorUserId: row.author_user_id, + strokeJson: row.stroke_json, + createdAt: row.created_at, + })); +} + +export async function insertWhiteboardStroke( + client: AppSupabaseClient, + params: { whiteboardId: string; strokeJson: unknown }, +): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const { data, error } = await client + .from('whiteboard_strokes') + .insert({ + whiteboard_id: params.whiteboardId, + author_user_id: session.user.id, + stroke_json: params.strokeJson as unknown as Json, + }) + .select('id, whiteboard_id, author_user_id, stroke_json, created_at') + .single(); + if (error) throw error; + return { + id: data.id, + whiteboardId: data.whiteboard_id, + authorUserId: data.author_user_id, + strokeJson: data.stroke_json, + createdAt: data.created_at, + }; +} + +export async function clearWhiteboardStrokes( + client: AppSupabaseClient, + whiteboardId: string, +): Promise { + const { error } = await client + .from('whiteboard_strokes') + .delete() + .eq('whiteboard_id', whiteboardId); + if (error) throw error; +}