From dea26f7ee8b05a0283fdbb9185e09d5c53782401 Mon Sep 17 00:00:00 2001 From: aarbit Date: Fri, 11 Sep 2026 11:28:58 -0500 Subject: [PATCH] Add diagram sharing/collaborators, version history, and view-only lockdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per organized-ideas.md §8. Backend tables/RLS (diagrams, diagram_collaborators, diagram_snapshots) already existed from an earlier phase — this is the frontend for them, plus two small backend additions. Backend (supabase/migrations/20260913000000_diagram_sharing.sql): - find_user_id_by_username(text): lets any authenticated user resolve a username to an id for "share with @username" — unlike general profile browsing (blocked by profiles_select_self_or_super_admin), a username is meant to be a shareable handle, so this is deliberately not gated. - diagram_collaborator_usernames / diagram_snapshot_saved_by_usernames: same pattern as the admin-review-queue phase's submitter-username lookup — batched per diagram, gated to "can you see this diagram at all" (reusing diagrams_select's own helper functions). - prune_diagram_snapshots trigger: keeps the 50 most recent snapshots per diagram, enforced at write time rather than a scheduled job (diagram_ snapshots has no update/delete policy for regular users at all). - 14 new pgTAP tests (52/52 total). Frontend: - DiagramCollaboratorRepository/store + DiagramSharingModal: add/remove collaborators by username, per-person view/edit permission, owner-only controls. - DiagramSnapshotRepository/store + VersionHistoryModal (its own top-bar button, not nested under Share — moved there after review): periodic checkpoints (one per 5 min of active editing) written as a side effect of normal saves, list + restore. - Restore's duplicate-snapshot problem: repeatedly jumping between old versions without editing in between was writing a near-duplicate safety snapshot on every jump. Fixed by having projectStore track which snapshot the diagram was last restored from and its updatedAt at that moment (touch() always advances updatedAt on a genuine edit) — a restore skips the safety snapshot when nothing has changed since the last one, and the tracking clears on any real edit so in-progress work stays protected. - DiagramRepository gains getAccess() (owner id + your own permission for the open diagram) — surfaced in projectStore as `access`. - View-only enforcement: FlowCanvas disables drag/connect/drop (nodesDraggable/nodesConnectable + guarded handlers), DeviceInspector/ ConnectionInspector wrap their controls in a disabled
, DevicePalette disables adding devices to the canvas, TopBar disables the rename field, and a ViewOnlyBanner makes the restriction visible instead of leaving a collaborator to discover it as controls that just don't work. Autosave itself also refuses to write for a view-only user, as a backstop behind the UI-level lockdown. Verified: tsc -b and oxlint clean; supabase db reset + 52/52 pgTAP tests pass; confirmed find_user_id_by_username works through the real REST API via a live curl call (signup, confirm, resolve). Manually tested two- account sharing (view vs. edit), restoring history, and the duplicate- snapshot fix. --- src/components/canvas/FlowCanvas.tsx | 38 ++++-- .../inspector/ConnectionInspector.tsx | 6 +- src/components/inspector/DeviceInspector.tsx | 6 +- src/components/layout/AppShell.tsx | 2 + src/components/layout/TopBar.tsx | 23 +++- src/components/layout/ViewOnlyBanner.tsx | 19 +++ src/components/palette/DevicePalette.tsx | 24 ++-- .../sharing/DiagramSharingModal.tsx | 124 ++++++++++++++++++ .../sharing/VersionHistoryModal.tsx | 74 +++++++++++ src/data/DiagramCollaboratorRepository.ts | 26 ++++ src/data/DiagramRepository.ts | 12 ++ src/data/DiagramSnapshotRepository.ts | 26 ++++ src/data/LocalStorageDiagramRepository.ts | 9 +- .../SupabaseDiagramCollaboratorRepository.ts | 67 ++++++++++ src/data/SupabaseDiagramRepository.ts | 65 ++++++++- src/data/SupabaseDiagramSnapshotRepository.ts | 73 +++++++++++ src/state/diagramCollaboratorStore.ts | 46 +++++++ src/state/diagramSnapshotStore.ts | 45 +++++++ src/state/projectStore.ts | 87 +++++++++++- .../20260913000000_diagram_sharing.sql | 123 +++++++++++++++++ supabase/tests/rls.sql | 82 +++++++++++- 21 files changed, 939 insertions(+), 38 deletions(-) create mode 100644 src/components/layout/ViewOnlyBanner.tsx create mode 100644 src/components/sharing/DiagramSharingModal.tsx create mode 100644 src/components/sharing/VersionHistoryModal.tsx create mode 100644 src/data/DiagramCollaboratorRepository.ts create mode 100644 src/data/DiagramSnapshotRepository.ts create mode 100644 src/data/SupabaseDiagramCollaboratorRepository.ts create mode 100644 src/data/SupabaseDiagramSnapshotRepository.ts create mode 100644 src/state/diagramCollaboratorStore.ts create mode 100644 src/state/diagramSnapshotStore.ts create mode 100644 supabase/migrations/20260913000000_diagram_sharing.sql diff --git a/src/components/canvas/FlowCanvas.tsx b/src/components/canvas/FlowCanvas.tsx index 6ac64ea..6732138 100644 --- a/src/components/canvas/FlowCanvas.tsx +++ b/src/components/canvas/FlowCanvas.tsx @@ -40,6 +40,13 @@ function FlowCanvasInner() { const selectConnection = useProjectStore((s) => s.selectConnection) const { screenToFlowPosition } = useReactFlow() const catalog = useCatalogStore((s) => s.catalog) + const access = useProjectStore((s) => s.access) + // Owner/edit-collaborator/(a Super Admin, who getAccess always reports as + // 'edit') can edit; a view-only collaborator can look but not touch — + // organized-ideas.md §8's per-collaborator permissions. `access` is only + // null in the brief window before the first diagram finishes loading, at + // which point nothing is rendered yet anyway (AppShell's isLoaded gate). + const canEdit = access?.myPermission !== 'view' const portTypes = useMemo(() => allPortTypes(catalog), [catalog]) const cableTypes = useMemo(() => allCableTypes(catalog), [catalog]) @@ -93,7 +100,7 @@ function FlowCanvasInner() { const c = edgeOrConnection as Connection const sourcePortId = parsePortId(c.sourceHandle) const targetPortId = parsePortId(c.targetHandle) - if (!c.source || !c.target || !sourcePortId || !targetPortId) return false + if (!canEdit || !c.source || !c.target || !sourcePortId || !targetPortId) return false return validateConnection(project, catalog, { sourceDeviceId: c.source, sourcePortId, @@ -101,11 +108,12 @@ function FlowCanvasInner() { targetPortId, }).valid }, - [project, catalog], + [project, catalog, canEdit], ) const onConnect = useCallback( (connection: Connection) => { + if (!canEdit) return const sourcePortId = parsePortId(connection.sourceHandle) const targetPortId = parsePortId(connection.targetHandle) if (!connection.source || !connection.target || !sourcePortId || !targetPortId) return @@ -126,11 +134,12 @@ function FlowCanvasInner() { cableTypeId: defaultCableType.id, }) }, - [project, catalog, addConnection], + [project, catalog, canEdit, addConnection], ) const onNodesChange = useCallback( (changes: NodeChange[]) => { + if (!canEdit) return for (const change of changes) { if (change.type === 'position' && change.position) { updateDevicePosition(change.id, change.position) @@ -139,33 +148,38 @@ function FlowCanvasInner() { } } }, - [updateDevicePosition, removeDevice], + [canEdit, updateDevicePosition, removeDevice], ) const onEdgesChange = useCallback( (changes: EdgeChange[]) => { + if (!canEdit) return for (const change of changes) { if (change.type === 'remove') removeConnection(change.id) } }, - [removeConnection], + [canEdit, removeConnection], ) - const onDragOver = useCallback((event: React.DragEvent) => { - if (!event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - }, []) + const onDragOver = useCallback( + (event: React.DragEvent) => { + if (!canEdit || !event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }, + [canEdit], + ) const onDrop = useCallback( (event: React.DragEvent) => { + if (!canEdit) return const templateId = event.dataTransfer.getData(TEMPLATE_DRAG_MIME) if (!templateId) return event.preventDefault() const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }) addDeviceFromTemplate(catalog, templateId, position) }, - [screenToFlowPosition, catalog, addDeviceFromTemplate], + [screenToFlowPosition, catalog, canEdit, addDeviceFromTemplate], ) return ( @@ -179,6 +193,8 @@ function FlowCanvasInner() { onEdgesChange={onEdgesChange} onConnect={onConnect} isValidConnection={isValidConnection} + nodesDraggable={canEdit} + nodesConnectable={canEdit} connectionMode={ConnectionMode.Loose} onNodeClick={(_, node) => selectDevice(node.id)} onEdgeClick={(_, edge) => selectConnection(edge.id)} diff --git a/src/components/inspector/ConnectionInspector.tsx b/src/components/inspector/ConnectionInspector.tsx index b15de00..a717ea8 100644 --- a/src/components/inspector/ConnectionInspector.tsx +++ b/src/components/inspector/ConnectionInspector.tsx @@ -6,6 +6,8 @@ import { useProjectStore } from '../../state/projectStore' export default function ConnectionInspector({ connectionId }: { connectionId: string }) { const project = useProjectStore((s) => s.project) const catalog = useCatalogStore((s) => s.catalog) + const access = useProjectStore((s) => s.access) + const canEdit = access?.myPermission !== 'view' const updateConnection = useProjectStore((s) => s.updateConnection) const removeConnection = useProjectStore((s) => s.removeConnection) @@ -27,7 +29,7 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null return ( -
+
From @@ -132,6 +134,6 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st Remove cable
-
+
) } diff --git a/src/components/inspector/DeviceInspector.tsx b/src/components/inspector/DeviceInspector.tsx index cf3d1f7..c38ff61 100644 --- a/src/components/inspector/DeviceInspector.tsx +++ b/src/components/inspector/DeviceInspector.tsx @@ -9,6 +9,8 @@ import CategorySelect from '../common/CategorySelect' export default function DeviceInspector({ deviceId }: { deviceId: string }) { const project = useProjectStore((s) => s.project) const catalog = useCatalogStore((s) => s.catalog) + const access = useProjectStore((s) => s.access) + const canEdit = access?.myPermission !== 'view' const updateDevice = useProjectStore((s) => s.updateDevice) const removeDevice = useProjectStore((s) => s.removeDevice) const addPort = useProjectStore((s) => s.addPort) @@ -29,7 +31,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) { project.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length return ( -
+
-
+
) } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 707adfd..8136213 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -9,6 +9,7 @@ import DevicePalette from '../palette/DevicePalette' import ConnectionErrorBanner from './ConnectionErrorBanner' import RightPanel from './RightPanel' import TopBar from './TopBar' +import ViewOnlyBanner from './ViewOnlyBanner' export default function AppShell() { const isLoaded = useProjectStore((s) => s.isLoaded) @@ -45,6 +46,7 @@ export default function AppShell() {
+
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 5576233..5b0877b 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -6,6 +6,8 @@ import { useAuthStore } from '../../state/authStore' import { useProjectStore } from '../../state/projectStore' import { useSubmissionStore } from '../../state/submissionStore' import AdminUsersModal from '../admin/AdminUsersModal' +import DiagramSharingModal from '../sharing/DiagramSharingModal' +import VersionHistoryModal from '../sharing/VersionHistoryModal' import AdminReviewModal from '../submissions/AdminReviewModal' import MySubmissionsModal from '../submissions/MySubmissionsModal' import DiagramManagerModal from './DiagramManagerModal' @@ -32,6 +34,8 @@ function setLastSeenSubmissionsAt(iso: string): void { export default function TopBar() { const project = useProjectStore((s) => s.project) + const access = useProjectStore((s) => s.access) + const canEdit = access?.myPermission !== 'view' const renameProject = useProjectStore((s) => s.renameProject) const importProject = useProjectStore((s) => s.importProject) const mySubmissions = useSubmissionStore((s) => s.mySubmissions) @@ -42,6 +46,8 @@ export default function TopBar() { const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length) const fileInputRef = useRef(null) const [diagramManagerOpen, setDiagramManagerOpen] = useState(false) + const [versionHistoryOpen, setVersionHistoryOpen] = useState(false) + const [sharingOpen, setSharingOpen] = useState(false) const [submissionsOpen, setSubmissionsOpen] = useState(false) const [adminReviewOpen, setAdminReviewOpen] = useState(false) const [adminUsersOpen, setAdminUsersOpen] = useState(false) @@ -84,7 +90,8 @@ export default function TopBar() { renameProject(e.target.value)} - className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none" + disabled={!canEdit} + className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" />
@@ -94,6 +101,18 @@ export default function TopBar() { > Diagrams + +
{diagramManagerOpen && setDiagramManagerOpen(false)} />} + {versionHistoryOpen && setVersionHistoryOpen(false)} />} + {sharingOpen && setSharingOpen(false)} />} {submissionsOpen && setSubmissionsOpen(false)} />} {adminReviewOpen && setAdminReviewOpen(false)} />} {adminUsersOpen && setAdminUsersOpen(false)} />} diff --git a/src/components/layout/ViewOnlyBanner.tsx b/src/components/layout/ViewOnlyBanner.tsx new file mode 100644 index 0000000..d7f8132 --- /dev/null +++ b/src/components/layout/ViewOnlyBanner.tsx @@ -0,0 +1,19 @@ +import { useProjectStore } from '../../state/projectStore' + +/** Shown when the current user only has view access to the open diagram + * (organized-ideas.md §8's per-collaborator permissions) — the canvas and + * inspectors disable their edit controls in this case too (see + * FlowCanvas/DeviceInspector/ConnectionInspector), this just makes that + * visible instead of leaving it to be discovered by a control not working. */ +export default function ViewOnlyBanner() { + const access = useProjectStore((s) => s.access) + if (access?.myPermission !== 'view') return null + + return ( +
+
+ View only — you don't have edit access to this diagram. +
+
+ ) +} diff --git a/src/components/palette/DevicePalette.tsx b/src/components/palette/DevicePalette.tsx index 2755554..a1272bf 100644 --- a/src/components/palette/DevicePalette.tsx +++ b/src/components/palette/DevicePalette.tsx @@ -20,6 +20,8 @@ type EditorTarget = export default function DevicePalette() { const project = useProjectStore((s) => s.project) + const access = useProjectStore((s) => s.access) + const canEdit = access?.myPermission !== 'view' const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate) const catalog = useCatalogStore((s) => s.catalog) const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds) @@ -71,11 +73,13 @@ export default function DevicePalette() { } const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => { + if (!canEdit) return event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id) event.dataTransfer.effectAllowed = 'move' } const handleQuickAdd = (template: DeviceTemplate) => { + if (!canEdit) return // Cascade placement so repeated quick-adds don't stack exactly on top of each other. const offset = (project.devices.length % 8) * 24 addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset }) @@ -163,9 +167,9 @@ export default function DevicePalette() { {group.templates.map((template) => (
  • handleDragStart(e, template)} - className="flex cursor-grab items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm active:cursor-grabbing" + className={`flex items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm ${canEdit ? 'cursor-grab active:cursor-grabbing' : ''}`} >
    {template.name}
    @@ -232,13 +236,15 @@ export default function DevicePalette() { > ✕ - + {canEdit && ( + + )}
  • diff --git a/src/components/sharing/DiagramSharingModal.tsx b/src/components/sharing/DiagramSharingModal.tsx new file mode 100644 index 0000000..0af60aa --- /dev/null +++ b/src/components/sharing/DiagramSharingModal.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react' +import type { CollaboratorPermission } from '../../data/DiagramCollaboratorRepository' +import { UnknownUsernameError } from '../../data/DiagramCollaboratorRepository' +import { useDiagramCollaboratorStore } from '../../state/diagramCollaboratorStore' +import { useProjectStore } from '../../state/projectStore' +import Modal from '../common/Modal' + +export default function DiagramSharingModal({ onClose }: { onClose: () => void }) { + const project = useProjectStore((s) => s.project) + const access = useProjectStore((s) => s.access) + const isOwner = access?.myPermission === 'owner' + const diagramId = project.id + + const collaborators = useDiagramCollaboratorStore((s) => s.collaborators) + const isLoaded = useDiagramCollaboratorStore((s) => s.isLoaded) + const load = useDiagramCollaboratorStore((s) => s.load) + const add = useDiagramCollaboratorStore((s) => s.add) + const updatePermission = useDiagramCollaboratorStore((s) => s.updatePermission) + const remove = useDiagramCollaboratorStore((s) => s.remove) + + const [username, setUsername] = useState('') + const [permission, setPermission] = useState('view') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + load(diagramId) + }, [diagramId, load]) + + const handleAdd = async () => { + const trimmed = username.trim() + if (!trimmed) return + setError(null) + setBusy(true) + try { + await add(diagramId, trimmed, permission) + setUsername('') + } catch (err) { + setError(err instanceof UnknownUsernameError ? err.message : 'Could not add that person. Try again.') + } finally { + setBusy(false) + } + } + + const handleRemove = (userId: string, name: string) => { + const confirmed = window.confirm(`Remove "${name}" from this diagram? They'll immediately lose access.`) + if (confirmed) remove(diagramId, userId) + } + + return ( + +
    + {isOwner && ( +
    +
    + setUsername(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + placeholder="Username" + className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs" + /> + + +
    + {error &&

    {error}

    } +
    + )} + +
    + {!isLoaded &&

    Loading…

    } + {isLoaded && collaborators.length === 0 && ( +

    + Not shared with anyone yet{isOwner ? ' — add someone above.' : '.'} +

    + )} + {collaborators.map((c) => ( +
    + {c.username} + {isOwner ? ( +
    + + +
    + ) : ( + {c.permission === 'edit' ? 'Can edit' : 'Can view'} + )} +
    + ))} +
    +
    +
    + +
    +
    + ) +} diff --git a/src/components/sharing/VersionHistoryModal.tsx b/src/components/sharing/VersionHistoryModal.tsx new file mode 100644 index 0000000..92fa2f4 --- /dev/null +++ b/src/components/sharing/VersionHistoryModal.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' +import { useDiagramSnapshotStore } from '../../state/diagramSnapshotStore' +import { useProjectStore } from '../../state/projectStore' +import Modal from '../common/Modal' + +function formatDateTime(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) +} + +export default function VersionHistoryModal({ onClose }: { onClose: () => void }) { + const project = useProjectStore((s) => s.project) + const access = useProjectStore((s) => s.access) + const canRestore = access?.myPermission === 'owner' || access?.myPermission === 'edit' + const diagramId = project.id + + const snapshots = useDiagramSnapshotStore((s) => s.snapshots) + const isLoaded = useDiagramSnapshotStore((s) => s.isLoaded) + const load = useDiagramSnapshotStore((s) => s.load) + const restore = useDiagramSnapshotStore((s) => s.restore) + const [restoringId, setRestoringId] = useState(null) + + useEffect(() => { + load(diagramId) + }, [diagramId, load]) + + const handleRestore = async (snapshotId: string, createdAt: string) => { + const confirmed = window.confirm( + `Restore the diagram to how it was on ${formatDateTime(createdAt)}? If you've made changes since your last restore, the current state is saved as a new checkpoint first, so this can be undone.`, + ) + if (!confirmed) return + setRestoringId(snapshotId) + try { + await restore(diagramId, snapshotId) + } finally { + setRestoringId(null) + } + } + + return ( + +
    +

    + Checkpoints saved periodically while editing (up to the 50 most recent). +

    + {!isLoaded &&

    Loading…

    } + {isLoaded && snapshots.length === 0 && ( +

    No checkpoints yet.

    + )} + {snapshots.map((s) => ( +
    +
    +
    {formatDateTime(s.createdAt)}
    + {s.savedByUsername &&
    by {s.savedByUsername}
    } +
    + {canRestore && ( + + )} +
    + ))} +
    +
    + +
    +
    + ) +} diff --git a/src/data/DiagramCollaboratorRepository.ts b/src/data/DiagramCollaboratorRepository.ts new file mode 100644 index 0000000..9df2267 --- /dev/null +++ b/src/data/DiagramCollaboratorRepository.ts @@ -0,0 +1,26 @@ +export type CollaboratorPermission = 'view' | 'edit' + +export interface DiagramCollaborator { + userId: string + username: string + permission: CollaboratorPermission +} + +/** Thrown by `add` when the given username doesn't resolve to any account — + * a normal, expected outcome (typo, or the person hasn't signed up yet), + * not a system failure. */ +export class UnknownUsernameError extends Error {} + +/** Storage abstraction for a diagram's collaborator list, per + * organized-ideas.md §8: the owner shares with specific people by + * username, choosing view or edit access per person. RLS restricts + * add/update/remove to the diagram's owner (or a Super Admin) — this + * interface doesn't re-check that, it just reflects what the backend allows. */ +export interface DiagramCollaboratorRepository { + list(diagramId: string): Promise + /** Resolves `username` to an account and adds them with `permission`. + * Throws UnknownUsernameError if no such username exists. */ + add(diagramId: string, username: string, permission: CollaboratorPermission): Promise + updatePermission(diagramId: string, userId: string, permission: CollaboratorPermission): Promise + remove(diagramId: string, userId: string): Promise +} diff --git a/src/data/DiagramRepository.ts b/src/data/DiagramRepository.ts index bd63744..eb15cbe 100644 --- a/src/data/DiagramRepository.ts +++ b/src/data/DiagramRepository.ts @@ -19,6 +19,16 @@ export interface DiagramSummary { * user-facing copy still say "Project" — that's a separate, larger mechanical rename * across the whole app, tracked as its own task rather than bundled into this one. */ +/** Your relationship to a diagram — who owns it, and what you personally + * can do with it (organized-ideas.md §8's per-collaborator view/edit + * permissions). Drives the frontend's own view-only lockdown, since RLS + * silently no-ops a blocked write rather than erroring — the UI needs to + * know *before* the user tries, not just fail quietly after. */ +export interface DiagramAccess { + ownerId: string + myPermission: 'owner' | 'edit' | 'view' +} + export interface DiagramRepository { /** Every diagram visible to the current user, most recently updated first. */ list(): Promise @@ -28,4 +38,6 @@ export interface DiagramRepository { save(project: Project): Promise /** Permanently delete one diagram by id. */ deleteById(id: string): Promise + /** Who owns this diagram and what the current user can do with it. */ + getAccess(id: string): Promise } diff --git a/src/data/DiagramSnapshotRepository.ts b/src/data/DiagramSnapshotRepository.ts new file mode 100644 index 0000000..a7997dc --- /dev/null +++ b/src/data/DiagramSnapshotRepository.ts @@ -0,0 +1,26 @@ +import type { Project } from '../domain/types' + +export interface DiagramSnapshotSummary { + id: string + createdAt: string + savedByUsername?: string +} + +/** Storage abstraction for a diagram's rolling snapshot history — + * organized-ideas.md §8's undo/recovery safety net, now that turn-based + * collaborators can edit a diagram too. Writing snapshots happens as a + * side effect of normal saves (see SupabaseDiagramRepository.save), not + * through this interface — this is purely the read/restore side. */ +export interface DiagramSnapshotRepository { + /** Most recent first. */ + list(diagramId: string): Promise + /** Restores a snapshot as the diagram's current data. When + * `writeSafetySnapshot` is true, the pre-restore state is snapshotted + * first so restoring is itself undoable — the caller passes false when + * it already knows the live state is an unmodified copy of a snapshot + * that still exists (e.g. restoring again right after an earlier + * restore, with no edits in between), so a second copy of it would just + * be a duplicate. Returns the restored Project so the caller can update + * the open diagram in place. */ + restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise +} diff --git a/src/data/LocalStorageDiagramRepository.ts b/src/data/LocalStorageDiagramRepository.ts index 81ed6d6..7ba5fc1 100644 --- a/src/data/LocalStorageDiagramRepository.ts +++ b/src/data/LocalStorageDiagramRepository.ts @@ -1,5 +1,5 @@ import type { Project } from '../domain/types' -import type { DiagramRepository, DiagramSummary } from './DiagramRepository' +import type { DiagramAccess, DiagramRepository, DiagramSummary } from './DiagramRepository' import { normalizeProject } from './exportImport' const STORAGE_KEY = 'av-planner:diagrams:v1' @@ -48,4 +48,11 @@ export class LocalStorageDiagramRepository implements DiagramRepository { delete all[id] writeAll(all) } + + /** localStorage is inherently single-user — always the owner, never a + * shared collaborator. */ + async getAccess(id: string): Promise { + const diagram = readAll()[id] + return diagram ? { ownerId: 'local', myPermission: 'owner' } : null + } } diff --git a/src/data/SupabaseDiagramCollaboratorRepository.ts b/src/data/SupabaseDiagramCollaboratorRepository.ts new file mode 100644 index 0000000..1925fe7 --- /dev/null +++ b/src/data/SupabaseDiagramCollaboratorRepository.ts @@ -0,0 +1,67 @@ +import type { + CollaboratorPermission, + DiagramCollaborator, + DiagramCollaboratorRepository, +} from './DiagramCollaboratorRepository' +import { UnknownUsernameError } from './DiagramCollaboratorRepository' +import { supabase } from './supabaseClient' + +interface CollaboratorRow { + user_id: string + permission: CollaboratorPermission +} + +export class SupabaseDiagramCollaboratorRepository implements DiagramCollaboratorRepository { + async list(diagramId: string): Promise { + const [rowsResult, usernamesResult] = await Promise.all([ + supabase.from('diagram_collaborators').select('user_id, permission').eq('diagram_id', diagramId), + supabase.rpc('diagram_collaborator_usernames', { p_diagram_id: diagramId }), + ]) + if (rowsResult.error) { + console.error('Failed to load collaborators from Supabase', rowsResult.error) + return [] + } + if (usernamesResult.error) { + console.error('Failed to resolve collaborator usernames from Supabase', usernamesResult.error) + } + const usernameById = new Map( + ((usernamesResult.data ?? []) as { user_id: string; username: string }[]).map((r) => [r.user_id, r.username]), + ) + return ((rowsResult.data ?? []) as CollaboratorRow[]).map((row) => ({ + userId: row.user_id, + username: usernameById.get(row.user_id) ?? row.user_id, + permission: row.permission, + })) + } + + async add(diagramId: string, username: string, permission: CollaboratorPermission): Promise { + const { data: userId, error: lookupError } = await supabase.rpc('find_user_id_by_username', { p_username: username }) + if (lookupError) { + console.error('Failed to resolve username to an id', lookupError) + throw lookupError + } + if (!userId) { + throw new UnknownUsernameError(`No account found for username "${username}".`) + } + const { error } = await supabase.from('diagram_collaborators').insert({ diagram_id: diagramId, user_id: userId, permission }) + if (error) { + console.error('Failed to add collaborator in Supabase', error) + throw error + } + return { userId, username, permission } + } + + async updatePermission(diagramId: string, userId: string, permission: CollaboratorPermission): Promise { + const { error } = await supabase + .from('diagram_collaborators') + .update({ permission }) + .eq('diagram_id', diagramId) + .eq('user_id', userId) + if (error) console.error('Failed to update collaborator permission in Supabase', error) + } + + async remove(diagramId: string, userId: string): Promise { + const { error } = await supabase.from('diagram_collaborators').delete().eq('diagram_id', diagramId).eq('user_id', userId) + if (error) console.error('Failed to remove collaborator in Supabase', error) + } +} diff --git a/src/data/SupabaseDiagramRepository.ts b/src/data/SupabaseDiagramRepository.ts index 1fe2548..2e73646 100644 --- a/src/data/SupabaseDiagramRepository.ts +++ b/src/data/SupabaseDiagramRepository.ts @@ -1,5 +1,5 @@ import type { Project } from '../domain/types' -import type { DiagramRepository, DiagramSummary } from './DiagramRepository' +import type { DiagramAccess, DiagramRepository, DiagramSummary } from './DiagramRepository' import { normalizeProject } from './exportImport' import { supabase } from './supabaseClient' @@ -49,10 +49,13 @@ export class SupabaseDiagramRepository implements DiagramRepository { // Deliberately update-or-insert rather than a blind upsert: an upsert // would resend owner_id on every save and silently reassign ownership - // to whoever saves last. Not reachable yet (no collaborator UI exists), - // but once diagram sharing (§8) lands, an edit-collaborator saving - // changes must never be able to overwrite who owns the diagram. - const { data: existing } = await supabase.from('diagrams').select('id').eq('id', project.id).maybeSingle() + // to whoever saves last — once diagram sharing (§8) is in play, an + // edit-collaborator saving changes must never be able to overwrite who + // owns the diagram. + const { data: existing } = await supabase.from('diagrams').select('id, data').eq('id', project.id).maybeSingle() + if (existing) { + await this.maybeWriteSnapshot(project.id, existing.data as Project, user.id) + } const payload = { name: project.name, data: project, updated_at: new Date().toISOString() } const { error } = existing @@ -64,10 +67,62 @@ export class SupabaseDiagramRepository implements DiagramRepository { } } + // Checkpoints the *pre-save* state periodically (§8's undo/recovery net) + // rather than on every autosave — autosave fires 400ms after each edit, + // which would otherwise flood the snapshot table with one row per + // keystroke-adjacent change instead of meaningful recovery points. + private static readonly SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000 + + private async maybeWriteSnapshot(diagramId: string, previousData: Project, userId: string): Promise { + const { data: lastSnapshot } = await supabase + .from('diagram_snapshots') + .select('created_at') + .eq('diagram_id', diagramId) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + + const dueForSnapshot = + !lastSnapshot || Date.now() - new Date(lastSnapshot.created_at).getTime() >= SupabaseDiagramRepository.SNAPSHOT_INTERVAL_MS + if (!dueForSnapshot) return + + const { error } = await supabase + .from('diagram_snapshots') + .insert({ diagram_id: diagramId, data: previousData, saved_by: userId }) + if (error) console.error('Failed to write diagram snapshot', error) + } + async deleteById(id: string): Promise { const { error } = await supabase.from('diagrams').delete().eq('id', id) if (error) { console.error('Failed to delete diagram from Supabase', error) } } + + async getAccess(id: string): Promise { + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return null + + const { data: diagram, error } = await supabase.from('diagrams').select('owner_id').eq('id', id).maybeSingle() + if (error) console.error('Failed to load diagram access info from Supabase', error) + if (!diagram) return null + + if (diagram.owner_id === user.id) return { ownerId: diagram.owner_id, myPermission: 'owner' } + + const { data: collaborator } = await supabase + .from('diagram_collaborators') + .select('permission') + .eq('diagram_id', id) + .eq('user_id', user.id) + .maybeSingle() + if (collaborator) return { ownerId: diagram.owner_id, myPermission: collaborator.permission } + + // Visible without being the owner or a listed collaborator only happens + // for a Super Admin (diagrams_select's override) — diagrams_update + // grants them unconditional edit rights too, so 'edit' is the accurate + // capability to report, not a misleading read-only default. + return { ownerId: diagram.owner_id, myPermission: 'edit' } + } } diff --git a/src/data/SupabaseDiagramSnapshotRepository.ts b/src/data/SupabaseDiagramSnapshotRepository.ts new file mode 100644 index 0000000..2fb0120 --- /dev/null +++ b/src/data/SupabaseDiagramSnapshotRepository.ts @@ -0,0 +1,73 @@ +import type { Project } from '../domain/types' +import type { DiagramSnapshotRepository, DiagramSnapshotSummary } from './DiagramSnapshotRepository' +import { normalizeProject } from './exportImport' +import { supabase } from './supabaseClient' + +interface SnapshotRow { + id: string + created_at: string + saved_by: string | null +} + +export class SupabaseDiagramSnapshotRepository implements DiagramSnapshotRepository { + async list(diagramId: string): Promise { + const [rowsResult, usernamesResult] = await Promise.all([ + supabase + .from('diagram_snapshots') + .select('id, created_at, saved_by') + .eq('diagram_id', diagramId) + .order('created_at', { ascending: false }), + supabase.rpc('diagram_snapshot_saved_by_usernames', { p_diagram_id: diagramId }), + ]) + if (rowsResult.error) { + console.error('Failed to load diagram snapshots from Supabase', rowsResult.error) + return [] + } + if (usernamesResult.error) { + console.error('Failed to resolve snapshot saver usernames from Supabase', usernamesResult.error) + } + const usernameById = new Map( + ((usernamesResult.data ?? []) as { user_id: string; username: string }[]).map((r) => [r.user_id, r.username]), + ) + return ((rowsResult.data ?? []) as SnapshotRow[]).map((row) => ({ + id: row.id, + createdAt: row.created_at, + savedByUsername: row.saved_by ? usernameById.get(row.saved_by) : undefined, + })) + } + + async restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise { + const { data: snapshot, error } = await supabase.from('diagram_snapshots').select('data').eq('id', snapshotId).maybeSingle() + if (error) console.error('Failed to load snapshot from Supabase', error) + if (!snapshot) return null + + // Snapshot the pre-restore state first, so restoring is itself + // recoverable — the same reasoning as any other checkpoint. Skipped + // when the caller already knows the live state is an unmodified copy + // of an existing snapshot (see DiagramSnapshotRepository's doc + // comment) — writing it again would just be a duplicate. + if (writeSafetySnapshot) { + const [{ data: current }, { data: userData }] = await Promise.all([ + supabase.from('diagrams').select('data').eq('id', diagramId).maybeSingle(), + supabase.auth.getUser(), + ]) + if (current) { + const { error: snapshotError } = await supabase + .from('diagram_snapshots') + .insert({ diagram_id: diagramId, data: current.data, saved_by: userData.user?.id ?? null }) + if (snapshotError) console.error('Failed to snapshot pre-restore state', snapshotError) + } + } + + const restored = normalizeProject(snapshot.data as Project) + const { error: updateError } = await supabase + .from('diagrams') + .update({ data: restored, name: restored.name, updated_at: new Date().toISOString() }) + .eq('id', diagramId) + if (updateError) { + console.error('Failed to restore snapshot in Supabase', updateError) + return null + } + return restored + } +} diff --git a/src/state/diagramCollaboratorStore.ts b/src/state/diagramCollaboratorStore.ts new file mode 100644 index 0000000..d94feee --- /dev/null +++ b/src/state/diagramCollaboratorStore.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand' +import type { + CollaboratorPermission, + DiagramCollaborator, + DiagramCollaboratorRepository, +} from '../data/DiagramCollaboratorRepository' +import { SupabaseDiagramCollaboratorRepository } from '../data/SupabaseDiagramCollaboratorRepository' + +const repository: DiagramCollaboratorRepository = new SupabaseDiagramCollaboratorRepository() + +interface DiagramCollaboratorStoreState { + collaborators: DiagramCollaborator[] + isLoaded: boolean + + load: (diagramId: string) => Promise + add: (diagramId: string, username: string, permission: CollaboratorPermission) => Promise + updatePermission: (diagramId: string, userId: string, permission: CollaboratorPermission) => Promise + remove: (diagramId: string, userId: string) => Promise +} + +export const useDiagramCollaboratorStore = create((set) => ({ + collaborators: [], + isLoaded: false, + + load: async (diagramId) => { + const collaborators = await repository.list(diagramId) + set({ collaborators, isLoaded: true }) + }, + + add: async (diagramId, username, permission) => { + const created = await repository.add(diagramId, username, permission) + set((state) => ({ collaborators: [...state.collaborators, created] })) + }, + + updatePermission: async (diagramId, userId, permission) => { + await repository.updatePermission(diagramId, userId, permission) + set((state) => ({ + collaborators: state.collaborators.map((c) => (c.userId === userId ? { ...c, permission } : c)), + })) + }, + + remove: async (diagramId, userId) => { + await repository.remove(diagramId, userId) + set((state) => ({ collaborators: state.collaborators.filter((c) => c.userId !== userId) })) + }, +})) diff --git a/src/state/diagramSnapshotStore.ts b/src/state/diagramSnapshotStore.ts new file mode 100644 index 0000000..3ff7d0e --- /dev/null +++ b/src/state/diagramSnapshotStore.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand' +import type { DiagramSnapshotRepository, DiagramSnapshotSummary } from '../data/DiagramSnapshotRepository' +import { SupabaseDiagramSnapshotRepository } from '../data/SupabaseDiagramSnapshotRepository' +import type { Project } from '../domain/types' +import { useProjectStore } from './projectStore' + +const repository: DiagramSnapshotRepository = new SupabaseDiagramSnapshotRepository() + +interface DiagramSnapshotStoreState { + snapshots: DiagramSnapshotSummary[] + isLoaded: boolean + + load: (diagramId: string) => Promise + /** Restores a snapshot and applies it to the open diagram (via + * projectStore.applyRestoredProject) in one step. Skips writing a + * pointless duplicate safety snapshot when the live diagram is already + * an unmodified copy of a snapshot still sitting in the list — see + * projectStore's restoredFromSnapshotId/restoredProjectUpdatedAt. + * Returns the restored Project, or null if the restore failed. */ + restore: (diagramId: string, snapshotId: string) => Promise +} + +export const useDiagramSnapshotStore = create((set) => ({ + snapshots: [], + isLoaded: false, + + load: async (diagramId) => { + const snapshots = await repository.list(diagramId) + set({ snapshots, isLoaded: true }) + }, + + restore: async (diagramId, snapshotId) => { + const { project, restoredFromSnapshotId, restoredProjectUpdatedAt } = useProjectStore.getState() + const knownUnmodifiedSinceLastRestore = + restoredFromSnapshotId !== null && project.updatedAt === restoredProjectUpdatedAt + + const restored = await repository.restore(diagramId, snapshotId, !knownUnmodifiedSinceLastRestore) + if (restored) { + useProjectStore.getState().applyRestoredProject(restored, snapshotId) + const snapshots = await repository.list(diagramId) + set({ snapshots }) + } + return restored + }, +})) diff --git a/src/state/projectStore.ts b/src/state/projectStore.ts index 72d80dc..1186e06 100644 --- a/src/state/projectStore.ts +++ b/src/state/projectStore.ts @@ -3,7 +3,7 @@ import { create } from 'zustand' import { validateConnection } from '../domain/compatibility' import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project' import type { Catalog, Connection, Device, DeviceCategory, Port, Project } from '../domain/types' -import type { DiagramRepository, DiagramSummary } from '../data/DiagramRepository' +import type { DiagramAccess, DiagramRepository, DiagramSummary } from '../data/DiagramRepository' import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository' // This is exactly the swap the repository pattern was built for: only this @@ -48,6 +48,26 @@ interface ProjectStoreState { * change, just refreshed at the moments that matter (initial load, and * whenever the diagram manager UI opens or changes something). */ diagramList: DiagramSummary[] + /** Who owns the *currently open* diagram and what the current user can do + * with it — refreshed whenever the open diagram changes, not kept live + * (matches §8's turn-based, not realtime, collaboration model: a + * permission change made by the owner while you're viewing takes effect + * next time you load the diagram, not mid-session). Null only during the + * brief window before the very first diagram finishes loading. */ + access: DiagramAccess | null + /** Set to a snapshot's id right after restoring it, meaning the live + * diagram data is verbatim identical to that already-existing snapshot + * row — see applyRestoredProject. `restoredProjectUpdatedAt` is the + * project's own `updatedAt` at that moment; touch() always advances + * `updatedAt` on a genuine edit, so comparing the two later is a free + * way to tell "has anything changed since this restore" without + * hooking every mutating action. Both null once anything invalidates + * the match (an edit, or loading/switching to a diagram fresh). Used by + * DiagramSnapshotStore to skip writing a pointless duplicate safety + * snapshot when the user restores several old versions in a row without + * editing in between. */ + restoredFromSnapshotId: string | null + restoredProjectUpdatedAt: string | null selectedDeviceId: string | null selectedConnectionId: string | null lastConnectionError: string | null @@ -65,6 +85,11 @@ interface ProjectStoreState { * rather than risking a collision with (or silently overwriting) an * existing one that happens to share the file's id. */ importProject: (project: Project) => Promise + /** Applies a diagram that was already persisted by something else (the + * version-history "Restore" action already wrote the restored data to + * Supabase) — just reflects it in the currently open canvas, without + * re-triggering an import-style "treat this as a brand new diagram". */ + applyRestoredProject: (project: Project, snapshotId: string) => void /** Switches the active diagram to another one of the user's own. */ switchToDiagram: (id: string) => Promise /** Permanently deletes a diagram. If it's the currently open one, falls @@ -115,6 +140,9 @@ export const useProjectStore = create((set, get) => ({ project: createEmptyProject(), isLoaded: false, diagramList: [], + access: null, + restoredFromSnapshotId: null, + restoredProjectUpdatedAt: null, selectedDeviceId: null, selectedConnectionId: null, lastConnectionError: null, @@ -125,7 +153,8 @@ export const useProjectStore = create((set, get) => ({ const loaded = lastId ? await repository.loadById(lastId) : null const project = loaded ?? (await loadMostRecent()) ?? createEmptyProject() setLastDiagramId(project.id) - set({ project, isLoaded: true }) + const access = await repository.getAccess(project.id) + set({ project, access, isLoaded: true, restoredFromSnapshotId: null, restoredProjectUpdatedAt: null }) get().refreshDiagramList() }, @@ -137,8 +166,16 @@ export const useProjectStore = create((set, get) => ({ newProject: async (name = 'Untitled Project') => { const project = createEmptyProject(name) setLastDiagramId(project.id) - set({ project, selectedDeviceId: null, selectedConnectionId: null }) + set({ + project, + selectedDeviceId: null, + selectedConnectionId: null, + restoredFromSnapshotId: null, + restoredProjectUpdatedAt: null, + }) await repository.save(project) + const access = await repository.getAccess(project.id) + set({ access }) get().refreshDiagramList() }, @@ -146,11 +183,29 @@ export const useProjectStore = create((set, get) => ({ set((state) => ({ project: touch({ ...state.project, name }) })) }, + applyRestoredProject: (project, snapshotId) => { + set({ + project, + selectedDeviceId: null, + selectedConnectionId: null, + restoredFromSnapshotId: snapshotId, + restoredProjectUpdatedAt: project.updatedAt, + }) + }, + importProject: async (project) => { const fresh = touch({ ...project, id: uuid() }) setLastDiagramId(fresh.id) - set({ project: fresh, selectedDeviceId: null, selectedConnectionId: null }) + set({ + project: fresh, + selectedDeviceId: null, + selectedConnectionId: null, + restoredFromSnapshotId: null, + restoredProjectUpdatedAt: null, + }) await repository.save(fresh) + const access = await repository.getAccess(fresh.id) + set({ access }) get().refreshDiagramList() }, @@ -159,7 +214,15 @@ export const useProjectStore = create((set, get) => ({ const loaded = await repository.loadById(id) if (!loaded) return setLastDiagramId(loaded.id) - set({ project: loaded, selectedDeviceId: null, selectedConnectionId: null }) + const access = await repository.getAccess(loaded.id) + set({ + project: loaded, + access, + selectedDeviceId: null, + selectedConnectionId: null, + restoredFromSnapshotId: null, + restoredProjectUpdatedAt: null, + }) }, deleteDiagram: async (id) => { @@ -167,7 +230,15 @@ export const useProjectStore = create((set, get) => ({ if (get().project.id === id) { const next = (await loadMostRecent()) ?? createEmptyProject() setLastDiagramId(next.id) - set({ project: next, selectedDeviceId: null, selectedConnectionId: null }) + const access = await repository.getAccess(next.id) + set({ + project: next, + access, + selectedDeviceId: null, + selectedConnectionId: null, + restoredFromSnapshotId: null, + restoredProjectUpdatedAt: null, + }) } get().refreshDiagramList() }, @@ -331,6 +402,10 @@ useProjectStore.subscribe((state, prevState) => { // load, not a user edit) and any tick where `project` didn't change. if (!prevState.isLoaded) return if (state.project === prevState.project) return + // Defense in depth: the UI shouldn't expose any mutating control to a + // view-only collaborator in the first place (see FlowCanvas/inspectors), + // but skip the write here too rather than relying on RLS's silent no-op. + if (state.access?.myPermission === 'view') return if (saveTimeout) clearTimeout(saveTimeout) saveTimeout = setTimeout(() => { repository.save(state.project).catch((err) => console.error('Autosave failed', err)) diff --git a/supabase/migrations/20260913000000_diagram_sharing.sql b/supabase/migrations/20260913000000_diagram_sharing.sql new file mode 100644 index 0000000..7a9e49b --- /dev/null +++ b/supabase/migrations/20260913000000_diagram_sharing.sql @@ -0,0 +1,123 @@ +-- Diagram sharing/collaborators UI support, per organized-ideas.md §8. The +-- diagrams/diagram_collaborators/diagram_snapshots tables and their RLS +-- already exist (init schema) — this migration adds the two small pieces +-- of backend those needed to actually be usable from a UI. + +-- ---------------------------------------------------------------------- +-- Username -> id lookup, for "share with @username". profiles_select +-- deliberately keeps a regular user from browsing other users' profiles +-- (organized-ideas.md §2/§6), but a username is meant to be a shareable +-- handle — that's the whole point of having one — so resolving it to an id +-- (and nothing else: no email, no role) is safe to expose broadly, unlike +-- the Admin-gated lookups elsewhere in this schema. Any authenticated user +-- can call this; there's no privilege check because none is needed. +-- ---------------------------------------------------------------------- + +create or replace function public.find_user_id_by_username(p_username text) +returns uuid +language sql +stable +security definer +set search_path = public +as $$ + select id from public.profiles where username = p_username; +$$; + +-- ---------------------------------------------------------------------- +-- Collaborator usernames for one diagram's sharing UI. Same problem as +-- above in reverse: diagram_collaborators only stores user ids, and +-- profiles_select_self_or_super_admin blocks a regular owner from reading +-- some *other* user's profile row directly to get their username. Gated +-- to "can you see this diagram at all" — the same condition as +-- diagrams_select's USING clause, reusing its own helper functions so the +-- two can't drift apart. +-- ---------------------------------------------------------------------- + +create or replace function public.diagram_collaborator_usernames(p_diagram_id uuid) +returns table(user_id uuid, username text) +language plpgsql +stable +security definer +set search_path = public +as $$ +begin + if not ( + public.is_super_admin() + or public.diagram_owner_id(p_diagram_id) = auth.uid() + or public.diagram_collaborator_permission(p_diagram_id, auth.uid()) is not null + ) then + raise exception 'insufficient_privilege' using errcode = '42501'; + end if; + + return query + select dc.user_id, p.username + from public.diagram_collaborators dc + join public.profiles p on p.id = dc.user_id + where dc.diagram_id = p_diagram_id; +end; +$$; + +-- ---------------------------------------------------------------------- +-- Rolling snapshot retention (organized-ideas.md §8: "keep a rolling +-- window of recent diagram snapshots... exact policy TBD" — settled on +-- count-based, 50 per diagram). Enforced at write time via a trigger +-- rather than a scheduled job: diagram_snapshots has no update/delete +-- policy for regular users at all (it's meant to be immutable from their +-- side), so pruning has to run as this SECURITY DEFINER function +-- regardless of whether it's trigger- or cron-driven — a trigger is +-- simpler than also standing up pg_cron for this app's scale. +-- ---------------------------------------------------------------------- + +create or replace function public.prune_diagram_snapshots() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + delete from public.diagram_snapshots + where diagram_id = new.diagram_id + and id not in ( + select id from public.diagram_snapshots + where diagram_id = new.diagram_id + order by created_at desc + limit 50 + ); + return new; +end; +$$; + +create trigger trg_prune_diagram_snapshots +after insert on public.diagram_snapshots +for each row +execute function public.prune_diagram_snapshots(); + +-- ---------------------------------------------------------------------- +-- Who saved each snapshot, for the version-history UI. Same shape as +-- diagram_collaborator_usernames above (gated to "can you see this +-- diagram", batched per diagram rather than per snapshot). +-- ---------------------------------------------------------------------- + +create or replace function public.diagram_snapshot_saved_by_usernames(p_diagram_id uuid) +returns table(user_id uuid, username text) +language plpgsql +stable +security definer +set search_path = public +as $$ +begin + if not ( + public.is_super_admin() + or public.diagram_owner_id(p_diagram_id) = auth.uid() + or public.diagram_collaborator_permission(p_diagram_id, auth.uid()) is not null + ) then + raise exception 'insufficient_privilege' using errcode = '42501'; + end if; + + return query + select distinct p.id, p.username + from public.diagram_snapshots s + join public.profiles p on p.id = s.saved_by + where s.diagram_id = p_diagram_id; +end; +$$; diff --git a/supabase/tests/rls.sql b/supabase/tests/rls.sql index 25c80c7..d80b804 100644 --- a/supabase/tests/rls.sql +++ b/supabase/tests/rls.sql @@ -25,7 +25,7 @@ begin; create extension if not exists pgtap with schema extensions; -select plan(43); +select plan(52); -- ---------------------------------------------------------------------- -- Fixtures (as postgres — RLS does not apply) @@ -177,8 +177,20 @@ select lives_ok( 'bob (edit collaborator) can now update alice''s diagram' ); +select is( + (select username from public.diagram_collaborator_usernames('b0000000-0000-0000-0000-000000000001') where user_id = '22222222-2222-2222-2222-222222222222'), + 'bob', + 'bob (a collaborator) can resolve the diagram''s collaborator usernames' +); + select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); +select throws_ok( + $$ select * from public.diagram_collaborator_usernames('b0000000-0000-0000-0000-000000000001') $$, + '42501'::char(5), null, + 'carol (no access to the diagram at all) cannot resolve its collaborator usernames' +); + select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 0, @@ -193,6 +205,74 @@ select is( 'dave (super admin) can see any diagram' ); +-- ---------------------------------------------------------------------- +-- Username lookup (organized-ideas.md §8's "share with @username" — any +-- authenticated user can resolve one, unlike general profile browsing). +-- ---------------------------------------------------------------------- + +select is( + (select public.find_user_id_by_username('alice')), + '11111111-1111-1111-1111-111111111111'::uuid, + 'any authenticated user can resolve a username to an id' +); + +select is( + (select public.find_user_id_by_username('no-such-user')), + null, + 'resolving an unknown username returns null, not an error' +); + +-- ---------------------------------------------------------------------- +-- Snapshot retention (organized-ideas.md §8: keep the 50 most recent per +-- diagram). Explicit, staggered created_at values below because pgTAP runs +-- inside one transaction — every row would otherwise share the exact same +-- now(), making "most recent" ambiguous for this test specifically (a +-- real editing session naturally spreads saves out over wall-clock time). +-- ---------------------------------------------------------------------- + +select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); + +insert into public.diagram_snapshots (diagram_id, data, saved_by, created_at) +select 'b0000000-0000-0000-0000-000000000001', jsonb_build_object('seq', g), '11111111-1111-1111-1111-111111111111', + now() + (g || ' seconds')::interval +from generate_series(1, 51) g; + +select is( + (select count(*)::int from public.diagram_snapshots where diagram_id = 'b0000000-0000-0000-0000-000000000001'), + 50, + 'only the 50 most recent snapshots are kept' +); + +select is( + (select count(*)::int from public.diagram_snapshots + where diagram_id = 'b0000000-0000-0000-0000-000000000001' and data ->> 'seq' = '1'), + 0, + 'the oldest snapshot was the one pruned' +); + +select is( + (select count(*)::int from public.diagram_snapshots + where diagram_id = 'b0000000-0000-0000-0000-000000000001' and data ->> 'seq' = '51'), + 1, + 'the newest snapshot survives' +); + +select is( + (select username from public.diagram_snapshot_saved_by_usernames('b0000000-0000-0000-0000-000000000001') where user_id = '11111111-1111-1111-1111-111111111111'), + 'alice', + 'alice (owner) can resolve who saved this diagram''s snapshots' +); + +select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); + +select throws_ok( + $$ select * from public.diagram_snapshot_saved_by_usernames('b0000000-0000-0000-0000-000000000001') $$, + '42501'::char(5), null, + 'carol (no access to the diagram at all) cannot resolve who saved its snapshots' +); + +select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); + -- ---------------------------------------------------------------------- -- Catalog submissions -- ----------------------------------------------------------------------