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
+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
}
}