// Package actor provides database access for the Actor resource. // It defines the DBActor struct and ActorTranslator to convert between // the API format (api.pb.go) and the database format (schema.sql). package actor import ( "database/sql" "errors" "fmt" "regexp" "strings" "time" v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" "github.com/google/uuid" "github.com/lib/pq" "google.golang.org/protobuf/types/known/timestamppb" ) var ( // ErrActorIDInvalid is returned when the actor_id format is invalid. ErrActorIDInvalid = errors.New("invalid actor_id format: must be 4-63 characters, matching pattern /[a-z][0-9-]/") // actorIDPattern validates the actor_id format. actorIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`) ) // DBActor represents an actor in the PostgreSQL database. type DBActor struct { ID int64 StoryID int64 ActorID string NameValue string Role string Notes string CreatedAt time.Time UpdatedAt time.Time Etag string } // ActorTranslator provides conversion methods between API and database formats. type ActorTranslator struct{} func NewActorTranslator() *ActorTranslator { return &ActorTranslator{} } func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) { if apiActor == nil { return nil, errors.New("api actor cannot be nil") } if apiActor.ActorId != "" { if err := validateActorID(apiActor.ActorId); err != nil { return nil, err } } if apiActor.NameValue == "" { return nil, errors.New("name_value is required") } var createdAt, updatedAt time.Time if apiActor.GetCreateTime() != nil { createdAt = apiActor.CreateTime.AsTime() } if apiActor.GetUpdateTime() != nil { updatedAt = apiActor.UpdateTime.AsTime() } return &DBActor{ ID: 0, StoryID: 0, ActorID: apiActor.ActorId, NameValue: apiActor.NameValue, Role: apiActor.Role, Notes: apiActor.Notes, CreatedAt: createdAt, UpdatedAt: updatedAt, Etag: apiActor.Etag, }, nil } func (t *ActorTranslator) ToAPI(dbActor *DBActor) *v1.Actor { if dbActor == nil { return nil } name := "" if dbActor.StoryID > 0 && dbActor.ActorID != "" { name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID) } return &v1.Actor{ Name: name, ActorId: dbActor.ActorID, NameValue: dbActor.NameValue, Role: dbActor.Role, Notes: dbActor.Notes, CreateTime: timestamppb.New(dbActor.CreatedAt), UpdateTime: timestamppb.New(dbActor.UpdatedAt), Etag: dbActor.Etag, } } func (t *ActorTranslator) ToAPIDetailed(dbActor *DBActor, storyID int64) *v1.Actor { apiActor := t.ToAPI(dbActor) if dbActor.StoryID > 0 { apiActor.Name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID) } return apiActor } func (a *DBActor) Validate() error { if a.ActorID == "" { return errors.New("actor_id is required") } if a.NameValue == "" { return errors.New("name_value is required") } if a.StoryID <= 0 { return errors.New("story_id is required") } if err := validateActorID(a.ActorID); err != nil { return err } return nil } func validateActorID(actorID string) error { if actorID == "" { return ErrActorIDInvalid } if len(actorID) < 4 || len(actorID) > 63 { return ErrActorIDInvalid } if !actorIDPattern.MatchString(actorID) { return ErrActorIDInvalid } return nil } func (a *DBActor) Save(db *sql.DB) error { if err := a.Validate(); err != nil { return err } if a.ID > 0 { return a.update(db) } return a.insert(db) } func (a *DBActor) insert(db *sql.DB) error { query := ` INSERT INTO actors (story_id, actor_id, name_value, role, notes, created_at, updated_at, etag) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, updated_at, etag ` now := time.Now() if a.CreatedAt.IsZero() { a.CreatedAt = now } if a.UpdatedAt.IsZero() { a.UpdatedAt = now } etag := a.Etag if etag == "" { etag = generateEtag() } var createdID int64 var createdAt, updatedAt time.Time var returnedEtag string err := db.QueryRow( query, a.StoryID, a.ActorID, a.NameValue, pq.Array([]string{a.Role}), pq.Array([]string{a.Notes}), a.CreatedAt, a.UpdatedAt, etag, ).Scan(&createdID, &createdAt, &updatedAt, &returnedEtag) if err != nil { return fmt.Errorf("failed to insert actor: %w", err) } a.ID = createdID a.CreatedAt = createdAt a.UpdatedAt = updatedAt a.Etag = returnedEtag return nil } func (a *DBActor) update(db *sql.DB) error { query := ` UPDATE actors SET name_value = $1, role = $2, notes = $3, updated_at = $4, etag = $5 WHERE id = $6 RETURNING created_at, updated_at, etag ` now := time.Now() if a.UpdatedAt.IsZero() { a.UpdatedAt = now } etag := a.Etag if etag == "" { etag = generateEtag() } var createdAt, updatedAt time.Time var returnedEtag string err := db.QueryRow( query, a.NameValue, a.Role, a.Notes, a.UpdatedAt, etag, a.ID, ).Scan(&createdAt, &updatedAt, &returnedEtag) if err != nil { return fmt.Errorf("failed to update actor: %w", err) } a.CreatedAt = createdAt a.UpdatedAt = updatedAt a.Etag = returnedEtag return nil } func generateEtag() string { return uuid.New().String() } func GetByID(db *sql.DB, id int64) (*DBActor, error) { query := ` SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag FROM actors WHERE id = $1 ` actor := &DBActor{} err := db.QueryRow(query, id).Scan( &actor.ID, &actor.StoryID, &actor.ActorID, &actor.NameValue, &actor.Role, &actor.Notes, &actor.CreatedAt, &actor.UpdatedAt, &actor.Etag, ) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("actor with id %d not found", id) } return nil, fmt.Errorf("failed to get actor: %w", err) } return actor, nil } func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) { if err := validateActorID(actorID); err != nil { return nil, err } query := ` SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag FROM actors WHERE story_id = $1 AND actor_id = $2 ` actor := &DBActor{} err := db.QueryRow(query, storyID, actorID).Scan( &actor.ID, &actor.StoryID, &actor.ActorID, &actor.NameValue, &actor.Role, &actor.Notes, &actor.CreatedAt, &actor.UpdatedAt, &actor.Etag, ) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("actor with story_id %d and actor_id '%s' not found", storyID, actorID) } return nil, fmt.Errorf("failed to get actor: %w", err) } return actor, nil } func ListByStoryID(db *sql.DB, storyID int64) ([]*DBActor, error) { if storyID <= 0 { return nil, errors.New("story_id must be positive") } query := ` SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag FROM actors WHERE story_id = $1 ORDER BY name_value ASC ` rows, err := db.Query(query, storyID) if err != nil { return nil, fmt.Errorf("failed to list actors: %w", err) } defer rows.Close() actors := make([]*DBActor, 0) for rows.Next() { var id int64 var storyID int64 var actorID, nameValue, role, notes string var createdAt, updatedAt time.Time var etag string err := rows.Scan(&id, &storyID, &actorID, &nameValue, &role, ¬es, &createdAt, &updatedAt, &etag) if err != nil { return nil, fmt.Errorf("failed to scan actor: %w", err) } actors = append(actors, &DBActor{ ID: id, StoryID: storyID, ActorID: actorID, NameValue: nameValue, Role: role, Notes: notes, CreatedAt: createdAt, UpdatedAt: updatedAt, Etag: etag, }) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating actors: %w", err) } return actors, nil } func Delete(db *sql.DB, id int64) error { query := ` DELETE FROM actors WHERE id = $1 ` result, err := db.Exec(query, id) if err != nil { return fmt.Errorf("failed to delete actor: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("actor with id %d not found", id) } return nil } func GetActorIDForResourceName(resourceName string) (int64, string, error) { prefix := "stories/" if !strings.HasPrefix(resourceName, prefix) { return 0, "", fmt.Errorf("invalid resource name: must start with '%s', got '%s'", prefix, resourceName) } remaining := strings.TrimPrefix(resourceName, prefix) actorPrefix := "actors/" if !strings.HasPrefix(remaining, actorPrefix) { return 0, "", fmt.Errorf("invalid actor resource name: must contain '%s', got '%s'", actorPrefix, remaining) } actorID := strings.TrimPrefix(remaining, actorPrefix) if actorID == "" { return 0, "", errors.New("actor_id is empty in resource name") } if err := validateActorID(actorID); err != nil { return 0, "", fmt.Errorf("invalid actor_id in resource name: %w", err) } storyPart := strings.TrimSuffix(remaining, actorPrefix+actorID) if storyPart == "" { return 0, "", errors.New("story_id is missing from resource name") } var storyID int64 _, err := fmt.Sscanf(storyPart, "%d", &storyID) if err != nil || storyID <= 0 { return 0, "", errors.New("invalid story_id in resource name") } return storyID, actorID, nil }