Have got post creation working.

This commit is contained in:
Leon Mika 2025-01-27 14:23:54 +11:00
parent 63b19a249a
commit 8e0ffb6c24
20 changed files with 479 additions and 11 deletions

View file

@ -4,6 +4,66 @@
package dbq package dbq
import (
"database/sql/driver"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
)
type PostState string
const (
PostStateDraft PostState = "draft"
PostStatePublished PostState = "published"
)
func (e *PostState) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = PostState(s)
case string:
*e = PostState(s)
default:
return fmt.Errorf("unsupported scan type for PostState: %T", src)
}
return nil
}
type NullPostState struct {
PostState PostState
Valid bool // Valid is true if PostState is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullPostState) Scan(value interface{}) error {
if value == nil {
ns.PostState, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.PostState.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullPostState) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.PostState), nil
}
type Post struct {
ID int64
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PostDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
}
type Site struct { type Site struct {
ID int64 ID int64
Name string Name string

83
gen/sqlc/dbq/posts.sql.go Normal file
View file

@ -0,0 +1,83 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: posts.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertPost = `-- name: InsertPost :one
INSERT INTO post (
site_id,
title,
body,
state,
props,
post_date,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`
type InsertPostParams struct {
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PostDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
}
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
row := q.db.QueryRow(ctx, insertPost,
arg.SiteID,
arg.Title,
arg.Body,
arg.State,
arg.Props,
arg.PostDate,
arg.CreatedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}
const listPosts = `-- name: ListPosts :many
SELECT id, site_id, title, body, state, props, post_date, created_at FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25
`
func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
rows, err := q.db.Query(ctx, listPosts, siteID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Post
for rows.Next() {
var i Post
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Title,
&i.Body,
&i.State,
&i.Props,
&i.PostDate,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

2
go.mod
View file

@ -41,4 +41,6 @@ require (
golang.org/x/sync v0.10.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 // indirect
) )

4
go.sum
View file

@ -81,3 +81,7 @@ golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 h1:Seqp9vlIw3uJBL0V/eWIM3dAnSuToJ/cztkRQtl3g20=
lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI=

14
handlers/ctx.go Normal file
View file

@ -0,0 +1,14 @@
package handlers
import (
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-crm/models"
)
type siteKeyType struct{}
var siteKey siteKeyType
func GetSite(c *fiber.Ctx) models.Site {
return c.UserContext().Value(siteKey).(models.Site)
}

49
handlers/post.go Normal file
View file

@ -0,0 +1,49 @@
package handlers
import (
"fmt"
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-crm/services/posts"
)
type Post struct {
Post *posts.Service
}
func (h *Post) Posts() fiber.Handler {
return func(c *fiber.Ctx) error {
site := GetSite(c)
posts, err := h.Post.ListPostOfSite(c.UserContext(), site)
if err != nil {
return err
}
return c.Render("sites/posts", fiber.Map{
"site": site,
"posts": posts,
}, "layouts/main")
}
}
func (h *Post) Create() fiber.Handler {
type Req struct {
Body string `json:"body" form:"body"`
}
return func(c *fiber.Ctx) error {
site := GetSite(c)
var req Req
if err := c.BodyParser(&req); err != nil {
return err
}
_, err := h.Post.Create(c.UserContext(), site, req.Body)
if err != nil {
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID))
}
}

View file

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-crm/services/sites" "lmika.dev/lmika/hugo-crm/services/sites"
@ -30,9 +31,29 @@ func (s *Site) Show() fiber.Handler {
} }
site, err := s.Site.GetSite(c.UserContext(), id) site, err := s.Site.GetSite(c.UserContext(), id)
if err != nil {
return err
}
return c.Render("sites/index", fiber.Map{ return c.Render("sites/index", fiber.Map{
"site": site, "site": site,
}, "layouts/main") }, "layouts/main")
} }
} }
func (s *Site) WithSite() fiber.Handler {
return func(c *fiber.Ctx) error {
id, err := c.ParamsInt("siteId")
if err != nil {
return err
}
site, err := s.Site.GetSite(c.UserContext(), id)
if err != nil {
return err
}
c.SetUserContext(context.WithValue(c.UserContext(), siteKey, site))
return c.Next()
}
}

View file

@ -11,6 +11,7 @@ import (
"lmika.dev/lmika/hugo-crm/providers/hugo" "lmika.dev/lmika/hugo-crm/providers/hugo"
"lmika.dev/lmika/hugo-crm/providers/themes" "lmika.dev/lmika/hugo-crm/providers/themes"
"lmika.dev/lmika/hugo-crm/services/jobs" "lmika.dev/lmika/hugo-crm/services/jobs"
"lmika.dev/lmika/hugo-crm/services/posts"
"lmika.dev/lmika/hugo-crm/services/sitebuilder" "lmika.dev/lmika/hugo-crm/services/sitebuilder"
"lmika.dev/lmika/hugo-crm/services/sites" "lmika.dev/lmika/hugo-crm/services/sites"
"lmika.dev/lmika/hugo-crm/templates" "lmika.dev/lmika/hugo-crm/templates"
@ -41,8 +42,10 @@ func main() {
siteBuilderService := sitebuilder.New(themesProvider, gitProvider, hugoProvider) siteBuilderService := sitebuilder.New(themesProvider, gitProvider, hugoProvider)
siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService) siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService)
postService := posts.New(dbp, siteBuilderService, jobService)
siteHandlers := handlers.Site{Site: siteService} siteHandlers := handlers.Site{Site: siteService}
postHandlers := handlers.Post{Post: postService}
log.Println("Connected to database") log.Println("Connected to database")
if err := dbp.Migrate(context.Background()); err != nil { if err := dbp.Migrate(context.Background()); err != nil {
@ -63,6 +66,12 @@ func main() {
app.Post("/sites", siteHandlers.Create()) app.Post("/sites", siteHandlers.Create())
app.Get("/sites/:siteId", siteHandlers.Show()) app.Get("/sites/:siteId", siteHandlers.Show())
siteGroup := app.Group("/sites/:siteId")
siteGroup.Use(siteHandlers.WithSite())
siteGroup.Get("/posts", postHandlers.Posts())
siteGroup.Post("/posts", postHandlers.Create())
jobService.Start() jobService.Start()
defer jobService.Stop() defer jobService.Stop()

20
models/posts.go Normal file
View file

@ -0,0 +1,20 @@
package models
import "time"
type PostState string
const (
PostStateDraft PostState = "draft"
PostStatePublished PostState = "published"
)
type Post struct {
ID int64
SiteID int64
Title string
Body string
State PostState
PostDate time.Time
CreatedAt time.Time
}

View file

@ -3,4 +3,10 @@ package models
type ThemeMeta struct { type ThemeMeta struct {
Name string `json:"name"` Name string `json:"name"`
URL string `json:"repo"` URL string `json:"repo"`
// Indicates that this theme prefers posts have titles.
PreferTitle bool
// Content directory for "blog" posts
PostDir string `json:"post_dir"`
} }

46
providers/db/posts.go Normal file
View file

@ -0,0 +1,46 @@
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"lmika.dev/lmika/hugo-crm/gen/sqlc/dbq"
"lmika.dev/lmika/hugo-crm/models"
"lmika.dev/pkg/modash/moslice"
)
func (db *DB) ListPostsOfSite(ctx context.Context, siteID int64) ([]models.Post, error) {
res, err := db.q.ListPosts(ctx, siteID)
if err != nil {
return nil, err
}
return moslice.Map(res, func(p dbq.Post) models.Post {
return models.Post{
ID: p.ID,
SiteID: p.SiteID,
Title: p.Title.String,
Body: p.Body,
State: models.PostState(p.State),
PostDate: p.PostDate.Time,
CreatedAt: p.CreatedAt.Time,
}
}), nil
}
func (db *DB) InsertPost(ctx context.Context, p *models.Post) error {
res, err := db.q.InsertPost(ctx, dbq.InsertPostParams{
SiteID: p.SiteID,
Title: pgtype.Text{String: p.Title, Valid: p.Title != ""},
Body: p.Body,
State: dbq.PostState(p.State),
Props: []byte(`{}`),
PostDate: pgtype.Timestamptz{Time: p.PostDate, Valid: !p.PostDate.IsZero()},
CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()},
})
if err != nil {
return err
}
p.ID = res
return nil
}

View file

@ -5,4 +5,5 @@ type SiteDir string
const ( const (
BaseSiteDir SiteDir = "base" BaseSiteDir SiteDir = "base"
ThemeSiteDir SiteDir = "theme" ThemeSiteDir SiteDir = "theme"
ContentSiteDir SiteDir = "content"
) )

View file

@ -36,7 +36,7 @@ func (p *Provider) SiteStagingDir(site models.Site, what SiteDir) string {
case ThemeSiteDir: case ThemeSiteDir:
return filepath.Join(p.stagingDir, site.Name, "themes", site.Theme) return filepath.Join(p.stagingDir, site.Name, "themes", site.Theme)
} }
return "" return filepath.Join(p.stagingDir, site.Name, string(what))
} }
func (p *Provider) NewSite(ctx context.Context, site models.Site) error { func (p *Provider) NewSite(ctx context.Context, site models.Site) error {

View file

@ -6,5 +6,7 @@ var themes = map[string]models.ThemeMeta{
"bear": models.ThemeMeta{ "bear": models.ThemeMeta{
Name: "bear", Name: "bear",
URL: "https://github.com/janraasch/hugo-bearblog", URL: "https://github.com/janraasch/hugo-bearblog",
PreferTitle: true,
PostDir: "blog",
}, },
} }

View file

@ -0,0 +1,47 @@
package posts
import (
"context"
"lmika.dev/lmika/hugo-crm/models"
"lmika.dev/lmika/hugo-crm/providers/db"
"lmika.dev/lmika/hugo-crm/services/jobs"
"lmika.dev/lmika/hugo-crm/services/sitebuilder"
"time"
)
type Service struct {
db *db.DB
sb *sitebuilder.Service
jobs *jobs.Service
}
func New(
db *db.DB,
sb *sitebuilder.Service,
jobs *jobs.Service,
) *Service {
return &Service{
db: db,
sb: sb,
jobs: jobs,
}
}
func (s *Service) ListPostOfSite(ctx context.Context, site models.Site) ([]models.Post, error) {
return s.db.ListPostsOfSite(ctx, site.ID)
}
func (s *Service) Create(ctx context.Context, site models.Site, body string) (models.Post, error) {
post := models.Post{
SiteID: site.ID,
Body: body,
State: models.PostStatePublished,
PostDate: time.Now(),
CreatedAt: time.Now(),
}
if err := s.db.InsertPost(ctx, &post); err != nil {
return models.Post{}, err
}
return post, s.jobs.Queue(ctx, s.sb.WritePost(site, post))
}

View file

@ -3,11 +3,15 @@ package sitebuilder
import ( import (
"context" "context"
"errors" "errors"
"gopkg.in/yaml.v3"
"lmika.dev/lmika/hugo-crm/models" "lmika.dev/lmika/hugo-crm/models"
"lmika.dev/lmika/hugo-crm/providers/git" "lmika.dev/lmika/hugo-crm/providers/git"
"lmika.dev/lmika/hugo-crm/providers/hugo" "lmika.dev/lmika/hugo-crm/providers/hugo"
"lmika.dev/lmika/hugo-crm/providers/themes" "lmika.dev/lmika/hugo-crm/providers/themes"
"log" "log"
"os"
"path/filepath"
"time"
) )
type Service struct { type Service struct {
@ -55,3 +59,57 @@ func (s *Service) CreateNewSite(site models.Site) models.Job {
}, },
} }
} }
func (s *Service) WritePost(site models.Site, post models.Post) models.Job {
return models.Job{
Do: func(ctx context.Context) error {
themeMeta, ok := s.themes.Lookup(site.Theme)
if !ok {
return errors.New("theme not found")
}
postFilename := filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md"))
log.Printf(" .. post %v", postFilename)
if err := os.MkdirAll(filepath.Dir(postFilename), 0755); err != nil {
return err
}
frontMatter := map[string]string{
"date": post.PostDate.Format(time.RFC3339),
}
if post.Title != "" {
frontMatter["title"] = post.Title
} else if themeMeta.PreferTitle {
frontMatter["title"] = post.PostDate.Format(time.ANSIC)
}
fmBytes, err := yaml.Marshal(frontMatter)
if err != nil {
return err
}
f, err := os.Create(postFilename)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString("---\n"); err != nil {
return err
}
if _, err := f.Write(fmBytes); err != nil {
return err
}
if _, err := f.WriteString("---\n"); err != nil {
return err
}
if _, err := f.WriteString(post.Body); err != nil {
return err
}
return nil
},
}
}

14
sql/queries/posts.sql Normal file
View file

@ -0,0 +1,14 @@
-- name: ListPosts :many
SELECT * FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25;
-- name: InsertPost :one
INSERT INTO post (
site_id,
title,
body,
state,
props,
post_date,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id;

View file

@ -1,3 +1,8 @@
CREATE TYPE post_state AS ENUM (
'draft',
'published'
);
CREATE TABLE site ( CREATE TABLE site (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
@ -6,3 +11,16 @@ CREATE TABLE site (
theme TEXT NOT NULL, theme TEXT NOT NULL,
props JSON NOT NULL props JSON NOT NULL
); );
CREATE TABLE post (
id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
title TEXT,
body TEXT NOT NULL,
state post_state NOT NULL,
props JSON NOT NULL,
post_date TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (site_id) REFERENCES site(id)
);

View file

@ -1 +0,0 @@
<h1>Site {{.site.Title}}</h1>

View file

@ -0,0 +1,15 @@
<h1>Site {{.site.Title}}</h1>
<form method="post" action="/sites/{{.site.ID}}/posts">
<textarea name="body"></textarea>
<input type="submit" value="Post">
</form>
{{range .posts}}
<div class="post">
{{.Body}}
</div>
<hr>
{{else}}
<p>No posts yet</p>
{{end}}