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