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 { 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 { const { data: { user }, } = await supabase.auth.getUser() return user?.id ?? null } private async insertDeviceCategory(name: string, isPublic: boolean, ownerId: string | null): Promise { 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 { return this.insertDeviceCategory(name, false, await this.currentUserId()) } adminAddDeviceCategory(name: string): Promise { return this.insertDeviceCategory(name, true, null) } async updateDeviceCategory(id: string, name: string): Promise { 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 { 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 { 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 { return this.insertManufacturer(name, false, await this.currentUserId()) } adminAddManufacturer(name: string): Promise { return this.insertManufacturer(name, true, null) } async updateManufacturer(id: string, name: string): Promise { 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 { 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, isPublic: boolean, ownerId: string | null, ): Promise { 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): Promise { return this.insertPortType(portType, false, await this.currentUserId()) } adminAddPortType(portType: Omit): Promise { return this.insertPortType(portType, true, null) } async updatePortType(id: string, patch: Partial>): Promise { const payload: Record = {} 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 { 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, isPublic: boolean, ownerId: string | null, ): Promise { 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): Promise { return this.insertCableType(cableType, false, await this.currentUserId()) } adminAddCableType(cableType: Omit): Promise { return this.insertCableType(cableType, true, null) } async updateCableType(id: string, patch: Partial>): Promise { const payload: Record = {} 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 { 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, isPublic: boolean, ownerId: string | null, ): Promise { 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): Promise { return this.insertDeviceTemplate(template, false, await this.currentUserId()) } adminAddDeviceTemplate(template: Omit): Promise { return this.insertDeviceTemplate(template, true, null) } async updateDeviceTemplate(id: string, patch: Partial>): Promise { const payload: Record = {} 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 { 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>): Promise { return this.updatePortType(id, patch) } adminUpdateCableType(id: string, patch: Partial>): Promise { return this.updateCableType(id, patch) } adminUpdateDeviceTemplate(id: string, patch: Partial>): Promise { return this.updateDeviceTemplate(id, patch) } adminUpdateDeviceCategory(id: string, name: string): Promise { return this.updateDeviceCategory(id, name) } adminUpdateManufacturer(id: string, name: string): Promise { return this.updateManufacturer(id, name) } async adminUnpublish(entityType: CatalogEntityType, id: string): Promise { 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 { return this.removeDeviceCategory(id) } adminRemoveManufacturer(id: string): Promise { return this.removeManufacturer(id) } adminRemovePortType(id: string): Promise { return this.removePortType(id) } adminRemoveCableType(id: string): Promise { return this.removeCableType(id) } async adminRemoveDeviceTemplate(id: string): Promise { 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 } } }