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