content page

This commit is contained in:
Daniel Noulet 2026-09-19 15:11:12 +02:00
parent fd2877da9e
commit 36b2c56987
14 changed files with 590 additions and 20 deletions

View file

@ -28,10 +28,19 @@ type Content struct {
Slug, Title, Summary, Body string
Status string
Published bool
CategoryID uint
Category string
Badges []string
CreatedAt, UpdatedAt time.Time
CreatedBy, UpdatedBy uint
}
type Category struct {
ID uint
Slug string
Name string
}
type ContentHistory struct {
ID uint
ContentID uint

View file

@ -9,7 +9,11 @@ func UserFromDomain(d domain.User) UserModel {
return UserModel{ID: d.ID, Email: d.Email, Name: d.Name, Provider: d.Provider, ProviderSubject: d.ProviderSubject, Role: d.Role, Editor: d.Editor, CreatedAt: d.CreatedAt}
}
func ContentToDomain(m ContentModel) domain.Content {
return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, CreatedBy: m.CreatedBy, UpdatedBy: m.UpdatedBy}
badges := make([]string, len(m.Badges))
for i := range m.Badges {
badges[i] = m.Badges[i].Name
}
return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CategoryID: m.CategoryID, Category: m.Category.Name, Badges: badges, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, CreatedBy: m.CreatedBy, UpdatedBy: m.UpdatedBy}
}
func ContentFromDomain(d domain.Content) ContentModel {
return ContentModel{ID: d.ID, Slug: d.Slug, Title: d.Title, Summary: d.Summary, Body: d.Body, Status: d.Status, Published: d.Published, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, CreatedBy: d.CreatedBy, UpdatedBy: d.UpdatedBy}

View file

@ -21,10 +21,28 @@ type ContentModel struct {
Body string `gorm:"not null"`
Status string `gorm:"not null;default:'draft'"`
Published bool `gorm:"not null;default:false"`
CategoryID uint
Category CategoryModel `gorm:"foreignKey:CategoryID"`
Badges []BadgeModel `gorm:"many2many:content_badges;joinForeignKey:ContentID;joinReferences:BadgeID"`
CreatedAt, UpdatedAt time.Time
CreatedBy, UpdatedBy uint
}
type CategoryModel struct {
ID uint `gorm:"primaryKey"`
Slug string `gorm:"uniqueIndex;not null"`
Name string `gorm:"not null"`
}
func (CategoryModel) TableName() string { return "categories" }
type BadgeModel struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"uniqueIndex;not null"`
}
func (BadgeModel) TableName() string { return "badges" }
func (ContentModel) TableName() string { return "content" }
type ContentHistoryModel struct {

View file

@ -63,18 +63,36 @@ func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain
type GORMContentRepository struct{ db *gorm.DB }
func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{db} }
func (r *GORMContentRepository) List(ctx context.Context, limit, offset int) ([]domain.Content, error) {
func (r *GORMContentRepository) List(ctx context.Context, limit, offset int, category string, badges []string) ([]domain.Content, error) {
var rows []persistence.ContentModel
err := r.db.WithContext(ctx).Order("updated_at DESC, created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&rows).Error
query := r.db.WithContext(ctx).Model(&persistence.ContentModel{}).Preload("Category").Preload("Badges")
if category != "" {
query = query.Where("category_id IN (SELECT id FROM categories WHERE slug = ?)", category)
}
for _, badge := range badges {
query = query.Where("EXISTS (SELECT 1 FROM content_badges cb JOIN badges b ON b.id = cb.badge_id WHERE cb.content_id = content.id AND b.name = ?)", badge)
}
err := query.Order("updated_at DESC, created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&rows).Error
out := make([]domain.Content, len(rows))
for i := range rows {
out[i] = persistence.ContentToDomain(rows[i])
}
return out, err
}
func (r *GORMContentRepository) ListCategories(ctx context.Context) ([]domain.Category, error) {
var rows []persistence.CategoryModel
if err := r.db.WithContext(ctx).Order("name ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]domain.Category, len(rows))
for i := range rows {
out[i] = domain.Category{ID: rows[i].ID, Slug: rows[i].Slug, Name: rows[i].Name}
}
return out, nil
}
func (r *GORMContentRepository) FindBySlug(ctx context.Context, slug string) (domain.Content, error) {
var row persistence.ContentModel
if err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&row).Error; err != nil {
if err := r.db.WithContext(ctx).Preload("Category").Preload("Badges").Where("slug = ?", slug).First(&row).Error; err != nil {
return domain.Content{}, err
}
return persistence.ContentToDomain(row), nil

View file

@ -19,7 +19,8 @@ type ISessionRepository interface {
FindValid(context.Context, string) (domain.Session, error)
}
type IContentRepository interface {
List(context.Context, int, int) ([]domain.Content, error)
List(context.Context, int, int, string, []string) ([]domain.Content, error)
ListCategories(context.Context) ([]domain.Category, error)
FindBySlug(context.Context, string) (domain.Content, error)
Update(context.Context, domain.Content, domain.ContentHistory) error
}

View file

@ -12,7 +12,8 @@ import (
)
type IContentService interface {
List(context.Context, int, int) ([]domain.Content, error)
List(context.Context, int, int, string, []string) ([]domain.Content, error)
ListCategories(context.Context) ([]domain.Category, error)
Get(context.Context, string) (domain.Content, error)
Update(context.Context, domain.Content, domain.Session) (domain.Content, error)
}
@ -24,7 +25,7 @@ type ContentService struct {
func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService {
return &ContentService{repo: r, auth: a}
}
func (s *ContentService) List(ctx context.Context, limit, offset int) ([]domain.Content, error) {
func (s *ContentService) List(ctx context.Context, limit, offset int, category string, badges []string) ([]domain.Content, error) {
if limit < 1 {
limit = 20
}
@ -34,7 +35,10 @@ func (s *ContentService) List(ctx context.Context, limit, offset int) ([]domain.
if offset < 0 {
offset = 0
}
return s.repo.List(ctx, limit, offset)
return s.repo.List(ctx, limit, offset, category, badges)
}
func (s *ContentService) ListCategories(ctx context.Context) ([]domain.Category, error) {
return s.repo.ListCategories(ctx)
}
func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content, error) {
if !validSlug(slug) {

View file

@ -37,6 +37,7 @@ func (h *Handler) Routes(origins string) http.Handler {
mux.HandleFunc("/api/test/protected", h.protected)
mux.HandleFunc("/api/protected", h.protected)
mux.HandleFunc("/api/content", h.contentList)
mux.HandleFunc("/api/content/categories", h.contentCategories)
mux.HandleFunc("/api/content/", h.contentDetail)
return cors(mux, origins)
}
@ -171,13 +172,43 @@ func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
limit, offset := 20, 0
_, _ = fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit)
_, _ = fmt.Sscanf(r.URL.Query().Get("offset"), "%d", &offset)
items, e := h.content.List(r.Context(), limit, offset)
badges := r.URL.Query()["badge"]
if len(badges) == 0 {
badges = strings.Split(strings.TrimSpace(r.URL.Query().Get("badges")), ",")
}
filtered := badges[:0]
for _, badge := range badges {
if badge = strings.TrimSpace(strings.ToLower(badge)); badge != "" {
filtered = append(filtered, badge)
}
}
items, e := h.content.List(r.Context(), limit, offset, strings.TrimSpace(strings.ToLower(r.URL.Query().Get("category"))), filtered)
if e != nil {
jsonWrite(w, 500, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, map[string]any{"items": items, "limit": limit, "offset": offset})
}
func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
if _, ok := h.sessionForRequest(r); !ok {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
return
}
if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
}
categories, err := h.content.ListCategories(r.Context())
if err != nil {
jsonWrite(w, 500, map[string]string{"error": err.Error()})
return
}
jsonWrite(w, 200, map[string]any{"items": categories})
}
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})