package postgres import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" "github.com/jmoiron/sqlx" "github.com/lib/pq" "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/domain" "git.threesix.ai/jordan/persona-community-5/services/persona-api/internal/port" ) // Compile-time interface check. var _ port.PersonaRepository = (*PersonaRepository)(nil) // personaRow maps to the personas table. type personaRow struct { ID string `db:"id"` Name string `db:"name"` Handle string `db:"handle"` Gender string `db:"gender"` Description string `db:"description"` Tags pq.StringArray `db:"tags"` SpecJSON []byte `db:"spec_json"` AnchorURL *string `db:"anchor_url"` AvatarURL *string `db:"avatar_url"` BannerURL *string `db:"banner_url"` ImageURLs pq.StringArray `db:"image_urls"` VideoURLs pq.StringArray `db:"video_urls"` Status string `db:"status"` CreatedAt time.Time `db:"created_at"` } func (r *personaRow) toDomain() *domain.Persona { p := &domain.Persona{ ID: domain.PersonaID(r.ID), Name: r.Name, Handle: r.Handle, Gender: r.Gender, Description: r.Description, Tags: []string(r.Tags), Status: domain.PersonaStatus(r.Status), CreatedAt: r.CreatedAt, ImageURLs: []string(r.ImageURLs), VideoURLs: []string(r.VideoURLs), } if p.Tags == nil { p.Tags = []string{} } if p.ImageURLs == nil { p.ImageURLs = []string{} } if p.VideoURLs == nil { p.VideoURLs = []string{} } if len(r.SpecJSON) > 0 { p.SpecJSON = json.RawMessage(r.SpecJSON) } if r.AnchorURL != nil { p.AnchorURL = *r.AnchorURL } if r.AvatarURL != nil { p.AvatarURL = *r.AvatarURL } if r.BannerURL != nil { p.BannerURL = *r.BannerURL } return p } // PersonaRepository implements port.PersonaRepository with PostgreSQL/CockroachDB. type PersonaRepository struct { db *sqlx.DB } // NewPersonaRepository creates a new Postgres-backed persona repository. func NewPersonaRepository(db *sqlx.DB) *PersonaRepository { return &PersonaRepository{db: db} } func (r *PersonaRepository) Create(ctx context.Context, persona *domain.Persona) error { var specJSON []byte if persona.SpecJSON != nil { specJSON = []byte(persona.SpecJSON) } var anchorURL, avatarURL, bannerURL *string if persona.AnchorURL != "" { anchorURL = &persona.AnchorURL } if persona.AvatarURL != "" { avatarURL = &persona.AvatarURL } if persona.BannerURL != "" { bannerURL = &persona.BannerURL } err := r.db.QueryRowContext(ctx, ` INSERT INTO personas (name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id `, persona.Name, persona.Handle, persona.Gender, persona.Description, pq.StringArray(persona.Tags), specJSON, anchorURL, avatarURL, bannerURL, pq.StringArray(persona.ImageURLs), pq.StringArray(persona.VideoURLs), string(persona.Status), persona.CreatedAt, ).Scan(&persona.ID) if err != nil { if isUniqueViolation(err) { return domain.ErrDuplicateHandle } return fmt.Errorf("insert persona: %w", err) } return nil } func (r *PersonaRepository) GetByID(ctx context.Context, id domain.PersonaID) (*domain.Persona, error) { var row personaRow err := r.db.GetContext(ctx, &row, ` SELECT id, name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at FROM personas WHERE id = $1 `, string(id)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrPersonaNotFound } return nil, fmt.Errorf("get persona: %w", err) } return row.toDomain(), nil } func (r *PersonaRepository) List(ctx context.Context, limit, offset int) ([]*domain.Persona, error) { var rows []personaRow err := r.db.SelectContext(ctx, &rows, ` SELECT id, name, handle, gender, description, tags, spec_json, anchor_url, avatar_url, banner_url, image_urls, video_urls, status, created_at FROM personas ORDER BY created_at DESC LIMIT $1 OFFSET $2 `, limit, offset) if err != nil { return nil, fmt.Errorf("list personas: %w", err) } result := make([]*domain.Persona, len(rows)) for i := range rows { result[i] = rows[i].toDomain() } return result, nil } func (r *PersonaRepository) Update(ctx context.Context, persona *domain.Persona) error { var specJSON []byte if persona.SpecJSON != nil { specJSON = []byte(persona.SpecJSON) } var anchorURL, avatarURL, bannerURL *string if persona.AnchorURL != "" { anchorURL = &persona.AnchorURL } if persona.AvatarURL != "" { avatarURL = &persona.AvatarURL } if persona.BannerURL != "" { bannerURL = &persona.BannerURL } result, err := r.db.ExecContext(ctx, ` UPDATE personas SET name = $2, handle = $3, gender = $4, description = $5, tags = $6, spec_json = $7, anchor_url = $8, avatar_url = $9, banner_url = $10, image_urls = $11, video_urls = $12, status = $13 WHERE id = $1 `, string(persona.ID), persona.Name, persona.Handle, persona.Gender, persona.Description, pq.StringArray(persona.Tags), specJSON, anchorURL, avatarURL, bannerURL, pq.StringArray(persona.ImageURLs), pq.StringArray(persona.VideoURLs), string(persona.Status), ) if err != nil { if isUniqueViolation(err) { return domain.ErrDuplicateHandle } return fmt.Errorf("update persona: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("update persona rows affected: %w", err) } if rows == 0 { return domain.ErrPersonaNotFound } return nil }