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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }))
|
||||
},
|
||||
}))
|
||||
@@ -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
|
||||
},
|
||||
}))
|
||||
@@ -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
@@ -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
|
||||
-- ----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user