diff --git a/.husky/pre-commit b/.husky/pre-commit
index 9e972a9..2ffefef 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -73,8 +73,9 @@ fi
# 6. Web checks — only when web/ files are staged.
if [ -n "$(staged_match '^web/')" ]; then
- echo "→ web: prettier --check"
- ( cd web && pnpm run format:check )
+ echo "→ web: prettier --write"
+ ( cd web && pnpm run format )
+ git add $(git diff --cached --name-only --diff-filter=ACMR | grep '^web/' | tr '\n' ' ')
echo "→ web: eslint"
( cd web && pnpm run lint )
diff --git a/backend/internal/domain/application/application_security_test.go b/backend/internal/domain/application/application_security_test.go
new file mode 100644
index 0000000..801b08f
--- /dev/null
+++ b/backend/internal/domain/application/application_security_test.go
@@ -0,0 +1,420 @@
+package application_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "marktvogt.de/backend/internal/domain/application"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+// -- in-memory fakes --
+
+type fakeRepo struct {
+ apps map[uuid.UUID]application.Application
+ log map[uuid.UUID][]application.StatusLogEntry
+}
+
+func newFakeRepo() *fakeRepo {
+ return &fakeRepo{
+ apps: make(map[uuid.UUID]application.Application),
+ log: make(map[uuid.UUID][]application.StatusLogEntry),
+ }
+}
+
+func (r *fakeRepo) Create(_ context.Context, a application.Application) (application.Application, error) {
+ for _, existing := range r.apps {
+ if existing.GroupID == a.GroupID && existing.MarketEditionID == a.MarketEditionID {
+ return application.Application{}, application.ErrDuplicateApplication
+ }
+ }
+ a.CreatedAt = time.Now()
+ a.UpdatedAt = time.Now()
+ r.apps[a.ID] = a
+ return a, nil
+}
+
+func (r *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (application.Application, error) {
+ a, ok := r.apps[id]
+ if !ok {
+ return application.Application{}, application.ErrApplicationNotFound
+ }
+ return a, nil
+}
+
+func (r *fakeRepo) ListByGroup(_ context.Context, groupID uuid.UUID) ([]application.Application, error) {
+ var out []application.Application
+ for _, a := range r.apps {
+ if a.GroupID == groupID {
+ out = append(out, a)
+ }
+ }
+ return out, nil
+}
+
+func (r *fakeRepo) Update(_ context.Context, id uuid.UUID, fields map[string]any) (application.Application, error) {
+ a, ok := r.apps[id]
+ if !ok {
+ return application.Application{}, application.ErrApplicationNotFound
+ }
+ a.UpdatedAt = time.Now()
+ r.apps[id] = a
+ return a, nil
+}
+
+func (r *fakeRepo) SetStatus(_ context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (application.Application, error) {
+ a, ok := r.apps[id]
+ if !ok {
+ return application.Application{}, application.ErrApplicationNotFound
+ }
+ a.Status = status
+ a.SubmittedBy = submittedBy
+ a.SubmittedAt = submittedAt
+ r.apps[id] = a
+ return a, nil
+}
+
+func (r *fakeRepo) AppendStatusLog(_ context.Context, e application.StatusLogEntry) error {
+ r.log[e.ApplicationID] = append(r.log[e.ApplicationID], e)
+ return nil
+}
+
+func (r *fakeRepo) ListStatusLog(_ context.Context, applicationID uuid.UUID) ([]application.StatusLogEntry, error) {
+ return r.log[applicationID], nil
+}
+
+// fakeGroupChecker controls IsGroupAdmin / IsGroupMember responses per user.
+type fakeGroupChecker struct {
+ admins map[uuid.UUID]bool
+ members map[uuid.UUID]bool
+}
+
+func newFakeGroupChecker() *fakeGroupChecker {
+ return &fakeGroupChecker{
+ admins: make(map[uuid.UUID]bool),
+ members: make(map[uuid.UUID]bool),
+ }
+}
+
+func (f *fakeGroupChecker) withAdmin(userID uuid.UUID) *fakeGroupChecker {
+ f.admins[userID] = true
+ f.members[userID] = true
+ return f
+}
+
+func (f *fakeGroupChecker) withMember(userID uuid.UUID) *fakeGroupChecker {
+ f.members[userID] = true
+ return f
+}
+
+func (f *fakeGroupChecker) IsGroupAdmin(_ context.Context, _, userID uuid.UUID) (bool, error) {
+ return f.admins[userID], nil
+}
+
+func (f *fakeGroupChecker) IsGroupMember(_ context.Context, _, userID uuid.UUID) (bool, error) {
+ return f.members[userID], nil
+}
+
+// -- router helpers --
+
+func newRouter(repo application.Repository, checker application.GroupMembershipChecker, authMiddleware gin.HandlerFunc) *gin.Engine {
+ svc := application.NewService(repo, checker)
+ h := application.NewHandler(svc)
+ router := gin.New()
+ application.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware)
+ return router
+}
+
+func stubAuth(userID uuid.UUID) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Set("user_id", userID)
+ c.Next()
+ }
+}
+
+func noAuth() gin.HandlerFunc {
+ return func(c *gin.Context) { c.AbortWithStatus(http.StatusUnauthorized) }
+}
+
+func jsonBody(v any) *bytes.Reader {
+ b, _ := json.Marshal(v)
+ return bytes.NewReader(b)
+}
+
+// PoC: all application endpoints reject unauthenticated requests (401).
+func TestApplicationEndpoints_Unauthenticated_Returns401(t *testing.T) {
+ t.Parallel()
+ repo := newFakeRepo()
+ checker := newFakeGroupChecker()
+ router := newRouter(repo, checker, noAuth())
+
+ groupID := uuid.New().String()
+ appID := uuid.New().String()
+
+ endpoints := []struct {
+ method string
+ path string
+ body any
+ }{
+ {http.MethodPost, "/api/v1/groups/" + groupID + "/applications", map[string]any{"market_edition_id": uuid.New(), "num_persons": 1}},
+ {http.MethodGet, "/api/v1/groups/" + groupID + "/applications", nil},
+ {http.MethodGet, "/api/v1/applications/" + appID, nil},
+ {http.MethodPatch, "/api/v1/applications/" + appID, map[string]string{"category": "Schmuck"}},
+ {http.MethodPost, "/api/v1/applications/" + appID + "/submit", nil},
+ {http.MethodGet, "/api/v1/applications/" + appID + "/history", nil},
+ }
+
+ for _, ep := range endpoints {
+ t.Run(ep.method+" "+ep.path, func(t *testing.T) {
+ var body *bytes.Reader
+ if ep.body != nil {
+ body = jsonBody(ep.body)
+ } else {
+ body = bytes.NewReader(nil)
+ }
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(ep.method, ep.path, body)
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// PoC: create/update/submit return 403 for non-group-admin users.
+func TestApplicationWriteEndpoints_NonAdmin_Returns403(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+ memberID := uuid.New()
+ groupID := uuid.New()
+ editionID := uuid.New()
+
+ checker := newFakeGroupChecker().withAdmin(adminID).withMember(memberID)
+ repo := newFakeRepo()
+
+ // Seed a draft application.
+ draftApp := application.Application{
+ ID: uuid.New(),
+ GroupID: groupID,
+ MarketEditionID: editionID,
+ Status: application.StatusDraft,
+ NumPersons: 1,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ repo.apps[draftApp.ID] = draftApp
+
+ router := newRouter(repo, checker, stubAuth(memberID))
+ appPath := "/api/v1/applications/" + draftApp.ID.String()
+
+ endpoints := []struct {
+ method string
+ path string
+ body any
+ }{
+ {http.MethodPost, "/api/v1/groups/" + groupID.String() + "/applications",
+ map[string]any{"market_edition_id": uuid.New(), "num_persons": 1}},
+ {http.MethodPatch, appPath, map[string]string{"category": "Schmuck"}},
+ {http.MethodPost, appPath + "/submit", nil},
+ }
+
+ for _, ep := range endpoints {
+ t.Run(ep.method+" "+ep.path, func(t *testing.T) {
+ var body *bytes.Reader
+ if ep.body != nil {
+ body = jsonBody(ep.body)
+ } else {
+ body = bytes.NewReader(nil)
+ }
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(ep.method, ep.path, body)
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// PoC: get/list/history return 403 for users who are not group members.
+func TestApplicationReadEndpoints_NonMember_Returns403(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+ outsiderID := uuid.New()
+ groupID := uuid.New()
+
+ checker := newFakeGroupChecker().withAdmin(adminID)
+ repo := newFakeRepo()
+
+ app := application.Application{
+ ID: uuid.New(),
+ GroupID: groupID,
+ Status: application.StatusDraft,
+ NumPersons: 1,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ repo.apps[app.ID] = app
+
+ router := newRouter(repo, checker, stubAuth(outsiderID))
+ appPath := "/api/v1/applications/" + app.ID.String()
+
+ endpoints := []struct{ method, path string }{
+ {http.MethodGet, "/api/v1/groups/" + groupID.String() + "/applications"},
+ {http.MethodGet, appPath},
+ {http.MethodGet, appPath + "/history"},
+ }
+
+ for _, ep := range endpoints {
+ t.Run(ep.method+" "+ep.path, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(ep.method, ep.path, nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// PoC: submitting a non-draft application returns 400.
+func TestSubmit_NonDraft_Returns400(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+ groupID := uuid.New()
+
+ checker := newFakeGroupChecker().withAdmin(adminID)
+ repo := newFakeRepo()
+
+ submittedApp := application.Application{
+ ID: uuid.New(),
+ GroupID: groupID,
+ Status: application.StatusSubmitted,
+ NumPersons: 1,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ repo.apps[submittedApp.ID] = submittedApp
+
+ router := newRouter(repo, checker, stubAuth(adminID))
+ path := "/api/v1/applications/" + submittedApp.ID.String() + "/submit"
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, path, nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d (body=%s)", w.Code, w.Body.String())
+ }
+}
+
+// PoC: group admin can create and submit an application; status log is appended.
+func TestCreateAndSubmit_Admin_Succeeds(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+ groupID := uuid.New()
+ editionID := uuid.New()
+
+ checker := newFakeGroupChecker().withAdmin(adminID)
+ repo := newFakeRepo()
+ router := newRouter(repo, checker, stubAuth(adminID))
+
+ // Create a draft.
+ createBody := jsonBody(map[string]any{
+ "market_edition_id": editionID,
+ "category": "Schmuck & Accessoires", //nolint:misspell
+ "description": "Wir verkaufen handgefertigten Schmuck",
+ "num_persons": 2,
+ "num_tents": 1,
+ })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/groups/"+groupID.String()+"/applications", createBody)
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create: want 201, got %d (body=%s)", w.Code, w.Body.String())
+ }
+
+ // Extract the application ID from the response.
+ var resp struct {
+ Data application.Application `json:"data"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("unmarshal create response: %v", err)
+ }
+ if resp.Data.Status != application.StatusDraft {
+ t.Errorf("want status draft, got %s", resp.Data.Status)
+ }
+
+ // Submit it.
+ w2 := httptest.NewRecorder()
+ req2 := httptest.NewRequest(http.MethodPost, "/api/v1/applications/"+resp.Data.ID.String()+"/submit", nil)
+ router.ServeHTTP(w2, req2)
+
+ if w2.Code != http.StatusOK {
+ t.Fatalf("submit: want 200, got %d (body=%s)", w2.Code, w2.Body.String())
+ }
+
+ var resp2 struct {
+ Data application.Application `json:"data"`
+ }
+ if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
+ t.Fatalf("unmarshal submit response: %v", err)
+ }
+ if resp2.Data.Status != application.StatusSubmitted {
+ t.Errorf("want status submitted, got %s", resp2.Data.Status)
+ }
+
+ // Verify status log has two entries (draft creation + submission).
+ if len(repo.log[resp.Data.ID]) < 2 {
+ t.Errorf("expected at least 2 status log entries, got %d", len(repo.log[resp.Data.ID]))
+ }
+}
+
+// PoC: duplicate application (same group + edition) returns 400.
+func TestCreate_Duplicate_Returns400(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+ groupID := uuid.New()
+ editionID := uuid.New()
+
+ checker := newFakeGroupChecker().withAdmin(adminID)
+ repo := newFakeRepo()
+ repo.apps[uuid.New()] = application.Application{
+ ID: uuid.New(), GroupID: groupID, MarketEditionID: editionID,
+ Status: application.StatusDraft, NumPersons: 1,
+ CreatedAt: time.Now(), UpdatedAt: time.Now(),
+ }
+
+ router := newRouter(repo, checker, stubAuth(adminID))
+ body := jsonBody(map[string]any{"market_edition_id": editionID, "num_persons": 1})
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/groups/"+groupID.String()+"/applications", body)
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d (body=%s)", w.Code, w.Body.String())
+ }
+}
diff --git a/backend/internal/domain/application/handler.go b/backend/internal/domain/application/handler.go
new file mode 100644
index 0000000..56a4edc
--- /dev/null
+++ b/backend/internal/domain/application/handler.go
@@ -0,0 +1,164 @@
+package application
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "marktvogt.de/backend/internal/pkg/apierror"
+ "marktvogt.de/backend/internal/pkg/validate"
+)
+
+type Handler struct {
+ svc *Service
+}
+
+func NewHandler(svc *Service) *Handler {
+ return &Handler{svc: svc}
+}
+
+func (h *Handler) Create(c *gin.Context) {
+ groupID, ok := parseGroupID(c)
+ if !ok {
+ return
+ }
+
+ var req CreateRequest
+ if apiErr := validate.BindJSON(c, &req); apiErr != nil {
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ requesterID := getRequesterID(c)
+ a, err := h.svc.Create(c.Request.Context(), requesterID, groupID, req)
+ if err != nil {
+ h.handleServiceError(c, err)
+ return
+ }
+ c.JSON(http.StatusCreated, gin.H{"data": a})
+}
+
+func (h *Handler) Get(c *gin.Context) {
+ id, ok := parseApplicationID(c)
+ if !ok {
+ return
+ }
+ requesterID := getRequesterID(c)
+ a, err := h.svc.Get(c.Request.Context(), requesterID, id)
+ if err != nil {
+ h.handleServiceError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": a})
+}
+
+func (h *Handler) ListByGroup(c *gin.Context) {
+ groupID, ok := parseGroupID(c)
+ if !ok {
+ return
+ }
+ requesterID := getRequesterID(c)
+ apps, err := h.svc.ListByGroup(c.Request.Context(), requesterID, groupID)
+ if err != nil {
+ h.handleServiceError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": apps})
+}
+
+func (h *Handler) Update(c *gin.Context) {
+ id, ok := parseApplicationID(c)
+ if !ok {
+ return
+ }
+
+ var req UpdateRequest
+ if apiErr := validate.BindJSON(c, &req); apiErr != nil {
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ requesterID := getRequesterID(c)
+ a, err := h.svc.Update(c.Request.Context(), requesterID, id, req)
+ if err != nil {
+ h.handleServiceError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": a})
+}
+
+func (h *Handler) Submit(c *gin.Context) {
+ id, ok := parseApplicationID(c)
+ if !ok {
+ return
+ }
+ requesterID := getRequesterID(c)
+ a, err := h.svc.Submit(c.Request.Context(), requesterID, id)
+ if err != nil {
+ h.handleServiceError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": a})
+}
+
+func (h *Handler) GetHistory(c *gin.Context) {
+ id, ok := parseApplicationID(c)
+ if !ok {
+ return
+ }
+ requesterID := getRequesterID(c)
+ entries, err := h.svc.GetHistory(c.Request.Context(), requesterID, id)
+ if err != nil {
+ h.handleServiceError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": entries})
+}
+
+func (h *Handler) handleServiceError(c *gin.Context, err error) {
+ switch {
+ case errors.Is(err, ErrApplicationNotFound):
+ apiErr := apierror.NotFound("application")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ case errors.Is(err, ErrForbidden):
+ apiErr := apierror.Forbidden("insufficient group permissions")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ case errors.Is(err, ErrNotDraft):
+ apiErr := apierror.BadRequest("not_draft", "application is not in draft status")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ case errors.Is(err, ErrDuplicateApplication):
+ apiErr := apierror.BadRequest("duplicate_application", "an application already exists for this group and market edition")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ default:
+ apiErr := apierror.Internal("internal error")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ }
+}
+
+func parseGroupID(c *gin.Context) (uuid.UUID, bool) {
+ id, err := uuid.Parse(c.Param("groupId"))
+ if err != nil {
+ apiErr := apierror.BadRequest("invalid_group_id", "invalid group id")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return uuid.Nil, false
+ }
+ return id, true
+}
+
+func parseApplicationID(c *gin.Context) (uuid.UUID, bool) {
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ apiErr := apierror.BadRequest("invalid_application_id", "invalid application id")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return uuid.Nil, false
+ }
+ return id, true
+}
+
+func getRequesterID(c *gin.Context) uuid.UUID {
+ v, _ := c.Get("user_id")
+ id, _ := v.(uuid.UUID)
+ return id
+}
diff --git a/backend/internal/domain/application/model.go b/backend/internal/domain/application/model.go
new file mode 100644
index 0000000..6b90f43
--- /dev/null
+++ b/backend/internal/domain/application/model.go
@@ -0,0 +1,52 @@
+package application
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Application struct {
+ ID uuid.UUID `json:"id"`
+ GroupID uuid.UUID `json:"group_id"`
+ MarketEditionID uuid.UUID `json:"market_edition_id"`
+ Status string `json:"status"`
+ Category string `json:"category"`
+ Description string `json:"description"`
+ AreaSqm *float64 `json:"area_sqm,omitempty"`
+ NeedsPower bool `json:"needs_power"`
+ NeedsWater bool `json:"needs_water"`
+ NumPersons int `json:"num_persons"`
+ NumTents int `json:"num_tents"`
+ Notes string `json:"notes"`
+ SubmittedBy *uuid.UUID `json:"submitted_by,omitempty"`
+ SubmittedAt *time.Time `json:"submitted_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type StatusLogEntry struct {
+ ID uuid.UUID `json:"id"`
+ ApplicationID uuid.UUID `json:"application_id"`
+ FromStatus *string `json:"from_status,omitempty"`
+ ToStatus string `json:"to_status"`
+ ChangedBy uuid.UUID `json:"changed_by"`
+ Note string `json:"note"`
+ ChangedAt time.Time `json:"changed_at"`
+}
+
+const (
+ StatusDraft = "draft"
+ StatusSubmitted = "submitted"
+ StatusReviewing = "reviewing"
+ StatusAccepted = "accepted"
+ StatusRejected = "rejected"
+ StatusWaitlisted = "waitlisted"
+)
+
+var (
+ ErrApplicationNotFound = fmt.Errorf("application not found")
+ ErrNotDraft = fmt.Errorf("application is not in draft status")
+ ErrDuplicateApplication = fmt.Errorf("application already exists for this group and market edition")
+)
diff --git a/backend/internal/domain/application/repository.go b/backend/internal/domain/application/repository.go
new file mode 100644
index 0000000..df1b907
--- /dev/null
+++ b/backend/internal/domain/application/repository.go
@@ -0,0 +1,198 @@
+package application
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Repository interface {
+ Create(ctx context.Context, a Application) (Application, error)
+ GetByID(ctx context.Context, id uuid.UUID) (Application, error)
+ ListByGroup(ctx context.Context, groupID uuid.UUID) ([]Application, error)
+ Update(ctx context.Context, id uuid.UUID, fields map[string]any) (Application, error)
+ SetStatus(ctx context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (Application, error)
+ AppendStatusLog(ctx context.Context, entry StatusLogEntry) error
+ ListStatusLog(ctx context.Context, applicationID uuid.UUID) ([]StatusLogEntry, error)
+}
+
+type pgRepository struct {
+ db *pgxpool.Pool
+}
+
+func NewRepository(db *pgxpool.Pool) Repository {
+ return &pgRepository{db: db}
+}
+
+var scanCols = `id, group_id, market_edition_id, status, category, description,
+ area_sqm, needs_power, needs_water, num_persons, num_tents, notes,
+ submitted_by, submitted_at, created_at, updated_at`
+
+func scanApplication(row interface{ Scan(...any) error }) (Application, error) {
+ var a Application
+ err := row.Scan(
+ &a.ID, &a.GroupID, &a.MarketEditionID, &a.Status,
+ &a.Category, &a.Description,
+ &a.AreaSqm, &a.NeedsPower, &a.NeedsWater, &a.NumPersons, &a.NumTents, &a.Notes,
+ &a.SubmittedBy, &a.SubmittedAt, &a.CreatedAt, &a.UpdatedAt,
+ )
+ return a, err
+}
+
+func (r *pgRepository) Create(ctx context.Context, a Application) (Application, error) {
+ row := r.db.QueryRow(ctx, fmt.Sprintf(`
+ INSERT INTO applications
+ (id, group_id, market_edition_id, category, description,
+ area_sqm, needs_power, needs_water, num_persons, num_tents, notes)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+ RETURNING %s
+ `, scanCols),
+ a.ID, a.GroupID, a.MarketEditionID, a.Category, a.Description,
+ a.AreaSqm, a.NeedsPower, a.NeedsWater, a.NumPersons, a.NumTents, a.Notes,
+ )
+ out, err := scanApplication(row)
+ if err != nil {
+ if isDuplicateKey(err) {
+ return Application{}, ErrDuplicateApplication
+ }
+ return Application{}, fmt.Errorf("creating application: %w", err)
+ }
+ return out, nil
+}
+
+func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Application, error) {
+ row := r.db.QueryRow(ctx, fmt.Sprintf(`
+ SELECT %s FROM applications WHERE id = $1
+ `, scanCols), id)
+ a, err := scanApplication(row)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Application{}, ErrApplicationNotFound
+ }
+ return Application{}, fmt.Errorf("getting application: %w", err)
+ }
+ return a, nil
+}
+
+func (r *pgRepository) ListByGroup(ctx context.Context, groupID uuid.UUID) ([]Application, error) {
+ rows, err := r.db.Query(ctx, fmt.Sprintf(`
+ SELECT %s FROM applications WHERE group_id = $1 ORDER BY created_at DESC
+ `, scanCols), groupID)
+ if err != nil {
+ return nil, fmt.Errorf("listing applications: %w", err)
+ }
+ defer rows.Close()
+
+ var apps []Application
+ for rows.Next() {
+ a, scanErr := scanApplication(rows)
+ if scanErr != nil {
+ return nil, fmt.Errorf("scanning application: %w", scanErr)
+ }
+ apps = append(apps, a)
+ }
+ return apps, rows.Err()
+}
+
+func (r *pgRepository) Update(ctx context.Context, id uuid.UUID, fields map[string]any) (Application, error) {
+ if len(fields) == 0 {
+ return r.GetByID(ctx, id)
+ }
+
+ setClauses := ""
+ args := []any{id}
+ i := 2
+ for k, v := range fields {
+ if setClauses != "" {
+ setClauses += ", "
+ }
+ setClauses += fmt.Sprintf("%s = $%d", k, i)
+ args = append(args, v)
+ i++
+ }
+
+ row := r.db.QueryRow(ctx, fmt.Sprintf(`
+ UPDATE applications SET %s, updated_at = NOW()
+ WHERE id = $1
+ RETURNING %s
+ `, setClauses, scanCols), args...)
+ a, err := scanApplication(row)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Application{}, ErrApplicationNotFound
+ }
+ return Application{}, fmt.Errorf("updating application: %w", err)
+ }
+ return a, nil
+}
+
+func (r *pgRepository) SetStatus(ctx context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (Application, error) {
+ row := r.db.QueryRow(ctx, fmt.Sprintf(`
+ UPDATE applications
+ SET status = $2, submitted_by = $3, submitted_at = $4, updated_at = NOW()
+ WHERE id = $1
+ RETURNING %s
+ `, scanCols), id, status, submittedBy, submittedAt)
+ a, err := scanApplication(row)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Application{}, ErrApplicationNotFound
+ }
+ return Application{}, fmt.Errorf("setting application status: %w", err)
+ }
+ return a, nil
+}
+
+func (r *pgRepository) AppendStatusLog(ctx context.Context, entry StatusLogEntry) error {
+ _, err := r.db.Exec(ctx, `
+ INSERT INTO application_status_log (id, application_id, from_status, to_status, changed_by, note)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ `, entry.ID, entry.ApplicationID, entry.FromStatus, entry.ToStatus, entry.ChangedBy, entry.Note)
+ if err != nil {
+ return fmt.Errorf("appending status log: %w", err)
+ }
+ return nil
+}
+
+func (r *pgRepository) ListStatusLog(ctx context.Context, applicationID uuid.UUID) ([]StatusLogEntry, error) {
+ rows, err := r.db.Query(ctx, `
+ SELECT id, application_id, from_status, to_status, changed_by, note, changed_at
+ FROM application_status_log
+ WHERE application_id = $1
+ ORDER BY changed_at ASC
+ `, applicationID)
+ if err != nil {
+ return nil, fmt.Errorf("listing status log: %w", err)
+ }
+ defer rows.Close()
+
+ var entries []StatusLogEntry
+ for rows.Next() {
+ var e StatusLogEntry
+ if err := rows.Scan(&e.ID, &e.ApplicationID, &e.FromStatus, &e.ToStatus, &e.ChangedBy, &e.Note, &e.ChangedAt); err != nil {
+ return nil, fmt.Errorf("scanning status log entry: %w", err)
+ }
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+func isDuplicateKey(err error) bool {
+ s := err.Error()
+ for i := 0; i <= len(s)-13; i++ {
+ if s[i:i+13] == "duplicate key" {
+ return true
+ }
+ }
+ for i := 0; i <= len(s)-5; i++ {
+ if s[i:i+5] == "23505" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/domain/application/routes.go b/backend/internal/domain/application/routes.go
new file mode 100644
index 0000000..bb8499d
--- /dev/null
+++ b/backend/internal/domain/application/routes.go
@@ -0,0 +1,15 @@
+package application
+
+import "github.com/gin-gonic/gin"
+
+func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
+ auth := rg.Group("", requireAuth)
+ {
+ auth.POST("/groups/:groupId/applications", h.Create)
+ auth.GET("/groups/:groupId/applications", h.ListByGroup)
+ auth.GET("/applications/:id", h.Get)
+ auth.PATCH("/applications/:id", h.Update)
+ auth.POST("/applications/:id/submit", h.Submit)
+ auth.GET("/applications/:id/history", h.GetHistory)
+ }
+}
diff --git a/backend/internal/domain/application/service.go b/backend/internal/domain/application/service.go
new file mode 100644
index 0000000..7fadf2a
--- /dev/null
+++ b/backend/internal/domain/application/service.go
@@ -0,0 +1,214 @@
+package application
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// GroupMembershipChecker is the subset of group.Repository the service needs.
+// Defined here to avoid an import cycle; satisfied by an adapter in server/routes.go.
+type GroupMembershipChecker interface {
+ IsGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) (bool, error)
+ IsGroupMember(ctx context.Context, groupID, userID uuid.UUID) (bool, error)
+}
+
+type Service struct {
+ repo Repository
+ groups GroupMembershipChecker
+}
+
+func NewService(repo Repository, groups GroupMembershipChecker) *Service {
+ return &Service{repo: repo, groups: groups}
+}
+
+type CreateRequest struct {
+ MarketEditionID uuid.UUID `json:"market_edition_id" validate:"required"`
+ Category string `json:"category" validate:"omitempty,max=100"`
+ Description string `json:"description" validate:"omitempty,max=5000"`
+ AreaSqm *float64 `json:"area_sqm" validate:"omitempty,gt=0"`
+ NeedsPower bool `json:"needs_power"`
+ NeedsWater bool `json:"needs_water"`
+ NumPersons int `json:"num_persons" validate:"min=1"`
+ NumTents int `json:"num_tents" validate:"min=0"`
+ Notes string `json:"notes" validate:"omitempty,max=2000"`
+}
+
+type UpdateRequest struct {
+ Category *string `json:"category" validate:"omitempty,max=100"`
+ Description *string `json:"description" validate:"omitempty,max=5000"`
+ AreaSqm *float64 `json:"area_sqm" validate:"omitempty,gt=0"`
+ NeedsPower *bool `json:"needs_power"`
+ NeedsWater *bool `json:"needs_water"`
+ NumPersons *int `json:"num_persons" validate:"omitempty,min=1"`
+ NumTents *int `json:"num_tents" validate:"omitempty,min=0"`
+ Notes *string `json:"notes" validate:"omitempty,max=2000"`
+}
+
+func (s *Service) Create(ctx context.Context, requesterID, groupID uuid.UUID, req CreateRequest) (Application, error) {
+ if err := s.requireGroupAdmin(ctx, groupID, requesterID); err != nil {
+ return Application{}, err
+ }
+
+ numPersons := req.NumPersons
+ if numPersons < 1 {
+ numPersons = 1
+ }
+
+ a := Application{
+ ID: uuid.New(),
+ GroupID: groupID,
+ MarketEditionID: req.MarketEditionID,
+ Status: StatusDraft,
+ Category: req.Category,
+ Description: req.Description,
+ AreaSqm: req.AreaSqm,
+ NeedsPower: req.NeedsPower,
+ NeedsWater: req.NeedsWater,
+ NumPersons: numPersons,
+ NumTents: req.NumTents,
+ Notes: req.Notes,
+ }
+
+ created, err := s.repo.Create(ctx, a)
+ if err != nil {
+ return Application{}, err
+ }
+
+ _ = s.repo.AppendStatusLog(ctx, StatusLogEntry{
+ ID: uuid.New(),
+ ApplicationID: created.ID,
+ ToStatus: StatusDraft,
+ ChangedBy: requesterID,
+ })
+
+ return created, nil
+}
+
+func (s *Service) Get(ctx context.Context, requesterID, id uuid.UUID) (Application, error) {
+ a, err := s.repo.GetByID(ctx, id)
+ if err != nil {
+ return Application{}, err
+ }
+ if err := s.requireGroupMember(ctx, a.GroupID, requesterID); err != nil {
+ return Application{}, err
+ }
+ return a, nil
+}
+
+func (s *Service) ListByGroup(ctx context.Context, requesterID, groupID uuid.UUID) ([]Application, error) {
+ if err := s.requireGroupMember(ctx, groupID, requesterID); err != nil {
+ return nil, err
+ }
+ return s.repo.ListByGroup(ctx, groupID)
+}
+
+func (s *Service) Update(ctx context.Context, requesterID, id uuid.UUID, req UpdateRequest) (Application, error) {
+ a, err := s.repo.GetByID(ctx, id)
+ if err != nil {
+ return Application{}, err
+ }
+ if a.Status != StatusDraft {
+ return Application{}, ErrNotDraft
+ }
+ if err := s.requireGroupAdmin(ctx, a.GroupID, requesterID); err != nil {
+ return Application{}, err
+ }
+
+ fields := make(map[string]any)
+ if req.Category != nil {
+ fields["category"] = *req.Category
+ }
+ if req.Description != nil {
+ fields["description"] = *req.Description
+ }
+ if req.AreaSqm != nil {
+ fields["area_sqm"] = *req.AreaSqm
+ }
+ if req.NeedsPower != nil {
+ fields["needs_power"] = *req.NeedsPower
+ }
+ if req.NeedsWater != nil {
+ fields["needs_water"] = *req.NeedsWater
+ }
+ if req.NumPersons != nil {
+ fields["num_persons"] = *req.NumPersons
+ }
+ if req.NumTents != nil {
+ fields["num_tents"] = *req.NumTents
+ }
+ if req.Notes != nil {
+ fields["notes"] = *req.Notes
+ }
+
+ return s.repo.Update(ctx, id, fields)
+}
+
+func (s *Service) Submit(ctx context.Context, requesterID, id uuid.UUID) (Application, error) {
+ a, err := s.repo.GetByID(ctx, id)
+ if err != nil {
+ return Application{}, err
+ }
+ if a.Status != StatusDraft {
+ return Application{}, ErrNotDraft
+ }
+ if err := s.requireGroupAdmin(ctx, a.GroupID, requesterID); err != nil {
+ return Application{}, err
+ }
+
+ from := StatusDraft
+ now := time.Now()
+ updated, err := s.repo.SetStatus(ctx, id, StatusSubmitted, &requesterID, &now)
+ if err != nil {
+ return Application{}, err
+ }
+
+ _ = s.repo.AppendStatusLog(ctx, StatusLogEntry{
+ ID: uuid.New(),
+ ApplicationID: id,
+ FromStatus: &from,
+ ToStatus: StatusSubmitted,
+ ChangedBy: requesterID,
+ })
+
+ return updated, nil
+}
+
+func (s *Service) GetHistory(ctx context.Context, requesterID, id uuid.UUID) ([]StatusLogEntry, error) {
+ a, err := s.repo.GetByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.requireGroupMember(ctx, a.GroupID, requesterID); err != nil {
+ return nil, err
+ }
+ return s.repo.ListStatusLog(ctx, id)
+}
+
+func (s *Service) requireGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) error {
+ ok, err := s.groups.IsGroupAdmin(ctx, groupID, userID)
+ if err != nil {
+ return fmt.Errorf("checking group admin: %w", err)
+ }
+ if !ok {
+ return ErrForbidden
+ }
+ return nil
+}
+
+func (s *Service) requireGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
+ ok, err := s.groups.IsGroupMember(ctx, groupID, userID)
+ if err != nil {
+ return fmt.Errorf("checking group membership: %w", err)
+ }
+ if !ok {
+ return ErrForbidden
+ }
+ return nil
+}
+
+// ErrForbidden is returned when the requester lacks the necessary group role.
+var ErrForbidden = errors.New("forbidden")
diff --git a/backend/internal/domain/auth/service.go b/backend/internal/domain/auth/service.go
index 6919179..2e07767 100644
--- a/backend/internal/domain/auth/service.go
+++ b/backend/internal/domain/auth/service.go
@@ -65,6 +65,10 @@ func (s *Service) Login(ctx context.Context, req LoginRequest, ip, ua string) (A
return AuthData{}, fmt.Errorf("invalid credentials")
}
+ if u.Status == user.StatusSuspended {
+ return AuthData{}, fmt.Errorf("account suspended")
+ }
+
if password.NeedsRehash(*u.PasswordHash) {
if newHash, hashErr := password.Hash(req.Password); hashErr == nil {
if _, updateErr := s.userRepo.Update(ctx, u.ID, map[string]any{"password_hash": newHash}); updateErr != nil {
diff --git a/backend/internal/domain/auth/service_refresh_test.go b/backend/internal/domain/auth/service_refresh_test.go
index ed2b72b..d447a61 100644
--- a/backend/internal/domain/auth/service_refresh_test.go
+++ b/backend/internal/domain/auth/service_refresh_test.go
@@ -270,6 +270,17 @@ func (r *fakeUserRepo) Restore(_ context.Context, _ uuid.UUID) error { return
func (r *fakeUserRepo) GetDeletedByID(_ context.Context, id uuid.UUID) (user.User, error) {
return user.User{}, user.ErrUserNotFound
}
+func (r *fakeUserRepo) ListByStatus(_ context.Context, _ string) ([]user.User, error) {
+ return nil, nil
+}
+func (r *fakeUserRepo) SetStatus(_ context.Context, id uuid.UUID, status string, _ *time.Time) (user.User, error) {
+ if u, ok := r.users[id]; ok {
+ u.Status = status
+ r.users[id] = u
+ return u, nil
+ }
+ return user.User{}, user.ErrUserNotFound
+}
func makeService(authRepo auth.Repository, userRepo user.Repository) *auth.Service {
return auth.NewService(authRepo, userRepo, auth.ServiceConfig{
diff --git a/backend/internal/domain/group/group_security_test.go b/backend/internal/domain/group/group_security_test.go
new file mode 100644
index 0000000..095c99a
--- /dev/null
+++ b/backend/internal/domain/group/group_security_test.go
@@ -0,0 +1,397 @@
+package group_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "marktvogt.de/backend/internal/domain/group"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+// -- in-memory repository --
+
+type fakeRepo struct {
+ groups map[uuid.UUID]group.Group
+ members map[uuid.UUID][]group.GroupMember // keyed by groupID
+ profiles map[uuid.UUID]group.GroupProfile
+}
+
+func newFakeRepo() *fakeRepo {
+ return &fakeRepo{
+ groups: make(map[uuid.UUID]group.Group),
+ members: make(map[uuid.UUID][]group.GroupMember),
+ profiles: make(map[uuid.UUID]group.GroupProfile),
+ }
+}
+
+func (r *fakeRepo) Create(_ context.Context, g group.Group) (group.Group, error) {
+ g.CreatedAt = time.Now()
+ g.UpdatedAt = time.Now()
+ r.groups[g.ID] = g
+ return g, nil
+}
+
+func (r *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (group.Group, error) {
+ g, ok := r.groups[id]
+ if !ok {
+ return group.Group{}, group.ErrGroupNotFound
+ }
+ return g, nil
+}
+
+func (r *fakeRepo) GetProfile(_ context.Context, groupID uuid.UUID) (group.GroupProfile, error) {
+ p, ok := r.profiles[groupID]
+ if !ok {
+ return group.GroupProfile{GroupID: groupID, Categories: []string{}}, nil
+ }
+ return p, nil
+}
+
+func (r *fakeRepo) UpsertProfile(_ context.Context, p group.GroupProfile) error {
+ if p.Categories == nil {
+ p.Categories = []string{}
+ }
+ r.profiles[p.GroupID] = p
+ return nil
+}
+
+func (r *fakeRepo) AddMember(_ context.Context, m group.GroupMember) error {
+ for _, existing := range r.members[m.GroupID] {
+ if existing.UserID == m.UserID {
+ return group.ErrAlreadyMember
+ }
+ }
+ m.JoinedAt = time.Now()
+ r.members[m.GroupID] = append(r.members[m.GroupID], m)
+ return nil
+}
+
+func (r *fakeRepo) RemoveMember(_ context.Context, groupID, userID uuid.UUID) error {
+ members := r.members[groupID]
+ for i, m := range members {
+ if m.UserID == userID {
+ r.members[groupID] = append(members[:i], members[i+1:]...)
+ return nil
+ }
+ }
+ return group.ErrMemberNotFound
+}
+
+func (r *fakeRepo) GetMember(_ context.Context, groupID, userID uuid.UUID) (group.GroupMember, error) {
+ for _, m := range r.members[groupID] {
+ if m.UserID == userID {
+ return m, nil
+ }
+ }
+ return group.GroupMember{}, group.ErrMemberNotFound
+}
+
+func (r *fakeRepo) ListMembers(_ context.Context, groupID uuid.UUID) ([]group.GroupMemberView, error) {
+ src := r.members[groupID]
+ views := make([]group.GroupMemberView, len(src))
+ for i, m := range src {
+ views[i] = group.GroupMemberView{GroupMember: m}
+ }
+ return views, nil
+}
+
+func (r *fakeRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]group.Group, error) {
+ var out []group.Group
+ for gid, members := range r.members {
+ for _, m := range members {
+ if m.UserID == userID {
+ if g, ok := r.groups[gid]; ok {
+ out = append(out, g)
+ }
+ }
+ }
+ }
+ return out, nil
+}
+
+func (r *fakeRepo) CountAdmins(_ context.Context, groupID uuid.UUID) (int, error) {
+ count := 0
+ for _, m := range r.members[groupID] {
+ if m.Role == group.MemberRoleAdmin {
+ count++
+ }
+ }
+ return count, nil
+}
+
+// -- router helpers --
+
+func newRouter(repo group.Repository, authMiddleware gin.HandlerFunc) *gin.Engine {
+ svc := group.NewService(repo)
+ h := group.NewHandler(svc)
+ router := gin.New()
+ group.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware)
+ return router
+}
+
+func stubAuth(userID uuid.UUID) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Set("user_id", userID)
+ c.Set("user_role", "user")
+ c.Next()
+ }
+}
+
+func noAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.AbortWithStatus(http.StatusUnauthorized)
+ }
+}
+
+func jsonBody(v any) *bytes.Reader {
+ b, _ := json.Marshal(v)
+ return bytes.NewReader(b)
+}
+
+// PoC: authenticated-only endpoints reject unauthenticated requests (401).
+func TestGroupEndpoints_Unauthenticated_Returns401(t *testing.T) {
+ t.Parallel()
+ repo := newFakeRepo()
+ router := newRouter(repo, noAuth())
+ groupID := uuid.New().String()
+ userID := uuid.New().String()
+
+ endpoints := []struct {
+ method string
+ path string
+ body any
+ }{
+ {http.MethodPost, "/api/v1/groups", map[string]string{"name": "Thors Schmiede", "kind": "haendler"}},
+ {http.MethodPatch, "/api/v1/groups/" + groupID + "/profile", map[string]string{"description": "test"}},
+ {http.MethodPost, "/api/v1/groups/" + groupID + "/members", map[string]string{"user_id": userID}},
+ {http.MethodDelete, "/api/v1/groups/" + groupID + "/members/" + userID, nil},
+ {http.MethodGet, "/api/v1/users/me/groups", nil},
+ }
+
+ for _, ep := range endpoints {
+ t.Run(ep.method+" "+ep.path, func(t *testing.T) {
+ var body *bytes.Reader
+ if ep.body != nil {
+ body = jsonBody(ep.body)
+ } else {
+ body = bytes.NewReader(nil)
+ }
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(ep.method, ep.path, body)
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// PoC: UpdateProfile and AddMember reject non-admins with 403.
+func TestGroupAdminEndpoints_NonAdmin_Returns403(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+ nonMemberID := uuid.New()
+
+ repo := newFakeRepo()
+ // create a group and add admin; non-member has no membership
+ g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindHaendler, CreatedBy: adminID}
+ repo.groups[g.ID] = g
+ repo.members[g.ID] = []group.GroupMember{
+ {ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
+ }
+
+ router := newRouter(repo, stubAuth(nonMemberID))
+ groupPath := "/api/v1/groups/" + g.ID.String()
+
+ endpoints := []struct {
+ method string
+ path string
+ body any
+ }{
+ {http.MethodPatch, groupPath + "/profile", map[string]string{"description": "hijacked"}},
+ {http.MethodPost, groupPath + "/members", map[string]string{"user_id": uuid.New().String()}},
+ }
+
+ for _, ep := range endpoints {
+ t.Run(ep.method+" "+ep.path, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(ep.method, ep.path, jsonBody(ep.body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// PoC: RemoveMember by a regular member (not self) returns 403.
+func TestRemoveMember_NonAdminNonSelf_Returns403(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+ memberID := uuid.New()
+ otherMemberID := uuid.New()
+
+ repo := newFakeRepo()
+ g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindLager, CreatedBy: adminID}
+ repo.groups[g.ID] = g
+ repo.members[g.ID] = []group.GroupMember{
+ {ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
+ {ID: uuid.New(), GroupID: g.ID, UserID: memberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
+ {ID: uuid.New(), GroupID: g.ID, UserID: otherMemberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
+ }
+
+ // memberID tries to remove otherMemberID (not self, not admin) → 403
+ router := newRouter(repo, stubAuth(memberID))
+ path := "/api/v1/groups/" + g.ID.String() + "/members/" + otherMemberID.String()
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodDelete, path, nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
+ }
+}
+
+// PoC: a member can remove themselves (self-remove is allowed without admin).
+func TestRemoveMember_SelfRemove_Succeeds(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+ memberID := uuid.New()
+
+ repo := newFakeRepo()
+ g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindKuenstler, CreatedBy: adminID}
+ repo.groups[g.ID] = g
+ repo.members[g.ID] = []group.GroupMember{
+ {ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
+ {ID: uuid.New(), GroupID: g.ID, UserID: memberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
+ }
+
+ router := newRouter(repo, stubAuth(memberID))
+ path := "/api/v1/groups/" + g.ID.String() + "/members/" + memberID.String()
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodDelete, path, nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNoContent {
+ t.Errorf("want 204, got %d (body=%s)", w.Code, w.Body.String())
+ }
+}
+
+// PoC: removing the last admin is rejected (409 / 400).
+func TestRemoveMember_LastAdmin_Rejected(t *testing.T) {
+ t.Parallel()
+
+ adminID := uuid.New()
+
+ repo := newFakeRepo()
+ g := group.Group{ID: uuid.New(), Name: "Solo Admin Group", Kind: group.KindHaendler, CreatedBy: adminID}
+ repo.groups[g.ID] = g
+ repo.members[g.ID] = []group.GroupMember{
+ {ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
+ }
+
+ // Admin tries to remove themselves — must be rejected because they're the last admin.
+ router := newRouter(repo, stubAuth(adminID))
+ path := "/api/v1/groups/" + g.ID.String() + "/members/" + adminID.String()
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodDelete, path, nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code == http.StatusNoContent {
+ t.Errorf("expected rejection (4xx), got 204")
+ }
+}
+
+// PoC: admin can create a group and the creator is automatically admin.
+func TestCreateGroup_Admin_SucceedsAndCreatorIsAdmin(t *testing.T) {
+ t.Parallel()
+
+ creatorID := uuid.New()
+ repo := newFakeRepo()
+ router := newRouter(repo, stubAuth(creatorID))
+
+ body := jsonBody(map[string]string{"name": "Thors Schmiede", "kind": "haendler"})
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/groups", body)
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("want 201, got %d (body=%s)", w.Code, w.Body.String())
+ }
+
+ // Verify creator was added as admin in the repo.
+ var foundAdmin bool
+ for _, members := range repo.members {
+ for _, m := range members {
+ if m.UserID == creatorID && m.Role == group.MemberRoleAdmin {
+ foundAdmin = true
+ }
+ }
+ }
+ if !foundAdmin {
+ t.Error("creator was not added as admin after group creation")
+ }
+}
+
+// PoC: public endpoints (GET group, GET members) are accessible without auth.
+func TestGroupPublicEndpoints_NoAuth_Succeeds(t *testing.T) {
+ t.Parallel()
+
+ repo := newFakeRepo()
+ g := group.Group{ID: uuid.New(), Name: "Open Group", Kind: group.KindLager, CreatedBy: uuid.New()}
+ g.CreatedAt = time.Now()
+ g.UpdatedAt = time.Now()
+ repo.groups[g.ID] = g
+
+ // noAuth middleware aborts auth-required routes; public routes bypass it.
+ router := newRouter(repo, noAuth())
+
+ t.Run("GET /groups/:id", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/groups/"+g.ID.String(), nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+
+ t.Run("GET /groups/:id/members", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/groups/"+g.ID.String()+"/members", nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+}
+
+// Verify ErrMemberNotFound sentinel satisfies errors.Is chain.
+func TestErrors_SentinelIdentity(t *testing.T) {
+ if !errors.Is(group.ErrGroupNotFound, group.ErrGroupNotFound) {
+ t.Error("ErrGroupNotFound sentinel broken")
+ }
+ if !errors.Is(group.ErrNotGroupAdmin, group.ErrNotGroupAdmin) {
+ t.Error("ErrNotGroupAdmin sentinel broken")
+ }
+}
diff --git a/backend/internal/domain/group/handler.go b/backend/internal/domain/group/handler.go
new file mode 100644
index 0000000..c8b7e60
--- /dev/null
+++ b/backend/internal/domain/group/handler.go
@@ -0,0 +1,213 @@
+package group
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "marktvogt.de/backend/internal/pkg/apierror"
+ "marktvogt.de/backend/internal/pkg/validate"
+)
+
+type Handler struct {
+ svc *Service
+}
+
+func NewHandler(svc *Service) *Handler {
+ return &Handler{svc: svc}
+}
+
+func (h *Handler) Create(c *gin.Context) {
+ var req CreateRequest
+ if apiErr := validate.BindJSON(c, &req); apiErr != nil {
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ creatorID := getRequesterID(c)
+ view, err := h.svc.CreateGroup(c.Request.Context(), creatorID, req)
+ if err != nil {
+ apiErr := apierror.Internal("failed to create group")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{"data": view})
+}
+
+func (h *Handler) Get(c *gin.Context) {
+ id, ok := parseGroupID(c)
+ if !ok {
+ return
+ }
+
+ view, err := h.svc.GetGroup(c.Request.Context(), id)
+ if err != nil {
+ if errors.Is(err, ErrGroupNotFound) {
+ apiErr := apierror.NotFound("group")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ apiErr := apierror.Internal("failed to get group")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"data": view})
+}
+
+func (h *Handler) UpdateProfile(c *gin.Context) {
+ id, ok := parseGroupID(c)
+ if !ok {
+ return
+ }
+
+ var req UpdateProfileRequest
+ if apiErr := validate.BindJSON(c, &req); apiErr != nil {
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ requestorID := getRequesterID(c)
+ profile, err := h.svc.UpdateProfile(c.Request.Context(), requestorID, id, req)
+ if err != nil {
+ if errors.Is(err, ErrGroupNotFound) {
+ apiErr := apierror.NotFound("group")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ if errors.Is(err, ErrNotGroupAdmin) {
+ apiErr := apierror.Forbidden("not a group admin")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ apiErr := apierror.Internal("failed to update group profile")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"data": profile})
+}
+
+func (h *Handler) ListMembers(c *gin.Context) {
+ id, ok := parseGroupID(c)
+ if !ok {
+ return
+ }
+
+ members, err := h.svc.ListMembers(c.Request.Context(), id)
+ if err != nil {
+ if errors.Is(err, ErrGroupNotFound) {
+ apiErr := apierror.NotFound("group")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ apiErr := apierror.Internal("failed to list members")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"data": members})
+}
+
+func (h *Handler) AddMember(c *gin.Context) {
+ id, ok := parseGroupID(c)
+ if !ok {
+ return
+ }
+
+ var req AddMemberRequest
+ if apiErr := validate.BindJSON(c, &req); apiErr != nil {
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ requestorID := getRequesterID(c)
+ err := h.svc.AddMember(c.Request.Context(), requestorID, id, req.UserID)
+ if err != nil {
+ switch {
+ case errors.Is(err, ErrGroupNotFound):
+ apiErr := apierror.NotFound("group")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ case errors.Is(err, ErrNotGroupAdmin):
+ apiErr := apierror.Forbidden("not a group admin")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ case errors.Is(err, ErrAlreadyMember):
+ apiErr := apierror.BadRequest("already_member", "user is already a member")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ default:
+ apiErr := apierror.Internal("failed to add member")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ }
+ return
+ }
+
+ c.JSON(http.StatusNoContent, nil)
+}
+
+func (h *Handler) RemoveMember(c *gin.Context) {
+ groupID, ok := parseGroupID(c)
+ if !ok {
+ return
+ }
+
+ targetID, err := uuid.Parse(c.Param("userId"))
+ if err != nil {
+ apiErr := apierror.BadRequest("invalid_user_id", "invalid user id")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ requestorID := getRequesterID(c)
+ if removeErr := h.svc.RemoveMember(c.Request.Context(), requestorID, groupID, targetID); removeErr != nil {
+ switch {
+ case errors.Is(removeErr, ErrGroupNotFound):
+ apiErr := apierror.NotFound("group")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ case errors.Is(removeErr, ErrNotGroupAdmin):
+ apiErr := apierror.Forbidden("not a group admin")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ case errors.Is(removeErr, ErrMemberNotFound):
+ apiErr := apierror.NotFound("member")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ case errors.Is(removeErr, ErrCannotRemoveLastAdmin):
+ apiErr := apierror.BadRequest("last_admin", "cannot remove the last group admin")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ default:
+ apiErr := apierror.Internal("failed to remove member")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ }
+ return
+ }
+
+ c.JSON(http.StatusNoContent, nil)
+}
+
+func (h *Handler) ListMyGroups(c *gin.Context) {
+ userID := getRequesterID(c)
+ groups, err := h.svc.ListMyGroups(c.Request.Context(), userID)
+ if err != nil {
+ apiErr := apierror.Internal("failed to list groups")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": groups})
+}
+
+func parseGroupID(c *gin.Context) (uuid.UUID, bool) {
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ apiErr := apierror.BadRequest("invalid_group_id", "invalid group id")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return uuid.Nil, false
+ }
+ return id, true
+}
+
+func getRequesterID(c *gin.Context) uuid.UUID {
+ v, _ := c.Get("user_id")
+ id, _ := v.(uuid.UUID)
+ return id
+}
diff --git a/backend/internal/domain/group/model.go b/backend/internal/domain/group/model.go
new file mode 100644
index 0000000..2c43941
--- /dev/null
+++ b/backend/internal/domain/group/model.go
@@ -0,0 +1,66 @@
+package group
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Group struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type GroupProfile struct {
+ GroupID uuid.UUID `json:"group_id"`
+ Description string `json:"description"`
+ Categories []string `json:"categories"`
+ AvatarURL string `json:"avatar_url"`
+ WebsiteURL string `json:"website_url"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type GroupMember struct {
+ ID uuid.UUID `json:"id"`
+ GroupID uuid.UUID `json:"group_id"`
+ UserID uuid.UUID `json:"user_id"`
+ Role string `json:"role"`
+ JoinedAt time.Time `json:"joined_at"`
+}
+
+// GroupView is the aggregated response for a single group (group + profile).
+type GroupView struct {
+ Group
+ Profile GroupProfile `json:"profile"`
+}
+
+// GroupMemberView augments GroupMember with user display fields for list responses.
+type GroupMemberView struct {
+ GroupMember
+ DisplayName string `json:"display_name"`
+ AvatarURL string `json:"avatar_url"`
+}
+
+const (
+ MemberRoleAdmin = "admin"
+ MemberRoleMember = "member"
+)
+
+const (
+ KindHaendler = "haendler"
+ KindKuenstler = "kuenstler"
+ KindLager = "lager"
+)
+
+var (
+ ErrGroupNotFound = fmt.Errorf("group not found")
+ ErrNotGroupAdmin = fmt.Errorf("not a group admin")
+ ErrAlreadyMember = fmt.Errorf("already a member")
+ ErrMemberNotFound = fmt.Errorf("member not found")
+ ErrCannotRemoveLastAdmin = fmt.Errorf("cannot remove the last group admin")
+)
diff --git a/backend/internal/domain/group/repository.go b/backend/internal/domain/group/repository.go
new file mode 100644
index 0000000..b8842bc
--- /dev/null
+++ b/backend/internal/domain/group/repository.go
@@ -0,0 +1,220 @@
+package group
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Repository interface {
+ Create(ctx context.Context, g Group) (Group, error)
+ GetByID(ctx context.Context, id uuid.UUID) (Group, error)
+ GetProfile(ctx context.Context, groupID uuid.UUID) (GroupProfile, error)
+ UpsertProfile(ctx context.Context, p GroupProfile) error
+ AddMember(ctx context.Context, m GroupMember) error
+ RemoveMember(ctx context.Context, groupID, userID uuid.UUID) error
+ GetMember(ctx context.Context, groupID, userID uuid.UUID) (GroupMember, error)
+ ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error)
+ ListByUser(ctx context.Context, userID uuid.UUID) ([]Group, error)
+ CountAdmins(ctx context.Context, groupID uuid.UUID) (int, error)
+}
+
+type pgRepository struct {
+ db *pgxpool.Pool
+}
+
+func NewRepository(db *pgxpool.Pool) Repository {
+ return &pgRepository{db: db}
+}
+
+func (r *pgRepository) Create(ctx context.Context, g Group) (Group, error) {
+ var out Group
+ err := r.db.QueryRow(ctx, `
+ INSERT INTO groups (id, name, kind, created_by)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id, name, kind, created_by, created_at, updated_at
+ `, g.ID, g.Name, g.Kind, g.CreatedBy).Scan(
+ &out.ID, &out.Name, &out.Kind, &out.CreatedBy, &out.CreatedAt, &out.UpdatedAt,
+ )
+ if err != nil {
+ return Group{}, fmt.Errorf("creating group: %w", err)
+ }
+ return out, nil
+}
+
+func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Group, error) {
+ var g Group
+ err := r.db.QueryRow(ctx, `
+ SELECT id, name, kind, created_by, created_at, updated_at
+ FROM groups WHERE id = $1
+ `, id).Scan(&g.ID, &g.Name, &g.Kind, &g.CreatedBy, &g.CreatedAt, &g.UpdatedAt)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Group{}, ErrGroupNotFound
+ }
+ return Group{}, fmt.Errorf("getting group: %w", err)
+ }
+ return g, nil
+}
+
+func (r *pgRepository) GetProfile(ctx context.Context, groupID uuid.UUID) (GroupProfile, error) {
+ var p GroupProfile
+ err := r.db.QueryRow(ctx, `
+ SELECT group_id, description, categories, avatar_url, website_url, updated_at
+ FROM group_profiles WHERE group_id = $1
+ `, groupID).Scan(&p.GroupID, &p.Description, &p.Categories, &p.AvatarURL, &p.WebsiteURL, &p.UpdatedAt)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return GroupProfile{GroupID: groupID, Categories: []string{}}, nil
+ }
+ return GroupProfile{}, fmt.Errorf("getting group profile: %w", err)
+ }
+ if p.Categories == nil {
+ p.Categories = []string{}
+ }
+ return p, nil
+}
+
+func (r *pgRepository) UpsertProfile(ctx context.Context, p GroupProfile) error {
+ categories := p.Categories
+ if categories == nil {
+ categories = []string{}
+ }
+ _, err := r.db.Exec(ctx, `
+ INSERT INTO group_profiles (group_id, description, categories, avatar_url, website_url, updated_at)
+ VALUES ($1, $2, $3, $4, $5, NOW())
+ ON CONFLICT (group_id) DO UPDATE SET
+ description = EXCLUDED.description,
+ categories = EXCLUDED.categories,
+ avatar_url = EXCLUDED.avatar_url,
+ website_url = EXCLUDED.website_url,
+ updated_at = NOW()
+ `, p.GroupID, p.Description, categories, p.AvatarURL, p.WebsiteURL)
+ if err != nil {
+ return fmt.Errorf("upserting group profile: %w", err)
+ }
+ return nil
+}
+
+func (r *pgRepository) AddMember(ctx context.Context, m GroupMember) error {
+ _, err := r.db.Exec(ctx, `
+ INSERT INTO group_members (id, group_id, user_id, role)
+ VALUES ($1, $2, $3, $4)
+ `, m.ID, m.GroupID, m.UserID, m.Role)
+ if err != nil {
+ if isDuplicateKey(err) {
+ return ErrAlreadyMember
+ }
+ return fmt.Errorf("adding group member: %w", err)
+ }
+ return nil
+}
+
+func (r *pgRepository) RemoveMember(ctx context.Context, groupID, userID uuid.UUID) error {
+ tag, err := r.db.Exec(ctx, `
+ DELETE FROM group_members WHERE group_id = $1 AND user_id = $2
+ `, groupID, userID)
+ if err != nil {
+ return fmt.Errorf("removing group member: %w", err)
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrMemberNotFound
+ }
+ return nil
+}
+
+func (r *pgRepository) GetMember(ctx context.Context, groupID, userID uuid.UUID) (GroupMember, error) {
+ var m GroupMember
+ err := r.db.QueryRow(ctx, `
+ SELECT id, group_id, user_id, role, joined_at
+ FROM group_members WHERE group_id = $1 AND user_id = $2
+ `, groupID, userID).Scan(&m.ID, &m.GroupID, &m.UserID, &m.Role, &m.JoinedAt)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return GroupMember{}, ErrMemberNotFound
+ }
+ return GroupMember{}, fmt.Errorf("getting group member: %w", err)
+ }
+ return m, nil
+}
+
+func (r *pgRepository) ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error) {
+ rows, err := r.db.Query(ctx, `
+ SELECT gm.id, gm.group_id, gm.user_id, gm.role, gm.joined_at,
+ u.display_name, u.avatar_url
+ FROM group_members gm
+ JOIN users u ON u.id = gm.user_id
+ WHERE gm.group_id = $1
+ ORDER BY gm.joined_at ASC
+ `, groupID)
+ if err != nil {
+ return nil, fmt.Errorf("listing group members: %w", err)
+ }
+ defer rows.Close()
+
+ var members []GroupMemberView
+ for rows.Next() {
+ var v GroupMemberView
+ if err := rows.Scan(
+ &v.ID, &v.GroupID, &v.UserID, &v.Role, &v.JoinedAt,
+ &v.DisplayName, &v.AvatarURL,
+ ); err != nil {
+ return nil, fmt.Errorf("scanning group member: %w", err)
+ }
+ members = append(members, v)
+ }
+ return members, rows.Err()
+}
+
+func (r *pgRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Group, error) {
+ rows, err := r.db.Query(ctx, `
+ SELECT g.id, g.name, g.kind, g.created_by, g.created_at, g.updated_at
+ FROM groups g
+ JOIN group_members gm ON gm.group_id = g.id
+ WHERE gm.user_id = $1
+ ORDER BY g.created_at DESC
+ `, userID)
+ if err != nil {
+ return nil, fmt.Errorf("listing groups by user: %w", err)
+ }
+ defer rows.Close()
+
+ var groups []Group
+ for rows.Next() {
+ var g Group
+ if err := rows.Scan(&g.ID, &g.Name, &g.Kind, &g.CreatedBy, &g.CreatedAt, &g.UpdatedAt); err != nil {
+ return nil, fmt.Errorf("scanning group: %w", err)
+ }
+ groups = append(groups, g)
+ }
+ return groups, rows.Err()
+}
+
+func (r *pgRepository) CountAdmins(ctx context.Context, groupID uuid.UUID) (int, error) {
+ var count int
+ err := r.db.QueryRow(ctx, `
+ SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND role = 'admin'
+ `, groupID).Scan(&count)
+ if err != nil {
+ return 0, fmt.Errorf("counting group admins: %w", err)
+ }
+ return count, nil
+}
+
+func isDuplicateKey(err error) bool {
+ s := err.Error()
+ return contains(s, "duplicate key") || contains(s, "23505")
+}
+
+func contains(s, sub string) bool {
+ for i := 0; i <= len(s)-len(sub); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/domain/group/routes.go b/backend/internal/domain/group/routes.go
new file mode 100644
index 0000000..b3616d1
--- /dev/null
+++ b/backend/internal/domain/group/routes.go
@@ -0,0 +1,17 @@
+package group
+
+import "github.com/gin-gonic/gin"
+
+func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
+ rg.GET("/groups/:id", h.Get)
+ rg.GET("/groups/:id/members", h.ListMembers)
+
+ auth := rg.Group("", requireAuth)
+ {
+ auth.POST("/groups", h.Create)
+ auth.PATCH("/groups/:id/profile", h.UpdateProfile)
+ auth.POST("/groups/:id/members", h.AddMember)
+ auth.DELETE("/groups/:id/members/:userId", h.RemoveMember)
+ auth.GET("/users/me/groups", h.ListMyGroups)
+ }
+}
diff --git a/backend/internal/domain/group/service.go b/backend/internal/domain/group/service.go
new file mode 100644
index 0000000..8486659
--- /dev/null
+++ b/backend/internal/domain/group/service.go
@@ -0,0 +1,172 @@
+package group
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/google/uuid"
+)
+
+type Service struct {
+ repo Repository
+}
+
+func NewService(repo Repository) *Service {
+ return &Service{repo: repo}
+}
+
+type CreateRequest struct {
+ Name string `json:"name" validate:"required,min=1,max=100"`
+ Kind string `json:"kind" validate:"required,oneof=haendler kuenstler lager"`
+}
+
+type UpdateProfileRequest struct {
+ Description *string `json:"description" validate:"omitempty,max=2000"`
+ Categories []string `json:"categories" validate:"omitempty,max=10,dive,min=1,max=100"`
+ AvatarURL *string `json:"avatar_url" validate:"omitempty,url"`
+ WebsiteURL *string `json:"website_url" validate:"omitempty,url"`
+}
+
+type AddMemberRequest struct {
+ UserID uuid.UUID `json:"user_id" validate:"required"`
+}
+
+func (s *Service) CreateGroup(ctx context.Context, creatorID uuid.UUID, req CreateRequest) (GroupView, error) {
+ g := Group{
+ ID: uuid.New(),
+ Name: req.Name,
+ Kind: req.Kind,
+ CreatedBy: creatorID,
+ }
+
+ g, err := s.repo.Create(ctx, g)
+ if err != nil {
+ return GroupView{}, fmt.Errorf("create group: %w", err)
+ }
+
+ if err := s.repo.AddMember(ctx, GroupMember{
+ ID: uuid.New(),
+ GroupID: g.ID,
+ UserID: creatorID,
+ Role: MemberRoleAdmin,
+ }); err != nil {
+ return GroupView{}, fmt.Errorf("adding creator as admin: %w", err)
+ }
+
+ profile := GroupProfile{GroupID: g.ID, Categories: []string{}}
+ if err := s.repo.UpsertProfile(ctx, profile); err != nil {
+ return GroupView{}, fmt.Errorf("creating group profile: %w", err)
+ }
+
+ return GroupView{Group: g, Profile: profile}, nil
+}
+
+func (s *Service) GetGroup(ctx context.Context, id uuid.UUID) (GroupView, error) {
+ g, err := s.repo.GetByID(ctx, id)
+ if err != nil {
+ return GroupView{}, err
+ }
+ p, err := s.repo.GetProfile(ctx, id)
+ if err != nil {
+ return GroupView{}, fmt.Errorf("get group profile: %w", err)
+ }
+ return GroupView{Group: g, Profile: p}, nil
+}
+
+func (s *Service) UpdateProfile(ctx context.Context, requestorID, groupID uuid.UUID, req UpdateProfileRequest) (GroupProfile, error) {
+ if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
+ return GroupProfile{}, err
+ }
+
+ current, err := s.repo.GetProfile(ctx, groupID)
+ if err != nil {
+ return GroupProfile{}, fmt.Errorf("get current profile: %w", err)
+ }
+
+ if req.Description != nil {
+ current.Description = *req.Description
+ }
+ if req.Categories != nil {
+ current.Categories = req.Categories
+ }
+ if req.AvatarURL != nil {
+ current.AvatarURL = *req.AvatarURL
+ }
+ if req.WebsiteURL != nil {
+ current.WebsiteURL = *req.WebsiteURL
+ }
+
+ if err := s.repo.UpsertProfile(ctx, current); err != nil {
+ return GroupProfile{}, fmt.Errorf("update profile: %w", err)
+ }
+
+ updated, err := s.repo.GetProfile(ctx, groupID)
+ if err != nil {
+ return GroupProfile{}, fmt.Errorf("re-fetch profile: %w", err)
+ }
+ return updated, nil
+}
+
+func (s *Service) AddMember(ctx context.Context, requestorID, groupID, targetUserID uuid.UUID) error {
+ if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
+ return err
+ }
+ return s.repo.AddMember(ctx, GroupMember{
+ ID: uuid.New(),
+ GroupID: groupID,
+ UserID: targetUserID,
+ Role: MemberRoleMember,
+ })
+}
+
+func (s *Service) RemoveMember(ctx context.Context, requestorID, groupID, targetUserID uuid.UUID) error {
+ // Self-remove is always allowed; otherwise require admin.
+ if requestorID != targetUserID {
+ if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
+ return err
+ }
+ }
+
+ // Guard against removing the last admin.
+ target, err := s.repo.GetMember(ctx, groupID, targetUserID)
+ if err != nil {
+ return err
+ }
+ if target.Role == MemberRoleAdmin {
+ n, err := s.repo.CountAdmins(ctx, groupID)
+ if err != nil {
+ return fmt.Errorf("count admins: %w", err)
+ }
+ if n <= 1 {
+ return ErrCannotRemoveLastAdmin
+ }
+ }
+
+ return s.repo.RemoveMember(ctx, groupID, targetUserID)
+}
+
+func (s *Service) ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error) {
+ if _, err := s.repo.GetByID(ctx, groupID); err != nil {
+ return nil, err
+ }
+ return s.repo.ListMembers(ctx, groupID)
+}
+
+func (s *Service) ListMyGroups(ctx context.Context, userID uuid.UUID) ([]Group, error) {
+ return s.repo.ListByUser(ctx, userID)
+}
+
+func (s *Service) requireAdmin(ctx context.Context, groupID, userID uuid.UUID) error {
+ m, err := s.repo.GetMember(ctx, groupID, userID)
+ if err != nil {
+ if errors.Is(err, ErrMemberNotFound) {
+ return ErrNotGroupAdmin
+ }
+ return fmt.Errorf("checking group membership: %w", err)
+ }
+ if m.Role != MemberRoleAdmin {
+ return ErrNotGroupAdmin
+ }
+ return nil
+}
diff --git a/backend/internal/domain/lagerleben/handler.go b/backend/internal/domain/lagerleben/handler.go
new file mode 100644
index 0000000..e74e8e3
--- /dev/null
+++ b/backend/internal/domain/lagerleben/handler.go
@@ -0,0 +1,76 @@
+package lagerleben
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "marktvogt.de/backend/internal/pkg/apierror"
+)
+
+type Handler struct {
+ svc *Service
+}
+
+func NewHandler(svc *Service) *Handler {
+ return &Handler{svc: svc}
+}
+
+func (h *Handler) ListArticles(c *gin.Context) {
+ articles, err := h.svc.ListArticles(c.Request.Context())
+ if err != nil {
+ apiErr := apierror.Internal("internal error")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ if articles == nil {
+ articles = []Article{}
+ }
+ c.JSON(http.StatusOK, gin.H{"data": articles})
+}
+
+func (h *Handler) GetArticle(c *gin.Context) {
+ slug := c.Param("slug")
+ article, err := h.svc.GetArticle(c.Request.Context(), slug)
+ if err != nil {
+ if errors.Is(err, ErrNotFound) {
+ apiErr := apierror.NotFound("article")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ apiErr := apierror.Internal("internal error")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": article})
+}
+
+func (h *Handler) ListCamps(c *gin.Context) {
+ camps, err := h.svc.ListCamps(c.Request.Context())
+ if err != nil {
+ apiErr := apierror.Internal("internal error")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ if camps == nil {
+ camps = []Camp{}
+ }
+ c.JSON(http.StatusOK, gin.H{"data": camps})
+}
+
+func (h *Handler) GetCamp(c *gin.Context) {
+ slug := c.Param("slug")
+ camp, err := h.svc.GetCamp(c.Request.Context(), slug)
+ if err != nil {
+ if errors.Is(err, ErrNotFound) {
+ apiErr := apierror.NotFound("camp")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ apiErr := apierror.Internal("internal error")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": camp})
+}
diff --git a/backend/internal/domain/lagerleben/lagerleben_test.go b/backend/internal/domain/lagerleben/lagerleben_test.go
new file mode 100644
index 0000000..87314e1
--- /dev/null
+++ b/backend/internal/domain/lagerleben/lagerleben_test.go
@@ -0,0 +1,237 @@
+package lagerleben_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "marktvogt.de/backend/internal/domain/lagerleben"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+// -- in-memory fake --
+
+type fakeRepo struct {
+ articles []lagerleben.Article
+ camps []lagerleben.Camp
+}
+
+func (r *fakeRepo) ListArticles(_ context.Context) ([]lagerleben.Article, error) {
+ var out []lagerleben.Article
+ for _, a := range r.articles {
+ if a.Published {
+ out = append(out, a)
+ }
+ }
+ return out, nil
+}
+
+func (r *fakeRepo) GetArticleBySlug(_ context.Context, slug string) (lagerleben.Article, error) {
+ for _, a := range r.articles {
+ if a.Slug == slug && a.Published {
+ return a, nil
+ }
+ }
+ return lagerleben.Article{}, lagerleben.ErrNotFound
+}
+
+func (r *fakeRepo) ListCamps(_ context.Context) ([]lagerleben.Camp, error) {
+ var out []lagerleben.Camp
+ for _, c := range r.camps {
+ if c.Published {
+ out = append(out, c)
+ }
+ }
+ return out, nil
+}
+
+func (r *fakeRepo) GetCampBySlug(_ context.Context, slug string) (lagerleben.Camp, error) {
+ for _, c := range r.camps {
+ if c.Slug == slug && c.Published {
+ return c, nil
+ }
+ }
+ return lagerleben.Camp{}, lagerleben.ErrNotFound
+}
+
+func newRouter(repo lagerleben.Repository) *gin.Engine {
+ svc := lagerleben.NewService(repo)
+ h := lagerleben.NewHandler(svc)
+ r := gin.New()
+ lagerleben.RegisterRoutes(r.Group("/api/v1"), h)
+ return r
+}
+
+func seedArticle() lagerleben.Article {
+ return lagerleben.Article{
+ Slug: "test-artikel",
+ Title: "Test Artikel",
+ Subtitle: "Untertitel",
+ Category: "Handwerk",
+ PublishedOn: lagerleben.NewDateOnly(time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC)),
+ Excerpt: "Kurzbeschreibung",
+ Published: true,
+ }
+}
+
+func seedCamp() lagerleben.Camp {
+ return lagerleben.Camp{
+ Slug: "test-lager",
+ Name: "Test Lager",
+ Region: "Bayern",
+ Period: "um 1350",
+ Excerpt: "Beschreibung",
+ Members: 12,
+ Published: true,
+ }
+}
+
+// PoC: list articles is public — no auth required.
+func TestListArticles_Public_Returns200(t *testing.T) {
+ t.Parallel()
+ repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
+ router := newRouter(repo)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles", nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ var resp struct {
+ Data []lagerleben.Article `json:"data"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(resp.Data) != 1 {
+ t.Errorf("want 1 article, got %d", len(resp.Data))
+ }
+}
+
+// PoC: unpublished articles are excluded from the list.
+func TestListArticles_ExcludesUnpublished(t *testing.T) {
+ t.Parallel()
+ a := seedArticle()
+ a.Published = false
+ repo := &fakeRepo{articles: []lagerleben.Article{a}}
+ router := newRouter(repo)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles", nil)
+ router.ServeHTTP(w, req)
+
+ var resp struct {
+ Data []lagerleben.Article `json:"data"`
+ }
+ _ = json.NewDecoder(w.Body).Decode(&resp)
+ if len(resp.Data) != 0 {
+ t.Errorf("want 0 articles (all unpublished), got %d", len(resp.Data))
+ }
+}
+
+// PoC: get article by slug returns 200 with correct data.
+func TestGetArticle_KnownSlug_Returns200(t *testing.T) {
+ t.Parallel()
+ repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
+ router := newRouter(repo)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/test-artikel", nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ var resp struct {
+ Data lagerleben.Article `json:"data"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.Data.Slug != "test-artikel" {
+ t.Errorf("want slug test-artikel, got %s", resp.Data.Slug)
+ }
+}
+
+// PoC: unknown article slug returns 404.
+func TestGetArticle_UnknownSlug_Returns404(t *testing.T) {
+ t.Parallel()
+ repo := &fakeRepo{}
+ router := newRouter(repo)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/does-not-exist", nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d", w.Code)
+ }
+}
+
+// PoC: list camps is public.
+func TestListCamps_Public_Returns200(t *testing.T) {
+ t.Parallel()
+ repo := &fakeRepo{camps: []lagerleben.Camp{seedCamp()}}
+ router := newRouter(repo)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/camps", nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ var resp struct {
+ Data []lagerleben.Camp `json:"data"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(resp.Data) != 1 {
+ t.Errorf("want 1 camp, got %d", len(resp.Data))
+ }
+}
+
+// PoC: unknown camp slug returns 404.
+func TestGetCamp_UnknownSlug_Returns404(t *testing.T) {
+ t.Parallel()
+ repo := &fakeRepo{}
+ router := newRouter(repo)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/camps/does-not-exist", nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d", w.Code)
+ }
+}
+
+// PoC: date is serialized as YYYY-MM-DD (not full RFC3339).
+func TestArticle_DateFormat(t *testing.T) {
+ t.Parallel()
+ repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
+ router := newRouter(repo)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/test-artikel", nil)
+ router.ServeHTTP(w, req)
+
+ var raw map[string]map[string]any
+ _ = json.NewDecoder(bytes.NewReader(w.Body.Bytes())).Decode(&raw)
+ date, _ := raw["data"]["date"].(string)
+ if date != "2026-04-12" {
+ t.Errorf("want date 2026-04-12, got %q", date)
+ }
+}
diff --git a/backend/internal/domain/lagerleben/model.go b/backend/internal/domain/lagerleben/model.go
new file mode 100644
index 0000000..af2ca60
--- /dev/null
+++ b/backend/internal/domain/lagerleben/model.go
@@ -0,0 +1,59 @@
+package lagerleben
+
+import (
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+var ErrNotFound = errors.New("not found")
+
+type Article struct {
+ ID uuid.UUID `json:"-"`
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Subtitle string `json:"subtitle"`
+ Category string `json:"category"`
+ PublishedOn dateOnly `json:"date"`
+ Excerpt string `json:"excerpt"`
+ Body string `json:"body,omitempty"`
+ Published bool `json:"-"`
+ CreatedAt time.Time `json:"-"`
+ UpdatedAt time.Time `json:"-"`
+}
+
+type Camp struct {
+ ID uuid.UUID `json:"-"`
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Region string `json:"region"`
+ Period string `json:"period"`
+ Excerpt string `json:"excerpt"`
+ Members int `json:"members"`
+ Published bool `json:"-"`
+ CreatedAt time.Time `json:"-"`
+ UpdatedAt time.Time `json:"-"`
+}
+
+// dateOnly marshals a time.Time as "YYYY-MM-DD" for JSON.
+type dateOnly struct {
+ time.Time
+}
+
+func NewDateOnly(t time.Time) dateOnly {
+ return dateOnly{t}
+}
+
+func (d dateOnly) MarshalJSON() ([]byte, error) {
+ return []byte(`"` + d.UTC().Format("2006-01-02") + `"`), nil
+}
+
+func (d *dateOnly) UnmarshalJSON(data []byte) error {
+ t, err := time.Parse(`"2006-01-02"`, string(data))
+ if err != nil {
+ return err
+ }
+ d.Time = t
+ return nil
+}
diff --git a/backend/internal/domain/lagerleben/repository.go b/backend/internal/domain/lagerleben/repository.go
new file mode 100644
index 0000000..e251df5
--- /dev/null
+++ b/backend/internal/domain/lagerleben/repository.go
@@ -0,0 +1,122 @@
+package lagerleben
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Repository interface {
+ ListArticles(ctx context.Context) ([]Article, error)
+ GetArticleBySlug(ctx context.Context, slug string) (Article, error)
+ ListCamps(ctx context.Context) ([]Camp, error)
+ GetCampBySlug(ctx context.Context, slug string) (Camp, error)
+}
+
+type pgRepository struct {
+ db *pgxpool.Pool
+}
+
+func NewRepository(db *pgxpool.Pool) Repository {
+ return &pgRepository{db: db}
+}
+
+func (r *pgRepository) ListArticles(ctx context.Context) ([]Article, error) {
+ rows, err := r.db.Query(ctx,
+ `SELECT id, slug, title, subtitle, category, published_on, excerpt, published, created_at, updated_at
+ FROM lagerleben_articles
+ WHERE published = TRUE
+ ORDER BY published_on DESC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []Article
+ for rows.Next() {
+ a, err := scanArticle(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+func (r *pgRepository) GetArticleBySlug(ctx context.Context, slug string) (Article, error) {
+ row := r.db.QueryRow(ctx,
+ `SELECT id, slug, title, subtitle, category, published_on, excerpt, published, created_at, updated_at
+ FROM lagerleben_articles
+ WHERE slug = $1 AND published = TRUE`,
+ slug)
+ a, err := scanArticle(row)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Article{}, ErrNotFound
+ }
+ return a, err
+}
+
+func (r *pgRepository) ListCamps(ctx context.Context) ([]Camp, error) {
+ rows, err := r.db.Query(ctx,
+ `SELECT id, slug, name, region, period, excerpt, members, published, created_at, updated_at
+ FROM lagerleben_camps
+ WHERE published = TRUE
+ ORDER BY name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []Camp
+ for rows.Next() {
+ c, err := scanCamp(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+func (r *pgRepository) GetCampBySlug(ctx context.Context, slug string) (Camp, error) {
+ row := r.db.QueryRow(ctx,
+ `SELECT id, slug, name, region, period, excerpt, members, published, created_at, updated_at
+ FROM lagerleben_camps
+ WHERE slug = $1 AND published = TRUE`,
+ slug)
+ c, err := scanCamp(row)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return Camp{}, ErrNotFound
+ }
+ return c, err
+}
+
+type scanner interface {
+ Scan(dest ...any) error
+}
+
+func scanArticle(row scanner) (Article, error) {
+ var a Article
+ var publishedOn time.Time
+ err := row.Scan(
+ &a.ID, &a.Slug, &a.Title, &a.Subtitle, &a.Category,
+ &publishedOn, &a.Excerpt, &a.Published, &a.CreatedAt, &a.UpdatedAt,
+ )
+ if err != nil {
+ return Article{}, err
+ }
+ a.PublishedOn = dateOnly{publishedOn}
+ return a, nil
+}
+
+func scanCamp(row scanner) (Camp, error) {
+ var c Camp
+ err := row.Scan(
+ &c.ID, &c.Slug, &c.Name, &c.Region, &c.Period,
+ &c.Excerpt, &c.Members, &c.Published, &c.CreatedAt, &c.UpdatedAt,
+ )
+ return c, err
+}
diff --git a/backend/internal/domain/lagerleben/routes.go b/backend/internal/domain/lagerleben/routes.go
new file mode 100644
index 0000000..c992255
--- /dev/null
+++ b/backend/internal/domain/lagerleben/routes.go
@@ -0,0 +1,10 @@
+package lagerleben
+
+import "github.com/gin-gonic/gin"
+
+func RegisterRoutes(rg *gin.RouterGroup, h *Handler) {
+ rg.GET("/lagerleben/articles", h.ListArticles)
+ rg.GET("/lagerleben/articles/:slug", h.GetArticle)
+ rg.GET("/lagerleben/camps", h.ListCamps)
+ rg.GET("/lagerleben/camps/:slug", h.GetCamp)
+}
diff --git a/backend/internal/domain/lagerleben/service.go b/backend/internal/domain/lagerleben/service.go
new file mode 100644
index 0000000..38ec129
--- /dev/null
+++ b/backend/internal/domain/lagerleben/service.go
@@ -0,0 +1,27 @@
+package lagerleben
+
+import "context"
+
+type Service struct {
+ repo Repository
+}
+
+func NewService(repo Repository) *Service {
+ return &Service{repo: repo}
+}
+
+func (s *Service) ListArticles(ctx context.Context) ([]Article, error) {
+ return s.repo.ListArticles(ctx)
+}
+
+func (s *Service) GetArticle(ctx context.Context, slug string) (Article, error) {
+ return s.repo.GetArticleBySlug(ctx, slug)
+}
+
+func (s *Service) ListCamps(ctx context.Context) ([]Camp, error) {
+ return s.repo.ListCamps(ctx)
+}
+
+func (s *Service) GetCamp(ctx context.Context, slug string) (Camp, error) {
+ return s.repo.GetCampBySlug(ctx, slug)
+}
diff --git a/backend/internal/domain/market/handler.go b/backend/internal/domain/market/handler.go
index f958be6..fa7a423 100644
--- a/backend/internal/domain/market/handler.go
+++ b/backend/internal/domain/market/handler.go
@@ -2,6 +2,7 @@ package market
import (
"errors"
+ "log/slog"
"net/http"
"strconv"
@@ -30,6 +31,7 @@ func (h *Handler) Search(c *gin.Context) { //nolint:dupl // similar structure to
markets, total, err := h.service.Search(c.Request.Context(), params)
if err != nil {
+ slog.ErrorContext(c.Request.Context(), "market search failed", "error", err)
apiErr := apierror.Internal("failed to search markets")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
@@ -77,6 +79,7 @@ func (h *Handler) GetBySlug(c *gin.Context) {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
+ slog.ErrorContext(c.Request.Context(), "market get by slug failed", "slug", slug, "error", err)
apiErr := apierror.Internal("failed to get market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
diff --git a/backend/internal/domain/user/admin_handler.go b/backend/internal/domain/user/admin_handler.go
new file mode 100644
index 0000000..eeb1234
--- /dev/null
+++ b/backend/internal/domain/user/admin_handler.go
@@ -0,0 +1,113 @@
+package user
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "marktvogt.de/backend/internal/pkg/apierror"
+)
+
+type AdminHandler struct {
+ svc *AdminService
+}
+
+func NewAdminHandler(svc *AdminService) *AdminHandler {
+ return &AdminHandler{svc: svc}
+}
+
+func (h *AdminHandler) ListPending(c *gin.Context) {
+ users, err := h.svc.ListPending(c.Request.Context())
+ if err != nil {
+ apiErr := apierror.Internal("failed to list pending users")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ items := make([]AdminUserData, len(users))
+ for i, u := range users {
+ items[i] = toAdminUserData(u)
+ }
+ c.JSON(http.StatusOK, gin.H{"data": items})
+}
+
+func (h *AdminHandler) Approve(c *gin.Context) {
+ id, ok := parseUserID(c)
+ if !ok {
+ return
+ }
+
+ u, err := h.svc.Approve(c.Request.Context(), id)
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ apiErr := apierror.NotFound("user")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ apiErr := apierror.Internal("failed to approve user")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"data": toAdminUserData(u)})
+}
+
+func (h *AdminHandler) Reject(c *gin.Context) {
+ id, ok := parseUserID(c)
+ if !ok {
+ return
+ }
+
+ u, err := h.svc.Reject(c.Request.Context(), id)
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ apiErr := apierror.NotFound("user")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+ apiErr := apierror.Internal("failed to reject user")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"data": toAdminUserData(u)})
+}
+
+func parseUserID(c *gin.Context) (uuid.UUID, bool) {
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ apiErr := apierror.BadRequest("invalid_user_id", "invalid user id")
+ c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
+ return uuid.Nil, false
+ }
+ return id, true
+}
+
+// AdminUserData is the response shape for admin user endpoints.
+type AdminUserData struct {
+ ID uuid.UUID `json:"id"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ Status string `json:"status"`
+ ApprovedAt *string `json:"approved_at,omitempty"`
+ CreatedAt string `json:"created_at"`
+}
+
+func toAdminUserData(u User) AdminUserData {
+ d := AdminUserData{
+ ID: u.ID,
+ Email: u.Email,
+ DisplayName: u.DisplayName,
+ Role: u.Role,
+ Status: u.Status,
+ CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
+ }
+ if u.ApprovedAt != nil {
+ s := u.ApprovedAt.Format("2006-01-02T15:04:05Z")
+ d.ApprovedAt = &s
+ }
+ return d
+}
diff --git a/backend/internal/domain/user/admin_routes.go b/backend/internal/domain/user/admin_routes.go
new file mode 100644
index 0000000..4672828
--- /dev/null
+++ b/backend/internal/domain/user/admin_routes.go
@@ -0,0 +1,12 @@
+package user
+
+import "github.com/gin-gonic/gin"
+
+func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, requireAuth, requireAdmin gin.HandlerFunc) {
+ admin := rg.Group("/admin/users", requireAuth, requireAdmin)
+ {
+ admin.GET("/pending", h.ListPending)
+ admin.POST("/:id/approve", h.Approve)
+ admin.POST("/:id/reject", h.Reject)
+ }
+}
diff --git a/backend/internal/domain/user/admin_security_test.go b/backend/internal/domain/user/admin_security_test.go
new file mode 100644
index 0000000..4ae7394
--- /dev/null
+++ b/backend/internal/domain/user/admin_security_test.go
@@ -0,0 +1,279 @@
+package user_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "marktvogt.de/backend/internal/domain/user"
+ "marktvogt.de/backend/internal/middleware"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+// fakeUserRepo satisfies user.Repository in-memory.
+type fakeUserRepo struct {
+ users map[uuid.UUID]user.User
+}
+
+func newFakeUserRepo(initial ...user.User) *fakeUserRepo {
+ r := &fakeUserRepo{users: make(map[uuid.UUID]user.User)}
+ for _, u := range initial {
+ r.users[u.ID] = u
+ }
+ return r
+}
+
+func (r *fakeUserRepo) Create(_ context.Context, email, hash, name string) (user.User, error) {
+ u := user.User{ID: uuid.New(), Email: email, DisplayName: name, Status: user.StatusActive}
+ r.users[u.ID] = u
+ return u, nil
+}
+
+func (r *fakeUserRepo) CreateOAuthUser(_ context.Context, email, name string, verified bool) (user.User, error) {
+ u := user.User{ID: uuid.New(), Email: email, DisplayName: name, EmailVerified: verified, Status: user.StatusActive}
+ r.users[u.ID] = u
+ return u, nil
+}
+
+func (r *fakeUserRepo) GetByID(_ context.Context, id uuid.UUID) (user.User, error) {
+ u, ok := r.users[id]
+ if !ok {
+ return user.User{}, user.ErrUserNotFound
+ }
+ return u, nil
+}
+
+func (r *fakeUserRepo) GetByEmail(_ context.Context, email string) (user.User, error) {
+ for _, u := range r.users {
+ if u.Email == email {
+ return u, nil
+ }
+ }
+ return user.User{}, user.ErrUserNotFound
+}
+
+func (r *fakeUserRepo) Update(_ context.Context, id uuid.UUID, _ map[string]any) (user.User, error) {
+ u, ok := r.users[id]
+ if !ok {
+ return user.User{}, user.ErrUserNotFound
+ }
+ return u, nil
+}
+
+func (r *fakeUserRepo) SoftDelete(_ context.Context, _ uuid.UUID) error { return nil }
+func (r *fakeUserRepo) Restore(_ context.Context, _ uuid.UUID) error { return nil }
+
+func (r *fakeUserRepo) GetDeletedByID(_ context.Context, id uuid.UUID) (user.User, error) {
+ return user.User{}, user.ErrUserNotFound
+}
+
+func (r *fakeUserRepo) ListByStatus(_ context.Context, status string) ([]user.User, error) {
+ var out []user.User
+ for _, u := range r.users {
+ if u.Status == status {
+ out = append(out, u)
+ }
+ }
+ return out, nil
+}
+
+func (r *fakeUserRepo) SetStatus(_ context.Context, id uuid.UUID, status string, approvedAt *time.Time) (user.User, error) {
+ u, ok := r.users[id]
+ if !ok {
+ return user.User{}, user.ErrUserNotFound
+ }
+ u.Status = status
+ u.ApprovedAt = approvedAt
+ r.users[id] = u
+ return u, nil
+}
+
+// fakeSessionRevoker satisfies user.SessionRevoker in-memory.
+type fakeSessionRevoker struct {
+ revoked []uuid.UUID
+}
+
+func (r *fakeSessionRevoker) DeleteUserSessions(_ context.Context, userID uuid.UUID) error {
+ r.revoked = append(r.revoked, userID)
+ return nil
+}
+
+// adminRouter builds a gin.Engine wired with the admin user routes.
+// The roleMiddleware parameter injects user_id and user_role into the context,
+// mimicking what RequireAuth does with real tokens.
+func adminRouter(repo user.Repository, roleMiddleware gin.HandlerFunc) *gin.Engine {
+ svc := user.NewAdminService(repo, &fakeSessionRevoker{})
+ h := user.NewAdminHandler(svc)
+
+ router := gin.New()
+ v1 := router.Group("/api/v1")
+ requireAdmin := middleware.RequireRole(user.RoleAdmin)
+ user.RegisterAdminRoutes(v1, h, roleMiddleware, requireAdmin)
+ return router
+}
+
+// stubAuth returns a middleware that sets gin context values to simulate an authenticated user.
+func stubAuth(userRole string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Set("user_id", uuid.New())
+ c.Set("user_role", userRole)
+ c.Next()
+ }
+}
+
+// noAuth returns a middleware that aborts with 401, matching RequireAuth with no valid token.
+func noAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.AbortWithStatus(http.StatusUnauthorized)
+ }
+}
+
+// PoC: admin user endpoints must reject unauthenticated requests (401).
+func TestAdminUserEndpoints_Unauthenticated_Returns401(t *testing.T) {
+ t.Parallel()
+ repo := newFakeUserRepo()
+ router := adminRouter(repo, noAuth())
+
+ endpoints := []struct {
+ method string
+ path string
+ }{
+ {http.MethodGet, "/api/v1/admin/users/pending"},
+ {http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/approve"},
+ {http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/reject"},
+ }
+
+ for _, ep := range endpoints {
+ t.Run(ep.method+" "+ep.path, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(ep.method, ep.path, nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// PoC: admin user endpoints must reject non-admin authenticated users (403).
+func TestAdminUserEndpoints_NonAdmin_Returns403(t *testing.T) {
+ t.Parallel()
+ repo := newFakeUserRepo()
+ router := adminRouter(repo, stubAuth(user.RoleUser))
+
+ endpoints := []struct {
+ method string
+ path string
+ }{
+ {http.MethodGet, "/api/v1/admin/users/pending"},
+ {http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/approve"},
+ {http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/reject"},
+ }
+
+ for _, ep := range endpoints {
+ t.Run(ep.method+" "+ep.path, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(ep.method, ep.path, nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// PoC: admin can list pending users and approve/reject them.
+func TestAdminUserEndpoints_Admin_Succeeds(t *testing.T) {
+ t.Parallel()
+ pendingID := uuid.New()
+ pending := user.User{
+ ID: pendingID,
+ Email: "pending@example.com",
+ DisplayName: "Pending User",
+ Role: user.RoleVeranstalter,
+ Status: user.StatusPending,
+ CreatedAt: time.Now(),
+ }
+ repo := newFakeUserRepo(pending)
+ revoker := &fakeSessionRevoker{}
+ svc := user.NewAdminService(repo, revoker)
+ h := user.NewAdminHandler(svc)
+
+ router := gin.New()
+ v1 := router.Group("/api/v1")
+ requireAdmin := middleware.RequireRole(user.RoleAdmin)
+ user.RegisterAdminRoutes(v1, h, stubAuth(user.RoleAdmin), requireAdmin)
+
+ t.Run("list pending returns pending user", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/pending", nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "pending@example.com") {
+ t.Errorf("response missing pending user email: %s", w.Body.String())
+ }
+ })
+
+ t.Run("approve changes status to active and revokes sessions", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+pendingID.String()+"/approve", nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), `"status":"active"`) {
+ t.Errorf("response missing active status: %s", w.Body.String())
+ }
+ if len(revoker.revoked) == 0 {
+ t.Error("expected sessions to be revoked on approval")
+ }
+ })
+}
+
+// PoC: reject changes status to suspended and revokes sessions.
+func TestAdminRejectUser_RevokesSessionsAndSuspends(t *testing.T) {
+ t.Parallel()
+ targetID := uuid.New()
+ target := user.User{
+ ID: targetID,
+ Email: "target@example.com",
+ DisplayName: "Target User",
+ Role: user.RoleVeranstalter,
+ Status: user.StatusPending,
+ CreatedAt: time.Now(),
+ }
+ repo := newFakeUserRepo(target)
+ revoker := &fakeSessionRevoker{}
+ svc := user.NewAdminService(repo, revoker)
+ h := user.NewAdminHandler(svc)
+
+ router := gin.New()
+ v1 := router.Group("/api/v1")
+ requireAdmin := middleware.RequireRole(user.RoleAdmin)
+ user.RegisterAdminRoutes(v1, h, stubAuth(user.RoleAdmin), requireAdmin)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+targetID.String()+"/reject", nil)
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), `"status":"suspended"`) {
+ t.Errorf("response missing suspended status: %s", w.Body.String())
+ }
+ if len(revoker.revoked) == 0 {
+ t.Error("expected sessions to be revoked on rejection")
+ }
+}
diff --git a/backend/internal/domain/user/admin_service.go b/backend/internal/domain/user/admin_service.go
new file mode 100644
index 0000000..dda5c27
--- /dev/null
+++ b/backend/internal/domain/user/admin_service.go
@@ -0,0 +1,51 @@
+package user
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// SessionRevoker is the subset of auth.Repository needed by AdminService.
+// Defined here to avoid an import cycle between the user and auth packages.
+type SessionRevoker interface {
+ DeleteUserSessions(ctx context.Context, userID uuid.UUID) error
+}
+
+type AdminService struct {
+ repo Repository
+ sessions SessionRevoker
+}
+
+func NewAdminService(repo Repository, sessions SessionRevoker) *AdminService {
+ return &AdminService{repo: repo, sessions: sessions}
+}
+
+func (s *AdminService) ListPending(ctx context.Context) ([]User, error) {
+ return s.repo.ListByStatus(ctx, StatusPending)
+}
+
+func (s *AdminService) Approve(ctx context.Context, id uuid.UUID) (User, error) {
+ if err := s.sessions.DeleteUserSessions(ctx, id); err != nil {
+ return User{}, fmt.Errorf("revoking sessions before approval: %w", err)
+ }
+ now := time.Now()
+ u, err := s.repo.SetStatus(ctx, id, StatusActive, &now)
+ if err != nil {
+ return User{}, fmt.Errorf("approving user: %w", err)
+ }
+ return u, nil
+}
+
+func (s *AdminService) Reject(ctx context.Context, id uuid.UUID) (User, error) {
+ if err := s.sessions.DeleteUserSessions(ctx, id); err != nil {
+ return User{}, fmt.Errorf("revoking sessions before rejection: %w", err)
+ }
+ u, err := s.repo.SetStatus(ctx, id, StatusSuspended, nil)
+ if err != nil {
+ return User{}, fmt.Errorf("rejecting user: %w", err)
+ }
+ return u, nil
+}
diff --git a/backend/internal/domain/user/model.go b/backend/internal/domain/user/model.go
index c66a7f9..7f32f88 100644
--- a/backend/internal/domain/user/model.go
+++ b/backend/internal/domain/user/model.go
@@ -14,6 +14,8 @@ type User struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Role string `json:"role"`
+ Status string `json:"status"`
+ ApprovedAt *time.Time `json:"approved_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
diff --git a/backend/internal/domain/user/repository.go b/backend/internal/domain/user/repository.go
index 419c5ec..48ca41b 100644
--- a/backend/internal/domain/user/repository.go
+++ b/backend/internal/domain/user/repository.go
@@ -25,6 +25,8 @@ type Repository interface {
SoftDelete(ctx context.Context, id uuid.UUID) error
Restore(ctx context.Context, id uuid.UUID) error
GetDeletedByID(ctx context.Context, id uuid.UUID) (User, error)
+ ListByStatus(ctx context.Context, status string) ([]User, error)
+ SetStatus(ctx context.Context, id uuid.UUID, status string, approvedAt *time.Time) (User, error)
}
type pgRepository struct {
@@ -40,10 +42,10 @@ func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayN
err := r.db.QueryRow(ctx, `
INSERT INTO users (email, password_hash, display_name)
VALUES ($1, $2, $3)
- RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
+ RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
`, email, passwordHash, displayName).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
- &u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
+ &u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if isDuplicateKeyError(err) {
@@ -59,10 +61,10 @@ func (r *pgRepository) CreateOAuthUser(ctx context.Context, email, displayName s
err := r.db.QueryRow(ctx, `
INSERT INTO users (email, email_verified, display_name)
VALUES ($1, $2, $3)
- RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
+ RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
`, email, emailVerified, displayName).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
- &u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
+ &u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if isDuplicateKeyError(err) {
@@ -88,12 +90,12 @@ func (r *pgRepository) GetDeletedByID(ctx context.Context, id uuid.UUID) (User,
func (r *pgRepository) getUser(ctx context.Context, where string, arg any) (User, error) {
var u User
err := r.db.QueryRow(ctx, fmt.Sprintf(`
- SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
+ SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
FROM users
WHERE %s
`, where), arg).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
- &u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
+ &u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -125,10 +127,10 @@ func (r *pgRepository) Update(ctx context.Context, id uuid.UUID, fields map[stri
err := r.db.QueryRow(ctx, fmt.Sprintf(`
UPDATE users SET %s
WHERE id = $1 AND deleted_at IS NULL
- RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
+ RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
`, setClauses), args...).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
- &u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
+ &u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -153,6 +155,51 @@ func (r *pgRepository) SoftDelete(ctx context.Context, id uuid.UUID) error {
return nil
}
+func (r *pgRepository) ListByStatus(ctx context.Context, status string) ([]User, error) {
+ rows, err := r.db.Query(ctx, `
+ SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
+ FROM users
+ WHERE status = $1 AND deleted_at IS NULL
+ ORDER BY created_at ASC
+ `, status)
+ if err != nil {
+ return nil, fmt.Errorf("listing users by status: %w", err)
+ }
+ defer rows.Close()
+
+ var users []User
+ for rows.Next() {
+ var u User
+ if err := rows.Scan(
+ &u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
+ &u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
+ ); err != nil {
+ return nil, fmt.Errorf("scanning user: %w", err)
+ }
+ users = append(users, u)
+ }
+ return users, rows.Err()
+}
+
+func (r *pgRepository) SetStatus(ctx context.Context, id uuid.UUID, status string, approvedAt *time.Time) (User, error) {
+ var u User
+ err := r.db.QueryRow(ctx, `
+ UPDATE users SET status = $2, approved_at = $3
+ WHERE id = $1 AND deleted_at IS NULL
+ RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
+ `, id, status, approvedAt).Scan(
+ &u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
+ &u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
+ )
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return User{}, ErrUserNotFound
+ }
+ return User{}, fmt.Errorf("setting user status: %w", err)
+ }
+ return u, nil
+}
+
func (r *pgRepository) Restore(ctx context.Context, id uuid.UUID) error {
tag, err := r.db.Exec(ctx, `
UPDATE users SET deleted_at = NULL
diff --git a/backend/internal/domain/user/roles.go b/backend/internal/domain/user/roles.go
new file mode 100644
index 0000000..a0676aa
--- /dev/null
+++ b/backend/internal/domain/user/roles.go
@@ -0,0 +1,16 @@
+package user
+
+const (
+ RoleGast = "gast"
+ RoleUser = "user"
+ RoleVeranstalter = "veranstalter"
+ RoleHaendler = "haendler"
+ RoleLager = "lager"
+ RoleAdmin = "admin"
+)
+
+const (
+ StatusPending = "pending"
+ StatusActive = "active"
+ StatusSuspended = "suspended"
+)
diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go
index de4b25b..c04934c 100644
--- a/backend/internal/server/routes.go
+++ b/backend/internal/server/routes.go
@@ -2,15 +2,20 @@ package server
import (
"context"
+ "errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "marktvogt.de/backend/internal/domain/application"
"marktvogt.de/backend/internal/domain/auth"
"marktvogt.de/backend/internal/domain/discovery"
"marktvogt.de/backend/internal/domain/discovery/crawler"
"marktvogt.de/backend/internal/domain/discovery/enrich"
+ "marktvogt.de/backend/internal/domain/group"
+ "marktvogt.de/backend/internal/domain/lagerleben"
"marktvogt.de/backend/internal/domain/market"
"marktvogt.de/backend/internal/domain/settings"
"marktvogt.de/backend/internal/domain/user"
@@ -85,6 +90,31 @@ func (s *Server) registerRoutes() {
userHandler := user.NewHandler(userSvc)
user.RegisterRoutes(v1, userHandler, requireAuth)
+ // Admin user management routes
+ adminUserSvc := user.NewAdminService(userRepo, authRepo)
+ adminUserHandler := user.NewAdminHandler(adminUserSvc)
+ user.RegisterAdminRoutes(v1, adminUserHandler, requireAuth, middleware.RequireRole(user.RoleAdmin))
+
+ // Group routes
+ groupRepo := group.NewRepository(s.db)
+ groupSvc := group.NewService(groupRepo)
+ groupHandler := group.NewHandler(groupSvc)
+ group.RegisterRoutes(v1, groupHandler, requireAuth)
+
+ // Application routes — GroupMembershipChecker is adapted from groupRepo
+ // to avoid a direct import of the group package from the application package.
+ groupChecker := &groupCheckerAdapter{repo: groupRepo}
+ appRepo := application.NewRepository(s.db)
+ appSvc := application.NewService(appRepo, groupChecker)
+ appHandler := application.NewHandler(appSvc)
+ application.RegisterRoutes(v1, appHandler, requireAuth)
+
+ // Lagerleben routes (public read-only)
+ lagerlebenRepo := lagerleben.NewRepository(s.db)
+ lagerlebenSvc := lagerleben.NewService(lagerlebenRepo)
+ lagerlebenHandler := lagerleben.NewHandler(lagerlebenSvc)
+ lagerleben.RegisterRoutes(v1, lagerlebenHandler)
+
// Market routes (public + submission + admin)
tsVerifier := turnstile.New(s.cfg.Turnstile.SecretKey)
@@ -146,6 +176,31 @@ func (s *Server) registerRoutes() {
settings.RegisterRoutes(v1, settingsHandler, requireAuth, requireAdmin)
}
+// groupCheckerAdapter adapts group.Repository to application.GroupMembershipChecker
+// so the application package does not import the group package directly.
+type groupCheckerAdapter struct {
+ repo group.Repository
+}
+
+func (a *groupCheckerAdapter) IsGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) (bool, error) {
+ m, err := a.repo.GetMember(ctx, groupID, userID)
+ if errors.Is(err, group.ErrMemberNotFound) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return m.Role == group.MemberRoleAdmin, nil
+}
+
+func (a *groupCheckerAdapter) IsGroupMember(ctx context.Context, groupID, userID uuid.UUID) (bool, error) {
+ _, err := a.repo.GetMember(ctx, groupID, userID)
+ if errors.Is(err, group.ErrMemberNotFound) {
+ return false, nil
+ }
+ return err == nil, err
+}
+
func (s *Server) healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
diff --git a/backend/migrations/000034_user_status_and_roles.down.sql b/backend/migrations/000034_user_status_and_roles.down.sql
new file mode 100644
index 0000000..4681dc7
--- /dev/null
+++ b/backend/migrations/000034_user_status_and_roles.down.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users
+ DROP CONSTRAINT IF EXISTS users_role_check,
+ DROP COLUMN IF EXISTS approved_at,
+ DROP COLUMN IF EXISTS status;
diff --git a/backend/migrations/000034_user_status_and_roles.up.sql b/backend/migrations/000034_user_status_and_roles.up.sql
new file mode 100644
index 0000000..75f3d24
--- /dev/null
+++ b/backend/migrations/000034_user_status_and_roles.up.sql
@@ -0,0 +1,17 @@
+-- Extend users table with approval workflow columns.
+-- status tracks the lifecycle of elevated-role accounts (pending → active | suspended).
+-- All existing users default to 'active' to preserve current behaviour.
+-- The role CHECK constraint formalises the allowed role values without an ENUM
+-- so future roles can be added with a simple ALTER TABLE + constraint update.
+
+ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'
+ CHECK (status IN ('pending', 'active', 'suspended')),
+ ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
+
+ALTER TABLE users
+ DROP CONSTRAINT IF EXISTS users_role_check;
+
+ALTER TABLE users
+ ADD CONSTRAINT users_role_check
+ CHECK (role IN ('gast', 'user', 'veranstalter', 'haendler', 'lager', 'admin'));
diff --git a/backend/migrations/000035_groups.down.sql b/backend/migrations/000035_groups.down.sql
new file mode 100644
index 0000000..b7af683
--- /dev/null
+++ b/backend/migrations/000035_groups.down.sql
@@ -0,0 +1,5 @@
+DROP INDEX IF EXISTS group_members_group_id_idx;
+DROP INDEX IF EXISTS group_members_user_id_idx;
+DROP TABLE IF EXISTS group_profiles;
+DROP TABLE IF EXISTS group_members;
+DROP TABLE IF EXISTS groups;
diff --git a/backend/migrations/000035_groups.up.sql b/backend/migrations/000035_groups.up.sql
new file mode 100644
index 0000000..67e6e73
--- /dev/null
+++ b/backend/migrations/000035_groups.up.sql
@@ -0,0 +1,32 @@
+-- Groups are the unit through which Haendler/Kuenstler/Lager apply to markets.
+-- A solo merchant is a one-person group; the model is uniform either way.
+
+CREATE TABLE groups (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('haendler', 'kuenstler', 'lager')),
+ created_by UUID NOT NULL REFERENCES users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE group_members (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
+ joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (group_id, user_id)
+);
+
+CREATE TABLE group_profiles (
+ group_id UUID PRIMARY KEY REFERENCES groups(id) ON DELETE CASCADE,
+ description TEXT NOT NULL DEFAULT '',
+ categories TEXT[] NOT NULL DEFAULT '{}',
+ avatar_url TEXT NOT NULL DEFAULT '',
+ website_url TEXT NOT NULL DEFAULT '',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX group_members_user_id_idx ON group_members(user_id);
+CREATE INDEX group_members_group_id_idx ON group_members(group_id);
diff --git a/backend/migrations/000036_applications.down.sql b/backend/migrations/000036_applications.down.sql
new file mode 100644
index 0000000..c799999
--- /dev/null
+++ b/backend/migrations/000036_applications.down.sql
@@ -0,0 +1,5 @@
+DROP INDEX IF EXISTS application_status_log_app_id_idx;
+DROP INDEX IF EXISTS applications_market_edition_id_idx;
+DROP INDEX IF EXISTS applications_group_id_idx;
+DROP TABLE IF EXISTS application_status_log;
+DROP TABLE IF EXISTS applications;
diff --git a/backend/migrations/000036_applications.up.sql b/backend/migrations/000036_applications.up.sql
new file mode 100644
index 0000000..9429e34
--- /dev/null
+++ b/backend/migrations/000036_applications.up.sql
@@ -0,0 +1,41 @@
+-- Applications are submitted by groups to specific market editions.
+-- One application per group per market edition (UNIQUE constraint).
+-- Status transitions: draft -> submitted -> reviewing -> accepted|rejected|waitlisted.
+-- Group-side transitions (draft, submit) live here; veranstalter review is Phase 4b.
+
+CREATE TABLE applications (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ group_id UUID NOT NULL REFERENCES groups(id),
+ market_edition_id UUID NOT NULL REFERENCES market_editions(id),
+ status TEXT NOT NULL DEFAULT 'draft'
+ CHECK (status IN ('draft', 'submitted', 'reviewing', 'accepted', 'rejected', 'waitlisted')),
+ -- Standard template fields (09-bewerbung.md)
+ category TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ area_sqm NUMERIC(8,2),
+ needs_power BOOLEAN NOT NULL DEFAULT FALSE,
+ needs_water BOOLEAN NOT NULL DEFAULT FALSE,
+ num_persons INT NOT NULL DEFAULT 1 CHECK (num_persons >= 1),
+ num_tents INT NOT NULL DEFAULT 0 CHECK (num_tents >= 0),
+ notes TEXT NOT NULL DEFAULT '',
+ submitted_by UUID REFERENCES users(id),
+ submitted_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (group_id, market_edition_id)
+);
+
+-- Full audit trail of every status change on an application.
+CREATE TABLE application_status_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
+ from_status TEXT,
+ to_status TEXT NOT NULL,
+ changed_by UUID NOT NULL REFERENCES users(id),
+ note TEXT NOT NULL DEFAULT '',
+ changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX applications_group_id_idx ON applications(group_id);
+CREATE INDEX applications_market_edition_id_idx ON applications(market_edition_id);
+CREATE INDEX application_status_log_app_id_idx ON application_status_log(application_id);
diff --git a/backend/migrations/000037_lagerleben.down.sql b/backend/migrations/000037_lagerleben.down.sql
new file mode 100644
index 0000000..96a9dca
--- /dev/null
+++ b/backend/migrations/000037_lagerleben.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS lagerleben_camps;
+DROP TABLE IF EXISTS lagerleben_articles;
diff --git a/backend/migrations/000037_lagerleben.up.sql b/backend/migrations/000037_lagerleben.up.sql
new file mode 100644
index 0000000..4ecc748
--- /dev/null
+++ b/backend/migrations/000037_lagerleben.up.sql
@@ -0,0 +1,72 @@
+CREATE TABLE lagerleben_articles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ slug TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ subtitle TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL DEFAULT '',
+ published_on DATE NOT NULL,
+ excerpt TEXT NOT NULL DEFAULT '',
+ body TEXT NOT NULL DEFAULT '',
+ published BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE lagerleben_camps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ slug TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ region TEXT NOT NULL DEFAULT '',
+ period TEXT NOT NULL DEFAULT '',
+ excerpt TEXT NOT NULL DEFAULT '',
+ members INT NOT NULL DEFAULT 0 CHECK (members >= 0),
+ published BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Seed articles from the design mock so the frontend isn't empty on first deploy.
+INSERT INTO lagerleben_articles (slug, title, subtitle, category, published_on, excerpt, published) VALUES
+ ('das-handwerk-des-schwertschmieds',
+ 'Das Handwerk des Schwertschmieds',
+ 'Zwischen Amboss und Feuer — ein Tag in der Werkstatt von Konrad Brenner',
+ 'Handwerk', '2026-04-12',
+ 'Seit dreißig Jahren schmiedet Konrad Brenner Schwerter für Mittelaltermärkte in ganz Europa. Wir haben ihn in seiner Werkstatt in Dinkelsbühl besucht und zugeschaut.',
+ TRUE),
+ ('lager-aufbauen-checkliste',
+ 'Lager aufbauen in 4 Stunden',
+ 'Die bewährte Checkliste des Compagnie du Cerf Rouge',
+ 'Praxis', '2026-03-28',
+ 'Wer ein Lager auf dem Markt aufbaut, kennt das Chaos der ersten Stunden. Die Compagnie du Cerf Rouge hat ihre Routine über Jahre verfeinert und teilt sie hier.',
+ TRUE),
+ ('historische-stoffe-1350',
+ 'Stoffe des 14. Jahrhunderts',
+ 'Was ist historisch korrekt — und was sieht nur so aus?',
+ 'Recherche', '2026-03-10',
+ 'Wollköper, Leinen, gelegentlich Seide: Die Auswahl historisch korrekter Stoffe ist kleiner als der Markt suggeriert. Ein Überblick über Quellen und Fallstricke.',
+ TRUE),
+ ('kinder-im-lager',
+ 'Kinder im Lager',
+ 'Wie Familien das Lagerleben gestalten — ohne auf Authentizität zu verzichten',
+ 'Gemeinschaft', '2026-02-20',
+ 'Immer mehr Familien sind Teil der Mittelalterszene. Was bedeutet das für das Lagerkonzept, den Marktablauf und die Gemeinschaft?',
+ TRUE)
+ON CONFLICT (slug) DO NOTHING;
+
+INSERT INTO lagerleben_camps (slug, name, region, period, excerpt, members, published) VALUES
+ ('compagnie-du-cerf-rouge',
+ 'Compagnie du Cerf Rouge',
+ 'Bayern', 'um 1350',
+ 'Lebendige Darstellung eines fahrenden Söldnertrupps des 14. Jahrhunderts. Schwerpunkt: textile Handarbeit, Feldkochen und Waffenkunde.',
+ 14, TRUE),
+ ('lagergemeinschaft-nordmark',
+ 'Lagergemeinschaft Nordmark',
+ 'Schleswig-Holstein', 'Wikingerzeit',
+ 'Gemeinschaft zur Darstellung des wikingerzeitlichen Alltags auf skandinavischen und deutschen Märkten.',
+ 22, TRUE),
+ ('familia-von-hohenstein',
+ 'Familia von Hohenstein',
+ 'Baden-Württemberg', 'Hochmittelalter',
+ 'Adelige Haushaltung mit vollständiger Küchenausstattung, Weberei und Kinderdarstellung. Besonderen Wert legen wir auf quellenbasierte Kostümierung.',
+ 8, TRUE)
+ON CONFLICT (slug) DO NOTHING;
diff --git a/web/docs/design-system.md b/web/docs/design-system.md
new file mode 100644
index 0000000..8b52f57
--- /dev/null
+++ b/web/docs/design-system.md
@@ -0,0 +1,172 @@
+# Marktvogt Design System — Burgund
+
+Established May 2026 via Claude Design handoff (bundle: `marktvogt-de.tar.gz`).
+
+## Identity
+
+**Burgund** is the locked visual identity for Marktvogt. The name comes from the deep sealing-wax red at its core.
+
+Design principles chosen in the original session:
+
+- **Editorial-classical** — Cormorant Garamond display, EB Garamond body, editorial proportions
+- **Mid-density** — enough whitespace to breathe, enough content to inform
+- **Minimal decoration** — one ornament glyph (✦), drop-caps, double rules only. No rounded corners, no gradient badges, no color-coded semantic variants.
+- **Body-lead hierarchy** — lead paragraph at 22px italic, then 16/15/14px body; display headlines at 76/56/44px
+
+## Tokens
+
+Source of truth: `web/src/app.css` (`@theme` block and `:root.dark` overrides).
+
+| Token | Light | Dark |
+| --------------------- | -------------------------------- | ------------------------ |
+| `--color-bg` | `#f5efe4` (warm parchment) | `#0f0c0a` (deep ink) |
+| `--color-surface` | `#ffffff` | `#191411` |
+| `--color-surface-alt` | `#ece4d4` | `#241c17` |
+| `--color-ink` | `#181410` | `#f0e6d2` |
+| `--color-ink-soft` | `#3a322a` | `#c0b094` |
+| `--color-ink-muted` | `#6e6253` | `#74644f` |
+| `--color-rule` | `#181410` | `#3a2e22` |
+| `--color-rule-soft` | `#c9b58c` | `#2a221d` |
+| `--color-accent` | `#9a1e2c` (sealing-wax burgundy) | `#d86268` (halbton rose) |
+| `--color-accent-soft` | `#c84858` | `#8a2a32` |
+| `--color-on-accent` | `#f5efe4` | `#0f0c0a` |
+
+The dark accent is the "Halbton" step chosen from the design session — midway between the original loud `#e84a5e` and the subdued `#c87a7a`. The dark Submit-CTA "Bordeau block" uses `surface-alt #241c17` with cream `ink` foreground.
+
+## Typography
+
+| Role | Font | Usage |
+| -------------- | ------------------ | ------------------------------------------------------------------- |
+| `font-display` | Cormorant Garamond | Headlines, section titles, drop-caps |
+| `font-serif` | EB Garamond | Body text, nav links, buttons, table content |
+| `font-sans` | Inter | Reserved; currently unused in UI — available for UI forms if needed |
+| `font-mono` | JetBrains Mono | CAPS labels, tags, mono counters |
+
+All fonts self-hosted under `web/static/fonts/`. Variable fonts covering the declared weight ranges.
+
+### Type scale (derived from design files)
+
+| Use | Size | Weight | Font |
+| --------------- | ----------- | ------- | --------------------------------------- |
+| Display hero | 76–88px | 500 | display |
+| Display large | 56px | 500 | display |
+| Display medium | 44px | 500 | display |
+| Display small | 24px | 400–500 | display |
+| Lead / intro | 22px italic | 400 | serif |
+| Body large | 19px | 400 | serif |
+| Body | 16px | 400 | serif |
+| Body small | 15px | 400 | serif |
+| UI text | 14px | 400 | serif |
+| Caption | 13px | 400 | serif |
+| Mono caps large | 11px | 400 | mono + uppercase + tracking 0.18em |
+| Mono caps | 10px | 400 | mono + uppercase + tracking 0.12–0.15em |
+| Mono caps small | 9px | 400 | mono + uppercase + tracking 0.1em |
+
+## Atoms
+
+Source files: `web/src/lib/components/atoms/`
+
+### `MarktvogtMark`
+
+The shield-M logo. Props: `size` (default 32). Uses `currentColor` — inherits from parent element color.
+
+```svelte
+