Add the user-submission flow for the catalog

Lets users submit a private catalog entry (port type, cable type, device
template) for promotion to the public catalog, or suggest an edit to an
existing public entry — both go into the catalog_submissions review queue
per organized-ideas.md §3. No Admin review UI yet (next sub-phase); this
covers the submitter's side only.

- data/SubmissionRepository + SupabaseSubmissionRepository: submit,
  resubmit, withdraw, list-mine, backed by the existing catalog_submissions
  RLS policies (no schema changes needed).
- data/catalogRowMapping.ts: shared domain<->row mappers, in both
  directions, so a submission's proposed_data is always shaped like the
  underlying table row (what an eventual admin-approval would write
  directly) and can be turned back into form-editable fields for revision.
- state/submissionStore.ts: mySubmissions + submit/resubmit/withdraw, plus
  syncProposedData — called from catalogStore's updateCustom* actions so a
  submission about your own still-private entry never goes stale relative
  to it (edits from the library and from My Submissions are the same
  action and always agree).
- UI: "Submit"/"Suggest edit" wired into PortTypeManager, CableTypeManager,
  DevicePalette/DeviceTemplateEditor; new MySubmissionsModal (opened from
  TopBar, with a pending-count badge) shows status, rejection reasons, and
  lets you edit/resubmit or withdraw.
- Deliberately deferred: device_category submissions (no listing UI to
  hang a button on yet) and the normalized manufacturer catalog.

Verified: tsc -b and oxlint clean; supabase db reset + 23/23 pgTAP RLS
tests still pass (no schema changes this round); manually tested submit,
suggest-edit, edit-from-either-side sync, reject/resubmit, and withdraw.
This commit is contained in:
2026-09-08 10:37:38 -05:00
parent a03987c36b
commit a0c598cb1b
14 changed files with 968 additions and 119 deletions
+59
View File
@@ -0,0 +1,59 @@
/** Which catalog table a submission is about. Mirrors `catalog_submissions.entity_type`'s
* check constraint — `manufacturer` is omitted since the app doesn't wire up the
* normalized manufacturer catalog yet (see organized-ideas.md §3, deferred alongside
* device_templates.manufacturer staying free text for now). */
export type CatalogEntityType = 'device_template' | 'port_type' | 'cable_type' | 'device_category'
/**
* A user's request to change the public catalog — either promoting one of
* their own private entries to public, or proposing an edit to an existing
* public entry. Regular users can never write a public catalog row directly
* (RLS only allows Admins to), so this is the only path either kind of
* change can take; an Admin review queue (not yet built — organized-ideas.md
* §9's next sub-phase) is what actually applies an approved one.
*/
export interface CatalogSubmission {
id: string
entityType: CatalogEntityType
/** The row this submission is about — always an existing row in this
* app's flow: either your own private entry (being promoted, possibly
* with edits) or a public entry (you're suggesting an edit to). */
entityId: string
/** Proposed field values, shaped like the underlying table's row (see
* data/catalogRowMapping.ts) — so applying an approval is just writing
* this object onto the row at `entityId`, not a separate translation
* step future Admin-approval logic would otherwise have to redo. */
proposedData: Record<string, unknown>
submitterId: string
status: 'pending' | 'approved' | 'rejected'
reviewerId?: string
/** Set by an Admin on rejection — organized-ideas.md §3's "notified either
* way, with a reason attached on rejection rather than a silent disappearance." */
reviewReason?: string
createdAt: string
updatedAt: string
}
/** Storage abstraction for the catalog submission/review queue — mirrors
* DiagramRepository/CatalogRepository's role for this concern. Only covers
* the submitter's-eye view (create, revise, withdraw your own) for now; an
* Admin's view (list all pending, approve/reject) is the next sub-phase. */
export interface SubmissionRepository {
/** Every submission the current user has made, most recent first. */
listMine(): Promise<CatalogSubmission[]>
/** Submits `entityId` (a row you own privately, or a public row you're
* proposing an edit to) for Admin review. */
submit(entityType: CatalogEntityType, entityId: string, proposedData: Record<string, unknown>): Promise<CatalogSubmission>
/** Revises a still-pending or previously-rejected submission of your own
* and puts it (back) into the pending queue. */
resubmit(id: string, proposedData: Record<string, unknown>): Promise<void>
/** Updates only `proposed_data`, leaving status/reviewer/reason untouched.
* Used to keep a submission's snapshot in sync with the live private
* entry it's about (see catalogStore) — a plain edit to your library
* entry shouldn't silently pull a rejected submission back into the
* pending queue, so status changes stay a separate, explicit action
* (`resubmit`), while the data itself is kept fresh automatically. */
updateProposedData(id: string, proposedData: Record<string, unknown>): Promise<void>
/** Withdraws your own still-pending submission. */
withdraw(id: string): Promise<void>
}
+9 -30
View File
@@ -1,5 +1,6 @@
import { v4 as uuid } from 'uuid'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Port, PortType } from '../domain/types'
import { cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping'
import type { CatalogRepository } from './CatalogRepository'
import { supabase } from './supabaseClient'
@@ -156,16 +157,9 @@ export class SupabaseCatalogRepository implements CatalogRepository {
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
const ownerId = await this.currentUserId()
const id = uuid()
const { error } = await supabase.from('port_types').insert({
id,
name: portType.name,
category: portType.category,
family: portType.family,
compatible_family_ids: portType.compatibleFamilyIds ?? [],
max_connections: portType.maxConnections ?? null,
is_public: false,
owner_id: ownerId,
})
const { error } = await supabase
.from('port_types')
.insert({ id, ...portTypeToRow(portType), is_public: false, owner_id: ownerId })
if (error) console.error('Failed to add port type to Supabase', error)
return { ...portType, id, custom: true }
}
@@ -184,16 +178,9 @@ export class SupabaseCatalogRepository implements CatalogRepository {
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
const ownerId = await this.currentUserId()
const id = uuid()
const { error } = await supabase.from('cable_types').insert({
id,
name: cableType.name,
family: cableType.family,
family2: cableType.family2 ?? null,
unit: cableType.unit,
cost_per_unit: cableType.costPerUnit ?? null,
is_public: false,
owner_id: ownerId,
})
const { error } = await supabase
.from('cable_types')
.insert({ id, ...cableTypeToRow(cableType), is_public: false, owner_id: ownerId })
if (error) console.error('Failed to add cable type to Supabase', error)
return { ...cableType, id, custom: true }
}
@@ -212,16 +199,8 @@ export class SupabaseCatalogRepository implements CatalogRepository {
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
const ownerId = await this.currentUserId()
const id = uuid()
const { error } = await supabase.from('device_templates').insert({
id,
name: template.name,
category_id: template.category,
manufacturer: template.manufacturer ?? null,
model: template.model ?? null,
cost: template.cost ?? null,
is_public: false,
owner_id: ownerId,
})
const { ports: _ports, ...templateRow } = deviceTemplateToRow(template)
const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: false, owner_id: ownerId })
if (error) {
console.error('Failed to add device template to Supabase', error)
return { ...template, id, custom: true }
+93
View File
@@ -0,0 +1,93 @@
import type { CatalogEntityType, CatalogSubmission, SubmissionRepository } from './SubmissionRepository'
import { supabase } from './supabaseClient'
interface SubmissionRow {
id: string
entity_type: CatalogEntityType
entity_id: string
proposed_data: Record<string, unknown>
submitter_id: string
status: CatalogSubmission['status']
reviewer_id: string | null
review_reason: string | null
created_at: string
updated_at: string
}
function toSubmission(row: SubmissionRow): CatalogSubmission {
return {
id: row.id,
entityType: row.entity_type,
entityId: row.entity_id,
proposedData: row.proposed_data,
submitterId: row.submitter_id,
status: row.status,
reviewerId: row.reviewer_id ?? undefined,
reviewReason: row.review_reason ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
/** Backs the app with the `catalog_submissions` table. RLS already scopes
* every query/write here to what a regular (non-Admin) user should be able
* to see and do with their own submissions — see that table's policies in
* the init schema migration. */
export class SupabaseSubmissionRepository implements SubmissionRepository {
async listMine(): Promise<CatalogSubmission[]> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return []
const { data, error } = await supabase
.from('catalog_submissions')
.select('*')
.eq('submitter_id', user.id)
.order('created_at', { ascending: false })
if (error) {
console.error('Failed to load submissions from Supabase', error)
return []
}
return ((data ?? []) as SubmissionRow[]).map(toSubmission)
}
async submit(entityType: CatalogEntityType, entityId: string, proposedData: Record<string, unknown>): Promise<CatalogSubmission> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in.')
const { data, error } = await supabase
.from('catalog_submissions')
.insert({ entity_type: entityType, entity_id: entityId, proposed_data: proposedData, submitter_id: user.id, status: 'pending' })
.select('*')
.single()
if (error || !data) {
console.error('Failed to submit to Supabase', error)
throw error ?? new Error('Submission failed.')
}
return toSubmission(data as SubmissionRow)
}
async resubmit(id: string, proposedData: Record<string, unknown>): Promise<void> {
// Clears reviewer_id/review_reason too — a resubmission starts a fresh
// review cycle, so the old rejection reason shouldn't linger as if it
// still applied to the (now revised) proposed_data.
const { error } = await supabase
.from('catalog_submissions')
.update({ proposed_data: proposedData, status: 'pending', reviewer_id: null, review_reason: null })
.eq('id', id)
if (error) console.error('Failed to resubmit to Supabase', error)
}
async updateProposedData(id: string, proposedData: Record<string, unknown>): Promise<void> {
const { error } = await supabase.from('catalog_submissions').update({ proposed_data: proposedData }).eq('id', id)
if (error) console.error('Failed to sync submission data in Supabase', error)
}
async withdraw(id: string): Promise<void> {
const { error } = await supabase.from('catalog_submissions').delete().eq('id', id)
if (error) console.error('Failed to withdraw submission from Supabase', error)
}
}
+102
View File
@@ -0,0 +1,102 @@
import type { CableType, DeviceTemplate, PortType } from '../domain/types'
/**
* Maps domain objects to the shape of their Supabase table row (snake_case
* columns, no id/is_public/owner_id). Shared by two call sites that both
* need a row-shaped payload: SupabaseCatalogRepository's inserts, and
* catalog submissions' `proposed_data` (see SubmissionRepository) — a
* submission's proposed_data is deliberately row-shaped so approving it is
* just writing that object onto the row at `entity_id`, not a translation
* step an Admin's approval action would otherwise have to duplicate.
*/
export function portTypeToRow(portType: Omit<PortType, 'id' | 'custom'>): Record<string, unknown> {
return {
name: portType.name,
category: portType.category,
family: portType.family,
compatible_family_ids: portType.compatibleFamilyIds ?? [],
max_connections: portType.maxConnections ?? null,
}
}
export function cableTypeToRow(cableType: Omit<CableType, 'id' | 'custom'>): Record<string, unknown> {
return {
name: cableType.name,
family: cableType.family,
family2: cableType.family2 ?? null,
unit: cableType.unit,
cost_per_unit: cableType.costPerUnit ?? null,
}
}
export function deviceCategoryToRow(name: string): Record<string, unknown> {
return { name }
}
export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> {
return {
name: template.name,
category_id: template.category,
manufacturer: template.manufacturer ?? null,
model: template.model ?? null,
cost: template.cost ?? null,
ports: template.ports.map((port, index) => ({
name: port.name,
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: index,
})),
}
}
/**
* The reverse direction: turns a `catalog_submissions.proposed_data` row
* back into domain-shaped fields, so a rejected/pending submission can be
* reopened in the same form component that created it (see MySubmissionsModal)
* instead of needing a bespoke "edit a raw JSON blob" UI. Safe because
* proposed_data is always a *complete* row projection (every field of the
* type, per the `*ToRow` functions above), never a partial patch.
*/
export function rowToPortTypeFields(row: Record<string, unknown>): Omit<PortType, 'id' | 'custom'> {
const compatibleFamilyIds = row.compatible_family_ids as string[] | undefined
return {
name: row.name as string,
category: row.category as PortType['category'],
family: row.family as string,
compatibleFamilyIds: compatibleFamilyIds && compatibleFamilyIds.length > 0 ? compatibleFamilyIds : undefined,
maxConnections: (row.max_connections as number | null) ?? undefined,
}
}
export function rowToCableTypeFields(row: Record<string, unknown>): Omit<CableType, 'id' | 'custom'> {
return {
name: row.name as string,
family: row.family as string,
family2: (row.family2 as string | null) ?? undefined,
unit: row.unit as CableType['unit'],
costPerUnit: (row.cost_per_unit as number | null) ?? undefined,
}
}
export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<DeviceTemplate, 'id' | 'custom'> {
const ports = (row.ports as Array<{ name: string; direction: DeviceTemplate['ports'][number]['direction']; port_type_id: string }>) ?? []
return {
name: row.name as string,
category: row.category_id as string,
manufacturer: (row.manufacturer as string | null) ?? undefined,
model: (row.model as string | null) ?? undefined,
cost: (row.cost as number | null) ?? undefined,
ports: ports.map((port, index) => ({
id: `draft-port-${index}`,
name: port.name,
direction: port.direction,
portTypeId: port.port_type_id,
})),
}
}
export function rowToDeviceCategoryName(row: Record<string, unknown>): string {
return row.name as string
}