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 + +``` + +The SVG source lives at `web/src/lib/assets/marktvogt-logo.svg`. + +### `Caps` + +Mono uppercase label. Props: `size` (px, default 11), `color` (CSS value, defaults to `ink-muted`). + +```svelte +Hessen · Nr. 015 +``` + +### `Tag` + +Pill/badge. Props: `accent` (boolean, default false). Accent variant: bg + border in accent color, on-accent foreground. + +```svelte +Empfohlen +Burg +``` + +### `Rule` + +Section divider. Props: `kind`: `'thin'` | `'double'` | `'ornament'` (default `'thin'`). + +```svelte + + + + + + +``` + +### `Heraldry` + +Procedural heraldic SVG by string seed. Used as market card hero fallback when no real photo is available. Props: `seed` (string), `palette` (`{ a, b, bg, fg }`). + +```svelte + +``` + +8 deterministic variants: Stripes, Checky, Chevron, Banner, Tower, Cross, Saltire, Fleury. + +## Decoration vocabulary + +**Allowed:** + +- `✦` ornament glyph — ornament Rule, section transitions +- Drop-cap — first character of lead paragraph, 40–56px Cormorant Garamond, accent color +- Double rule — between major page sections +- Section headers: mono-caps label + thin rule below +- Breadcrumb line: mono-caps "Verzeichnis · Region · Nr. XXX" + +**Forbidden:** + +- Gold underline on headings (the old `h1::after` accent-400 stripe — removed) +- Color-coded semantic Alert variants (info/success/warning/error with distinct hues) — use `surface-alt + border-rule-soft` for neutral blocks, `border-accent` for errors only +- Rounded corners (`rounded-*`) on content elements — squares only; `rounded-sm` allowed on focus rings only +- Badge pills with backgrounds other than accent (the full surface-alt + rule-soft pair only) + +## Dark mode + +Dark mode applies via `.dark` class on `` (set by `lib/theme.ts`, user-controlled via ThemeToggle). Three states: light / dark / system. + +The dark "Bordeau block" pattern (used in Submit-CTA, strong CTAs): + +```css +background: var(--color-surface-alt); /* #241c17 */ +color: var(--color-ink); /* #f0e6d2 cream */ +accent: var(--color-accent); /* #d86268 */ +``` + +## Voice & tone + +Editorial-warm. "Hannes" is the editorial voice — a Kunstfigur (constructed character) representing the collective editorial team. The disclaimer appears under his signature: + +> ✦ — Hannes, der Marktvogt · Hessen · Met-Brauer · Lagergänger seit 2003 +> _eine Kunstfigur · die Redaktion arbeitet kollektiv_ + +This is intentional and not removable. See `chat1.md` lines 860–895 for the design decision. + +## Open items (Phase 1 does not address) + +- Photography pipeline — Lagerleben articles, camp profiles, real market photos +- OCR/AI program parsing — Submit-Flow optional upload step +- Real-time messaging — dashboard inboxes use REST for Phase 4b; WebSocket later +- Newsletter/Saisonbrief backend — Home form ships as non-functional in Phase 2 + +## Phase roadmap + +| Phase | Branch | Status | +| ---------------------------------------------------------------------------------- | ------------------------- | ----------- | +| 1 · Design system | `feat/burgund-redesign` | In progress | +| 2 · Public surfaces (Startseite, Verzeichnis, Detail, Kalender, Karte, Lagerleben) | `feat/burgund-public` | Planned | +| 3 · Flows (Auth, Submit-Flow) | `feat/burgund-flows` | Planned | +| 4a · Backend (roles, groups, applications, Lagerleben CMS) | `feat/burgund-backend` | Planned | +| 4b · Dashboards (Veranstalter, Admin, Händler, Lager) | `feat/burgund-dashboards` | Planned | diff --git a/web/src/app.css b/web/src/app.css index fa9170c..7c0bbfd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -2,154 +2,211 @@ /* ── Fonts ───────────────────────────────────────────────── */ +/* Cormorant Garamond — display */ @font-face { - font-family: 'MedievalSharp'; - src: url('/fonts/medievalsharp-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Crimson Pro'; - src: url('/fonts/crimsonpro-400.woff2') format('woff2'); + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400-ext.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; } - @font-face { - font-family: 'Crimson Pro'; - src: url('/fonts/crimsonpro-400i.woff2') format('woff2'); - font-weight: 400; + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400i-ext.woff2') format('woff2'); + font-weight: 400 700; font-style: italic; font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400i.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } -/* ── Dark mode variant: .dark class only (JS resolves system preference) ── */ +/* EB Garamond — body serif */ +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400-ext.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400i-ext.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400i.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* Inter — sans UI */ +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-400-ext.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-400.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* JetBrains Mono — mono caps labels */ +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains-mono-400-ext.woff2') format('woff2'); + font-weight: 100 800; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains-mono-400.woff2') format('woff2'); + font-weight: 100 800; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* ── Dark mode: .dark class (JS resolves system preference) ── */ @custom-variant dark (&:is(.dark *)); -/* ── Theme tokens ────────────────────────────────────────── */ +/* ── Burgund design token system ─────────────────────────── */ @theme { - /* Forest green primary */ - --color-primary-50: oklch(0.97 0.02 150); - --color-primary-100: oklch(0.93 0.04 150); - --color-primary-200: oklch(0.86 0.08 150); - --color-primary-300: oklch(0.77 0.13 150); - --color-primary-400: oklch(0.67 0.16 150); - --color-primary-500: oklch(0.55 0.16 150); - --color-primary-600: oklch(0.47 0.14 150); - --color-primary-700: oklch(0.4 0.12 150); - --color-primary-800: oklch(0.33 0.09 150); - --color-primary-900: oklch(0.27 0.07 150); - --color-primary-950: oklch(0.18 0.05 150); - - /* Gold / amber accent */ - --color-accent-50: oklch(0.98 0.02 75); - --color-accent-100: oklch(0.94 0.06 70); - --color-accent-200: oklch(0.88 0.11 65); - --color-accent-300: oklch(0.82 0.15 60); - --color-accent-400: oklch(0.75 0.16 58); - --color-accent-500: oklch(0.68 0.16 55); - --color-accent-600: oklch(0.58 0.14 55); - --color-accent-700: oklch(0.48 0.11 55); - --color-accent-800: oklch(0.4 0.08 55); - --color-accent-900: oklch(0.32 0.06 55); - --color-accent-950: oklch(0.22 0.04 55); - - /* Warm stone neutrals (constant scale — dark mode uses dark: utilities) */ - --color-stone-50: oklch(0.98 0.005 70); - --color-stone-100: oklch(0.96 0.008 60); - --color-stone-200: oklch(0.92 0.01 55); - --color-stone-300: oklch(0.87 0.012 50); - --color-stone-400: oklch(0.71 0.013 55); - --color-stone-500: oklch(0.56 0.013 58); - --color-stone-600: oklch(0.45 0.012 58); - --color-stone-700: oklch(0.38 0.011 55); - --color-stone-800: oklch(0.32 0.01 50); - --color-stone-900: oklch(0.25 0.008 45); - --color-stone-950: oklch(0.17 0.006 40); - - /* Semantic surface colors (overridden in .dark via CSS vars) */ - --color-parchment: oklch(0.96 0.012 70); - --color-vellum: oklch(0.99 0.006 70); - - /* Danger / brick red */ - --color-danger-50: oklch(0.97 0.015 25); - --color-danger-100: oklch(0.93 0.04 25); - --color-danger-200: oklch(0.87 0.08 25); - --color-danger-300: oklch(0.78 0.12 25); - --color-danger-400: oklch(0.68 0.15 25); - --color-danger-500: oklch(0.58 0.16 25); - --color-danger-600: oklch(0.5 0.15 25); - --color-danger-700: oklch(0.42 0.12 25); - --color-danger-800: oklch(0.35 0.09 25); - --color-danger-900: oklch(0.28 0.06 25); - --color-danger-950: oklch(0.2 0.04 25); + /* Color tokens */ + --color-bg: #f5efe4; + --color-surface: #ffffff; + --color-surface-alt: #ece4d4; + --color-ink: #181410; + --color-ink-soft: #3a322a; + --color-ink-muted: #6e6253; + --color-rule: #181410; + --color-rule-soft: #c9b58c; + --color-accent: #9a1e2c; + --color-accent-soft: #c84858; + --color-on-accent: #f5efe4; /* Typography */ - --font-heading: 'MedievalSharp', cursive; - --font-sans: 'Crimson Pro', 'Georgia', serif; + --font-display: 'Cormorant Garamond', 'Garamond', serif; + --font-serif: 'EB Garamond', 'Garamond', serif; + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; } -/* ── Dark surface overrides (.dark class set by JS) ──────── */ +/* ── Dark mode overrides (.dark on ) ───────────────── */ :root.dark { color-scheme: dark; - --color-parchment: oklch(0.14 0.007 20); - --color-vellum: oklch(0.19 0.009 18); + --color-bg: #0f0c0a; + --color-surface: #191411; + --color-surface-alt: #241c17; + --color-ink: #f0e6d2; + --color-ink-soft: #c0b094; + --color-ink-muted: #74644f; + --color-rule: #3a2e22; + --color-rule-soft: #2a221d; + --color-accent: #d86268; + --color-accent-soft: #8a2a32; + --color-on-accent: #0f0c0a; } /* ── Base layer ──────────────────────────────────────────── */ @layer base { body { - @apply bg-parchment text-stone-800 antialiased; + @apply bg-bg text-ink font-serif antialiased; } - .dark body { - @apply text-stone-200; - } - - /* Skip-to-content link (visible on focus only) */ + /* Skip-to-content link */ .skip-link { - @apply bg-primary-700 absolute -top-full left-4 z-50 rounded-b-lg px-4 py-2 text-sm font-medium text-white; - @apply focus:ring-primary-400 focus:top-0 focus:ring-2 focus:outline-none; + @apply bg-accent text-on-accent absolute -top-full left-4 z-50 px-4 py-2 text-sm font-medium; + @apply focus:ring-accent focus:top-0 focus:ring-2 focus:outline-none; } h1, h2, h3, h4 { - font-family: var(--font-heading); + font-family: var(--font-display); } - h1 { - @apply relative pb-3; - } - - h1::after { - content: ''; - @apply bg-accent-400 absolute bottom-0 left-0 h-0.5 w-16; - } - - h1:is([class*='text-center'])::after { - @apply left-1/2 -translate-x-1/2; - } - - /* ── Shared form control styles ───────────────────────── */ + /* ── Form controls ───────────────────────────────────── */ input:where( :not([type='hidden']):not([type='checkbox']):not([type='radio']):not([type='submit']):not( [type='button'] ):not([type='reset']) ), - textarea { - @apply bg-vellum block w-full rounded-lg border border-stone-300 px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors; - @apply placeholder-stone-400; - @apply focus:border-primary-500 focus:ring-primary-500 focus:ring-2 focus:outline-none; + textarea, + select { + @apply border-rule-soft bg-surface text-ink block w-full border px-3 py-2 text-sm shadow-sm transition-colors; + @apply placeholder-ink-muted; + @apply focus:border-accent focus:ring-accent focus:ring-1 focus:outline-none; + border-radius: 0; } .dark @@ -158,23 +215,18 @@ [type='button'] ):not([type='reset']) ), - .dark textarea { - @apply border-stone-600 bg-stone-800 text-stone-100 placeholder-stone-500; + .dark textarea, + .dark select { + @apply border-rule-soft bg-surface-alt text-ink placeholder-ink-muted; } - /* Error state */ input[aria-invalid='true'], - textarea[aria-invalid='true'] { - @apply border-danger-400 text-danger-900 placeholder-danger-300; + textarea[aria-invalid='true'], + select[aria-invalid='true'] { + @apply border-accent-soft; } - .dark input[aria-invalid='true'], - .dark textarea[aria-invalid='true'] { - @apply border-danger-500 text-danger-200; - } - - /* Focus ring offset matches background */ *:focus-visible { - --tw-ring-offset-color: var(--color-parchment); + --tw-ring-offset-color: var(--color-bg); } } diff --git a/web/src/app.html b/web/src/app.html index 6bbb9c9..54097c3 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -7,18 +7,18 @@ - - + + svelte-logo + + + + + diff --git a/web/src/lib/assets/marktvogt-logo.svg b/web/src/lib/assets/marktvogt-logo.svg new file mode 100644 index 0000000..4f66a53 --- /dev/null +++ b/web/src/lib/assets/marktvogt-logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/src/lib/components/admin/MarketForm.svelte b/web/src/lib/components/admin/MarketForm.svelte index 6a9263c..7d81815 100644 --- a/web/src/lib/components/admin/MarketForm.svelte +++ b/web/src/lib/components/admin/MarketForm.svelte @@ -270,18 +270,14 @@ {#if error} -
-
diff --git a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte index 80cce5a..cb8febe 100644 --- a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte @@ -81,7 +81,7 @@

Markt bearbeiten

- {#if planError} diff --git a/web/src/routes/auth/anmelden/+page.svelte b/web/src/routes/auth/anmelden/+page.svelte index dd39bf1..c6260cd 100644 --- a/web/src/routes/auth/anmelden/+page.svelte +++ b/web/src/routes/auth/anmelden/+page.svelte @@ -11,26 +11,26 @@
-

Anmelden

+

Anmelden

-
-
+
+
-

+

Noch kein Konto? - Registrieren + Registrieren

diff --git a/web/src/routes/auth/registrieren/+page.svelte b/web/src/routes/auth/registrieren/+page.svelte index b7e1be5..cb4ffd6 100644 --- a/web/src/routes/auth/registrieren/+page.svelte +++ b/web/src/routes/auth/registrieren/+page.svelte @@ -9,20 +9,14 @@
-

- Konto erstellen -

+

Konto erstellen

-
+
-

+

Bereits ein Konto? - Anmelden + Anmelden

diff --git a/web/src/routes/datenschutz/+page.svelte b/web/src/routes/datenschutz/+page.svelte index 6dcafb4..54e2d0a 100644 --- a/web/src/routes/datenschutz/+page.svelte +++ b/web/src/routes/datenschutz/+page.svelte @@ -1,443 +1,512 @@ + + - Datenschutzerklärung - Marktvogt + Datenschutzerklärung — Marktvogt - - + -
-

Datenschutzerklärung

- -
-

1. Verantwortlicher

-

- Christian Nachtigall
- Karwendelstr. 21
- 82061 Neuried
- E-Mail: - contact@marktvogt.de -

-
- -
-

- 2. Überblick der Verarbeitungen -

-

- Marktvogt ist ein Verzeichnis für Mittelaltermärkte und historische Feste. Wir verarbeiten - personenbezogene Daten nur, soweit dies zur Bereitstellung der Funktionen unserer Website - erforderlich ist. Die Verarbeitung erfolgt auf Grundlage der DSGVO - (Datenschutz-Grundverordnung). -

-
- -
-

3. Hosting

-

- Diese Website wird auf Infrastruktur von itsh.dev gehostet. Beim Aufruf unserer - Website werden durch den Hostinganbieter automatisch Informationen in sogenannten Server-Logfiles - erfasst. Dazu gehören: -

-
    -
  • IP-Adresse des zugreifenden Geräts
  • -
  • Datum und Uhrzeit der Anfrage
  • -
  • HTTP-Methode und aufgerufene URL
  • -
  • HTTP-Statuscode
  • -
  • Antwortzeit des Servers
  • -
-

- Diese Daten werden zur Sicherstellung eines störungsfreien Betriebs erhoben und zur Erkennung - von Missbrauch ausgewertet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes - Interesse). -

-
- -
-

- 4. Registrierung und Benutzerkonto -

-

- Sie können auf unserer Website ein Benutzerkonto erstellen. Dabei werden folgende Daten - verarbeitet: -

-
    -
  • E-Mail-Adresse – zur Identifikation und Kommunikation
  • -
  • - Passwort – wird ausschließlich als bcrypt-Hash gespeichert; das Klartext-Passwort - wird nicht gespeichert -
  • -
  • Anzeigename – frei wählbarer Name zur Darstellung im Profil
  • -
  • Profilbild-URL – sofern über einen OAuth-Anbieter bereitgestellt
  • -
-

- Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) sowie - zur Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO). Ihr Konto kann jederzeit gelöscht werden. -

-
- -
-

- 5. Anmeldung über Drittanbieter (OAuth) -

-

- Sie können sich mit einem bestehenden Konto bei folgenden Anbietern anmelden: -

-
    -
  • - Google – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild, E-Mail-Verifizierungsstatus -
  • -
  • - GitHub – Abgerufene Daten: E-Mail-Adresse (primäre, verifizierte E-Mail) -
  • -
  • Facebook – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild
  • -
-

- Wir speichern die vom Anbieter übermittelten Daten (Anbieter-ID, Name, E-Mail) sowie ein - Zugriffstoken zur Verifizierung der Verknüpfung. Die OAuth-Anmeldung erfolgt auf Grundlage - Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können die Verknüpfung jederzeit in Ihren - Profileinstellungen aufheben. Bitte beachten Sie die Datenschutzerklärungen der jeweiligen - Anbieter: -

- -
- -
-

- 6. Magic-Link-Anmeldung -

-

- Sie können sich über einen per E-Mail versendeten Einmal-Link (Magic Link) anmelden. Dabei - wird Ihre E-Mail-Adresse verarbeitet und ein einmalig gültiger, zeitlich begrenzter Token (15 - Minuten) erzeugt. Der Token wird als SHA-256-Hash gespeichert und nach Verwendung ungültig. - Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). -

-
- -
-

- 7. Sitzungsverwaltung (Sessions) -

-

- Nach der Anmeldung wird eine Sitzung erstellt. Dabei werden folgende Daten gespeichert: -

-
    -
  • IP-Adresse – zum Zeitpunkt der Sitzungserstellung
  • -
  • User-Agent – Browserkennung zum Zeitpunkt der Anmeldung
  • -
  • Sitzungstoken – als SHA-256-Hash gespeichert
  • -
-

- Sitzungen laufen nach 30 Tagen automatisch ab. Die Speicherung dient der Sicherheit Ihres - Kontos (Erkennung ungewöhnlicher Anmeldeaktivitäten). Rechtsgrundlage ist Art. 6 Abs. 1 lit. f - DSGVO (berechtigtes Interesse an der Kontosicherheit). -

-
- -
-

- 8. Zwei-Faktor-Authentifizierung (2FA) -

-

- Optional können Sie die Zwei-Faktor-Authentifizierung über ein TOTP-Verfahren (z. B. - Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret) mit - Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung des - Kontos gelöscht. -

-
- -
-

- 9. Cookies und lokale Speicherung -

-

- Wir verwenden ausschließlich technisch notwendige Cookies: -

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
NameZweckDauer
access_tokenZugriffstoken für authentifizierte Anfragen30 Minuten
refresh_tokenSitzungstoken zur Erneuerung des Zugriffstokens30 Tage
access_expires_atAblaufzeitpunkt des Zugriffstokens (kein HttpOnly)30 Minuten
+ +
+
+ Rechtliche Angaben +

+ Datenschutz­erklärung +

+
+
-

- Zusätzlich wird im localStorage des Browsers die Einstellung für das - Farbschema (marktvogt-theme) - gespeichert. Dies enthält keine personenbezogenen Daten. -

-

- Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt. -

-
+
+
-
-

- 10. Markt einreichen (Einreichungsformular) -

-

- Sie können über das Formular unter „Markt einreichen" einen Mittelaltermarkt zur Aufnahme - vorschlagen. Dabei werden folgende Daten verarbeitet: -

-
    -
  • - Marktdaten – Name, Beschreibung, Ort, Zeitraum, Website, Veranstalter, ggf. Koordinaten -
  • -
  • - Kontaktdaten – Ihr Name und Ihre E-Mail-Adresse (werden nicht veröffentlicht) -
  • -
-

- Die Kontaktdaten werden ausschließlich für Rückfragen zur Einreichung verwendet und nicht an - Dritte weitergegeben. Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 - lit. a DSGVO). Eingereichte Daten werden bei Ablehnung des Marktes gelöscht. -

-
- -
-

- 11. Spam-Schutz (Cloudflare Turnstile) -

-

- Zum Schutz des Einreichungsformulars vor automatisiertem Missbrauch setzen wir - Cloudflare Turnstile ein. Dabei werden technische Daten (z. B. IP-Adresse, - Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen, ob die Eingabe von einem Menschen - stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking durchgeführt. -

-

- Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Schutz vor Spam). - Weitere Informationen finden Sie in der - Datenschutzerklärung von Cloudflare. -

-
- -
-

12. Standortdaten

-

- Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen - zwei Verfahren zum Einsatz: -

-
    -
  • - Browser-Geolokalisierung – Ihr Browser fragt Ihre Erlaubnis, bevor Standortdaten - bereitgestellt werden. Die Koordinaten werden nicht auf unserem Server gespeichert, sondern nur - zur einmaligen Berechnung der Entfernung zu Märkten verwendet. -
  • -
  • - IP-basierte Geolokalisierung (Fallback) – Falls die - Browser-Geolokalisierung nicht verfügbar ist, wird der Dienst - geojs.io +
    +
    +
    + 1 · Verantwortlicher +

    + Christian Nachtigall
    + Karwendelstr. 21
    + 82061 Neuried
    + E-Mail: + contact@marktvogt.de - zur ungefähren Standortbestimmung genutzt. Dabei wird Ihre IP-Adresse an geojs.io übermittelt. - Bitte beachten Sie die +

    +
    + + + +
    + 2 · Überblick der Verarbeitungen +

    + Marktvogt ist ein Verzeichnis für Mittelaltermärkte und historische Feste. Wir verarbeiten + personenbezogene Daten nur, soweit dies zur Bereitstellung der Funktionen unserer Website + erforderlich ist. Die Verarbeitung erfolgt auf Grundlage der DSGVO + (Datenschutz-Grundverordnung). +

    +
    + + + +
    + 3 · Hosting +

    + Diese Website wird auf Infrastruktur von itsh.dev gehostet. Beim Aufruf unserer Website werden durch den Hostinganbieter automatisch Informationen + in sogenannten Server-Logfiles erfasst. Dazu gehören: +

    +
      +
    • + IP-Adresse des zugreifenden Geräts +
    • +
    • + Datum und Uhrzeit der Anfrage +
    • +
    • + HTTP-Methode und aufgerufene URL +
    • +
    • HTTP-Statuscode
    • +
    • + Antwortzeit des Servers +
    • +
    +

    + Diese Daten werden zur Sicherstellung eines störungsfreien Betriebs erhoben und zur + Erkennung von Missbrauch ausgewertet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO + (berechtigtes Interesse). +

    +
    + + + +
    + 4 · Registrierung und Benutzerkonto +

    + Sie können auf unserer Website ein Benutzerkonto erstellen. Dabei werden folgende Daten + verarbeitet: +

    +
      +
    • + E-Mail-Adresse – zur Identifikation und Kommunikation +
    • +
    • + Passwort – wird ausschließlich als bcrypt-Hash + gespeichert; das Klartext-Passwort wird nicht gespeichert +
    • +
    • + Anzeigename – frei wählbarer Name zur Darstellung + im Profil +
    • +
    • + Profilbild-URL – sofern über einen OAuth-Anbieter + bereitgestellt +
    • +
    +

    + Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) sowie + zur Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO). Ihr Konto kann jederzeit gelöscht + werden. +

    +
    + + + +
    + 5 · Anmeldung über Drittanbieter (OAuth) +

    + Sie können sich mit einem bestehenden Konto bei folgenden Anbietern anmelden: +

    +
      +
    • + Google – Abgerufene Daten: E-Mail-Adresse, Name, + Profilbild, E-Mail-Verifizierungsstatus +
    • +
    • + GitHub – Abgerufene Daten: E-Mail-Adresse (primäre, + verifizierte E-Mail) +
    • +
    • + Facebook – Abgerufene Daten: E-Mail-Adresse, Name, + Profilbild +
    • +
    +

    + Wir speichern die vom Anbieter übermittelten Daten (Anbieter-ID, Name, E-Mail) sowie ein + Zugriffstoken zur Verifizierung der Verknüpfung. Die OAuth-Anmeldung erfolgt auf Grundlage + Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können die Verknüpfung jederzeit in + Ihren Profileinstellungen aufheben. Bitte beachten Sie die Datenschutzerklärungen der + jeweiligen Anbieter: +

    + +
    + + + +
    + 6 · Magic-Link-Anmeldung +

    + Sie können sich über einen per E-Mail versendeten Einmal-Link (Magic Link) anmelden. Dabei + wird Ihre E-Mail-Adresse verarbeitet und ein einmalig gültiger, zeitlich begrenzter Token + (15 Minuten) erzeugt. Der Token wird als SHA-256-Hash gespeichert und nach Verwendung + ungültig. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). +

    +
    + + + +
    + 7 · Sitzungsverwaltung (Sessions) +

    + Nach der Anmeldung wird eine Sitzung erstellt. Dabei werden folgende Daten gespeichert: +

    +
      +
    • + IP-Adresse – zum Zeitpunkt der Sitzungserstellung +
    • +
    • + User-Agent – Browserkennung zum Zeitpunkt der Anmeldung +
    • +
    • + Sitzungstoken – als SHA-256-Hash gespeichert +
    • +
    +

    + Sitzungen laufen nach 30 Tagen automatisch ab. Die Speicherung dient der Sicherheit Ihres + Kontos (Erkennung ungewöhnlicher Anmeldeaktivitäten). Rechtsgrundlage ist Art. 6 Abs. 1 lit. + f DSGVO (berechtigtes Interesse an der Kontosicherheit). +

    +
    + + + +
    + 8 · Zwei-Faktor-Authentifizierung (2FA) +

    + Optional können Sie die Zwei-Faktor-Authentifizierung über ein TOTP-Verfahren (z. B. + Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret) + mit Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung + des Kontos gelöscht. +

    +
    + + + +
    + 9 · Cookies und lokale Speicherung +

    + Wir verwenden ausschließlich technisch notwendige Cookies: +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameZweckDauer
    access_tokenZugriffstoken für authentifizierte Anfragen30 Minuten
    refresh_tokenSitzungstoken zur Erneuerung des Zugriffstokens30 Tage
    access_expires_atAblaufzeitpunkt des Zugriffstokens (kein HttpOnly)30 Minuten
    +
    +

    + Zusätzlich wird im localStorage des Browsers + die Einstellung für das Farbschema (marktvogt-theme) gespeichert. Dies enthält keine personenbezogenen Daten. +

    +

    + Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt. +

    +
    + + + +
    + 10 · Markt einreichen (Einreichungsformular) +

    + Sie können über das Formular unter „Markt einreichen" einen Mittelaltermarkt zur Aufnahme + vorschlagen. Dabei werden folgende Daten verarbeitet: +

    +
      +
    • + Marktdaten – Name, Beschreibung, Ort, Zeitraum, + Website, Veranstalter, ggf. Koordinaten +
    • +
    • + Kontaktdaten – Ihr Name und Ihre E-Mail-Adresse + (werden nicht veröffentlicht) +
    • +
    +

    + Die Kontaktdaten werden ausschließlich für Rückfragen zur Einreichung verwendet und nicht an + Dritte weitergegeben. Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. + 1 lit. a DSGVO). Eingereichte Daten werden bei Ablehnung des Marktes gelöscht. +

    +
    + + + +
    + 11 · Spam-Schutz (Cloudflare Turnstile) +

    + Zum Schutz des Einreichungsformulars vor automatisiertem Missbrauch setzen wir + Cloudflare Turnstile ein. Dabei werden technische + Daten (z. B. IP-Adresse, Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen, + ob die Eingabe von einem Menschen stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking + durchgeführt. +

    +

    + Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Schutz vor Spam). + Weitere Informationen finden Sie in der Datenschutzerklärung von geojs.ioDatenschutzerklärung von Cloudflare. -

  • -
-
+

+ -
-

13. Kartendarstellung

-

- Zur Darstellung von Karten verwenden wir Leaflet mit Kartenkacheln von - OpenStreetMap. Beim Laden der Karte werden Kartendaten von den Servern der - OpenStreetMap Foundation (tile.openstreetmap.org) abgerufen. Dabei wird Ihre IP-Adresse an die OpenStreetMap Foundation übermittelt. Weitere - Informationen finden Sie in der - Datenschutzerklärung der OpenStreetMap Foundation. -

-

- Die Leaflet-Bibliothek wird über unpkg.com (CDN) geladen. Dabei kann Ihre IP-Adresse an den CDN-Betreiber übermittelt werden. -

-
+ -
-

14. Ihre Rechte

-

- Sie haben gemäß DSGVO folgende Rechte bezüglich Ihrer personenbezogenen Daten: -

-
    -
  • - Auskunft (Art. 15 DSGVO) – Sie können Auskunft über Ihre gespeicherten Daten - verlangen. -
  • -
  • - Berichtigung (Art. 16 DSGVO) – Sie können die Berichtigung unrichtiger Daten - verlangen. -
  • -
  • - Löschung (Art. 17 DSGVO) – Sie können die Löschung Ihrer Daten verlangen. -
  • -
  • - Einschränkung (Art. 18 DSGVO) – Sie können die Einschränkung der Verarbeitung - verlangen. -
  • -
  • - Datenübertragbarkeit (Art. 20 DSGVO) – Sie können Ihre Daten in einem maschinenlesbaren - Format erhalten. -
  • -
  • - Widerspruch (Art. 21 DSGVO) – Sie können der Verarbeitung auf Basis berechtigter - Interessen widersprechen. -
  • -
  • - Widerruf der Einwilligung (Art. 7 Abs. 3 DSGVO) – Erteilte Einwilligungen können - jederzeit widerrufen werden. -
  • -
-

- Zur Ausübung Ihrer Rechte wenden Sie sich an: contact@marktvogt.de -

-
+
+ 12 · Standortdaten +

+ Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen + zwei Verfahren zum Einsatz: +

+
    +
  • + Browser-Geolokalisierung – Ihr Browser fragt Ihre + Erlaubnis, bevor Standortdaten bereitgestellt werden. Die Koordinaten werden nicht auf unserem + Server gespeichert, sondern nur zur einmaligen Berechnung der Entfernung zu Märkten verwendet. +
  • +
  • + IP-basierte Geolokalisierung (Fallback) – + Falls die Browser-Geolokalisierung nicht verfügbar ist, wird der Dienst + geojs.io + zur ungefähren Standortbestimmung genutzt. Dabei wird Ihre IP-Adresse an geojs.io übermittelt. + Bitte beachten Sie die + Datenschutzerklärung von geojs.io. +
  • +
+
-
-

15. Beschwerderecht

-

- Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer - personenbezogenen Daten zu beschweren. Die für uns zuständige Aufsichtsbehörde ist: -

-

- Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)
- Promenade 18
- 91522 Ansbach
- www.lda.bayern.de -

-
+ -
-

- 16. Datenlöschung und Speicherdauer -

-

- Personenbezogene Daten werden gelöscht, sobald der Zweck der Speicherung entfällt: -

-
    -
  • - Benutzerkonto – Bei Löschung Ihres Kontos werden Ihre Daten zunächst für 30 Tage - zur möglichen Wiederherstellung aufbewahrt und anschließend endgültig gelöscht. -
  • -
  • Sitzungsdaten – Automatische Löschung nach Ablauf (30 Tage).
  • -
  • Magic-Link-Tokens – Laufen nach 15 Minuten ab.
  • -
  • - Server-Logfiles – Werden nach den beim Hostinganbieter üblichen Fristen gelöscht. -
  • -
-
+
+ 13 · Kartendarstellung +

+ Zur Darstellung von Karten verwenden wir Leaflet + mit Kartenkacheln von CARTO (basierend auf + OpenStreetMap-Daten). Beim Laden der Karte werden Kartendaten von den CARTO-Servern + abgerufen. Dabei wird Ihre IP-Adresse an CARTO übermittelt. Weitere Informationen finden Sie + in der + Datenschutzerklärung von CARTO + sowie der + Datenschutzerklärung der OpenStreetMap Foundation. +

+

+ Die Leaflet-Bibliothek wird über unpkg.com + (CDN) geladen. Dabei kann Ihre IP-Adresse an den CDN-Betreiber übermittelt werden. +

+
-
-

- 17. Änderungen dieser Datenschutzerklärung -

-

- Wir behalten uns vor, diese Datenschutzerklärung anzupassen, um sie an geänderte Rechtslagen - oder Änderungen des Dienstes anzupassen. Die aktuelle Version finden Sie stets auf dieser - Seite. -

-

Stand: Februar 2026

-
+ + +
+ 14 · Ihre Rechte +

+ Sie haben gemäß DSGVO folgende Rechte bezüglich Ihrer personenbezogenen Daten: +

+
    +
  • + Auskunft (Art. 15 DSGVO) – Sie können Auskunft + über Ihre gespeicherten Daten verlangen. +
  • +
  • + Berichtigung (Art. 16 DSGVO) – Sie können die Berichtigung + unrichtiger Daten verlangen. +
  • +
  • + Löschung (Art. 17 DSGVO) – Sie können die Löschung + Ihrer Daten verlangen. +
  • +
  • + Einschränkung (Art. 18 DSGVO) – Sie können die + Einschränkung der Verarbeitung verlangen. +
  • +
  • + Datenübertragbarkeit (Art. 20 DSGVO) – Sie können + Ihre Daten in einem maschinenlesbaren Format erhalten. +
  • +
  • + Widerspruch (Art. 21 DSGVO) – Sie können der Verarbeitung + auf Basis berechtigter Interessen widersprechen. +
  • +
  • + Widerruf der Einwilligung (Art. 7 Abs. 3 DSGVO) + – Erteilte Einwilligungen können jederzeit widerrufen werden. +
  • +
+

+ Zur Ausübung Ihrer Rechte wenden Sie sich an: + contact@marktvogt.de +

+
+ + + +
+ 15 · Beschwerderecht +

+ Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer + personenbezogenen Daten zu beschweren. Die für uns zuständige Aufsichtsbehörde ist: +

+

+ Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)
+ Promenade 18
+ 91522 Ansbach
+ www.lda.bayern.de +

+
+ + + +
+ 16 · Datenlöschung und Speicherdauer +

+ Personenbezogene Daten werden gelöscht, sobald der Zweck der Speicherung entfällt: +

+
    +
  • + Benutzerkonto – Bei Löschung Ihres Kontos werden + Ihre Daten zunächst für 30 Tage zur möglichen Wiederherstellung aufbewahrt und anschließend + endgültig gelöscht. +
  • +
  • + Sitzungsdaten – Automatische Löschung nach Ablauf + (30 Tage). +
  • +
  • + Magic-Link-Tokens – Laufen nach 15 Minuten ab. +
  • +
  • + Server-Logfiles – Werden nach den beim Hostinganbieter + üblichen Fristen gelöscht. +
  • +
+
+ + + +
+ 17 · Änderungen dieser Datenschutzerklärung +

+ Wir behalten uns vor, diese Datenschutzerklärung anzupassen, um sie an geänderte Rechtslagen + oder Änderungen des Dienstes anzupassen. Die aktuelle Version finden Sie stets auf dieser + Seite. +

+

+ Stand: Februar 2026 +

+
+
diff --git a/web/src/routes/impressum/+page.svelte b/web/src/routes/impressum/+page.svelte index 4f3539b..cdb7a47 100644 --- a/web/src/routes/impressum/+page.svelte +++ b/web/src/routes/impressum/+page.svelte @@ -1,118 +1,145 @@ + + - Impressum - Marktvogt + Impressum — Marktvogt - - + -
-

Impressum

+ +
+
+ Rechtliche Angaben +

+ Impressum +

+
+ +
+
+
-
-

Angaben gemäß § 5 TMG

-

- Christian Nachtigall
- Karwendelstr. 21
- 82061 Neuried -

-
+ +
+
+
+ Angaben gemäß § 5 TMG +

+ Christian Nachtigall
+ Karwendelstr. 21
+ 82061 Neuried +

+
-
-

Kontakt

-

- E-Mail: contact@marktvogt.de -

-
+ -
-

- Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV -

-

- Christian Nachtigall
- Karwendelstr. 21
- 82061 Neuried -

-
+
+ Kontakt +

+ E-Mail: contact@marktvogt.de +

+
-
-

Haftung für Inhalte

-

- Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach - den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter - jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen - oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. -

-

- Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den - allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab - dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von - entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. -

-
+ -
-

- Keine Gewähr für Vollständigkeit und Richtigkeit -

-

- Die auf dieser Plattform bereitgestellten Informationen zu Mittelaltermärkten, Veranstaltungen - und Anbietern werden nach bestem Wissen zusammengestellt. Wir übernehmen jedoch keine Gewähr - für die Aktualität, Vollständigkeit oder Richtigkeit der dargestellten Daten. Angaben zu - Terminen, Orten, Preisen und sonstigen Veranstaltungsdetails können sich kurzfristig ändern. - Verbindliche Informationen sind stets direkt beim jeweiligen Veranstalter einzuholen. -

-
+
+ Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV +

+ Christian Nachtigall
+ Karwendelstr. 21
+ 82061 Neuried +

+
-
-

- Nutzereingereichte Inhalte -

-

- Nutzer können über das Formular „Markt einreichen" Informationen zu Mittelaltermärkten zur - Veröffentlichung vorschlagen. Alle Einreichungen werden vor der Veröffentlichung redaktionell - geprüft. Für die Richtigkeit der von Nutzern eingesandten Informationen übernehmen wir keine - Gewähr. -

-
+ -
-

Haftung für Links

-

- Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen - Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für - die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten - verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche - Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht - erkennbar. -

-

- Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete - Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen - werden wir derartige Links umgehend entfernen. -

-
+
+ Haftung für Inhalte +

+ Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach + den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter + jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen + oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. +

+

+ Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den + allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst + ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden + von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. +

+
-
-

Urheberrecht

-

- Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem - deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der - Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des - jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den - privaten, nicht kommerziellen Gebrauch gestattet. -

-

- Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die - Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. - Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen - entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte - umgehend entfernen. -

-
+ + +
+ Keine Gewähr für Vollständigkeit und Richtigkeit +

+ Die auf dieser Plattform bereitgestellten Informationen zu Mittelaltermärkten, + Veranstaltungen und Anbietern werden nach bestem Wissen zusammengestellt. Wir übernehmen + jedoch keine Gewähr für die Aktualität, Vollständigkeit oder Richtigkeit der dargestellten + Daten. Angaben zu Terminen, Orten, Preisen und sonstigen Veranstaltungsdetails können sich + kurzfristig ändern. Verbindliche Informationen sind stets direkt beim jeweiligen + Veranstalter einzuholen. +

+
+ + + +
+ Nutzereingereichte Inhalte +

+ Nutzer können über das Formular „Markt einreichen" Informationen zu Mittelaltermärkten zur + Veröffentlichung vorschlagen. Alle Einreichungen werden vor der Veröffentlichung + redaktionell geprüft. Für die Richtigkeit der von Nutzern eingesandten Informationen + übernehmen wir keine Gewähr. +

+
+ + + +
+ Haftung für Links +

+ Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen + Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. + Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der + Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf + mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung + nicht erkennbar. +

+

+ Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete + Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von + Rechtsverletzungen werden wir derartige Links umgehend entfernen. +

+
+ + + +
+ Urheberrecht +

+ Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem + deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der + Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung + des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den + privaten, nicht kommerziellen Gebrauch gestattet. +

+

+ Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die + Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche + gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, + bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden + wir derartige Inhalte umgehend entfernen. +

+
+
diff --git a/web/src/routes/kalender/+page.server.ts b/web/src/routes/kalender/+page.server.ts new file mode 100644 index 0000000..ea822ff --- /dev/null +++ b/web/src/routes/kalender/+page.server.ts @@ -0,0 +1,24 @@ +import type { PageServerLoad } from './$types.js'; +import { apiFetch } from '$lib/api/client.js'; +import type { MarketSummary } from '$lib/api/types.js'; + +export const load: PageServerLoad = async ({ url, fetch }) => { + const now = new Date(); + const year = parseInt(url.searchParams.get('year') ?? String(now.getUTCFullYear())); + const month = parseInt(url.searchParams.get('month') ?? String(now.getUTCMonth() + 1)); + + const mm = String(month).padStart(2, '0'); + const from = `${year}-${mm}-01`; + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const to = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`; + + try { + const res = await apiFetch( + `/markets?from=${from}&to=${to}&per_page=200&sort=date`, + { fetch } + ); + return { markets: res.data, year, month }; + } catch { + return { markets: [] as MarketSummary[], year, month }; + } +}; diff --git a/web/src/routes/kalender/+page.svelte b/web/src/routes/kalender/+page.svelte new file mode 100644 index 0000000..07e5bb1 --- /dev/null +++ b/web/src/routes/kalender/+page.svelte @@ -0,0 +1,288 @@ + + + + Kalender · {MONTHS_DE[data.month - 1]} {data.year} — Marktvogt + + + + +
+
+ +
+ Kalender historischer Märkte +

+ {MONTHS_DE[data.month - 1]} + {data.year} +

+ {#if uniqueMarkets.length > 0} +

+ {uniqueMarkets.length} + {uniqueMarkets.length === 1 ? 'Markt' : 'Märkte'} in diesem Monat +

+ {/if} +
+ + + +
+
+ + +
+
+ +
+ {#each DAYS_SHORT as day, i} +
+ + {day} +
+ {/each} +
+ + + {#each calendarWeeks as week} +
+ {#each week as cell} + {@const cellMarkets = cell.date ? (marketsByDate.get(cell.date) ?? []) : []} + {@const isToday = cell.date === todayIso} + {@const isWeekend = cell.colIdx >= 5} + {@const hasMarkets = cellMarkets.length > 0} +
+ {#if cell.day !== null} + +
+ + {cell.day} + + {#if hasMarkets} + + {/if} +
+ + + {#each cellMarkets.slice(0, 3) as market} + + {market.name} + + {/each} + {#if cellMarkets.length > 3} + + +{cellMarkets.length - 3} weitere + + {/if} + {/if} +
+ {/each} +
+ {/each} +
+
+ + +
+
+ {#if uniqueMarkets.length === 0} +
+
+

+ Keine Märkte in diesem Monat. +

+ + Alle Märkte anzeigen › + +
+ {:else} +
+ + {uniqueMarkets.length} + {uniqueMarkets.length === 1 ? 'Markt' : 'Märkte'} im {MONTHS_DE[data.month - 1]} + + +
+ + {#each uniqueMarkets as market} + +
+
+ {new Date(market.start_date).toLocaleDateString('de-DE', { + day: '2-digit', + month: 'short', + timeZone: 'UTC' + })} +
+
+
+
+ {market.name} +
+
+ {market.city} · {market.state} +
+
+ +
+ {/each} + {/if} +
+
diff --git a/web/src/routes/karte/+page.server.ts b/web/src/routes/karte/+page.server.ts new file mode 100644 index 0000000..6c09edd --- /dev/null +++ b/web/src/routes/karte/+page.server.ts @@ -0,0 +1,13 @@ +import type { PageServerLoad } from './$types.js'; +import { apiFetch } from '$lib/api/client.js'; +import type { MarketSummary } from '$lib/api/types.js'; + +export const load: PageServerLoad = async ({ fetch }) => { + const today = new Date().toISOString().slice(0, 10); + try { + const res = await apiFetch(`/markets?from=${today}&per_page=500`, { fetch }); + return { markets: res.data }; + } catch { + return { markets: [] as MarketSummary[] }; + } +}; diff --git a/web/src/routes/karte/+page.svelte b/web/src/routes/karte/+page.svelte new file mode 100644 index 0000000..eab463c --- /dev/null +++ b/web/src/routes/karte/+page.svelte @@ -0,0 +1,148 @@ + + + + Karte — Marktvogt + + + +
+ + + + +
+ (selected = m)} + /> +
+
diff --git a/web/src/routes/lagerleben/+page.server.ts b/web/src/routes/lagerleben/+page.server.ts new file mode 100644 index 0000000..ba4c81f --- /dev/null +++ b/web/src/routes/lagerleben/+page.server.ts @@ -0,0 +1,15 @@ +import type { PageServerLoad } from './$types.js'; +import { apiFetch } from '$lib/api/client.js'; +import type { LagerlebenArticle, LagerlebenCamp } from '$lib/api/types.js'; + +export const load: PageServerLoad = async ({ fetch }) => { + const [articlesRes, campsRes] = await Promise.all([ + apiFetch('/lagerleben/articles', { fetch }).catch(() => ({ + data: [] as LagerlebenArticle[] + })), + apiFetch('/lagerleben/camps', { fetch }).catch(() => ({ + data: [] as LagerlebenCamp[] + })) + ]); + return { articles: articlesRes.data, camps: campsRes.data }; +}; diff --git a/web/src/routes/lagerleben/+page.svelte b/web/src/routes/lagerleben/+page.svelte new file mode 100644 index 0000000..64b5d21 --- /dev/null +++ b/web/src/routes/lagerleben/+page.svelte @@ -0,0 +1,156 @@ + + + + Lagerleben — Marktvogt + + + + +
+
+ Das Magazin für lebendiges Mittelalter +

+ Lagerleben +

+
+ + Handwerk · Recherche · Gemeinschaft + +
+
+
+ + +
+
+ Im Aufbau +

+ Beispielinhalte — vollständige Redaktion und Einreichung folgen in einer späteren Phase. +

+
+
+ + +
+
+ {#if data.articles.length > 0} + {@const lead = data.articles[0]} + + +
+
+ +
+
+
+ {lead.category} +

{lead.title}

+

{lead.subtitle}

+ +

{lead.excerpt}

+

+ {fmtDate(lead.date)} +

+
+
+ + +
+ Weitere Beiträge + +
+ + + + {/if} +
+
+ + +
+
+
+
+ Lagerporträts +

+ Gruppen und Gemeinschaften vorstellen +

+
+ +
+ + +
+
diff --git a/web/src/routes/lagerleben/lager/[slug]/+page.server.ts b/web/src/routes/lagerleben/lager/[slug]/+page.server.ts new file mode 100644 index 0000000..3830436 --- /dev/null +++ b/web/src/routes/lagerleben/lager/[slug]/+page.server.ts @@ -0,0 +1,16 @@ +import type { PageServerLoad } from './$types.js'; +import { error } from '@sveltejs/kit'; +import { apiFetch, ApiClientError } from '$lib/api/client.js'; +import type { LagerlebenCamp } from '$lib/api/types.js'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + try { + const res = await apiFetch(`/lagerleben/camps/${params.slug}`, { fetch }); + return { camp: res.data }; + } catch (err) { + if (err instanceof ApiClientError && err.status === 404) { + error(404, 'Lager nicht gefunden'); + } + error(500, 'Fehler beim Laden des Lagerporträts'); + } +}; diff --git a/web/src/routes/lagerleben/lager/[slug]/+page.svelte b/web/src/routes/lagerleben/lager/[slug]/+page.svelte new file mode 100644 index 0000000..b85cc61 --- /dev/null +++ b/web/src/routes/lagerleben/lager/[slug]/+page.svelte @@ -0,0 +1,72 @@ + + + + {camp.name} — Lagerleben — Marktvogt + + + + +
+
+ +
+
+ +
+ + + {camp.period} · {camp.region} +

+ {camp.name} +

+ +
+ + {camp.members} Mitglieder +
+ +
+ Im Aufbau +

+ Lagerporträts befinden sich noch im Aufbau. Vollständige Profile mit Galerie, Mitgliederliste + und Kontaktformular folgen in einer späteren Phase. +

+
+ +

+ {camp.excerpt} +

+ +
+ +
+ + +
diff --git a/web/src/routes/lagerleben/reportage/[slug]/+page.server.ts b/web/src/routes/lagerleben/reportage/[slug]/+page.server.ts new file mode 100644 index 0000000..92955a0 --- /dev/null +++ b/web/src/routes/lagerleben/reportage/[slug]/+page.server.ts @@ -0,0 +1,17 @@ +import type { PageServerLoad } from './$types.js'; +import { error } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import type { LagerlebenArticle } from '$lib/api/types.js'; +import { ApiClientError } from '$lib/api/client.js'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + try { + const res = await apiFetch(`/lagerleben/articles/${params.slug}`, { fetch }); + return { article: res.data }; + } catch (err) { + if (err instanceof ApiClientError && err.status === 404) { + error(404, 'Beitrag nicht gefunden'); + } + error(500, 'Fehler beim Laden des Beitrags'); + } +}; diff --git a/web/src/routes/lagerleben/reportage/[slug]/+page.svelte b/web/src/routes/lagerleben/reportage/[slug]/+page.svelte new file mode 100644 index 0000000..465b3ec --- /dev/null +++ b/web/src/routes/lagerleben/reportage/[slug]/+page.svelte @@ -0,0 +1,82 @@ + + + + {article.title} — Lagerleben — Marktvogt + + + + +
+
+ +
+
+ +
+ + + {article.category} +

+ {article.title} +

+

{article.subtitle}

+ +
+ + {fmtDate(article.date)} +
+ +
+ Im Aufbau +

+ Dieser Beitrag ist ein Beispielinhalt. Die vollständigen Inhalte des Lagerleben-Magazins + folgen in einer späteren Entwicklungsphase. +

+
+ +
+ +
+ +

+ {article.excerpt} +

+ + +
diff --git a/web/src/routes/maerkte/+page.server.ts b/web/src/routes/maerkte/+page.server.ts index bcaae79..4270537 100644 --- a/web/src/routes/maerkte/+page.server.ts +++ b/web/src/routes/maerkte/+page.server.ts @@ -1,43 +1,81 @@ import type { PageServerLoad } from './$types.js'; -import { apiFetch } from '$lib/api/client.js'; -import type { MarketSummary } from '$lib/api/types.js'; -import { stateToSlug, STATE_SLUGS } from '$lib/utils/slug.js'; +import { apiFetch, buildSearchQuery } from '$lib/api/client.js'; +import type { MarketSummary, PaginationMeta } from '$lib/api/types.js'; -export interface StateInfo { - slug: string; - name: string; - count: number; +type Coords = { lat?: string; lon?: string }; + +async function resolveCoords( + plz: string | null, + urlLat: string | null, + urlLon: string | null, + fetch: typeof globalThis.fetch +): Promise { + if (urlLat && urlLon) return { lat: urlLat, lon: urlLon }; + if (!plz) return {}; + + try { + const res = await apiFetch<{ latitude: number | null; longitude: number | null }>('/geocode', { + method: 'POST', + body: JSON.stringify({ city: '', zip: plz, country: 'DE' }), + fetch + }); + const { latitude, longitude } = res.data; + if (latitude == null || longitude == null) return {}; + return { lat: String(latitude), lon: String(longitude) }; + } catch { + return {}; + } } -export const load: PageServerLoad = async ({ fetch }) => { - let markets: MarketSummary[] = []; +export const load: PageServerLoad = async ({ url, fetch }) => { + const q = url.searchParams.get('q'); + const plz = url.searchParams.get('plz'); + const lat = url.searchParams.get('lat'); + const lon = url.searchParams.get('lon'); + const radius = url.searchParams.get('radius'); + const from = url.searchParams.get('from'); + const to = url.searchParams.get('to'); + const sort = url.searchParams.get('sort'); + const page = url.searchParams.get('page'); + + const coords = await resolveCoords(plz, lat, lon, fetch); + + const params: Record = {}; + if (q) params.q = q; + if (coords.lat) params.lat = coords.lat; + if (coords.lon) params.lon = coords.lon; + if (radius) params.radius = radius; + if (from) params.from = from; + if (to) params.to = to; + if (sort) params.sort = sort; + if (page) params.page = page; + + const query = buildSearchQuery(params); + const path = `/markets${query ? `?${query}` : ''}`; + + const searchParams = { + q: q ?? '', + plz: plz ?? '', + lat: lat ? Number(lat) : undefined, + lon: lon ? Number(lon) : undefined, + radius: radius ? Number(radius) : 25, + from: from ?? '', + to: to ?? '', + sort: sort ?? '' + }; + try { - const res = await apiFetch('/markets?per_page=1000', { fetch }); - markets = res.data; + const res = await apiFetch(path, { fetch }); + return { + markets: res.data, + meta: res.meta as PaginationMeta, + searchParams + }; } catch { - // Backend unreachable + return { + markets: [] as MarketSummary[], + meta: { page: 1, per_page: 20, total: 0, total_pages: 0 } as PaginationMeta, + searchParams + }; } - - const countByState = new Map(); - for (const m of markets) { - countByState.set(m.state, (countByState.get(m.state) ?? 0) + 1); - } - - const states: StateInfo[] = Object.entries(STATE_SLUGS) - .map(([slug, name]) => ({ - slug, - name, - count: countByState.get(name) ?? 0 - })) - .filter((s) => s.count > 0) - .sort((a, b) => a.name.localeCompare(b.name, 'de')); - - // Also include any states from data not in the canonical list - for (const [state, count] of countByState) { - if (!states.some((s) => s.name === state)) { - states.push({ slug: stateToSlug(state), name: state, count }); - } - } - - return { states }; }; diff --git a/web/src/routes/maerkte/+page.svelte b/web/src/routes/maerkte/+page.svelte index 97dbc7b..4407079 100644 --- a/web/src/routes/maerkte/+page.svelte +++ b/web/src/routes/maerkte/+page.svelte @@ -1,85 +1,245 @@ - Mittelaltermärkte nach Bundesland - Marktvogt + Alle {data.meta.total} Märkte — Marktvogt - - + - {@html jsonLdHtml} -
- - -

- Mittelaltermärkte nach Bundesland -

-

- Entdecke Mittelaltermärkte, Ritterturniere und historische Feste in ganz Deutschland. -

- - {#if states.length > 0} - - {:else} -
+
+
+ Verzeichnis historischer Märkte +

-

Aktuell keine Märkte verfügbar.

+ Alle {data.meta.total} Märkte +

+
+
- {/if} +
+
+ + +
+
+
+ + + + + + {#if data.searchParams.q || data.searchParams.from || data.searchParams.to} + × Filter + {/if} +
+ + +
+ {data.meta.total} Märkte + + {#each [['cards', '▦'], ['rows', '☰'], ['map', '⊕']] as [v, icon]} + + {/each} + +
+
+
+ + +
+
+ {#if data.markets.length === 0} +
+
+

+ Keine Märkte gefunden. Andere Suchkriterien versuchen? +

+ Alle Märkte anzeigen › +
+ {:else if view === 'map'} + + {:else if view === 'rows'} + + {@const grouped = (() => { + const map = new Map(); + for (const m of data.markets) { + const key = new Date(m.start_date).toLocaleDateString('de-DE', { + month: 'long', + year: 'numeric', + timeZone: 'UTC' + }); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(m); + } + return [...map.entries()]; + })()} + {#each grouped as [month, markets]} + + {/each} + {:else} + + + {/if} + + {#if view !== 'map' && data.meta.total_pages > 1} +
+ +
+ {/if} +
diff --git a/web/src/routes/markt/[slug]/+page.svelte b/web/src/routes/markt/[slug]/+page.svelte index 3e59ab3..dbb92e5 100644 --- a/web/src/routes/markt/[slug]/+page.svelte +++ b/web/src/routes/markt/[slug]/+page.svelte @@ -1,6 +1,9 @@ - Profil - Marktvogt + Profil — Marktvogt -
-

Profil

+
+

+ Profil +

-
- -
-

- Kontoinformationen -

+ - {#if form?.success} - {form.success} - {/if} - {#if form?.error} - {form.error} - {/if} + +
+ Kontoinformationen -
{ - updateLoading = true; - return async ({ update }) => { - updateLoading = false; - await update(); - }; - }} - class="mt-4 space-y-4" + {#if form?.success} +
{form.success}
+ {/if} + {#if form?.error} +
{form.error}
+ {/if} + +
+ E-Mail -
- E-Mail: - {data.profile.email} -
+ {data.profile.email} +
- + { + updateLoading = true; + return async ({ update }) => { + updateLoading = false; + await update(); + }; + }} + class="space-y-5" + > +
+ + +
- + + +
- - -
+
+ +
+ + - -
-

Sicherheit

+ -
- -
-

- {data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'} -

+ +
+ Sicherheit + + +

+ {data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'} +

+ +
{ + passwordLoading = true; + return async ({ update }) => { + passwordLoading = false; + await update(); + }; + }} + class="space-y-5" + > + {#if data.profile.has_password} +
+ + +
+ {/if} + +
+ + +
+ +
+ + +
+ +
+ +
+
+ + + + + + Zwei-Faktor-Authentifizierung verwalten → + +
+ + + + +
+ Konto löschen +

+ Dein Konto wird deaktiviert und nach 30 Tagen endgültig gelöscht. +

+ + {#if !showDeleteConfirm} + + {:else} +
+

+ Bist du sicher? Diese Aktion kann innerhalb von 30 Tagen rückgängig gemacht werden. +

+
{ - passwordLoading = true; + deleteLoading = true; return async ({ update }) => { - passwordLoading = false; + deleteLoading = false; await update(); }; }} - class="space-y-4" > - {#if data.profile.has_password} - - {/if} - - - - - - +
-
- - -
-
- - -
-

Konto löschen

-

- Dein Konto wird deaktiviert und nach 30 Tagen endgültig gelöscht. -

- - {#if !showDeleteConfirm} - - {:else} -
-

- Bist du sicher? Diese Aktion kann innerhalb von 30 Tagen rückgängig gemacht werden. -

-
-
{ - deleteLoading = true; - return async ({ update }) => { - deleteLoading = false; - await update(); - }; - }} - > - -
- -
-
- {/if} -
-
+ {/if} +
diff --git a/web/src/routes/profile/security/+page.svelte b/web/src/routes/profile/security/+page.svelte index a411584..6d87af2 100644 --- a/web/src/routes/profile/security/+page.svelte +++ b/web/src/routes/profile/security/+page.svelte @@ -1,6 +1,8 @@ - Sicherheit - Marktvogt + Sicherheit — Marktvogt -
-
+ + -

+

Zwei-Faktor-Authentifizierung

-
+ + +
{#if form?.success} - {form.success} +
{form.success}
{/if} {#if form?.totpSecret} {:else} -
-

- Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy). -

+ Authenticator-App +

+ Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy). +

-
-
{ - setupLoading = true; - return async ({ update }) => { - setupLoading = false; - await update(); - }; - }} +
+ { + setupLoading = true; + return async ({ update }) => { + setupLoading = false; + await update(); + }; + }} + > + - + {#if setupLoading}{/if} + 2FA einrichten + + - {#if !showDisableConfirm} - - {/if} -
- - {#if showDisableConfirm} -
(showDisableConfirm = true)} + class="border-rule-soft text-ink-muted hover:text-ink border px-5 py-2.5 font-serif text-[14px]" > -

- Bist du sicher? Dein Konto wird weniger sicher sein. -

-
-
{ - disableLoading = true; - return async ({ update }) => { - disableLoading = false; - await update(); - }; - }} - > - -
- -
-
- {/if} - - {#if form?.error && !form?.totpSecret} - {form.error} + 2FA deaktivieren + {/if}
+ + {#if showDisableConfirm} +
+

+ Bist du sicher? Dein Konto wird weniger sicher sein. +

+
+
{ + disableLoading = true; + return async ({ update }) => { + disableLoading = false; + await update(); + }; + }} + > + +
+ +
+
+ {/if} + + {#if form?.error && !form?.totpSecret} +
{form.error}
+ {/if} {/if} -
+
diff --git a/web/static/apple-touch-icon.png b/web/static/apple-touch-icon.png index adad01f..80511e4 100644 Binary files a/web/static/apple-touch-icon.png and b/web/static/apple-touch-icon.png differ diff --git a/web/static/favicon-32.png b/web/static/favicon-32.png index b434832..3f965a7 100644 Binary files a/web/static/favicon-32.png and b/web/static/favicon-32.png differ diff --git a/web/static/favicon.ico b/web/static/favicon.ico index 22bf84a..69430d7 100644 Binary files a/web/static/favicon.ico and b/web/static/favicon.ico differ diff --git a/web/static/favicon.svg b/web/static/favicon.svg index 5926a40..5aa07ac 100644 --- a/web/static/favicon.svg +++ b/web/static/favicon.svg @@ -1,10 +1,5 @@ - - - - - - - - - + + + + diff --git a/web/static/fonts/cormorant-garamond-400-ext.woff2 b/web/static/fonts/cormorant-garamond-400-ext.woff2 new file mode 100644 index 0000000..e90a82d Binary files /dev/null and b/web/static/fonts/cormorant-garamond-400-ext.woff2 differ diff --git a/web/static/fonts/cormorant-garamond-400.woff2 b/web/static/fonts/cormorant-garamond-400.woff2 new file mode 100644 index 0000000..a1828a8 Binary files /dev/null and b/web/static/fonts/cormorant-garamond-400.woff2 differ diff --git a/web/static/fonts/cormorant-garamond-400i-ext.woff2 b/web/static/fonts/cormorant-garamond-400i-ext.woff2 new file mode 100644 index 0000000..17d3772 Binary files /dev/null and b/web/static/fonts/cormorant-garamond-400i-ext.woff2 differ diff --git a/web/static/fonts/cormorant-garamond-400i.woff2 b/web/static/fonts/cormorant-garamond-400i.woff2 new file mode 100644 index 0000000..d927264 Binary files /dev/null and b/web/static/fonts/cormorant-garamond-400i.woff2 differ diff --git a/web/static/fonts/eb-garamond-400-ext.woff2 b/web/static/fonts/eb-garamond-400-ext.woff2 new file mode 100644 index 0000000..e2399e5 Binary files /dev/null and b/web/static/fonts/eb-garamond-400-ext.woff2 differ diff --git a/web/static/fonts/eb-garamond-400.woff2 b/web/static/fonts/eb-garamond-400.woff2 new file mode 100644 index 0000000..b65625c Binary files /dev/null and b/web/static/fonts/eb-garamond-400.woff2 differ diff --git a/web/static/fonts/eb-garamond-400i-ext.woff2 b/web/static/fonts/eb-garamond-400i-ext.woff2 new file mode 100644 index 0000000..8f22455 Binary files /dev/null and b/web/static/fonts/eb-garamond-400i-ext.woff2 differ diff --git a/web/static/fonts/eb-garamond-400i.woff2 b/web/static/fonts/eb-garamond-400i.woff2 new file mode 100644 index 0000000..8d8f8aa Binary files /dev/null and b/web/static/fonts/eb-garamond-400i.woff2 differ diff --git a/web/static/fonts/inter-400-ext.woff2 b/web/static/fonts/inter-400-ext.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/web/static/fonts/inter-400-ext.woff2 differ diff --git a/web/static/fonts/inter-400.woff2 b/web/static/fonts/inter-400.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/web/static/fonts/inter-400.woff2 differ diff --git a/web/static/fonts/jetbrains-mono-400-ext.woff2 b/web/static/fonts/jetbrains-mono-400-ext.woff2 new file mode 100644 index 0000000..82f9668 Binary files /dev/null and b/web/static/fonts/jetbrains-mono-400-ext.woff2 differ diff --git a/web/static/fonts/jetbrains-mono-400.woff2 b/web/static/fonts/jetbrains-mono-400.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/web/static/fonts/jetbrains-mono-400.woff2 differ diff --git a/web/static/logo-signet.svg b/web/static/logo-signet.svg index 5e18db8..4f66a53 100644 --- a/web/static/logo-signet.svg +++ b/web/static/logo-signet.svg @@ -1,11 +1,5 @@ - - - - - - - - - - - + + + + + \ No newline at end of file diff --git a/web/static/site.webmanifest b/web/static/site.webmanifest index 1d50804..313501d 100644 --- a/web/static/site.webmanifest +++ b/web/static/site.webmanifest @@ -5,7 +5,7 @@ { "src": "/favicon-32.png", "sizes": "32x32", "type": "image/png" }, { "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" } ], - "theme_color": "#1a3d24", - "background_color": "#0f2818", + "theme_color": "#f5efe4", + "background_color": "#f5efe4", "display": "standalone" }