diff options
Diffstat (limited to 'internal/store')
| -rw-r--r-- | internal/store/blogs.go | 118 | ||||
| -rw-r--r-- | internal/store/images.go | 25 | ||||
| -rw-r--r-- | internal/store/menu.go | 54 | ||||
| -rw-r--r-- | internal/store/modules.go | 57 | ||||
| -rw-r--r-- | internal/store/pages.go | 69 | ||||
| -rw-r--r-- | internal/store/posts.go | 58 | ||||
| -rw-r--r-- | internal/store/sections.go | 51 | ||||
| -rw-r--r-- | internal/store/store.go | 39 | ||||
| -rw-r--r-- | internal/store/users.go | 22 |
9 files changed, 299 insertions, 194 deletions
diff --git a/internal/store/blogs.go b/internal/store/blogs.go index b7edee0..a2e88a8 100644 --- a/internal/store/blogs.go +++ b/internal/store/blogs.go @@ -3,34 +3,52 @@ 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 - CreatedAt time.Time UpdatedAt time.Time } -const blogCols = `id, owner_id, subdomain, title, tagline, theme, created_at, updated_at` +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.Title, &b.Tagline, &b.ThemeJSON, &b.CreatedAt, &b.UpdatedAt) + err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.DBName, &b.CreatedAt) if err != nil { return nil, wrap(err) } return &b, nil } -// CreateBlogger creates a user, their blog and a default home page in one transaction. +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 { @@ -43,13 +61,10 @@ func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdo if err != nil { return nil, nil, err } - b, err := createBlog(ctx, tx, u.ID, subdomain, title) + b, err := s.createBlog(ctx, tx, u.ID, subdomain, title) if err != nil { return nil, nil, err } - if err := tx.Commit(ctx); err != nil { - return nil, nil, err - } return u, b, nil } @@ -60,30 +75,64 @@ func (s *Store) CreateBlog(ctx context.Context, ownerID int64, subdomain, title return nil, err } defer tx.Rollback(ctx) - b, err := createBlog(ctx, tx, ownerID, subdomain, title) + 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 } - return b, tx.Commit(ctx) + if err := bs.loadSettings(ctx, b); err != nil { + return nil, err + } + return b, nil } -func createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) { - b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, title) VALUES ($1,$2,$3) RETURNING `+blogCols, - ownerID, subdomain, title)) +// 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 := tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true) RETURNING id`, b.ID).Scan(&homeID); err != nil { + 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, tx, b.ID, homeID); err != nil { + if err := addMenuPage(ctx, btx, homeID); err != nil { return nil, err } - if err := insertDefaultModules(ctx, tx, b.ID); err != nil { + if err := insertDefaultModules(ctx, btx); err != nil { return nil, err } - return b, nil + return bs, btx.Commit(ctx) } func (s *Store) BlogByID(ctx context.Context, id int64) (*Blog, error) { @@ -98,17 +147,38 @@ 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)) } -func (s *Store) UpdateBlogSettings(ctx context.Context, id int64, title, tagline string) error { - _, err := s.db.Exec(ctx, `UPDATE blogs SET title=$2, tagline=$3, updated_at=now() WHERE id=$1`, id, title, tagline) - return err +// 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 (s *Store) UpdateBlogTheme(ctx context.Context, id int64, theme json.RawMessage) error { - _, err := s.db.Exec(ctx, `UPDATE blogs SET theme=$2, updated_at=now() WHERE id=$1`, id, theme) +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 (s *Store) DeleteBlog(ctx context.Context, id int64) error { - _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, id) +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 } diff --git a/internal/store/images.go b/internal/store/images.go index 3699e75..b222468 100644 --- a/internal/store/images.go +++ b/internal/store/images.go @@ -9,7 +9,6 @@ import ( type Image struct { ID uuid.UUID - BlogID int64 Filename string ContentType string Size int @@ -17,15 +16,15 @@ type Image struct { CreatedAt time.Time } -func (s *Store) CreateImage(ctx context.Context, blogID int64, filename, contentType string, data []byte) (*Image, error) { - img := &Image{ID: uuid.New(), BlogID: blogID, Filename: filename, ContentType: contentType, Size: len(data)} - err := s.db.QueryRow(ctx, `INSERT INTO images (id, blog_id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5,$6) RETURNING created_at`, - img.ID, blogID, filename, contentType, len(data), data).Scan(&img.CreatedAt) +func (bs *BlogStore) CreateImage(ctx context.Context, filename, contentType string, data []byte) (*Image, error) { + img := &Image{ID: uuid.New(), Filename: filename, ContentType: contentType, Size: len(data)} + err := bs.db.QueryRow(ctx, `INSERT INTO images (id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5) RETURNING created_at`, + img.ID, filename, contentType, len(data), data).Scan(&img.CreatedAt) return img, err } -func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) { - rows, err := s.db.Query(ctx, `SELECT id, blog_id, filename, content_type, size, created_at FROM images WHERE blog_id=$1 ORDER BY created_at DESC`, blogID) +func (bs *BlogStore) ListImages(ctx context.Context) ([]Image, error) { + rows, err := bs.db.Query(ctx, `SELECT id, filename, content_type, size, created_at FROM images ORDER BY created_at DESC`) if err != nil { return nil, err } @@ -33,7 +32,7 @@ func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) { var out []Image for rows.Next() { var i Image - if err := rows.Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil { + if err := rows.Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil { return nil, err } out = append(out, i) @@ -42,17 +41,17 @@ func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) { } // ImageData loads an image including its bytes. -func (s *Store) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) { +func (bs *BlogStore) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) { var i Image - err := s.db.QueryRow(ctx, `SELECT id, blog_id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id). - Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt) + err := bs.db.QueryRow(ctx, `SELECT id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id). + Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt) if err != nil { return nil, wrap(err) } return &i, nil } -func (s *Store) DeleteImage(ctx context.Context, blogID int64, id uuid.UUID) error { - _, err := s.db.Exec(ctx, `DELETE FROM images WHERE blog_id=$1 AND id=$2`, blogID, id) +func (bs *BlogStore) DeleteImage(ctx context.Context, id uuid.UUID) error { + _, err := bs.db.Exec(ctx, `DELETE FROM images WHERE id=$1`, id) return err } diff --git a/internal/store/menu.go b/internal/store/menu.go index 2d3eabd..cedb7ad 100644 --- a/internal/store/menu.go +++ b/internal/store/menu.go @@ -2,14 +2,11 @@ package store import ( "context" - - "github.com/jackc/pgx/v5/pgconn" ) // MenuItem is one entry of a blog's menu: either a page of the blog or a custom link. type MenuItem struct { ID int64 - BlogID int64 PageID *int64 // nil for custom links Label string // custom links only; pages use their title URL string // custom links only @@ -39,11 +36,11 @@ func (m MenuItem) Href() string { return "/" + m.PageSlug } -const menuCols = `m.id, m.blog_id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)` +const menuCols = `m.id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)` -func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error) { - rows, err := s.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id - WHERE m.blog_id=$1 ORDER BY m.sort_order, m.id`, blogID) +func (bs *BlogStore) ListMenu(ctx context.Context) ([]MenuItem, error) { + rows, err := bs.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id + ORDER BY m.sort_order, m.id`) if err != nil { return nil, err } @@ -51,7 +48,7 @@ func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error) var out []MenuItem for rows.Next() { var m MenuItem - if err := rows.Scan(&m.ID, &m.BlogID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil { + if err := rows.Scan(&m.ID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil { return nil, err } out = append(out, m) @@ -60,41 +57,41 @@ func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error) } // AddMenuPage appends a page to the menu; a page already in the menu is left where it is. -func (s *Store) AddMenuPage(ctx context.Context, blogID, pageID int64) error { - return addMenuPage(ctx, s.db, blogID, pageID) +func (bs *BlogStore) AddMenuPage(ctx context.Context, pageID int64) error { + return addMenuPage(ctx, bs.db, pageID) } -func addMenuPage(ctx context.Context, db querier, blogID, pageID int64) error { - _, err := db.Exec(ctx, `INSERT INTO menu_items (blog_id, page_id, sort_order) - SELECT $1, id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1) - FROM pages WHERE blog_id=$1 AND id=$2 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, blogID, pageID) +func addMenuPage(ctx context.Context, db querier, pageID int64) error { + _, err := db.Exec(ctx, `INSERT INTO menu_items (page_id, sort_order) + SELECT id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items) + FROM pages WHERE id=$1 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, pageID) return err } // SetPageInMenu adds or removes a page's menu entry. -func (s *Store) SetPageInMenu(ctx context.Context, blogID, pageID int64, on bool) error { +func (bs *BlogStore) SetPageInMenu(ctx context.Context, pageID int64, on bool) error { if on { - return s.AddMenuPage(ctx, blogID, pageID) + return bs.AddMenuPage(ctx, pageID) } - _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, blogID, pageID) + _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, pageID) return err } // AddMenuLink appends a custom link to the menu. -func (s *Store) AddMenuLink(ctx context.Context, blogID int64, label, url string) error { - _, err := s.db.Exec(ctx, `INSERT INTO menu_items (blog_id, label, url, sort_order) - VALUES ($1,$2,$3,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1))`, blogID, label, url) +func (bs *BlogStore) AddMenuLink(ctx context.Context, label, url string) error { + _, err := bs.db.Exec(ctx, `INSERT INTO menu_items (label, url, sort_order) + VALUES ($1,$2,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items))`, label, url) return err } -func (s *Store) DeleteMenuItem(ctx context.Context, blogID, id int64) error { - _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND id=$2`, blogID, id) +func (bs *BlogStore) DeleteMenuItem(ctx context.Context, id int64) error { + _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE id=$1`, id) return err } // MoveMenuItem swaps sort_order with the neighbouring entry (dir = -1 up, +1 down). -func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) error { - items, err := s.ListMenu(ctx, blogID) +func (bs *BlogStore) MoveMenuItem(ctx context.Context, id int64, dir int) error { + items, err := bs.ListMenu(ctx) if err != nil { return err } @@ -109,20 +106,15 @@ func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) err return nil } items[idx], items[j] = items[j], items[idx] - tx, err := s.db.Begin(ctx) + tx, err := bs.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) for i, m := range items { // renumber everything; keeps orders dense - if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil { + if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil { return err } } return tx.Commit(ctx) } - -// querier is what the helpers shared with page transactions need: the pool or a pgx.Tx. -type querier interface { - Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) -} diff --git a/internal/store/modules.go b/internal/store/modules.go index c7d9760..e0c3cd5 100644 --- a/internal/store/modules.go +++ b/internal/store/modules.go @@ -9,7 +9,6 @@ import ( // block of custom HTML… placed in one of the areas of the page. type Module struct { ID int64 - BlogID int64 Area string // header | left | right | above | below | footer Kind string // title | logo | menu | archive | recent | html | rss | text | sitemap Title string // optional heading (side columns and footer) @@ -20,20 +19,20 @@ type Module struct { UpdatedAt time.Time } -const moduleCols = `id, blog_id, area, kind, title, body, count, sort_order, created_at, updated_at` +const moduleCols = `id, area, kind, title, body, count, sort_order, created_at, updated_at` func scanModule(row interface{ Scan(...any) error }) (*Module, error) { var m Module - err := row.Scan(&m.ID, &m.BlogID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt) + err := row.Scan(&m.ID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt) if err != nil { return nil, wrap(err) } return &m, nil } -// ListModules returns every module of a blog, grouped by area in display order. -func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error) { - rows, err := s.db.Query(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 ORDER BY area, sort_order, id`, blogID) +// ListModules returns every module of the blog, grouped by area in display order. +func (bs *BlogStore) ListModules(ctx context.Context) ([]Module, error) { + rows, err := bs.db.Query(ctx, `SELECT `+moduleCols+` FROM modules ORDER BY area, sort_order, id`) if err != nil { return nil, err } @@ -49,36 +48,36 @@ func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error) return out, rows.Err() } -func (s *Store) ModuleByID(ctx context.Context, blogID, id int64) (*Module, error) { - return scanModule(s.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id)) +func (bs *BlogStore) ModuleByID(ctx context.Context, id int64) (*Module, error) { + return scanModule(bs.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE id=$1`, id)) } // CreateModule appends a module to the end of its area. -func (s *Store) CreateModule(ctx context.Context, m *Module) (*Module, error) { - return scanModule(s.db.QueryRow(ctx, `INSERT INTO modules (blog_id, area, kind, title, body, count, sort_order) - VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE blog_id=$1 AND area=$2)) RETURNING `+moduleCols, - m.BlogID, m.Area, m.Kind, m.Title, m.Body, m.Count)) +func (bs *BlogStore) CreateModule(ctx context.Context, m *Module) (*Module, error) { + return scanModule(bs.db.QueryRow(ctx, `INSERT INTO modules (area, kind, title, body, count, sort_order) + VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE area=$1)) RETURNING `+moduleCols, + m.Area, m.Kind, m.Title, m.Body, m.Count)) } // UpdateModule saves the editable settings; area and kind never change. -func (s *Store) UpdateModule(ctx context.Context, m *Module) error { - _, err := s.db.Exec(ctx, `UPDATE modules SET title=$3, body=$4, count=$5, updated_at=now() WHERE blog_id=$1 AND id=$2`, - m.BlogID, m.ID, m.Title, m.Body, m.Count) +func (bs *BlogStore) UpdateModule(ctx context.Context, m *Module) error { + _, err := bs.db.Exec(ctx, `UPDATE modules SET title=$2, body=$3, count=$4, updated_at=now() WHERE id=$1`, + m.ID, m.Title, m.Body, m.Count) return wrap(err) } -func (s *Store) DeleteModule(ctx context.Context, blogID, id int64) error { - _, err := s.db.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id) +func (bs *BlogStore) DeleteModule(ctx context.Context, id int64) error { + _, err := bs.db.Exec(ctx, `DELETE FROM modules WHERE id=$1`, id) return err } // MoveModule swaps sort_order with the neighbouring module of the same area (dir = -1 up, +1 down). -func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error { - cur, err := s.ModuleByID(ctx, blogID, id) +func (bs *BlogStore) MoveModule(ctx context.Context, id int64, dir int) error { + cur, err := bs.ModuleByID(ctx, id) if err != nil { return err } - all, err := s.ListModules(ctx, blogID) + all, err := bs.ListModules(ctx) if err != nil { return err } @@ -98,13 +97,13 @@ func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error return nil } mods[idx], mods[j] = mods[j], mods[idx] - tx, err := s.db.Begin(ctx) + tx, err := bs.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) for i, m := range mods { // renumber the area; keeps orders dense - if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil { + if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil { return err } } @@ -112,16 +111,16 @@ func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error } // ResetModules puts the layout back to what a new blog starts with. -func (s *Store) ResetModules(ctx context.Context, blogID int64) error { - tx, err := s.db.Begin(ctx) +func (bs *BlogStore) ResetModules(ctx context.Context) error { + tx, err := bs.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) - if _, err := tx.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1`, blogID); err != nil { + if _, err := tx.Exec(ctx, `DELETE FROM modules`); err != nil { return err } - if err := insertDefaultModules(ctx, tx, blogID); err != nil { + if err := insertDefaultModules(ctx, tx); err != nil { return err } return tx.Commit(ctx) @@ -129,8 +128,8 @@ func (s *Store) ResetModules(ctx context.Context, blogID int64) error { // insertDefaultModules is the starting layout: title and menu in the header, // an RSS link in the footer. Must match what the layout migration gave old blogs. -func insertDefaultModules(ctx context.Context, tx querier, blogID int64) error { - _, err := tx.Exec(ctx, `INSERT INTO modules (blog_id, area, kind, sort_order) VALUES - ($1,'header','title',0), ($1,'header','menu',1), ($1,'footer','rss',0)`, blogID) +func insertDefaultModules(ctx context.Context, tx querier) error { + _, err := tx.Exec(ctx, `INSERT INTO modules (area, kind, sort_order) VALUES + ('header','title',0), ('header','menu',1), ('footer','rss',0)`) return err } diff --git a/internal/store/pages.go b/internal/store/pages.go index cd0e652..55cad33 100644 --- a/internal/store/pages.go +++ b/internal/store/pages.go @@ -7,7 +7,6 @@ import ( type Page struct { ID int64 - BlogID int64 Slug string Title string IntroMD string @@ -19,20 +18,20 @@ type Page struct { PostCount int // filled by ListPages only } -const pageCols = `id, blog_id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at` +const pageCols = `id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at` func scanPage(row interface{ Scan(...any) error }) (*Page, error) { var p Page - err := row.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt) + err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt) if err != nil { return nil, wrap(err) } return &p, nil } -func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) { - rows, err := s.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id) - FROM pages WHERE blog_id=$1 ORDER BY nav_order, id`, blogID) +func (bs *BlogStore) ListPages(ctx context.Context) ([]Page, error) { + rows, err := bs.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id) + FROM pages ORDER BY nav_order, id`) if err != nil { return nil, err } @@ -40,7 +39,7 @@ func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) { var out []Page for rows.Next() { var p Page - if err := rows.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil { + if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil { return nil, err } out = append(out, p) @@ -48,34 +47,34 @@ func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) { return out, rows.Err() } -func (s *Store) PageByID(ctx context.Context, blogID, id int64) (*Page, error) { - return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND id=$2`, blogID, id)) +func (bs *BlogStore) PageByID(ctx context.Context, id int64) (*Page, error) { + return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE id=$1`, id)) } -func (s *Store) PageBySlug(ctx context.Context, blogID int64, slug string) (*Page, error) { - return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND slug=$2`, blogID, slug)) +func (bs *BlogStore) PageBySlug(ctx context.Context, slug string) (*Page, error) { + return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE slug=$1`, slug)) } -func (s *Store) HomePage(ctx context.Context, blogID int64) (*Page, error) { - return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 ORDER BY is_home DESC, nav_order, id LIMIT 1`, blogID)) +func (bs *BlogStore) HomePage(ctx context.Context) (*Page, error) { + return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages ORDER BY is_home DESC, nav_order, id LIMIT 1`)) } // CreatePage adds a page at the end of the page list and, when ShowInNav is // set, to the end of the menu. -func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) { - tx, err := s.db.Begin(ctx) +func (bs *BlogStore) CreatePage(ctx context.Context, p *Page) (*Page, error) { + tx, err := bs.db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) - created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, intro_md, intro_html, nav_order) - VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages WHERE blog_id=$1)) RETURNING `+pageCols, - p.BlogID, p.Slug, p.Title, p.IntroMD, p.IntroHTML)) + created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, nav_order) + VALUES ($1,$2,$3,$4,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols, + p.Slug, p.Title, p.IntroMD, p.IntroHTML)) if err != nil { return nil, err } if p.ShowInNav { - if err := addMenuPage(ctx, tx, p.BlogID, created.ID); err != nil { + if err := addMenuPage(ctx, tx, created.ID); err != nil { return nil, err } created.ShowInNav = true @@ -84,20 +83,20 @@ func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) { } // UpdatePage saves the page and keeps its menu entry in step with ShowInNav. -func (s *Store) UpdatePage(ctx context.Context, p *Page) error { - tx, err := s.db.Begin(ctx) +func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error { + tx, err := bs.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) - if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$3, title=$4, intro_md=$5, intro_html=$6 WHERE blog_id=$1 AND id=$2`, - p.BlogID, p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil { + if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5 WHERE id=$1`, + p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil { return wrap(err) } if p.ShowInNav { - err = addMenuPage(ctx, tx, p.BlogID, p.ID) + err = addMenuPage(ctx, tx, p.ID) } else { - _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, p.BlogID, p.ID) + _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, p.ID) } if err != nil { return err @@ -105,30 +104,30 @@ func (s *Store) UpdatePage(ctx context.Context, p *Page) error { return tx.Commit(ctx) } -func (s *Store) DeletePage(ctx context.Context, blogID, id int64) error { - _, err := s.db.Exec(ctx, `DELETE FROM pages WHERE blog_id=$1 AND id=$2 AND NOT is_home`, blogID, id) +func (bs *BlogStore) DeletePage(ctx context.Context, id int64) error { + _, err := bs.db.Exec(ctx, `DELETE FROM pages WHERE id=$1 AND NOT is_home`, id) return err } // SetHomePage moves the home flag to the given page. -func (s *Store) SetHomePage(ctx context.Context, blogID, id int64) error { - tx, err := s.db.Begin(ctx) +func (bs *BlogStore) SetHomePage(ctx context.Context, id int64) error { + tx, err := bs.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) - if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE blog_id=$1 AND is_home`, blogID); err != nil { + if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE is_home`); err != nil { return err } - if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE blog_id=$1 AND id=$2`, blogID, id); err != nil { + if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE id=$1`, id); err != nil { return err } return tx.Commit(ctx) } // MovePage swaps nav_order with the neighbouring page (dir = -1 up, +1 down). -func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error { - pages, err := s.ListPages(ctx, blogID) +func (bs *BlogStore) MovePage(ctx context.Context, id int64, dir int) error { + pages, err := bs.ListPages(ctx) if err != nil { return err } @@ -143,13 +142,13 @@ func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error { return nil } pages[idx], pages[j] = pages[j], pages[idx] - tx, err := s.db.Begin(ctx) + tx, err := bs.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) for i, p := range pages { // renumber everything; keeps orders dense - if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, p.ID, i); err != nil { + if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$2 WHERE id=$1`, p.ID, i); err != nil { return err } } diff --git a/internal/store/posts.go b/internal/store/posts.go index 9f3c4f9..94bc856 100644 --- a/internal/store/posts.go +++ b/internal/store/posts.go @@ -31,8 +31,8 @@ func scanPost(row interface{ Scan(...any) error }) (*Post, error) { return &p, nil } -func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) { - rows, err := s.db.Query(ctx, q, args...) +func (bs *BlogStore) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) { + rows, err := bs.db.Query(ctx, q, args...) if err != nil { return nil, err } @@ -48,21 +48,21 @@ func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post return out, rows.Err() } -// ListPosts returns all posts of a blog for the dashboard, optionally filtered by page. -func (s *Store) ListPosts(ctx context.Context, blogID int64, pageID int64) ([]Post, error) { - return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id - WHERE g.blog_id=$1 AND ($2=0 OR p.page_id=$2) ORDER BY p.created_at DESC, p.id DESC`, blogID, pageID) +// ListPosts returns all posts of the blog for the dashboard, optionally filtered by page. +func (bs *BlogStore) ListPosts(ctx context.Context, pageID int64) ([]Post, error) { + return bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + WHERE ($1=0 OR p.page_id=$1) ORDER BY p.created_at DESC, p.id DESC`, pageID) } // PublishedPosts returns a page of published posts for the public site. -func (s *Store) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) { - posts, err := s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id +func (bs *BlogStore) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) { + posts, err := bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.page_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3`, pageID, limit, offset) if err != nil { return nil, 0, err } var total int - err = s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total) + err = bs.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total) return posts, total, err } @@ -74,10 +74,10 @@ type PostRef struct { CreatedAt time.Time } -// PublishedPostIndex lists every published post of a blog, newest first, without bodies. -func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef, error) { - rows, err := s.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at FROM posts p JOIN pages g ON g.id=p.page_id - WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC`, blogID) +// PublishedPostIndex lists every published post of the blog, newest first, without bodies. +func (bs *BlogStore) PublishedPostIndex(ctx context.Context) ([]PostRef, error) { + rows, err := bs.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at FROM posts p JOIN pages g ON g.id=p.page_id + WHERE p.published ORDER BY p.created_at DESC, p.id DESC`) if err != nil { return nil, err } @@ -93,40 +93,38 @@ func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef return out, rows.Err() } -// RecentPublishedPosts returns the newest published posts across a whole blog (for feeds). -func (s *Store) RecentPublishedPosts(ctx context.Context, blogID int64, limit int) ([]Post, error) { - return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id - WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2`, blogID, limit) +// RecentPublishedPosts returns the newest published posts across the whole blog (for feeds). +func (bs *BlogStore) RecentPublishedPosts(ctx context.Context, limit int) ([]Post, error) { + return bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + WHERE p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $1`, limit) } -func (s *Store) PostByID(ctx context.Context, blogID, id int64) (*Post, error) { - return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id - WHERE g.blog_id=$1 AND p.id=$2`, blogID, id)) +func (bs *BlogStore) PostByID(ctx context.Context, id int64) (*Post, error) { + return scanPost(bs.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, id)) } -func (s *Store) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) { - return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id +func (bs *BlogStore) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) { + return scanPost(bs.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.page_id=$1 AND p.slug=$2 AND p.published`, pageID, slug)) } -func (s *Store) CreatePost(ctx context.Context, p *Post) (*Post, error) { +func (bs *BlogStore) CreatePost(ctx context.Context, p *Post) (*Post, error) { var id int64 - err := s.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + err := bs.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published).Scan(&id) if err != nil { return nil, wrap(err) } - return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, id)) + return bs.PostByID(ctx, id) } -// UpdatePost updates a post; the page must belong to the same blog (checked by handler). -func (s *Store) UpdatePost(ctx context.Context, p *Post) error { - _, err := s.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, updated_at=now() WHERE id=$1`, +func (bs *BlogStore) UpdatePost(ctx context.Context, p *Post) error { + _, err := bs.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, updated_at=now() WHERE id=$1`, p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published) return wrap(err) } -func (s *Store) DeletePost(ctx context.Context, blogID, id int64) error { - _, err := s.db.Exec(ctx, `DELETE FROM posts p USING pages g WHERE g.id=p.page_id AND g.blog_id=$1 AND p.id=$2`, blogID, id) +func (bs *BlogStore) DeletePost(ctx context.Context, id int64) error { + _, err := bs.db.Exec(ctx, `DELETE FROM posts WHERE id=$1`, id) return err } diff --git a/internal/store/sections.go b/internal/store/sections.go index caac251..2f742f7 100644 --- a/internal/store/sections.go +++ b/internal/store/sections.go @@ -9,7 +9,6 @@ import ( // Section is an announcement shown on every page and post of a blog. type Section struct { ID int64 - BlogID int64 Title string BodyMD string BodyHTML string @@ -25,19 +24,19 @@ type Section struct { func (s Section) Column() string { c, _, _ := strings.Cut(s.Placement, "-"); return c } func (s Section) Position() string { _, p, _ := strings.Cut(s.Placement, "-"); return p } -const sectionCols = `id, blog_id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at` +const sectionCols = `id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at` func scanSection(row interface{ Scan(...any) error }) (*Section, error) { var s Section - err := row.Scan(&s.ID, &s.BlogID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt) + err := row.Scan(&s.ID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt) if err != nil { return nil, wrap(err) } return &s, nil } -func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) { - rows, err := s.db.Query(ctx, q, args...) +func (bs *BlogStore) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) { + rows, err := bs.db.Query(ctx, q, args...) if err != nil { return nil, err } @@ -53,44 +52,44 @@ func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]S return out, rows.Err() } -func (s *Store) ListSections(ctx context.Context, blogID int64) ([]Section, error) { - return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 ORDER BY sort_order, id`, blogID) +func (bs *BlogStore) ListSections(ctx context.Context) ([]Section, error) { + return bs.collectSections(ctx, `SELECT `+sectionCols+` FROM sections ORDER BY sort_order, id`) } // EnabledSections is what the public blog renders. -func (s *Store) EnabledSections(ctx context.Context, blogID int64) ([]Section, error) { - return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND enabled ORDER BY sort_order, id`, blogID) +func (bs *BlogStore) EnabledSections(ctx context.Context) ([]Section, error) { + return bs.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE enabled ORDER BY sort_order, id`) } -func (s *Store) SectionByID(ctx context.Context, blogID, id int64) (*Section, error) { - return scanSection(s.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id)) +func (bs *BlogStore) SectionByID(ctx context.Context, id int64) (*Section, error) { + return scanSection(bs.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE id=$1`, id)) } -func (s *Store) CreateSection(ctx context.Context, sec *Section) (*Section, error) { - return scanSection(s.db.QueryRow(ctx, `INSERT INTO sections (blog_id, title, body_md, body_html, placement, style, enabled, sort_order) - VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(sort_order),-1)+1 FROM sections WHERE blog_id=$1)) RETURNING `+sectionCols, - sec.BlogID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)) +func (bs *BlogStore) CreateSection(ctx context.Context, sec *Section) (*Section, error) { + return scanSection(bs.db.QueryRow(ctx, `INSERT INTO sections (title, body_md, body_html, placement, style, enabled, sort_order) + VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM sections)) RETURNING `+sectionCols, + sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)) } -func (s *Store) UpdateSection(ctx context.Context, sec *Section) error { - _, err := s.db.Exec(ctx, `UPDATE sections SET title=$3, body_md=$4, body_html=$5, placement=$6, style=$7, enabled=$8, updated_at=now() WHERE blog_id=$1 AND id=$2`, - sec.BlogID, sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled) +func (bs *BlogStore) UpdateSection(ctx context.Context, sec *Section) error { + _, err := bs.db.Exec(ctx, `UPDATE sections SET title=$2, body_md=$3, body_html=$4, placement=$5, style=$6, enabled=$7, updated_at=now() WHERE id=$1`, + sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled) return wrap(err) } -func (s *Store) SetSectionEnabled(ctx context.Context, blogID, id int64, on bool) error { - _, err := s.db.Exec(ctx, `UPDATE sections SET enabled=$3, updated_at=now() WHERE blog_id=$1 AND id=$2`, blogID, id, on) +func (bs *BlogStore) SetSectionEnabled(ctx context.Context, id int64, on bool) error { + _, err := bs.db.Exec(ctx, `UPDATE sections SET enabled=$2, updated_at=now() WHERE id=$1`, id, on) return err } -func (s *Store) DeleteSection(ctx context.Context, blogID, id int64) error { - _, err := s.db.Exec(ctx, `DELETE FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id) +func (bs *BlogStore) DeleteSection(ctx context.Context, id int64) error { + _, err := bs.db.Exec(ctx, `DELETE FROM sections WHERE id=$1`, id) return err } // MoveSection swaps sort_order with the neighbouring section (dir = -1 up, +1 down). -func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) error { - secs, err := s.ListSections(ctx, blogID) +func (bs *BlogStore) MoveSection(ctx context.Context, id int64, dir int) error { + secs, err := bs.ListSections(ctx) if err != nil { return err } @@ -105,13 +104,13 @@ func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) erro return nil } secs[idx], secs[j] = secs[j], secs[idx] - tx, err := s.db.Begin(ctx) + tx, err := bs.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) for i, sec := range secs { // renumber everything; keeps orders dense - if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, sec.ID, i); err != nil { + if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$2 WHERE id=$1`, sec.ID, i); err != nil { return err } } diff --git a/internal/store/store.go b/internal/store/store.go index 472bb2a..c8e97ea 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,9 +1,15 @@ // Package store holds the data models and all SQL queries. +// +// Store talks to the control database (users, blog registry) and hands out a +// BlogStore per blog, which is bound to that blog's own database — nothing a +// BlogStore does can reach another blog's rows. package store import ( + "context" "errors" + "github.com/gramanas/blogspace/internal/db" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" @@ -13,10 +19,36 @@ var ErrNotFound = errors.New("not found") var ErrConflict = errors.New("already exists") type Store struct { + db *pgxpool.Pool // control database + cluster *db.Cluster +} + +func New(cluster *db.Cluster) *Store { return &Store{db: cluster.Control(), cluster: cluster} } + +// BlogStore runs the content queries of one blog against its database. +type BlogStore struct { db *pgxpool.Pool } -func New(db *pgxpool.Pool) *Store { return &Store{db: db} } +// Open returns the blog's store and fills in the settings (title, tagline, +// theme) kept in the blog database. +func (s *Store) Open(ctx context.Context, b *Blog) (*BlogStore, error) { + pool, err := s.cluster.Blog(ctx, b.DBName) + if err != nil { + return nil, err + } + bs := &BlogStore{db: pool} + if err := bs.loadSettings(ctx, b); err != nil { + // A database restored underneath a running app (dropdb --force, + // createdb, psql < dump) leaves the pool holding dead connections; + // drop them and try once more before giving up. + pool.Reset() + if err := bs.loadSettings(ctx, b); err != nil { + return nil, err + } + } + return bs, nil +} // wrap maps driver errors onto the store's sentinel errors. func wrap(err error) error { @@ -32,3 +64,8 @@ func wrap(err error) error { } return err } + +// querier is what the helpers shared with transactions need: the pool or a pgx.Tx. +type querier interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} diff --git a/internal/store/users.go b/internal/store/users.go index 3ea9bdb..0c47c24 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "time" ) @@ -69,9 +70,21 @@ func (s *Store) SetUserDisabled(ctx context.Context, id int64, disabled bool) er return err } +// DeleteUser removes the user, their registry row and their blog database. +// The registry goes first: a stale blog database is harmless and can be +// dropped by hand, a registry row without a database is not. func (s *Store) DeleteUser(ctx context.Context, id int64) error { - _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id) - return err + b, err := s.BlogByOwner(ctx, id) + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + if _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id); err != nil { + return err + } + if b != nil { + return s.cluster.DropBlogDB(ctx, b.DBName) + } + return nil } // UserWithBlog is a row for the admin overview. @@ -79,12 +92,11 @@ type UserWithBlog struct { User BlogID *int64 Subdomain *string - BlogTitle *string } func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) { rows, err := s.db.Query(ctx, `SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.token_version, u.created_at, - b.id, b.subdomain, b.title + b.id, b.subdomain FROM users u LEFT JOIN blogs b ON b.owner_id = u.id ORDER BY u.role, u.username`) if err != nil { @@ -95,7 +107,7 @@ func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) { for rows.Next() { var r UserWithBlog if err := rows.Scan(&r.ID, &r.Username, &r.PasswordHash, &r.Role, &r.Disabled, &r.TokenVersion, &r.CreatedAt, - &r.BlogID, &r.Subdomain, &r.BlogTitle); err != nil { + &r.BlogID, &r.Subdomain); err != nil { return nil, err } out = append(out, r) |
