Add diagram sharing/collaborators, version history, and view-only lockdown

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 <fieldset>,
  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.
This commit is contained in:
2026-09-11 11:28:58 -05:00
parent 4c45b5afa7
commit dea26f7ee8
21 changed files with 939 additions and 38 deletions
+25 -9
View File
@@ -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<DeviceFlowNode>[]) => {
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<CableFlowEdge>[]) => {
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
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)}
@@ -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 (
<div className="space-y-3 p-3">
<fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 p-3">
<div className="rounded border border-slate-200 bg-slate-50 p-2 text-xs">
<div className="flex items-center justify-between py-0.5">
<span className="text-slate-500">From</span>
@@ -132,6 +134,6 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
Remove cable
</button>
</div>
</div>
</fieldset>
)
}
+4 -2
View File
@@ -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 (
<div className="space-y-3 p-3">
<fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 p-3">
<div className="grid grid-cols-2 gap-2">
<label className="col-span-2 text-xs font-medium text-slate-600">
Name
@@ -172,6 +174,6 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
Delete device
</button>
</div>
</div>
</fieldset>
)
}
+2
View File
@@ -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() {
<DevicePalette />
<main className="relative min-w-0 flex-1">
<ConnectionErrorBanner />
<ViewOnlyBanner />
<FlowCanvas />
</main>
<RightPanel />
+22 -1
View File
@@ -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<HTMLInputElement>(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() {
<input
value={project.name}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-1.5">
@@ -94,6 +101,18 @@ export default function TopBar() {
>
Diagrams
</button>
<button
onClick={() => setVersionHistoryOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
History
</button>
<button
onClick={() => setSharingOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Share
</button>
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
My Submissions
{unseenOutcomeCount > 0 ? (
@@ -151,6 +170,8 @@ export default function TopBar() {
</button>
</div>
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
{versionHistoryOpen && <VersionHistoryModal onClose={() => setVersionHistoryOpen(false)} />}
{sharingOpen && <DiagramSharingModal onClose={() => setSharingOpen(false)} />}
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
{adminUsersOpen && <AdminUsersModal onClose={() => setAdminUsersOpen(false)} />}
+19
View File
@@ -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 (
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center">
<div className="pointer-events-auto rounded border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-sm">
View only — you don't have edit access to this diagram.
</div>
</div>
)
}
+8 -2
View File
@@ -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) => (
<li key={template.id} className="group">
<div
draggable
draggable={canEdit}
onDragStart={(e) => 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' : ''}`}
>
<div className="min-w-0">
<div className="truncate font-medium text-slate-700">{template.name}</div>
@@ -232,6 +236,7 @@ export default function DevicePalette() {
>
✕
</button>
{canEdit && (
<button
onClick={() => handleQuickAdd(template)}
title="Add to canvas"
@@ -239,6 +244,7 @@ export default function DevicePalette() {
>
+
</button>
)}
</div>
</div>
</li>
@@ -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<CollaboratorPermission>('view')
const [error, setError] = useState<string | null>(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 (
<Modal title={`Share "${project.name}"`} onClose={onClose} width="max-w-md">
<div className="space-y-3">
{isOwner && (
<div className="space-y-1.5 rounded border border-slate-200 bg-slate-50 p-2">
<div className="flex gap-1.5">
<input
value={username}
onChange={(e) => 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"
/>
<select
value={permission}
onChange={(e) => setPermission(e.target.value as CollaboratorPermission)}
className="rounded border border-slate-300 px-1 py-1 text-xs"
>
<option value="view">Can view</option>
<option value="edit">Can edit</option>
</select>
<button
onClick={handleAdd}
disabled={busy || !username.trim()}
className="shrink-0 rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Add
</button>
</div>
{error && <p className="text-[11px] text-red-600">{error}</p>}
</div>
)}
<div className="space-y-1.5">
{!isLoaded && <p className="py-2 text-center text-xs italic text-slate-400">Loading…</p>}
{isLoaded && collaborators.length === 0 && (
<p className="py-2 text-center text-xs italic text-slate-400">
Not shared with anyone yet{isOwner ? ' — add someone above.' : '.'}
</p>
)}
{collaborators.map((c) => (
<div key={c.userId} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs">
<span className="font-medium text-slate-700">{c.username}</span>
{isOwner ? (
<div className="flex shrink-0 items-center gap-1.5">
<select
value={c.permission}
onChange={(e) => updatePermission(diagramId, c.userId, e.target.value as CollaboratorPermission)}
className="rounded border border-slate-300 px-1 py-1 text-[11px]"
>
<option value="view">Can view</option>
<option value="edit">Can edit</option>
</select>
<button
onClick={() => handleRemove(c.userId, c.username)}
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
>
Remove
</button>
</div>
) : (
<span className="shrink-0 text-[11px] text-slate-400">{c.permission === 'edit' ? 'Can edit' : 'Can view'}</span>
)}
</div>
))}
</div>
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
@@ -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<string | null>(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 (
<Modal title={`Version History — "${project.name}"`} onClose={onClose} width="max-w-md">
<div className="space-y-1.5">
<p className="text-[11px] text-slate-500">
Checkpoints saved periodically while editing (up to the 50 most recent).
</p>
{!isLoaded && <p className="py-2 text-center text-xs italic text-slate-400">Loading…</p>}
{isLoaded && snapshots.length === 0 && (
<p className="py-2 text-center text-xs italic text-slate-400">No checkpoints yet.</p>
)}
{snapshots.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs">
<div className="min-w-0">
<div className="font-medium text-slate-700">{formatDateTime(s.createdAt)}</div>
{s.savedByUsername && <div className="text-[10px] text-slate-400">by {s.savedByUsername}</div>}
</div>
{canRestore && (
<button
onClick={() => handleRestore(s.id, s.createdAt)}
disabled={restoringId === s.id}
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700 disabled:cursor-not-allowed disabled:opacity-40"
>
Restore
</button>
)}
</div>
))}
</div>
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Close
</button>
</div>
</Modal>
)
}
+26
View File
@@ -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<DiagramCollaborator[]>
/** 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<DiagramCollaborator>
updatePermission(diagramId: string, userId: string, permission: CollaboratorPermission): Promise<void>
remove(diagramId: string, userId: string): Promise<void>
}
+12
View File
@@ -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<DiagramSummary[]>
@@ -28,4 +38,6 @@ export interface DiagramRepository {
save(project: Project): Promise<void>
/** Permanently delete one diagram by id. */
deleteById(id: string): Promise<void>
/** Who owns this diagram and what the current user can do with it. */
getAccess(id: string): Promise<DiagramAccess | null>
}
+26
View File
@@ -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<DiagramSnapshotSummary[]>
/** 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<Project | null>
}
+8 -1
View File
@@ -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<DiagramAccess | null> {
const diagram = readAll()[id]
return diagram ? { ownerId: 'local', myPermission: 'owner' } : null
}
}
@@ -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<DiagramCollaborator[]> {
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<string, string>(
((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<DiagramCollaborator> {
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<void> {
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<void> {
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)
}
}
+60 -5
View File
@@ -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<void> {
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<void> {
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<DiagramAccess | null> {
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' }
}
}
@@ -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<DiagramSnapshotSummary[]> {
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<string, string>(
((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<Project | null> {
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
}
}
+46
View File
@@ -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<void>
add: (diagramId: string, username: string, permission: CollaboratorPermission) => Promise<void>
updatePermission: (diagramId: string, userId: string, permission: CollaboratorPermission) => Promise<void>
remove: (diagramId: string, userId: string) => Promise<void>
}
export const useDiagramCollaboratorStore = create<DiagramCollaboratorStoreState>((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) }))
},
}))
+45
View File
@@ -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<void>
/** 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<Project | null>
}
export const useDiagramSnapshotStore = create<DiagramSnapshotStoreState>((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
},
}))
+81 -6
View File
@@ -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<void>
/** 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<void>
/** Permanently deletes a diagram. If it's the currently open one, falls
@@ -115,6 +140,9 @@ export const useProjectStore = create<ProjectStoreState>((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<ProjectStoreState>((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<ProjectStoreState>((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<ProjectStoreState>((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<ProjectStoreState>((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<ProjectStoreState>((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))
@@ -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;
$$;
+81 -1
View File
@@ -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
-- ----------------------------------------------------------------------