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 <noreply@anthropic.com>
This commit is contained in:
@@ -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: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
|
|||||||
@@ -76,7 +76,17 @@ export interface PollPayload {
|
|||||||
options: PollOption[];
|
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 =
|
export type ParsedMessagePayload =
|
||||||
| {
|
| {
|
||||||
@@ -95,6 +105,10 @@ export type ParsedMessagePayload =
|
|||||||
kind: 'poll';
|
kind: 'poll';
|
||||||
question: string;
|
question: string;
|
||||||
options: PollOption[];
|
options: PollOption[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'whiteboard';
|
||||||
|
whiteboardId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeMessagePayload(payload: MessagePayload): string {
|
export function serializeMessagePayload(payload: MessagePayload): string {
|
||||||
@@ -151,6 +165,13 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
|||||||
options,
|
options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (obj.type === 'whiteboard') {
|
||||||
|
const p = obj as Partial<WhiteboardPayload>;
|
||||||
|
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;
|
const t = obj as TextMessagePayload;
|
||||||
return {
|
return {
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * from './userKeyMigration';
|
|||||||
export * from './pinnedMessages';
|
export * from './pinnedMessages';
|
||||||
export * from './mentions';
|
export * from './mentions';
|
||||||
export * from './viewOnceAttachments';
|
export * from './viewOnceAttachments';
|
||||||
|
export * from './whiteboards';
|
||||||
|
|
||||||
// ----- RPC wrappers ---------------------------------------------------------
|
// ----- RPC wrappers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Whiteboard> {
|
||||||
|
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<WhiteboardStroke[]> {
|
||||||
|
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<WhiteboardStroke> {
|
||||||
|
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<void> {
|
||||||
|
const { error } = await client
|
||||||
|
.from('whiteboard_strokes')
|
||||||
|
.delete()
|
||||||
|
.eq('whiteboard_id', whiteboardId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user