persona-community-5/services/persona-api/internal/adapter/postgres/persona.go
rdev-worker 9c009926d1
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /implement-feature persona-model --requirements 'DB migration in pers...
2026-02-24 07:58:27 +00:00

219 lines
5.7 KiB
Go

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
}