// Package story provides database access for the Story resource. // It defines the DBStory struct and StoryTranslator to convert between // the API format (api.pb.go) and the database format (schema.sql). package story import ( "database/sql" "errors" "fmt" "regexp" "strings" "time" v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" "github.com/google/uuid" "google.golang.org/protobuf/types/known/timestamppb" ) var ( // ErrStoryIDInvalid is returned when the story_id format is invalid. ErrStoryIDInvalid = errors.New("invalid story_id format: must be 4-63 characters, matching pattern /[a-z][0-9-]/") // storyIDPattern validates the story_id format. // Must start with a lowercase letter, end with a letter or number, // and contain only lowercase letters, numbers, and hyphens. storyIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`) ) // DBStory represents a story in the PostgreSQL database. // This is the database model that corresponds to the schema.sql structure. type DBStory struct { // Database-generated unique identifier ID int64 // StoryID is the resource identifier used in the story's resource name. // Format: stories/{story_id} StoryID string // Title of the story. Title string // Content of the story. Content string // Description or summary of the story. Description string // Labels for organizing and categorizing the story. Labels []string // CreatedAt is when the story was created. CreatedAt time.Time // UpdatedAt is when the story was last updated. UpdatedAt time.Time // Etag is used for concurrency control. Etag string } // StoryTranslator provides conversion methods between API and database formats. type StoryTranslator struct{} // NewStoryTranslator creates a new StoryTranslator instance. func NewStoryTranslator() *StoryTranslator { return &StoryTranslator{} } // FromAPI converts an API Story message to a DBStory struct. // This handles the conversion from the protobuf-generated type to the database model. func (t *StoryTranslator) FromAPI(apiStory *v1.Story) (*DBStory, error) { if apiStory == nil { return nil, errors.New("api story cannot be nil") } // Validate story_id if provided (required for create operations) if apiStory.StoryId != "" { if err := validateStoryID(apiStory.StoryId); err != nil { return nil, err } } // Validate title (required field) if apiStory.Title == "" { return nil, errors.New("title is required") } // Convert labels (may be nil) labels := apiStory.GetLabels() if labels == nil { labels = []string{} } // Build resource name if not set name := apiStory.GetName() if name == "" && apiStory.StoryId != "" { name = fmt.Sprintf("stories/%s", apiStory.StoryId) } // Convert timestamps if provided var createdAt, updatedAt time.Time if apiStory.GetCreateTime() != nil { createdAt = apiStory.CreateTime.AsTime() } if apiStory.GetUpdateTime() != nil { updatedAt = apiStory.UpdateTime.AsTime() } return &DBStory{ ID: 0, // Will be generated by database StoryID: apiStory.StoryId, Title: apiStory.Title, Content: apiStory.Content, Description: apiStory.Description, Labels: labels, CreatedAt: createdAt, UpdatedAt: updatedAt, Etag: apiStory.Etag, }, nil } // ToAPI converts a DBStory struct to an API Story message. // This prepares the database model for response serialization. func (t *StoryTranslator) ToAPI(dbStory *DBStory) *v1.Story { if dbStory == nil { return nil } // Build scenes list from the story scenes := make([]*v1.Scene, 0) // Build actors list from the story actors := make([]*v1.Actor, 0) // Build resource name name := fmt.Sprintf("stories/%s", dbStory.StoryID) return &v1.Story{ Name: name, StoryId: dbStory.StoryID, Title: dbStory.Title, Content: dbStory.Content, Description: dbStory.Description, Labels: dbStory.Labels, CreateTime: timestamppb.New(dbStory.CreatedAt), UpdateTime: timestamppb.New(dbStory.UpdatedAt), Etag: dbStory.Etag, Scenes: scenes, Actors: actors, } } // ToAPIDetailed converts a DBStory to an API Story with nested scenes and actors. // This is used when loading stories with their related resources. func (t *StoryTranslator) ToAPIDetailed(dbStory *DBStory, scenes []*v1.Scene, actors []*v1.Actor) *v1.Story { apiStory := t.ToAPI(dbStory) apiStory.Scenes = scenes apiStory.Actors = actors return apiStory } // Validate validates the DBStory struct. func (s *DBStory) Validate() error { if s.StoryID == "" { return errors.New("story_id is required") } if err := validateStoryID(s.StoryID); err != nil { return err } if s.Title == "" { return errors.New("title is required") } return nil } // validateStoryID validates the story_id format. func validateStoryID(storyID string) error { if storyID == "" { return ErrStoryIDInvalid } if len(storyID) < 4 || len(storyID) > 63 { return ErrStoryIDInvalid } if !storyIDPattern.MatchString(storyID) { return ErrStoryIDInvalid } return nil } // Save saves the DBStory to the database. // If the story has an ID, it performs an UPDATE; otherwise, it performs an INSERT. func (s *DBStory) Save(db *sql.DB) error { if err := s.Validate(); err != nil { return err } // Check if this is an update (has ID from database) or insert if s.ID > 0 { return s.update(db) } return s.insert(db) } // insert inserts a new story into the database. func (s *DBStory) insert(db *sql.DB) error { query := ` INSERT INTO stories (story_id, title, content, description, labels, created_at, updated_at, etag) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, updated_at, etag ` labels := make([]string, len(s.Labels)) copy(labels, s.Labels) now := time.Now() if s.CreatedAt.IsZero() { s.CreatedAt = now } if s.UpdatedAt.IsZero() { s.UpdatedAt = now } // Generate etag if not provided etag := s.Etag if etag == "" { etag = generateEtag() } var createdID int64 var createdAt, updatedAt time.Time var returnedEtag string err := db.QueryRow( query, s.StoryID, s.Title, s.Content, s.Description, labels, s.CreatedAt, s.UpdatedAt, etag, ).Scan(&createdID, &createdAt, &updatedAt, &returnedEtag) if err != nil { return fmt.Errorf("failed to insert story: %w", err) } s.ID = createdID s.CreatedAt = createdAt s.UpdatedAt = updatedAt s.Etag = returnedEtag return nil } // update updates an existing story in the database. func (s *DBStory) update(db *sql.DB) error { query := ` UPDATE stories SET title = $1, content = $2, description = $3, labels = $4, updated_at = $5, etag = $6 WHERE id = $7 RETURNING created_at, updated_at, etag ` labels := make([]string, len(s.Labels)) copy(labels, s.Labels) now := time.Now() if s.UpdatedAt.IsZero() { s.UpdatedAt = now } etag := s.Etag if etag == "" { etag = generateEtag() } var createdAt, updatedAt time.Time var returnedEtag string err := db.QueryRow( query, s.Title, s.Content, s.Description, labels, s.UpdatedAt, etag, s.ID, ).Scan(&createdAt, &updatedAt, &returnedEtag) if err != nil { return fmt.Errorf("failed to update story: %w", err) } s.CreatedAt = createdAt s.UpdatedAt = updatedAt s.Etag = returnedEtag return nil } // generateEtag generates a unique etag for the story. func generateEtag() string { return uuid.New().String() } // GetByID retrieves a story by its primary key ID. func GetByID(db *sql.DB, id int64) (*DBStory, error) { query := ` SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag FROM stories WHERE id = $1 ` story := &DBStory{} err := db.QueryRow(query, id).Scan( &story.ID, &story.StoryID, &story.Title, &story.Content, &story.Description, &story.Labels, &story.CreatedAt, &story.UpdatedAt, &story.Etag, ) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("story with id %d not found", id) } return nil, fmt.Errorf("failed to get story: %w", err) } return story, nil } // GetByStoryID retrieves a story by its story_id. func GetByStoryID(db *sql.DB, storyID string) (*DBStory, error) { if err := validateStoryID(storyID); err != nil { return nil, err } query := ` SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag FROM stories WHERE story_id = $1 ` story := &DBStory{} err := db.QueryRow(query, storyID).Scan( &story.ID, &story.StoryID, &story.Title, &story.Content, &story.Description, &story.Labels, &story.CreatedAt, &story.UpdatedAt, &story.Etag, ) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("story with story_id '%s' not found", storyID) } return nil, fmt.Errorf("failed to get story: %w", err) } return story, nil } // List returns a paginated list of stories. func List(db *sql.DB, pageSize int, pageToken string, filter string, orderBy string) ([]*DBStory, string, error) { if pageSize <= 0 || pageSize > 1000 { pageSize = 50 } var query string var args []interface{} switch orderBy { case "title": query = ` SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag FROM stories WHERE 1=1 ` case "create_time": fallthrough default: query = ` SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag FROM stories WHERE 1=1 ` } // Parse page token for pagination if pageToken != "" { query += " AND id > " + pageToken } query += " ORDER BY id DESC" query += fmt.Sprintf(" LIMIT %d", pageSize) rows, err := db.Query(query, args...) if err != nil { return nil, "", fmt.Errorf("failed to list stories: %w", err) } defer rows.Close() stories := make([]*DBStory, 0) for rows.Next() { var id int64 var storyID string var title, content, description, etag string var labels []string var createdAt, updatedAt time.Time err := rows.Scan(&id, &storyID, &title, &content, &description, &labels, &createdAt, &updatedAt, &etag) if err != nil { return nil, "", fmt.Errorf("failed to scan story: %w", err) } stories = append(stories, &DBStory{ ID: id, StoryID: storyID, Title: title, Content: content, Description: description, Labels: labels, CreatedAt: createdAt, UpdatedAt: updatedAt, Etag: etag, }) } if err := rows.Err(); err != nil { return nil, "", fmt.Errorf("error iterating stories: %w", err) } var nextPageToken string if len(stories) == pageSize { // Use the last story's ID as the next page token nextPageToken = fmt.Sprintf("%d", stories[len(stories)-1].ID) } return stories, nextPageToken, nil } // Delete removes a story from the database. func Delete(db *sql.DB, id int64) error { query := ` DELETE FROM stories WHERE id = $1 ` result, err := db.Exec(query, id) if err != nil { return fmt.Errorf("failed to delete story: %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("story with id %d not found", id) } return nil } // GetStoryIDForResourceName extracts the story_id from a resource name. // Resource name format: stories/{story_id} func GetStoryIDForResourceName(resourceName string) (string, error) { prefix := "stories/" if !strings.HasPrefix(resourceName, prefix) { return "", fmt.Errorf("invalid resource name: must start with '%s', got '%s'", prefix, resourceName) } storyID := strings.TrimPrefix(resourceName, prefix) if storyID == "" { return "", errors.New("story_id is empty in resource name") } if err := validateStoryID(storyID); err != nil { return "", fmt.Errorf("invalid story_id in resource name: %w", err) } return storyID, nil }