Files
av-planner/src/data/SupabaseCatalogRepository.ts
T
aarbitandClaude Sonnet 5 29c3ed46a3 Add manufacturer catalog, category/manufacturer library management, and built-in cable flag
Normalizes manufacturer as a shared catalog entity (like device categories)
instead of free text on each device, giving the admin duplicate-detection
nudge a reliable signal. Adds full CRUD (including Admin direct-publish,
bypassing the submission queue) for categories, manufacturers, port types,
and cable types, plus a Categories & Manufacturers library modal and a
browse-by-manufacturer/search view in the device palette. Adds
Port.builtInCable so a captive/permanently-attached cable (a keyboard's USB
lead, a budget AVR's power cord) can be flagged and excluded from the BOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
2026-09-28 10:04:05 -05:00

434 lines
17 KiB
TypeScript

import { v4 as uuid } from 'uuid'
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Manufacturer, Port, PortType } from '../domain/types'
import { CATALOG_TABLE_BY_ENTITY_TYPE, cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping'
import type { CatalogRepository } from './CatalogRepository'
import type { CatalogEntityType } from './SubmissionRepository'
import { supabase } from './supabaseClient'
interface DeviceCategoryRow {
id: string
name: string
is_public: boolean
}
interface ManufacturerRow {
id: string
name: string
is_public: boolean
}
interface PortTypeRow {
id: string
name: string
category: PortType['category']
family: string
compatible_family_ids: string[]
max_connections: number | null
is_public: boolean
}
interface CableTypeRow {
id: string
name: string
family: string
family2: string | null
unit: CableType['unit']
cost_per_unit: number | null
is_public: boolean
}
interface DeviceTemplateRow {
id: string
name: string
category_id: string
manufacturer_id: string | null
model: string | null
cost: number | null
is_public: boolean
}
interface DeviceTemplatePortRow {
id: string
device_template_id: string
name: string
direction: Port['direction']
port_type_id: string
sort_order: number
built_in_cable: boolean
}
function toDeviceCategory(row: DeviceCategoryRow): DeviceCategoryDef {
return { id: row.id, name: row.name, custom: !row.is_public }
}
function toManufacturer(row: ManufacturerRow): Manufacturer {
return { id: row.id, name: row.name, custom: !row.is_public }
}
function toPortType(row: PortTypeRow): PortType {
return {
id: row.id,
name: row.name,
category: row.category,
family: row.family,
compatibleFamilyIds: row.compatible_family_ids.length > 0 ? row.compatible_family_ids : undefined,
maxConnections: row.max_connections ?? undefined,
custom: !row.is_public,
}
}
function toCableType(row: CableTypeRow): CableType {
return {
id: row.id,
name: row.name,
family: row.family,
family2: row.family2 ?? undefined,
unit: row.unit,
costPerUnit: row.cost_per_unit ?? undefined,
custom: !row.is_public,
}
}
function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRow[]): DeviceTemplate {
return {
id: row.id,
name: row.name,
category: row.category_id,
manufacturerId: row.manufacturer_id ?? undefined,
model: row.model ?? undefined,
cost: row.cost ?? undefined,
ports: portRows
.filter((p) => p.device_template_id === row.id)
.sort((a, b) => a.sort_order - b.sort_order)
.map((p) => ({ id: p.id, name: p.name, direction: p.direction, portTypeId: p.port_type_id, builtInCable: p.built_in_cable })),
custom: !row.is_public,
}
}
/**
* Backs the app with the shared Supabase catalog tables instead of the
* per-diagram embedded custom-type arrays the app used before this pass.
* RLS scopes every select to what the current user can see (all public
* entries, plus their own private ones) — see the `*_select` policies in
* the init schema migration. Every write here creates or edits a private
* (is_public = false) row owned by the current user; there is no public
* write path yet (that's the submission workflow, organized-ideas.md §3).
*/
export class SupabaseCatalogRepository implements CatalogRepository {
async load(): Promise<Catalog> {
const [categories, manufacturers, portTypes, cableTypes, templates, templatePorts] = await Promise.all([
supabase.from('device_categories').select('id, name, is_public').order('name'),
supabase.from('manufacturers').select('id, name, is_public').order('name'),
supabase
.from('port_types')
.select('id, name, category, family, compatible_family_ids, max_connections, is_public')
.order('name'),
supabase
.from('cable_types')
.select('id, name, family, family2, unit, cost_per_unit, is_public')
.order('name'),
supabase.from('device_templates').select('id, name, category_id, manufacturer_id, model, cost, is_public').order('name'),
supabase
.from('device_template_ports')
.select('id, device_template_id, name, direction, port_type_id, sort_order, built_in_cable'),
])
for (const [label, result] of [
['device categories', categories],
['manufacturers', manufacturers],
['port types', portTypes],
['cable types', cableTypes],
['device templates', templates],
['device template ports', templatePorts],
] as const) {
if (result.error) console.error(`Failed to load ${label} from Supabase`, result.error)
}
const portRows = (templatePorts.data ?? []) as DeviceTemplatePortRow[]
return {
deviceCategories: ((categories.data ?? []) as DeviceCategoryRow[]).map(toDeviceCategory),
manufacturers: ((manufacturers.data ?? []) as ManufacturerRow[]).map(toManufacturer),
portTypes: ((portTypes.data ?? []) as PortTypeRow[]).map(toPortType),
cableTypes: ((cableTypes.data ?? []) as CableTypeRow[]).map(toCableType),
deviceTemplates: ((templates.data ?? []) as DeviceTemplateRow[]).map((row) => toDeviceTemplate(row, portRows)),
}
}
private async currentUserId(): Promise<string | null> {
const {
data: { user },
} = await supabase.auth.getUser()
return user?.id ?? null
}
private async insertDeviceCategory(name: string, isPublic: boolean, ownerId: string | null): Promise<DeviceCategoryDef> {
const id = uuid()
const { error } = await supabase.from('device_categories').insert({ id, name, is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add device category to Supabase', error)
return { id, name, custom: !isPublic }
}
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
return this.insertDeviceCategory(name, false, await this.currentUserId())
}
adminAddDeviceCategory(name: string): Promise<DeviceCategoryDef> {
return this.insertDeviceCategory(name, true, null)
}
async updateDeviceCategory(id: string, name: string): Promise<void> {
const { error } = await supabase.from('device_categories').update({ name }).eq('id', id)
if (error) console.error('Failed to update device category in Supabase', error)
}
async removeDeviceCategory(id: string): Promise<void> {
const { error } = await supabase.from('device_categories').delete().eq('id', id)
if (error) {
console.error('Failed to remove device category from Supabase', error)
throw error
}
}
private async insertManufacturer(name: string, isPublic: boolean, ownerId: string | null): Promise<Manufacturer> {
const id = uuid()
const { error } = await supabase.from('manufacturers').insert({ id, name, is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add manufacturer to Supabase', error)
return { id, name, custom: !isPublic }
}
async addManufacturer(name: string): Promise<Manufacturer> {
return this.insertManufacturer(name, false, await this.currentUserId())
}
adminAddManufacturer(name: string): Promise<Manufacturer> {
return this.insertManufacturer(name, true, null)
}
async updateManufacturer(id: string, name: string): Promise<void> {
const { error } = await supabase.from('manufacturers').update({ name }).eq('id', id)
if (error) console.error('Failed to update manufacturer in Supabase', error)
}
async removeManufacturer(id: string): Promise<void> {
const { error } = await supabase.from('manufacturers').delete().eq('id', id)
if (error) {
console.error('Failed to remove manufacturer from Supabase', error)
throw error
}
}
private async insertPortType(
portType: Omit<PortType, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<PortType> {
const id = uuid()
const { error } = await supabase
.from('port_types')
.insert({ id, ...portTypeToRow(portType), is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add port type to Supabase', error)
return { ...portType, id, custom: !isPublic }
}
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
return this.insertPortType(portType, false, await this.currentUserId())
}
adminAddPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
return this.insertPortType(portType, true, null)
}
async updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
const payload: Record<string, unknown> = {}
if (patch.name !== undefined) payload.name = patch.name
if (patch.category !== undefined) payload.category = patch.category
if (patch.family !== undefined) payload.family = patch.family
if (patch.compatibleFamilyIds !== undefined) payload.compatible_family_ids = patch.compatibleFamilyIds
if (patch.maxConnections !== undefined) payload.max_connections = patch.maxConnections
const { error } = await supabase.from('port_types').update(payload).eq('id', id)
if (error) console.error('Failed to update port type in Supabase', error)
}
async removePortType(id: string): Promise<void> {
const { error } = await supabase.from('port_types').delete().eq('id', id)
if (error) {
console.error('Failed to remove port type from Supabase', error)
throw error
}
}
private async insertCableType(
cableType: Omit<CableType, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<CableType> {
const id = uuid()
const { error } = await supabase
.from('cable_types')
.insert({ id, ...cableTypeToRow(cableType), is_public: isPublic, owner_id: ownerId })
if (error) console.error('Failed to add cable type to Supabase', error)
return { ...cableType, id, custom: !isPublic }
}
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
return this.insertCableType(cableType, false, await this.currentUserId())
}
adminAddCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
return this.insertCableType(cableType, true, null)
}
async updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
const payload: Record<string, unknown> = {}
if (patch.name !== undefined) payload.name = patch.name
if (patch.family !== undefined) payload.family = patch.family
if (patch.family2 !== undefined) payload.family2 = patch.family2
if (patch.unit !== undefined) payload.unit = patch.unit
if (patch.costPerUnit !== undefined) payload.cost_per_unit = patch.costPerUnit
const { error } = await supabase.from('cable_types').update(payload).eq('id', id)
if (error) console.error('Failed to update cable type in Supabase', error)
}
async removeCableType(id: string): Promise<void> {
const { error } = await supabase.from('cable_types').delete().eq('id', id)
if (error) {
console.error('Failed to remove cable type from Supabase', error)
throw error
}
}
private async insertDeviceTemplate(
template: Omit<DeviceTemplate, 'id' | 'custom'>,
isPublic: boolean,
ownerId: string | null,
): Promise<DeviceTemplate> {
const id = uuid()
const { ports: _ports, ...templateRow } = deviceTemplateToRow(template)
const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: isPublic, owner_id: ownerId })
if (error) {
console.error('Failed to add device template to Supabase', error)
return { ...template, id, custom: !isPublic }
}
const ports = template.ports.map((port, index) => ({ ...port, id: uuid(), sortOrder: index }))
if (ports.length > 0) {
const { error: portsError } = await supabase.from('device_template_ports').insert(
ports.map((port) => ({
id: port.id,
device_template_id: id,
name: port.name,
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: port.sortOrder,
built_in_cable: port.builtInCable ?? false,
})),
)
if (portsError) console.error('Failed to add device template ports to Supabase', portsError)
}
return { ...template, id, ports, custom: !isPublic }
}
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
return this.insertDeviceTemplate(template, false, await this.currentUserId())
}
adminAddDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
return this.insertDeviceTemplate(template, true, null)
}
async updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
const payload: Record<string, unknown> = {}
if (patch.name !== undefined) payload.name = patch.name
if (patch.category !== undefined) payload.category_id = patch.category
if (patch.manufacturerId !== undefined) payload.manufacturer_id = patch.manufacturerId
if (patch.model !== undefined) payload.model = patch.model
if (patch.cost !== undefined) payload.cost = patch.cost
if (Object.keys(payload).length > 0) {
const { error } = await supabase.from('device_templates').update(payload).eq('id', id)
if (error) console.error('Failed to update device template in Supabase', error)
}
if (patch.ports !== undefined) {
// Simplest correct approach: replace the whole port set rather than
// diffing. Templates are edited as a whole in the UI (DeviceTemplateEditor),
// so there's no risk of clobbering a concurrent partial edit.
const { error: deleteError } = await supabase.from('device_template_ports').delete().eq('device_template_id', id)
if (deleteError) console.error('Failed to clear device template ports in Supabase', deleteError)
if (patch.ports.length > 0) {
const { error: insertError } = await supabase.from('device_template_ports').insert(
patch.ports.map((port, index) => ({
id: uuid(),
device_template_id: id,
name: port.name,
direction: port.direction,
port_type_id: port.portTypeId,
sort_order: index,
built_in_cable: port.builtInCable ?? false,
})),
)
if (insertError) console.error('Failed to replace device template ports in Supabase', insertError)
}
}
}
async removeDeviceTemplate(id: string): Promise<void> {
const { error } = await supabase.from('device_templates').delete().eq('id', id)
if (error) console.error('Failed to remove device template from Supabase', error)
}
// The update* methods above never check ownership themselves — RLS does,
// server-side, based on the caller's identity — so an Admin/Super-Admin
// direct edit of a public entry is *exactly* the same write, just
// permitted by a different branch of the same policy (`is_admin()`
// instead of "owns it and it's still private"). These are aliases, not
// separate logic, so the two paths can't drift apart.
adminUpdatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
return this.updatePortType(id, patch)
}
adminUpdateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
return this.updateCableType(id, patch)
}
adminUpdateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
return this.updateDeviceTemplate(id, patch)
}
adminUpdateDeviceCategory(id: string, name: string): Promise<void> {
return this.updateDeviceCategory(id, name)
}
adminUpdateManufacturer(id: string, name: string): Promise<void> {
return this.updateManufacturer(id, name)
}
async adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void> {
const table = CATALOG_TABLE_BY_ENTITY_TYPE[entityType]
const { error } = await supabase.from(table).update({ is_public: false }).eq('id', id)
if (error) console.error('Failed to unpublish catalog entry in Supabase', error)
}
adminRemoveDeviceCategory(id: string): Promise<void> {
return this.removeDeviceCategory(id)
}
adminRemoveManufacturer(id: string): Promise<void> {
return this.removeManufacturer(id)
}
adminRemovePortType(id: string): Promise<void> {
return this.removePortType(id)
}
adminRemoveCableType(id: string): Promise<void> {
return this.removeCableType(id)
}
async adminRemoveDeviceTemplate(id: string): Promise<void> {
const { error } = await supabase.from('device_templates').delete().eq('id', id)
if (error) {
console.error('Failed to remove device template from Supabase', error)
throw error
}
}
}