package store import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/gramanas/blogspace/internal/db" "github.com/jackc/pgx/v5" ) // Blog is a registry row (control database) plus the settings row of the // blog's own database, which Store.Open fills in. type Blog struct { ID int64 OwnerID int64 Subdomain string DBName string CreatedAt time.Time // from the blog database Title string Tagline string ThemeJSON json.RawMessage UpdatedAt time.Time } const blogCols = `id, owner_id, subdomain, db_name, created_at` func scanBlog(row interface{ Scan(...any) error }) (*Blog, error) { var b Blog err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.DBName, &b.CreatedAt) if err != nil { return nil, wrap(err) } return &b, nil } func (bs *BlogStore) loadSettings(ctx context.Context, b *Blog) error { err := bs.db.QueryRow(ctx, `SELECT title, tagline, theme, updated_at FROM settings`).Scan(&b.Title, &b.Tagline, &b.ThemeJSON, &b.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { // registered, but the database is empty: not a 404 return fmt.Errorf("database %s of blog %q has no settings row (empty or wrong dump?)", b.DBName, b.Subdomain) } if err != nil { return fmt.Errorf("settings of %s: %w", b.DBName, err) } return nil } // CreateBlogger creates a user, their blog and a default home page. func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdomain, title string) (*User, *Blog, error) { tx, err := s.db.Begin(ctx) if err != nil { return nil, nil, err } defer tx.Rollback(ctx) u, err := scanUser(tx.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING `+userCols, username, passwordHash, RoleBlogger)) if err != nil { return nil, nil, err } b, err := s.createBlog(ctx, tx, u.ID, subdomain, title) if err != nil { return nil, nil, err } return u, b, nil } // CreateBlog creates a blog with a default home page for an existing user. func (s *Store) CreateBlog(ctx context.Context, ownerID int64, subdomain, title string) (*Blog, error) { tx, err := s.db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) return s.createBlog(ctx, tx, ownerID, subdomain, title) } // createBlog registers the blog in tx, then creates and fills its database and // commits. The registry insert goes first so a taken subdomain fails before // any DDL; if anything after that fails the transaction rolls back and the new // database is dropped again. func (s *Store) createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) { name := db.DBName(subdomain) b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, db_name) VALUES ($1,$2,$3) RETURNING `+blogCols, ownerID, subdomain, name)) if err != nil { return nil, err } if err := s.cluster.CreateBlogDB(ctx, name); err != nil { return nil, err } bs, err := s.fillNewBlog(ctx, b, title) if err == nil { err = tx.Commit(ctx) } if err != nil { s.cluster.DropBlogDB(ctx, name) return nil, err } if err := bs.loadSettings(ctx, b); err != nil { return nil, err } return b, nil } // fillNewBlog writes what every new blog starts with: its settings, a home // page in the menu and the default layout. func (s *Store) fillNewBlog(ctx context.Context, b *Blog, title string) (*BlogStore, error) { pool, err := s.cluster.Blog(ctx, b.DBName) if err != nil { return nil, err } bs := &BlogStore{db: pool} btx, err := pool.Begin(ctx) if err != nil { return nil, err } defer btx.Rollback(ctx) if _, err := btx.Exec(ctx, `INSERT INTO settings (title, created_at, updated_at) VALUES ($1,$2,$2)`, title, b.CreatedAt); err != nil { return nil, err } var homeID int64 if err := btx.QueryRow(ctx, `INSERT INTO pages (slug, title, nav_order, is_home) VALUES ('home','Home',0,true) RETURNING id`).Scan(&homeID); err != nil { return nil, wrap(err) } if err := addMenuPage(ctx, btx, homeID); err != nil { return nil, err } if err := insertDefaultModules(ctx, btx); err != nil { return nil, err } return bs, btx.Commit(ctx) } func (s *Store) BlogByID(ctx context.Context, id int64) (*Blog, error) { return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE id=$1`, id)) } func (s *Store) BlogBySubdomain(ctx context.Context, sub string) (*Blog, error) { return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE subdomain=$1`, sub)) } func (s *Store) BlogByOwner(ctx context.Context, ownerID int64) (*Blog, error) { return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE owner_id=$1`, ownerID)) } // ListBlogs returns every registered blog, oldest first. func (s *Store) ListBlogs(ctx context.Context) ([]Blog, error) { rows, err := s.db.Query(ctx, `SELECT `+blogCols+` FROM blogs ORDER BY id`) if err != nil { return nil, err } defer rows.Close() var out []Blog for rows.Next() { b, err := scanBlog(rows) if err != nil { return nil, err } out = append(out, *b) } return out, rows.Err() } // DeleteBlog unregisters the blog and drops its database. func (s *Store) DeleteBlog(ctx context.Context, b *Blog) error { if _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, b.ID); err != nil { return err } return s.cluster.DropBlogDB(ctx, b.DBName) } func (bs *BlogStore) UpdateSettings(ctx context.Context, title, tagline string) error { _, err := bs.db.Exec(ctx, `UPDATE settings SET title=$1, tagline=$2, updated_at=now()`, title, tagline) return err } func (bs *BlogStore) UpdateTheme(ctx context.Context, theme json.RawMessage) error { _, err := bs.db.Exec(ctx, `UPDATE settings SET theme=$1, updated_at=now()`, theme) return err }