package item import ( "context" "database/sql" "fmt" "time" "github.com/jackc/pgx/v5/pgtype" ) // Nullable creates a sql.Null[T] from a pointer. func Nullable[T any](v *T) sql.Null[T] { if v == nil { return sql.Null[T]{} } return sql.Null[T]{V: *v, Valid: true} } // Ptr returns a pointer from sql.Null[T], or nil if not valid. func Ptr[T any](n sql.Null[T]) *T { if !n.Valid { return nil } return &n.V } type Row struct { ID int64 PubID string Title sql.Null[string] Description sql.Null[string] LinkURL sql.Null[string] ItemType string // 'image', 'video', 'quote', 'embed' EmbedProvider sql.Null[string] EmbedVideoID sql.Null[string] EmbedHTML sql.Null[string] EmbedVideoURL sql.Null[string] CreatedAt time.Time DeletedAt sql.Null[time.Time] } type CreateParams struct { Title sql.Null[string] Description sql.Null[string] LinkURL sql.Null[string] ItemType string EmbedProvider sql.Null[string] EmbedVideoID sql.Null[string] EmbedHTML sql.Null[string] EmbedVideoURL sql.Null[string] } // QCreate creates a new item. func QCreate(ctx context.Context, db *sql.DB, p CreateParams) (Row, error) { query := ` INSERT INTO item (title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at ` var row Row var pubID pgtype.UUID err := db.QueryRowContext(ctx, query, p.Title, p.Description, p.LinkURL, p.ItemType, p.EmbedProvider, p.EmbedVideoID, p.EmbedHTML, p.EmbedVideoURL, ).Scan( &row.ID, &pubID, &row.Title, &row.Description, &row.LinkURL, &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ) if err == nil { row.PubID = formatUUID(pubID) } return row, err } // QFindByPubID finds an item by its public ID. func QFindByPubID(ctx context.Context, db *sql.DB, pubID string) (*Row, error) { query := ` SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at FROM item WHERE pub_id = $1 ` var row Row var pubUUID pgtype.UUID err := db.QueryRowContext(ctx, query, pubID).Scan( &row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL, &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } row.PubID = formatUUID(pubUUID) return &row, nil } // QFindByID finds an item by its internal ID. func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) { query := ` SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at FROM item WHERE id = $1 ` var row Row var pubUUID pgtype.UUID err := db.QueryRowContext(ctx, query, id).Scan( &row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL, &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } row.PubID = formatUUID(pubUUID) return &row, nil } // QList returns all non-deleted items, newest first. func QList(ctx context.Context, db *sql.DB) ([]Row, error) { query := ` SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at FROM item WHERE deleted_at IS NULL ORDER BY created_at DESC ` rows, err := db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var items []Row for rows.Next() { var row Row var pubUUID pgtype.UUID if err := rows.Scan( &row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL, &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ); err != nil { return nil, err } row.PubID = formatUUID(pubUUID) items = append(items, row) } return items, rows.Err() } // QListByTag returns all non-deleted items with a specific tag, newest first. func QListByTag(ctx context.Context, db *sql.DB, tagName string) ([]Row, error) { query := ` SELECT i.id, i.pub_id, i.title, i.description, i.link_url, i.item_type, i.embed_provider, i.embed_video_id, i.embed_html, i.embed_video_url, i.created_at, i.deleted_at FROM item i JOIN item_tag it ON i.id = it.item_id JOIN tag t ON it.tag_id = t.id WHERE i.deleted_at IS NULL AND t.name = $1 ORDER BY i.created_at DESC ` rows, err := db.QueryContext(ctx, query, tagName) if err != nil { return nil, err } defer rows.Close() var items []Row for rows.Next() { var row Row var pubUUID pgtype.UUID if err := rows.Scan( &row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL, &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL, &row.CreatedAt, &row.DeletedAt, ); err != nil { return nil, err } row.PubID = formatUUID(pubUUID) items = append(items, row) } return items, rows.Err() } type UpdateParams struct { Title sql.Null[string] Description sql.Null[string] LinkURL sql.Null[string] } // QUpdate updates an item's editable fields. func QUpdate(ctx context.Context, db *sql.DB, id int64, p UpdateParams) error { query := ` UPDATE item SET title = $2, description = $3, link_url = $4 WHERE id = $1 ` _, err := db.ExecContext(ctx, query, id, p.Title, p.Description, p.LinkURL) return err } // QUpdateType updates an item's type. func QUpdateType(ctx context.Context, db *sql.DB, id int64, itemType string) error { query := `UPDATE item SET item_type = $2 WHERE id = $1` _, err := db.ExecContext(ctx, query, id, itemType) return err } // QUpdateVideoURL updates an item's embed video URL. func QUpdateVideoURL(ctx context.Context, db *sql.DB, id int64, videoURL string) error { query := `UPDATE item SET embed_video_url = $2 WHERE id = $1` _, err := db.ExecContext(ctx, query, id, videoURL) return err } // QSoftDelete soft deletes an item. func QSoftDelete(ctx context.Context, db *sql.DB, id int64) error { query := `UPDATE item SET deleted_at = NOW() WHERE id = $1` _, err := db.ExecContext(ctx, query, id) return err } // QRestore restores a soft-deleted item. func QRestore(ctx context.Context, db *sql.DB, id int64) error { query := `UPDATE item SET deleted_at = NULL WHERE id = $1` _, err := db.ExecContext(ctx, query, id) return err } func formatUUID(u pgtype.UUID) string { if !u.Valid { return "" } b := u.Bytes return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) }