package storage import ( "database/sql" "encoding/json" "fmt" "strings" "time" ) type AssetRecord struct { Name string Description string TemplateName string BusinessName string BodyJSON string CreatedAt string UpdatedAt string } type IntegrationServiceRecord struct { Name string ServiceType string Description string Enabled bool BodyJSON string CreatedAt string UpdatedAt string } type VideoSourceRecord struct { ID int Name string SourceType string Area string Description string BodyJSON string CreatedAt string UpdatedAt string } type DeviceAssignmentRecord struct { DeviceID string ProfileName string Description string BodyJSON string CreatedAt string UpdatedAt string } type RecognitionUnitRecord struct { SceneTemplateName string Name string DisplayName string SiteName string VideoSourceRef string OutputChannel string RTSPPort string Description string BodyJSON string CreatedAt string UpdatedAt string } type AssetsRepo struct { db *sql.DB } func NewAssetsRepo(db *sql.DB) *AssetsRepo { return &AssetsRepo{db: db} } func (r *AssetsRepo) SaveTemplate(name string, description string, bodyJSON string) error { return r.saveAsset("templates", AssetRecord{ Name: name, Description: description, BodyJSON: bodyJSON, }) } func (r *AssetsRepo) SaveProfile(name string, templateName string, businessName string, description string, bodyJSON string) error { return r.saveAsset("profiles", AssetRecord{ Name: name, TemplateName: templateName, BusinessName: businessName, Description: description, BodyJSON: bodyJSON, }) } func (r *AssetsRepo) SaveOverlay(name string, description string, bodyJSON string) error { return r.saveAsset("overlays", AssetRecord{ Name: name, Description: description, BodyJSON: bodyJSON, }) } func (r *AssetsRepo) SaveIntegrationService(name string, serviceType string, description string, enabled bool, bodyJSON string) error { if r == nil || r.db == nil { return nil } now := time.Now().Format(time.RFC3339) _, err := r.db.Exec(` INSERT INTO integration_services(name, type, description, enabled, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM integration_services WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET type=excluded.type, description=excluded.description, enabled=excluded.enabled, body_json=excluded.body_json, updated_at=excluded.updated_at `, name, serviceType, description, enabled, bodyJSON, name, now, now) return err } func (r *AssetsRepo) SaveVideoSource(id int, name string, sourceType string, area string, description string, bodyJSON string) error { if r == nil || r.db == nil { return nil } now := time.Now().Format(time.RFC3339) if id > 0 { // Update existing by ID _, err := r.db.Exec(` UPDATE video_sources SET name=?, source_type=?, area=?, description=?, body_json=?, updated_at=? WHERE id=?`, name, sourceType, area, description, bodyJSON, now, id) return err } // Insert new _, err := r.db.Exec(` INSERT INTO video_sources(name, source_type, area, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?) `, name, sourceType, area, description, bodyJSON, now, now) return err } func (r *AssetsRepo) SaveDeviceAssignment(deviceID string, profileName string, description string, bodyJSON string) error { if r == nil || r.db == nil { return nil } now := time.Now().Format(time.RFC3339) _, err := r.db.Exec(` INSERT INTO device_assignments(device_id, profile_name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM device_assignments WHERE device_id = ?), ?), ?) ON CONFLICT(device_id) DO UPDATE SET profile_name=excluded.profile_name, description=excluded.description, body_json=excluded.body_json, updated_at=excluded.updated_at `, deviceID, profileName, description, bodyJSON, deviceID, now, now) return err } func (r *AssetsRepo) SaveRecognitionUnit(record RecognitionUnitRecord) error { if r == nil || r.db == nil { return nil } now := time.Now().Format(time.RFC3339) _, err := r.db.Exec(` INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?) ON CONFLICT(scene_template_name, name) DO UPDATE SET display_name=excluded.display_name, site_name=excluded.site_name, video_source_ref=excluded.video_source_ref, output_channel=excluded.output_channel, rtsp_port=excluded.rtsp_port, description=excluded.description, body_json=excluded.body_json, updated_at=excluded.updated_at `, record.SceneTemplateName, record.Name, record.DisplayName, record.SiteName, record.VideoSourceRef, record.OutputChannel, record.RTSPPort, record.Description, record.BodyJSON, record.SceneTemplateName, record.Name, now, now) return err } func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) { return r.listAssets("templates") } func (r *AssetsRepo) ListProfiles() ([]AssetRecord, error) { return r.listAssets("profiles") } func (r *AssetsRepo) ListOverlays() ([]AssetRecord, error) { return r.listAssets("overlays") } func (r *AssetsRepo) ListIntegrationServices() ([]IntegrationServiceRecord, error) { if r == nil || r.db == nil { return nil, nil } rows, err := r.db.Query(` SELECT name, type, description, enabled, body_json, created_at, updated_at FROM integration_services ORDER BY updated_at DESC, name ASC `) if err != nil { return nil, err } defer rows.Close() var out []IntegrationServiceRecord for rows.Next() { var item IntegrationServiceRecord if err := rows.Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (r *AssetsRepo) ListVideoSources() ([]VideoSourceRecord, error) { if r == nil || r.db == nil { return nil, nil } rows, err := r.db.Query(` SELECT id, name, source_type, area, description, body_json, created_at, updated_at FROM video_sources ORDER BY updated_at DESC, name ASC `) if err != nil { return nil, err } defer rows.Close() var out []VideoSourceRecord for rows.Next() { var item VideoSourceRecord if err := rows.Scan(&item.ID, &item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (r *AssetsRepo) ListDeviceAssignments() ([]DeviceAssignmentRecord, error) { if r == nil || r.db == nil { return nil, nil } rows, err := r.db.Query(` SELECT device_id, profile_name, description, body_json, created_at, updated_at FROM device_assignments ORDER BY updated_at DESC, device_id ASC `) if err != nil { return nil, err } defer rows.Close() var out []DeviceAssignmentRecord for rows.Next() { var item DeviceAssignmentRecord if err := rows.Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (r *AssetsRepo) ListRecognitionUnits() ([]RecognitionUnitRecord, error) { if r == nil || r.db == nil { return nil, nil } rows, err := r.db.Query(` SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at FROM recognition_units ORDER BY scene_template_name ASC, name ASC `) if err != nil { return nil, err } defer rows.Close() var out []RecognitionUnitRecord for rows.Next() { var item RecognitionUnitRecord if err := rows.Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) { return r.getAsset("templates", name) } func (r *AssetsRepo) GetProfile(name string) (*AssetRecord, error) { return r.getAsset("profiles", name) } func (r *AssetsRepo) GetOverlay(name string) (*AssetRecord, error) { return r.getAsset("overlays", name) } func (r *AssetsRepo) GetIntegrationService(name string) (*IntegrationServiceRecord, error) { if r == nil || r.db == nil { return nil, nil } var item IntegrationServiceRecord err := r.db.QueryRow(` SELECT name, type, description, enabled, body_json, created_at, updated_at FROM integration_services WHERE name = ? `, name).Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *AssetsRepo) GetVideoSource(name string) (*VideoSourceRecord, error) { if r == nil || r.db == nil { return nil, nil } var item VideoSourceRecord err := r.db.QueryRow(` SELECT id, name, source_type, area, description, body_json, created_at, updated_at FROM video_sources WHERE name = ? `, name).Scan(&item.ID, &item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *AssetsRepo) GetDeviceAssignment(deviceID string) (*DeviceAssignmentRecord, error) { if r == nil || r.db == nil { return nil, nil } var item DeviceAssignmentRecord err := r.db.QueryRow(` SELECT device_id, profile_name, description, body_json, created_at, updated_at FROM device_assignments WHERE device_id = ? `, deviceID).Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *AssetsRepo) GetRecognitionUnit(sceneTemplateName string, name string) (*RecognitionUnitRecord, error) { if r == nil || r.db == nil { return nil, nil } var item RecognitionUnitRecord err := r.db.QueryRow(` SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at FROM recognition_units WHERE scene_template_name = ? AND name = ? `, sceneTemplateName, name).Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *AssetsRepo) DeleteTemplate(name string) error { return r.deleteAsset("templates", name) } func (r *AssetsRepo) DeleteProfile(name string) error { return r.deleteAsset("profiles", name) } func (r *AssetsRepo) DeleteIntegrationService(name string) error { if r == nil || r.db == nil { return nil } _, err := r.db.Exec(`DELETE FROM integration_services WHERE name = ?`, name) return err } func (r *AssetsRepo) DeleteVideoSource(name string) error { if r == nil || r.db == nil { return nil } _, err := r.db.Exec(`DELETE FROM video_sources WHERE name = ?`, name) return err } func (r *AssetsRepo) DeleteDeviceAssignment(deviceID string) error { if r == nil || r.db == nil { return nil } _, err := r.db.Exec(`DELETE FROM device_assignments WHERE device_id = ?`, deviceID) return err } func (r *AssetsRepo) DeleteRecognitionUnit(sceneTemplateName string, name string) error { if r == nil || r.db == nil { return nil } _, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ? AND name = ?`, sceneTemplateName, name) return err } func (r *AssetsRepo) DeleteOverlay(name string) error { return r.deleteAsset("overlays", name) } func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error { if r == nil || r.db == nil { return nil } oldName = strings.TrimSpace(oldName) newName = strings.TrimSpace(newName) if oldName == "" || newName == "" { return fmt.Errorf("template name is required") } if oldName == newName { return r.SaveTemplate(newName, description, bodyJSON) } tx, err := r.db.Begin() if err != nil { return err } defer func() { if tx != nil { _ = tx.Rollback() } }() var exists int if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, oldName).Scan(&exists); err != nil { return err } if exists == 0 { return sql.ErrNoRows } if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, newName).Scan(&exists); err != nil { return err } if exists > 0 { return fmt.Errorf("template %q already exists", newName) } now := time.Now().Format(time.RFC3339) if _, err := tx.Exec(` INSERT INTO templates(name, description, body_json, created_at, updated_at) SELECT ?, ?, ?, created_at, ? FROM templates WHERE name = ? `, newName, description, bodyJSON, now, oldName); err != nil { return err } rows, err := tx.Query(` SELECT name, description, business_name, body_json FROM scene_templates WHERE primary_template_name = ? OR body_json LIKE ? `, oldName, "%\"primary_template_name\": \""+oldName+"\"%") if err != nil { return err } defer rows.Close() type profileUpdate struct { name string description string businessName string bodyJSON string } updates := make([]profileUpdate, 0) for rows.Next() { var item profileUpdate if err := rows.Scan(&item.name, &item.description, &item.businessName, &item.bodyJSON); err != nil { return err } rewritten, changed, err := rewriteProfileTemplateRefs(item.bodyJSON, oldName, newName) if err != nil { return err } if changed { item.bodyJSON = rewritten } updates = append(updates, item) } if err := rows.Err(); err != nil { return err } for _, item := range updates { if _, err := tx.Exec(` UPDATE scene_templates SET primary_template_name = ?, body_json = ?, updated_at = ? WHERE name = ? `, newName, item.bodyJSON, now, item.name); err != nil { return err } } if _, err := tx.Exec(` UPDATE recognition_units SET body_json = REPLACE(body_json, ?, ?), updated_at = ? WHERE body_json LIKE ? `, `"`+"template"+`": "`+oldName+`"`, `"`+"template"+`": "`+newName+`"`, now, "%\"template\": \""+oldName+"\"%"); err != nil { return err } if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil { return err } if err := tx.Commit(); err != nil { return err } tx = nil return nil } func (r *AssetsRepo) saveAsset(table string, record AssetRecord) error { if r == nil || r.db == nil { fmt.Printf("[DB] saveAsset SKIP nil table=%s\n", table) return nil } fmt.Printf("[DB] saveAsset table=%s name=%s\n", table, record.Name) now := time.Now().Format(time.RFC3339) switch table { case "templates", "overlays": _, err := r.db.Exec(` INSERT INTO `+table+`(name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, COALESCE((SELECT created_at FROM `+table+` WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET description=excluded.description, body_json=excluded.body_json, updated_at=excluded.updated_at `, record.Name, record.Description, record.BodyJSON, record.Name, now, now) return err case "profiles": normalized, units, replaceUnits, err := normalizeProfileRecord(record) if err != nil { return err } // Save profile row (atomic Exec, no transaction). _, err = r.db.Exec(` INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM scene_templates WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET primary_template_name=excluded.primary_template_name, business_name=excluded.business_name, description=excluded.description, body_json=excluded.body_json, updated_at=excluded.updated_at `, normalized.Name, normalized.TemplateName, normalized.BusinessName, normalized.Description, normalized.BodyJSON, normalized.Name, now, now) if err != nil { return err } // Recognition units are saved separately via SaveRecognitionUnit. // Only do replace logic here when called from the old profile editor UI // (where units are embedded in the profile JSON and not saved separately). if replaceUnits { if _, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil { return err } for _, unit := range units { if _, err := r.db.Exec(` INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?) ON CONFLICT(scene_template_name, name) DO UPDATE SET display_name=excluded.display_name, site_name=excluded.site_name, video_source_ref=excluded.video_source_ref, output_channel=excluded.output_channel, rtsp_port=excluded.rtsp_port, description=excluded.description, body_json=excluded.body_json, updated_at=excluded.updated_at `, unit.SceneTemplateName, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, unit.Description, unit.BodyJSON, unit.SceneTemplateName, unit.Name, now, now); err != nil { return err } } } return nil default: return nil } } func normalizeProfileRecord(record AssetRecord) (AssetRecord, []RecognitionUnitRecord, bool, error) { raw := map[string]any{} if strings.TrimSpace(record.BodyJSON) != "" { if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { return AssetRecord{}, nil, false, err } } if raw == nil { raw = map[string]any{} } _, hasInstances := raw["instances"] if !hasInstances { return record, nil, false, nil } templateDoc, units, err := splitLegacyProfileDocument(struct { Name string TemplateName string BusinessName string Description string BodyJSON string CreatedAt string UpdatedAt string }{ Name: record.Name, TemplateName: record.TemplateName, BusinessName: record.BusinessName, Description: record.Description, BodyJSON: record.BodyJSON, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, }) if err != nil { return AssetRecord{}, nil, false, err } body, err := json.MarshalIndent(templateDoc, "", " ") if err != nil { return AssetRecord{}, nil, false, err } normalized := record normalized.BodyJSON = string(append(body, '\n')) outUnits := make([]RecognitionUnitRecord, 0, len(units)) for _, unit := range units { outUnits = append(outUnits, RecognitionUnitRecord{ SceneTemplateName: record.Name, Name: unit.Name, DisplayName: unit.DisplayName, SiteName: unit.SiteName, VideoSourceRef: unit.VideoSourceRef, OutputChannel: unit.OutputChannel, RTSPPort: unit.RTSPPort, Description: record.Description, BodyJSON: unit.BodyJSON, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, }) } return normalized, outUnits, true, nil } func (r *AssetsRepo) listAssets(table string) ([]AssetRecord, error) { if r == nil || r.db == nil { return nil, nil } query := ` SELECT name, description, body_json, created_at, updated_at, '', '' FROM ` + table + ` ORDER BY updated_at DESC, name ASC ` if table == "profiles" { query = ` SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name FROM scene_templates ORDER BY updated_at DESC, name ASC ` } rows, err := r.db.Query(query) if err != nil { return nil, err } defer rows.Close() var out []AssetRecord for rows.Next() { var item AssetRecord if err := rows.Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (r *AssetsRepo) getAsset(table string, name string) (*AssetRecord, error) { if r == nil || r.db == nil { return nil, nil } query := ` SELECT name, description, body_json, created_at, updated_at, '', '' FROM ` + table + ` WHERE name = ? ` if table == "profiles" { query = ` SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name FROM scene_templates WHERE name = ? ` } var item AssetRecord err := r.db.QueryRow(query, name).Scan(&item.Name, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt, &item.TemplateName, &item.BusinessName) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &item, nil } func (r *AssetsRepo) deleteAsset(table string, name string) error { if r == nil || r.db == nil { return nil } if table == "profiles" { table = "scene_templates" } _, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name) return err } func rewriteProfileTemplateRefs(bodyJSON string, oldName string, newName string) (string, bool, error) { var raw map[string]any if err := json.Unmarshal([]byte(bodyJSON), &raw); err != nil { return "", false, err } changed := false if strings.TrimSpace(valueString(raw["primary_template_name"])) == oldName { raw["primary_template_name"] = newName changed = true } instances, _ := raw["instances"].([]any) for _, item := range instances { instanceMap, _ := item.(map[string]any) if strings.TrimSpace(valueString(instanceMap["template"])) == oldName { instanceMap["template"] = newName changed = true } } if !changed { return bodyJSON, false, nil } body, err := json.MarshalIndent(raw, "", " ") if err != nil { return "", false, err } return string(append(body, '\n')), true, nil } func valueString(v any) string { switch vv := v.(type) { case string: return vv case float64: return fmt.Sprintf("%v", vv) default: return "" } } // ---- device_features ---- // DeviceFeatureRecord holds the persisted feature selection for a device. type DeviceFeatureRecord struct { DeviceID string `json:"device_id"` FeaturesJSON string `json:"features_json"` // JSON array of feature keys, e.g. ["face","shoe"] UpdatedAt string `json:"updated_at"` } // SaveDeviceFeatures upserts the feature selection for a device. func (r *AssetsRepo) SaveDeviceFeatures(deviceID string, featuresJSON string) error { if r == nil || r.db == nil { fmt.Printf("[DB] SaveDeviceFeatures SKIP nil\n") return nil } fmt.Printf("[DB] SaveDeviceFeatures device=%s val=%s\n", deviceID, featuresJSON) now := time.Now().Format(time.RFC3339) _, err := r.db.Exec(` INSERT INTO device_features(device_id, features_json, updated_at) VALUES(?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET features_json=excluded.features_json, updated_at=excluded.updated_at `, deviceID, featuresJSON, now) return err } // GetDeviceFeatures returns the persisted features for a device, or nil. func (r *AssetsRepo) GetDeviceFeatures(deviceID string) (*DeviceFeatureRecord, error) { if r == nil || r.db == nil { return nil, nil } var rec DeviceFeatureRecord err := r.db.QueryRow( "SELECT device_id, features_json, updated_at FROM device_features WHERE device_id = ?", deviceID, ).Scan(&rec.DeviceID, &rec.FeaturesJSON, &rec.UpdatedAt) if err != nil { return nil, nil // not found is not an error } return &rec, nil }