Compare commits
52 Commits
main
...
feat/burgund
| Author | SHA1 | Date | |
|---|---|---|---|
| 284944e582 | |||
| db3a24a4a6 | |||
| a473878cb4 | |||
| 76886fdbf3 | |||
| 591e3190be | |||
| d1066fecbc | |||
| a2e1bf239e | |||
| 3b940d4e30 | |||
| f81dba7132 | |||
| 47d7173edc | |||
| e6b70279b1 | |||
| 79dd71ff73 | |||
| 5bbb9814a4 | |||
| c9c210111b | |||
| 874e1798b8 | |||
| 0fd097fa73 | |||
| 2624d1ed90 | |||
| bc0e9a49ee | |||
| 6588814321 | |||
| 4f0669cca0 | |||
| 70cef9e179 | |||
| c7d290773c | |||
| 85bf7355be | |||
| c7521687dd | |||
| fa988b3a5b | |||
| e044a85007 | |||
| 70983b19d6 | |||
| c6b2ca2717 | |||
| 9ecdb2359f | |||
| b08acfb5b3 | |||
| 42f6e1706a | |||
| 6cf560ec43 | |||
| d815d47242 | |||
| 034453fd05 | |||
| 97532b85bc | |||
| eb8c395b16 | |||
| 4b191a3cfd | |||
| 6a8fc07a3d | |||
| 740bad2176 | |||
| 4be9e9bce1 | |||
| 6b09d0c84e | |||
| a87c7026fe | |||
| 5422217a74 | |||
| 8253093a16 | |||
| 808f07800e | |||
| b62271eeb6 | |||
| a37e79ec16 | |||
| b5748121dd | |||
| 911439ebd8 | |||
| 5e24be03af | |||
| 00d43675ff | |||
| 418a4411f3 |
+3
-2
@@ -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 )
|
||||
|
||||
@@ -56,7 +56,9 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@marktvogt.de
|
||||
|
||||
# Cloudflare Turnstile
|
||||
TURNSTILE_SECRET_KEY=
|
||||
# Local dev test key (always passes): 1x0000000000000000000000000000000AA
|
||||
# Always-block test key: 2x0000000000000000000000000000000AB
|
||||
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
|
||||
# Notifications
|
||||
ADMIN_EMAIL=
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
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()
|
||||
noop := func(c *gin.Context) { c.Next() }
|
||||
application.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware, noop)
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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("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 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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/pgerr"
|
||||
)
|
||||
|
||||
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 pgerr.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()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package application
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc, requireEmailVerified gin.HandlerFunc) {
|
||||
auth := rg.Group("", requireAuth)
|
||||
{
|
||||
auth.POST("/groups/:id/applications", requireEmailVerified, h.Create)
|
||||
auth.GET("/groups/:id/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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -10,6 +10,7 @@ type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||
DisplayName string `json:"display_name" validate:"required,min=1,max=100"`
|
||||
Role string `json:"role" validate:"omitempty,oneof=user veranstalter haendler lager"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -82,3 +83,12 @@ type MessageResponse struct {
|
||||
type MessageData struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type PasswordResetRequestDTO struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
type PasswordResetConfirmRequest struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/user"
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
"marktvogt.de/backend/internal/pkg/email"
|
||||
)
|
||||
|
||||
const emailVerifyTTL = 48 * time.Hour
|
||||
|
||||
type EmailVerifyHandler struct {
|
||||
authRepo Repository
|
||||
userRepo user.Repository
|
||||
email email.Sender
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func NewEmailVerifyHandler(authRepo Repository, userRepo user.Repository, emailSender email.Sender, frontendURL string) *EmailVerifyHandler {
|
||||
return &EmailVerifyHandler{
|
||||
authRepo: authRepo,
|
||||
userRepo: userRepo,
|
||||
email: emailSender,
|
||||
frontendURL: frontendURL,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfirmEmailVerify validates the token and marks the user's email as verified.
|
||||
func (h *EmailVerifyHandler) ConfirmEmailVerify(c *gin.Context) {
|
||||
var req struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
||||
apiErr := apierror.BadRequest("invalid_request", "token is required")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
tokenHash := HashToken(req.Token)
|
||||
|
||||
evt, err := h.authRepo.ConsumeEmailVerifyToken(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailVerifyNotFound) || errors.Is(err, ErrEmailVerifyExpired) || errors.Is(err, ErrEmailVerifyUsed) {
|
||||
apiErr := apierror.BadRequest("invalid_token", "verify link is invalid, expired, or already used")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to verify token")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authRepo.MarkEmailVerified(ctx, evt.UserID); err != nil {
|
||||
apiErr := apierror.Internal("failed to update email verification status")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
// Flush stale Valkey-cached sessions so the updated EmailVerified flag is
|
||||
// reflected on the next request rather than after AccessTTL expires.
|
||||
if err := h.authRepo.InvalidateUserSessionCaches(ctx, evt.UserID); err != nil {
|
||||
slog.Warn("failed to invalidate session caches after email verify", "user_id", evt.UserID, "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "email verified"}})
|
||||
}
|
||||
|
||||
// ResendEmailVerify creates a fresh verify token and emails it to the authenticated user.
|
||||
// Requires auth (but not email verification). Rate-limited to prevent abuse.
|
||||
func (h *EmailVerifyHandler) ResendEmailVerify(c *gin.Context) {
|
||||
userIDVal, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
apiErr := apierror.Unauthorized("not authenticated")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
userID, ok := userIDVal.(uuid.UUID)
|
||||
if !ok {
|
||||
apiErr := apierror.Internal("invalid user context")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
u, err := h.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to load user")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if u.EmailVerified {
|
||||
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "email already verified"}})
|
||||
return
|
||||
}
|
||||
|
||||
h.SendVerifyEmail(ctx, u.ID, u.Email)
|
||||
|
||||
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "verification email sent"}})
|
||||
}
|
||||
|
||||
// SendVerifyEmail creates a token and dispatches the verification email asynchronously.
|
||||
// Used by ResendEmailVerify and by the auth.Service.Register hook.
|
||||
func (h *EmailVerifyHandler) SendVerifyEmail(ctx context.Context, userID uuid.UUID, toEmail string) {
|
||||
rawToken := GenerateOpaqueToken()
|
||||
tokenHash := HashToken(rawToken)
|
||||
|
||||
evt := EmailVerifyToken{
|
||||
ID: uuid.New(),
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: time.Now().Add(emailVerifyTTL),
|
||||
}
|
||||
if err := h.authRepo.CreateEmailVerifyToken(ctx, evt); err != nil {
|
||||
slog.Error("failed to create email verify token", "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
verifyURL := fmt.Sprintf("%s/auth/email-bestaetigen/%s", h.frontendURL, rawToken)
|
||||
|
||||
if h.email != nil {
|
||||
go func() {
|
||||
msg := email.Message{
|
||||
To: toEmail,
|
||||
Subject: "E-Mail-Adresse bestätigen – Marktvogt",
|
||||
Body: fmt.Sprintf("Bestätige deine E-Mail-Adresse:\n\n%s\n\nDer Link ist 48 Stunden gültig.", verifyURL),
|
||||
}
|
||||
|
||||
html, err := email.Render("email_verify", email.TemplateData{
|
||||
PreheaderText: "E-Mail-Adresse bestätigen",
|
||||
BaseURL: h.frontendURL,
|
||||
Year: time.Now().Year(),
|
||||
Content: email.EmailVerifyData{
|
||||
VerifyURL: verifyURL,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to render email verify template", "error", err)
|
||||
} else {
|
||||
msg.HTML = html
|
||||
}
|
||||
|
||||
if err := h.email.Send(context.Background(), msg); err != nil {
|
||||
slog.Error("failed to send email verify message", "email", toEmail, "error", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
slog.Info("email verify token created (no email sender configured)", "email", toEmail, "url", verifyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterEmailVerifyRoutes(rg *gin.RouterGroup, h *EmailVerifyHandler, requireAuth gin.HandlerFunc, limit gin.HandlerFunc) {
|
||||
rg.POST("/auth/email-verify/confirm", h.ConfirmEmailVerify)
|
||||
auth := rg.Group("", requireAuth)
|
||||
{
|
||||
auth.POST("/auth/email-verify/resend", limit, h.ResendEmailVerify)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package auth_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/auth"
|
||||
"marktvogt.de/backend/internal/domain/user"
|
||||
)
|
||||
|
||||
// --- fakeRepo extension: email verify tokens ---
|
||||
// fakeRepo struct is defined in service_refresh_test.go (same package).
|
||||
|
||||
func (r *fakeRepo) CreateEmailVerifyToken(_ context.Context, token auth.EmailVerifyToken) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
clone := token
|
||||
r.emailVerifyTokens[token.TokenHash] = &clone
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ConsumeEmailVerifyToken(_ context.Context, tokenHash string) (auth.EmailVerifyToken, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
t, ok := r.emailVerifyTokens[tokenHash]
|
||||
if !ok {
|
||||
return auth.EmailVerifyToken{}, auth.ErrEmailVerifyNotFound
|
||||
}
|
||||
if t.Used {
|
||||
return auth.EmailVerifyToken{}, auth.ErrEmailVerifyUsed
|
||||
}
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
return auth.EmailVerifyToken{}, auth.ErrEmailVerifyExpired
|
||||
}
|
||||
t.Used = true
|
||||
return *t, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) MarkEmailVerified(_ context.Context, userID uuid.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.emailVerifiedUsers[userID] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) PutOAuthExchangeCode(_ context.Context, _ string, _ auth.AuthData, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ConsumeOAuthExchangeCode(_ context.Context, _ string) (auth.AuthData, error) {
|
||||
return auth.AuthData{}, auth.ErrOAuthStateUnknown
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func newEmailVerifyRouter(h *auth.EmailVerifyHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
rg := r.Group("/api/v1")
|
||||
auth.RegisterEmailVerifyRoutes(rg, h, func(c *gin.Context) {
|
||||
// minimal auth middleware stub: inject user_id from query for tests
|
||||
if uid := c.Query("user_id"); uid != "" {
|
||||
id, _ := uuid.Parse(uid)
|
||||
c.Set("user_id", id)
|
||||
}
|
||||
c.Next()
|
||||
}, func(c *gin.Context) { c.Next() }) // no-op rate limiter
|
||||
return r
|
||||
}
|
||||
|
||||
func postConfirm(t *testing.T, router *gin.Engine, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/email-verify/confirm", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// --- Iron Law PoC tests (written before the handler that makes them pass) ---
|
||||
|
||||
// TestVerifyEmail_ExpiredToken verifies that confirming an expired token returns 400.
|
||||
func TestVerifyEmail_ExpiredToken(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
testUser := user.User{ID: uuid.New(), Email: "u@test.com"}
|
||||
|
||||
rawToken := "expired-token-raw"
|
||||
hash := auth.HashToken(rawToken)
|
||||
_ = repo.CreateEmailVerifyToken(context.Background(), auth.EmailVerifyToken{
|
||||
ID: uuid.New(),
|
||||
UserID: testUser.ID,
|
||||
TokenHash: hash,
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour), // already expired
|
||||
})
|
||||
|
||||
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(testUser), nil, "http://localhost")
|
||||
r := newEmailVerifyRouter(h)
|
||||
|
||||
w := postConfirm(t, r, map[string]string{"token": rawToken})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for expired token, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyEmail_AlreadyUsedToken verifies that reusing a consumed token returns 400.
|
||||
func TestVerifyEmail_AlreadyUsedToken(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
testUser := user.User{ID: uuid.New(), Email: "u@test.com"}
|
||||
|
||||
rawToken := "used-token-raw"
|
||||
hash := auth.HashToken(rawToken)
|
||||
tok := auth.EmailVerifyToken{
|
||||
ID: uuid.New(),
|
||||
UserID: testUser.ID,
|
||||
TokenHash: hash,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
_ = repo.CreateEmailVerifyToken(context.Background(), tok)
|
||||
// Consume it once to mark it used.
|
||||
_, _ = repo.ConsumeEmailVerifyToken(context.Background(), hash)
|
||||
|
||||
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(testUser), nil, "http://localhost")
|
||||
r := newEmailVerifyRouter(h)
|
||||
|
||||
w := postConfirm(t, r, map[string]string{"token": rawToken})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for already-used token, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyEmail_UnknownToken verifies that an unrecognised token returns 400.
|
||||
func TestVerifyEmail_UnknownToken(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(), nil, "http://localhost")
|
||||
r := newEmailVerifyRouter(h)
|
||||
|
||||
w := postConfirm(t, r, map[string]string{"token": "never-issued"})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for unknown token, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyEmail_HappyPath verifies that a valid token marks the user verified.
|
||||
func TestVerifyEmail_HappyPath(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
testUser := user.User{ID: uuid.New(), Email: "u@test.com"}
|
||||
|
||||
rawToken := "valid-token"
|
||||
hash := auth.HashToken(rawToken)
|
||||
_ = repo.CreateEmailVerifyToken(context.Background(), auth.EmailVerifyToken{
|
||||
ID: uuid.New(),
|
||||
UserID: testUser.ID,
|
||||
TokenHash: hash,
|
||||
ExpiresAt: time.Now().Add(48 * time.Hour),
|
||||
})
|
||||
|
||||
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(testUser), nil, "http://localhost")
|
||||
r := newEmailVerifyRouter(h)
|
||||
|
||||
w := postConfirm(t, r, map[string]string{"token": rawToken})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !repo.emailVerifiedUsers[testUser.ID] {
|
||||
t.Fatal("expected MarkEmailVerified to be called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyEmail_Resend_RequiresAuth verifies that resend without auth returns 401.
|
||||
func TestVerifyEmail_Resend_RequiresAuth(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(), nil, "http://localhost")
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
rg := engine.Group("/api/v1")
|
||||
// Use an auth middleware that rejects all requests (no user_id in context).
|
||||
rejectAll := func(c *gin.Context) {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
auth.RegisterEmailVerifyRoutes(rg, h, rejectAll, func(c *gin.Context) { c.Next() })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/email-verify/resend", nil)
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for unauthenticated resend, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyEmail_TokenHashing_DoesNotStoreRawToken verifies that the stored token
|
||||
// is a hash, not the raw value. Stored hash != raw token.
|
||||
func TestVerifyEmail_TokenHashing_DoesNotStoreRawToken(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
testUser := user.User{ID: uuid.New(), Email: "u@test.com"}
|
||||
|
||||
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(testUser), nil, "http://localhost")
|
||||
|
||||
// Trigger token creation via resend endpoint with a stub auth middleware.
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
rg := engine.Group("/api/v1")
|
||||
auth.RegisterEmailVerifyRoutes(rg, h, func(c *gin.Context) {
|
||||
c.Set("user_id", testUser.ID)
|
||||
c.Next()
|
||||
}, func(c *gin.Context) { c.Next() })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/email-verify/resend", nil)
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resend failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Inspect stored tokens: none should have the value equal to its own hash key.
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
for storedHash, tok := range repo.emailVerifyTokens {
|
||||
// The key is the hash; if it equaled the raw token that would mean no hashing occurred.
|
||||
// We just verify the hash key doesn't appear verbatim in the row's TokenHash field
|
||||
// (which IS the hash), and that the row exists at all.
|
||||
if storedHash != tok.TokenHash {
|
||||
t.Errorf("map key %q does not match token.TokenHash %q", storedHash, tok.TokenHash)
|
||||
}
|
||||
// A SHA-256 hex string is 64 chars; a UUID is 36. If we stored the raw UUID as
|
||||
// the token hash it would be shorter.
|
||||
if len(storedHash) < 32 {
|
||||
t.Errorf("token hash looks too short to be a real hash: %q", storedHash)
|
||||
}
|
||||
}
|
||||
if len(repo.emailVerifyTokens) == 0 {
|
||||
t.Fatal("expected at least one email verify token to be stored")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure errors package is used (for ErrEmailVerifyExpired assertion in the test).
|
||||
var _ = errors.Is
|
||||
@@ -160,7 +160,7 @@ func (h *MagicLinkHandler) findOrCreateUser(ctx context.Context, email string) (
|
||||
}
|
||||
|
||||
// Create new user without password
|
||||
return h.userRepo.CreateOAuthUser(ctx, email, user.GenerateDisplayName(), true)
|
||||
return h.userRepo.CreateOAuthUser(ctx, email, user.GenerateDisplayName(), "", true)
|
||||
}
|
||||
|
||||
func RegisterMagicLinkRoutes(rg *gin.RouterGroup, h *MagicLinkHandler, requestLimit gin.HandlerFunc) {
|
||||
|
||||
@@ -7,13 +7,14 @@ import (
|
||||
)
|
||||
|
||||
// Session represents an opaque-token session. AccessTokenHash and RefreshTokenHash
|
||||
// are SHA-256 hashes of the raw bearer tokens. UserEmail and UserRole are cached
|
||||
// from the user record at creation time; role changes take effect within accessTTL (≤30m).
|
||||
// are SHA-256 hashes of the raw bearer tokens. UserEmail, UserRole, and EmailVerified
|
||||
// are cached from the user record at creation time; changes take effect within accessTTL (≤30m).
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserRole string `json:"user_role"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
AccessTokenHash string `json:"-"`
|
||||
RefreshTokenHash string `json:"-"`
|
||||
FamilyID uuid.UUID `json:"family_id"`
|
||||
@@ -66,3 +67,25 @@ type BackupCode struct {
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PasswordResetToken is a single-use time-limited token for password recovery.
|
||||
// TokenHash is SHA-256 of the raw opaque token sent in the reset link.
|
||||
type PasswordResetToken struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
TokenHash string `json:"-"`
|
||||
Used bool `json:"used"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// EmailVerifyToken is a single-use time-limited token for email address verification.
|
||||
// TokenHash is SHA-256 of the raw opaque token sent in the verify link.
|
||||
type EmailVerifyToken struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
TokenHash string `json:"-"`
|
||||
Used bool `json:"used"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ import (
|
||||
// IdP's callback. 15 min is generous for slow consent + 2FA at the IdP.
|
||||
const oauthStateTTL = 15 * time.Minute
|
||||
|
||||
// oauthExchangeTTL is how long the one-time exchange code lives in Valkey.
|
||||
// The SvelteKit server should redeem it immediately after the redirect lands.
|
||||
const oauthExchangeTTL = 2 * time.Minute
|
||||
|
||||
var googleEndpoint = oauth2.Endpoint{
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
@@ -34,13 +38,14 @@ var facebookEndpoint = oauth2.Endpoint{
|
||||
}
|
||||
|
||||
type OAuthHandler struct {
|
||||
providers map[string]*oauth2.Config
|
||||
service *Service
|
||||
userRepo user.Repository
|
||||
authRepo Repository
|
||||
providers map[string]*oauth2.Config
|
||||
frontendURL string
|
||||
service *Service
|
||||
userRepo user.Repository
|
||||
authRepo Repository
|
||||
}
|
||||
|
||||
func NewOAuthHandler(cfg config.OAuthConfig, service *Service, userRepo user.Repository, authRepo Repository) *OAuthHandler {
|
||||
func NewOAuthHandler(cfg config.OAuthConfig, frontendURL string, service *Service, userRepo user.Repository, authRepo Repository) *OAuthHandler {
|
||||
baseURL := cfg.RedirectBaseURL
|
||||
providers := make(map[string]*oauth2.Config)
|
||||
|
||||
@@ -75,10 +80,11 @@ func NewOAuthHandler(cfg config.OAuthConfig, service *Service, userRepo user.Rep
|
||||
}
|
||||
|
||||
return &OAuthHandler{
|
||||
providers: providers,
|
||||
service: service,
|
||||
userRepo: userRepo,
|
||||
authRepo: authRepo,
|
||||
providers: providers,
|
||||
frontendURL: frontendURL,
|
||||
service: service,
|
||||
userRepo: userRepo,
|
||||
authRepo: authRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +108,7 @@ func (h *OAuthHandler) StartOAuth(c *gin.Context) {
|
||||
}
|
||||
|
||||
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"url": url, "state": state}})
|
||||
c.Redirect(http.StatusFound, url)
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) Callback(c *gin.Context) {
|
||||
@@ -163,6 +169,13 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if u.AvatarURL == "" && info.AvatarURL != "" {
|
||||
updated, updateErr := h.userRepo.Update(ctx, u.ID, map[string]any{"avatar_url": info.AvatarURL})
|
||||
if updateErr == nil {
|
||||
u = updated
|
||||
}
|
||||
}
|
||||
|
||||
data, err := h.service.createTokenPair(ctx, u, uuid.Nil, nil, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to create session")
|
||||
@@ -170,7 +183,7 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, AuthResponse{Data: data})
|
||||
h.redirectWithExchangeCode(c, data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,7 +202,7 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
// Brand-new account. Pass the IdP's verified-email claim through so the
|
||||
// user record reflects whether we trust the email.
|
||||
u, err = h.userRepo.CreateOAuthUser(ctx, info.Email, displayName, info.EmailVerified)
|
||||
u, err = h.userRepo.CreateOAuthUser(ctx, info.Email, displayName, info.AvatarURL, info.EmailVerified)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to create user")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
@@ -234,13 +247,47 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, AuthResponse{Data: data})
|
||||
h.redirectWithExchangeCode(c, data)
|
||||
}
|
||||
|
||||
// redirectWithExchangeCode stores the token pair as a single-use exchange code
|
||||
// in Valkey and redirects the browser to the SvelteKit /auth/oauth/complete page.
|
||||
// This avoids cross-origin cookie issues between the backend (localhost:8080) and
|
||||
// the frontend (localhost:5173 in dev; same domain in prod via nginx).
|
||||
func (h *OAuthHandler) redirectWithExchangeCode(c *gin.Context, data AuthData) {
|
||||
code := GenerateOpaqueToken()
|
||||
if err := h.authRepo.PutOAuthExchangeCode(c.Request.Context(), code, data, oauthExchangeTTL); err != nil {
|
||||
apiErr := apierror.Internal("failed to create exchange code")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, h.frontendURL+"/auth/oauth/complete?code="+code)
|
||||
}
|
||||
|
||||
// ExchangeOAuthCode redeems a one-time exchange code for an auth token pair.
|
||||
// Called by the SvelteKit /auth/oauth/complete server load immediately after
|
||||
// the browser lands on that page.
|
||||
func (h *OAuthHandler) ExchangeOAuthCode(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
apiErr := apierror.BadRequest("missing_code", "code parameter is required")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
data, err := h.authRepo.ConsumeOAuthExchangeCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_code", "exchange code is invalid or expired")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, AuthResponse{Data: data})
|
||||
}
|
||||
|
||||
type oauthUserInfo struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
EmailVerified bool
|
||||
}
|
||||
|
||||
@@ -267,6 +314,7 @@ func fetchGoogleUser(ctx context.Context, token *oauth2.Token) (oauthUserInfo, e
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
VerifiedEmail bool `json:"verified_email"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &data); err != nil {
|
||||
@@ -277,6 +325,7 @@ func fetchGoogleUser(ctx context.Context, token *oauth2.Token) (oauthUserInfo, e
|
||||
ID: data.ID,
|
||||
Email: data.Email,
|
||||
Name: data.Name,
|
||||
AvatarURL: data.Picture,
|
||||
EmailVerified: data.VerifiedEmail,
|
||||
}, nil
|
||||
}
|
||||
@@ -403,5 +452,6 @@ func RegisterOAuthRoutes(rg *gin.RouterGroup, h *OAuthHandler) {
|
||||
{
|
||||
oauth.GET("/:provider", h.StartOAuth)
|
||||
oauth.GET("/:provider/callback", h.Callback)
|
||||
oauth.GET("/exchange", h.ExchangeOAuthCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func newOAuthHandler(t *testing.T, repo *fakeRepo) *auth.OAuthHandler {
|
||||
ClientSecret: "google-secret",
|
||||
},
|
||||
}
|
||||
return auth.NewOAuthHandler(cfg, svc, users, repo)
|
||||
return auth.NewOAuthHandler(cfg, "http://localhost:5173", svc, users, repo)
|
||||
}
|
||||
|
||||
// PoC for audit C1: Callback rejects requests without a state parameter.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/user"
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
"marktvogt.de/backend/internal/pkg/email"
|
||||
"marktvogt.de/backend/internal/pkg/password"
|
||||
"marktvogt.de/backend/internal/pkg/validate"
|
||||
)
|
||||
|
||||
const passwordResetTTL = time.Hour
|
||||
|
||||
type PasswordResetHandler struct {
|
||||
authRepo Repository
|
||||
userRepo user.Repository
|
||||
email email.Sender
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func NewPasswordResetHandler(authRepo Repository, userRepo user.Repository, emailSender email.Sender, frontendURL string) *PasswordResetHandler {
|
||||
return &PasswordResetHandler{
|
||||
authRepo: authRepo,
|
||||
userRepo: userRepo,
|
||||
email: emailSender,
|
||||
frontendURL: frontendURL,
|
||||
}
|
||||
}
|
||||
|
||||
// RequestPasswordReset generates a reset token and emails it. Always returns 200
|
||||
// so the response reveals no information about whether the email is registered.
|
||||
func (h *PasswordResetHandler) RequestPasswordReset(c *gin.Context) {
|
||||
var req PasswordResetRequestDTO
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
u, err := h.userRepo.GetByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
// Silently succeed — don't reveal whether the email exists.
|
||||
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{
|
||||
Message: "if an account exists with that email, a reset link has been sent",
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
token := uuid.New().String()
|
||||
tokenHash := HashToken(token)
|
||||
|
||||
prt := PasswordResetToken{
|
||||
ID: uuid.New(),
|
||||
UserID: u.ID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: time.Now().Add(passwordResetTTL),
|
||||
}
|
||||
if err := h.authRepo.CreatePasswordResetToken(ctx, prt); err != nil {
|
||||
apiErr := apierror.Internal("failed to create reset token")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
resetURL := fmt.Sprintf("%s/auth/passwort-zuruecksetzen/%s", h.frontendURL, token)
|
||||
|
||||
if h.email != nil {
|
||||
go func() {
|
||||
msg := email.Message{
|
||||
To: req.Email,
|
||||
Subject: "Passwort zurücksetzen – Marktvogt",
|
||||
Body: fmt.Sprintf("Klicke auf den folgenden Link, um dein Passwort zurückzusetzen:\n\n%s\n\nDer Link ist 1 Stunde gültig.", resetURL),
|
||||
}
|
||||
|
||||
html, err := email.Render("password_reset", email.TemplateData{
|
||||
PreheaderText: "Passwort zurücksetzen",
|
||||
BaseURL: h.frontendURL,
|
||||
Year: time.Now().Year(),
|
||||
Content: email.PasswordResetData{
|
||||
ResetURL: resetURL,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to render password reset email template", "error", err)
|
||||
} else {
|
||||
msg.HTML = html
|
||||
}
|
||||
|
||||
if err := h.email.Send(context.Background(), msg); err != nil {
|
||||
slog.Error("failed to send password reset email", "email", req.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
slog.Info("password reset token created (no email sender configured)", "email", req.Email, "url", resetURL)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{
|
||||
Message: "if an account exists with that email, a reset link has been sent",
|
||||
}})
|
||||
}
|
||||
|
||||
// ConfirmPasswordReset validates the token and updates the user's password.
|
||||
func (h *PasswordResetHandler) ConfirmPasswordReset(c *gin.Context) {
|
||||
var req PasswordResetConfirmRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
tokenHash := HashToken(req.Token)
|
||||
|
||||
prt, err := h.authRepo.ConsumePasswordResetToken(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPasswordResetNotFound) || errors.Is(err, ErrPasswordResetExpired) || errors.Is(err, ErrPasswordResetUsed) {
|
||||
apiErr := apierror.BadRequest("invalid_token", "reset link is invalid, expired, or already used")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to verify reset token")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := password.Hash(req.Password)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to process new password")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.userRepo.Update(ctx, prt.UserID, map[string]any{"password_hash": hash, "email_verified": true}); err != nil {
|
||||
apiErr := apierror.Internal("failed to update password")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke all existing sessions so stolen sessions are invalidated after a reset.
|
||||
if err := h.authRepo.DeleteUserSessions(ctx, prt.UserID); err != nil {
|
||||
slog.Warn("failed to revoke sessions after password reset", "user_id", prt.UserID, "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "password updated"}})
|
||||
}
|
||||
|
||||
func RegisterPasswordResetRoutes(rg *gin.RouterGroup, h *PasswordResetHandler, limit gin.HandlerFunc) {
|
||||
rg.POST("/auth/password-reset/request", limit, h.RequestPasswordReset)
|
||||
rg.POST("/auth/password-reset/confirm", h.ConfirmPasswordReset)
|
||||
}
|
||||
@@ -27,11 +27,17 @@ type EncryptionKeys struct {
|
||||
}
|
||||
|
||||
var (
|
||||
ErrSessionNotFound = fmt.Errorf("session not found")
|
||||
ErrSessionExpired = fmt.Errorf("session expired")
|
||||
ErrMagicLinkNotFound = fmt.Errorf("magic link not found")
|
||||
ErrMagicLinkExpired = fmt.Errorf("magic link expired")
|
||||
ErrMagicLinkUsed = fmt.Errorf("magic link already used")
|
||||
ErrSessionNotFound = fmt.Errorf("session not found")
|
||||
ErrSessionExpired = fmt.Errorf("session expired")
|
||||
ErrMagicLinkNotFound = fmt.Errorf("magic link not found")
|
||||
ErrMagicLinkExpired = fmt.Errorf("magic link expired")
|
||||
ErrMagicLinkUsed = fmt.Errorf("magic link already used")
|
||||
ErrPasswordResetNotFound = fmt.Errorf("password reset token not found")
|
||||
ErrPasswordResetExpired = fmt.Errorf("password reset token expired")
|
||||
ErrPasswordResetUsed = fmt.Errorf("password reset token already used")
|
||||
ErrEmailVerifyNotFound = fmt.Errorf("email verify token not found")
|
||||
ErrEmailVerifyExpired = fmt.Errorf("email verify token expired")
|
||||
ErrEmailVerifyUsed = fmt.Errorf("email verify token already used")
|
||||
|
||||
// ErrOAuthStateUnknown is returned when the callback presents a state value that
|
||||
// was never issued (CSRF attempt) or has already been consumed (replay).
|
||||
@@ -79,6 +85,12 @@ type Repository interface {
|
||||
PutOAuthState(ctx context.Context, state, provider string, ttl time.Duration) error
|
||||
ConsumeOAuthState(ctx context.Context, state string) (string, error)
|
||||
|
||||
// OAuth exchange codes — single-use short-lived codes that let the SvelteKit
|
||||
// server exchange a just-completed OAuth callback for the issued token pair.
|
||||
// Stored as JSON in valkey; ConsumeOAuthExchangeCode is GETDEL (single-use).
|
||||
PutOAuthExchangeCode(ctx context.Context, code string, data AuthData, ttl time.Duration) error
|
||||
ConsumeOAuthExchangeCode(ctx context.Context, code string) (AuthData, error)
|
||||
|
||||
// TOTP code replay guard — rejects a (user_id, code) pair that has already been
|
||||
// used inside the validity window. TTL covers period * (skew + 1) seconds with a
|
||||
// safety margin. Returns ErrTOTPCodeReplayed when the same code is submitted twice.
|
||||
@@ -100,6 +112,25 @@ type Repository interface {
|
||||
ConsumeBackupCode(ctx context.Context, userID uuid.UUID, codeHash string) error
|
||||
DeleteUserBackupCodes(ctx context.Context, userID uuid.UUID) error
|
||||
|
||||
// Password reset tokens — single-use time-limited tokens for account recovery.
|
||||
// CreatePasswordResetToken persists the hashed token. ConsumePasswordResetToken
|
||||
// atomically marks it used and returns it; returns ErrPasswordResetNotFound,
|
||||
// ErrPasswordResetUsed, or ErrPasswordResetExpired on failure paths.
|
||||
CreatePasswordResetToken(ctx context.Context, token PasswordResetToken) error
|
||||
ConsumePasswordResetToken(ctx context.Context, tokenHash string) (PasswordResetToken, error)
|
||||
|
||||
// Email verify tokens — single-use time-limited tokens for email address verification.
|
||||
// CreateEmailVerifyToken persists the hashed token. ConsumeEmailVerifyToken
|
||||
// atomically marks it used and returns it; returns ErrEmailVerifyNotFound,
|
||||
// ErrEmailVerifyUsed, or ErrEmailVerifyExpired on failure paths.
|
||||
// MarkEmailVerified sets users.email_verified = TRUE for the given user.
|
||||
// InvalidateUserSessionCaches removes Valkey cache entries for all active sessions
|
||||
// of the user so that the updated EmailVerified flag is reflected immediately.
|
||||
CreateEmailVerifyToken(ctx context.Context, token EmailVerifyToken) error
|
||||
ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (EmailVerifyToken, error)
|
||||
MarkEmailVerified(ctx context.Context, userID uuid.UUID) error
|
||||
InvalidateUserSessionCaches(ctx context.Context, userID uuid.UUID) error
|
||||
|
||||
// Session listing and targeted revocation
|
||||
ListUserSessions(ctx context.Context, userID uuid.UUID) ([]Session, error)
|
||||
GetSessionByID(ctx context.Context, id uuid.UUID) (Session, error)
|
||||
@@ -197,10 +228,10 @@ func (r *pgRepository) GetSessionByAccessHash(ctx context.Context, hash string)
|
||||
}
|
||||
}
|
||||
|
||||
// Postgres fallback — join users to get email and role without a second query.
|
||||
// Postgres fallback — join users to get email, role, and email_verified without a second query.
|
||||
var s Session
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT s.id, s.user_id, u.email, u.role,
|
||||
SELECT s.id, s.user_id, u.email, u.role, u.email_verified,
|
||||
s.access_token_hash, s.refresh_token_hash, s.family_id, s.parent_session_id,
|
||||
s.ip_address::text, s.user_agent,
|
||||
s.access_expires_at, s.absolute_expires_at, s.last_used_at, s.revoked_at, s.created_at
|
||||
@@ -210,7 +241,7 @@ func (r *pgRepository) GetSessionByAccessHash(ctx context.Context, hash string)
|
||||
AND s.revoked_at IS NULL
|
||||
AND s.access_expires_at > NOW()
|
||||
`, hash).Scan(
|
||||
&s.ID, &s.UserID, &s.UserEmail, &s.UserRole,
|
||||
&s.ID, &s.UserID, &s.UserEmail, &s.UserRole, &s.EmailVerified,
|
||||
&s.AccessTokenHash, &s.RefreshTokenHash, &s.FamilyID, &s.ParentSessionID,
|
||||
&s.IPAddress, &s.UserAgent,
|
||||
&s.AccessExpiresAt, &s.AbsoluteExpiresAt, &s.LastUsedAt, &s.RevokedAt, &s.CreatedAt,
|
||||
@@ -324,6 +355,8 @@ func (r *pgRepository) CreateMagicLink(ctx context.Context, link MagicLink) erro
|
||||
// calls with the same token race against the WHERE clause (used = FALSE AND
|
||||
// expires_at > NOW()) — exactly one returns the row; the other gets pgx.ErrNoRows
|
||||
// which we then disambiguate against the row-existence check.
|
||||
//
|
||||
//nolint:dupl // same atomic-consume pattern as ConsumePasswordResetToken; different table, types, and errors
|
||||
func (r *pgRepository) ConsumeMagicLink(ctx context.Context, tokenHash string) (MagicLink, error) {
|
||||
var ml MagicLink
|
||||
err := r.db.QueryRow(ctx, `
|
||||
@@ -631,6 +664,38 @@ func oauthStateValkeyKey(state string) string {
|
||||
return "mv:v2:auth:oauth:state:" + state
|
||||
}
|
||||
|
||||
func oauthExchangeValkeyKey(code string) string {
|
||||
return "mv:v2:auth:oauth:exchange:" + code
|
||||
}
|
||||
|
||||
func (r *pgRepository) PutOAuthExchangeCode(ctx context.Context, code string, data AuthData, ttl time.Duration) error {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("put oauth exchange code: marshal: %w", err)
|
||||
}
|
||||
key := oauthExchangeValkeyKey(code)
|
||||
if err := r.vk.Do(ctx, r.vk.B().Set().Key(key).Value(string(b)).Nx().Ex(ttl).Build()).Error(); err != nil {
|
||||
return fmt.Errorf("put oauth exchange code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ConsumeOAuthExchangeCode(ctx context.Context, code string) (AuthData, error) {
|
||||
if code == "" {
|
||||
return AuthData{}, ErrOAuthStateUnknown
|
||||
}
|
||||
key := oauthExchangeValkeyKey(code)
|
||||
raw, err := r.vk.Do(ctx, r.vk.B().Getdel().Key(key).Build()).ToString()
|
||||
if err != nil || raw == "" {
|
||||
return AuthData{}, ErrOAuthStateUnknown
|
||||
}
|
||||
var data AuthData
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
return AuthData{}, fmt.Errorf("consume oauth exchange code: unmarshal: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func totpReplayValkeyKey(userID uuid.UUID, codeHash string) string {
|
||||
return "mv:v2:auth:totp:used:" + userID.String() + ":" + codeHash
|
||||
}
|
||||
@@ -714,6 +779,126 @@ func (r *pgRepository) revokeBulk(ctx context.Context, sql string, args ...any)
|
||||
return hashes, rows.Err()
|
||||
}
|
||||
|
||||
// Password reset token methods
|
||||
|
||||
func (r *pgRepository) CreatePasswordResetToken(ctx context.Context, token PasswordResetToken) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, token.ID, token.UserID, token.TokenHash, token.ExpiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:dupl // same atomic-consume pattern as ConsumeMagicLink; different table, types, and errors
|
||||
func (r *pgRepository) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (PasswordResetToken, error) {
|
||||
var t PasswordResetToken
|
||||
err := r.db.QueryRow(ctx, `
|
||||
UPDATE password_reset_tokens SET used = TRUE
|
||||
WHERE token_hash = $1 AND used = FALSE AND expires_at > NOW()
|
||||
RETURNING id, user_id, token_hash, used, expires_at, created_at
|
||||
`, tokenHash).Scan(&t.ID, &t.UserID, &t.TokenHash, &t.Used, &t.ExpiresAt, &t.CreatedAt)
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return PasswordResetToken{}, fmt.Errorf("consuming password reset token: %w", err)
|
||||
}
|
||||
|
||||
var used bool
|
||||
var expires time.Time
|
||||
lookupErr := r.db.QueryRow(ctx,
|
||||
`SELECT used, expires_at FROM password_reset_tokens WHERE token_hash = $1`,
|
||||
tokenHash,
|
||||
).Scan(&used, &expires)
|
||||
if errors.Is(lookupErr, pgx.ErrNoRows) {
|
||||
return PasswordResetToken{}, ErrPasswordResetNotFound
|
||||
}
|
||||
if lookupErr != nil {
|
||||
return PasswordResetToken{}, fmt.Errorf("password reset token lookup: %w", lookupErr)
|
||||
}
|
||||
if used {
|
||||
return PasswordResetToken{}, ErrPasswordResetUsed
|
||||
}
|
||||
return PasswordResetToken{}, ErrPasswordResetExpired
|
||||
}
|
||||
|
||||
// Email verify token methods
|
||||
|
||||
func (r *pgRepository) CreateEmailVerifyToken(ctx context.Context, token EmailVerifyToken) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO email_verify_tokens (id, user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, token.ID, token.UserID, token.TokenHash, token.ExpiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:dupl // same atomic-consume pattern as ConsumeMagicLink/ConsumePasswordResetToken; different table, types, errors
|
||||
func (r *pgRepository) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (EmailVerifyToken, error) {
|
||||
var t EmailVerifyToken
|
||||
err := r.db.QueryRow(ctx, `
|
||||
UPDATE email_verify_tokens SET used = TRUE
|
||||
WHERE token_hash = $1 AND used = FALSE AND expires_at > NOW()
|
||||
RETURNING id, user_id, token_hash, used, expires_at, created_at
|
||||
`, tokenHash).Scan(&t.ID, &t.UserID, &t.TokenHash, &t.Used, &t.ExpiresAt, &t.CreatedAt)
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return EmailVerifyToken{}, fmt.Errorf("consuming email verify token: %w", err)
|
||||
}
|
||||
|
||||
var used bool
|
||||
var expires time.Time
|
||||
lookupErr := r.db.QueryRow(ctx,
|
||||
`SELECT used, expires_at FROM email_verify_tokens WHERE token_hash = $1`,
|
||||
tokenHash,
|
||||
).Scan(&used, &expires)
|
||||
if errors.Is(lookupErr, pgx.ErrNoRows) {
|
||||
return EmailVerifyToken{}, ErrEmailVerifyNotFound
|
||||
}
|
||||
if lookupErr != nil {
|
||||
return EmailVerifyToken{}, fmt.Errorf("email verify token lookup: %w", lookupErr)
|
||||
}
|
||||
if used {
|
||||
return EmailVerifyToken{}, ErrEmailVerifyUsed
|
||||
}
|
||||
return EmailVerifyToken{}, ErrEmailVerifyExpired
|
||||
}
|
||||
|
||||
func (r *pgRepository) MarkEmailVerified(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := r.db.Exec(ctx,
|
||||
`UPDATE users SET email_verified = TRUE WHERE id = $1`,
|
||||
userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// InvalidateUserSessionCaches removes the Valkey cache entries for all active sessions
|
||||
// belonging to userID. Called after MarkEmailVerified so the updated EmailVerified
|
||||
// flag is visible on the next request rather than after the AccessTTL expires.
|
||||
func (r *pgRepository) InvalidateUserSessionCaches(ctx context.Context, userID uuid.UUID) error {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT access_token_hash FROM sessions
|
||||
WHERE user_id = $1 AND revoked_at IS NULL AND access_expires_at > NOW()
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying active session hashes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var hashes []string
|
||||
for rows.Next() {
|
||||
var h string
|
||||
if scanErr := rows.Scan(&h); scanErr != nil {
|
||||
return fmt.Errorf("scanning session hash: %w", scanErr)
|
||||
}
|
||||
hashes = append(hashes, h)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterating session hashes: %w", err)
|
||||
}
|
||||
r.invalidateCachedSessions(ctx, hashes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateCachedSessions removes Valkey cache entries for the given access
|
||||
// token hashes. The cache stores the JSON-serialized Session at the time of
|
||||
// CreateSession and is not auto-refreshed when the row is updated, so without
|
||||
|
||||
@@ -21,22 +21,38 @@ type ServiceConfig struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
authRepo Repository
|
||||
userRepo user.Repository
|
||||
cfg ServiceConfig
|
||||
authRepo Repository
|
||||
userRepo user.Repository
|
||||
cfg ServiceConfig
|
||||
sendVerifyFn func(ctx context.Context, userID uuid.UUID, email string)
|
||||
}
|
||||
|
||||
func NewService(authRepo Repository, userRepo user.Repository, cfg ServiceConfig) *Service {
|
||||
return &Service{authRepo: authRepo, userRepo: userRepo, cfg: cfg}
|
||||
}
|
||||
|
||||
// SetEmailVerifySender wires the email verification send function so Register()
|
||||
// can dispatch a verify email without a circular dependency on EmailVerifyHandler.
|
||||
func (s *Service) SetEmailVerifySender(fn func(ctx context.Context, userID uuid.UUID, email string)) {
|
||||
s.sendVerifyFn = fn
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, req RegisterRequest, ip, ua string) (AuthData, error) {
|
||||
hash, err := password.Hash(req.Password)
|
||||
if err != nil {
|
||||
return AuthData{}, fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
|
||||
u, err := s.userRepo.Create(ctx, req.Email, hash, req.DisplayName)
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = user.RoleUser
|
||||
}
|
||||
status := user.StatusActive
|
||||
if role != user.RoleUser {
|
||||
status = user.StatusPending
|
||||
}
|
||||
|
||||
u, err := s.userRepo.Create(ctx, req.Email, hash, req.DisplayName, role, status)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrEmailAlreadyTaken) {
|
||||
return AuthData{}, err
|
||||
@@ -44,6 +60,10 @@ func (s *Service) Register(ctx context.Context, req RegisterRequest, ip, ua stri
|
||||
return AuthData{}, fmt.Errorf("creating user: %w", err)
|
||||
}
|
||||
|
||||
if s.sendVerifyFn != nil {
|
||||
go s.sendVerifyFn(ctx, u.ID, u.Email)
|
||||
}
|
||||
|
||||
return s.createTokenPair(ctx, u, uuid.Nil, nil, ip, ua)
|
||||
}
|
||||
|
||||
@@ -65,6 +85,14 @@ func (s *Service) Login(ctx context.Context, req LoginRequest, ip, ua string) (A
|
||||
return AuthData{}, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
if u.Status == user.StatusPending {
|
||||
return AuthData{}, fmt.Errorf("account pending review")
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -147,6 +175,7 @@ func (s *Service) createTokenPair(ctx context.Context, u user.User, familyID uui
|
||||
UserID: u.ID,
|
||||
UserEmail: u.Email,
|
||||
UserRole: u.Role,
|
||||
EmailVerified: u.EmailVerified,
|
||||
AccessTokenHash: HashToken(accessToken),
|
||||
RefreshTokenHash: HashToken(refreshToken),
|
||||
FamilyID: familyID,
|
||||
|
||||
@@ -20,10 +20,13 @@ type fakeRepo struct {
|
||||
sessions map[string]*auth.Session // keyed by access hash
|
||||
byRefresh map[string]*auth.Session // keyed by refresh hash
|
||||
|
||||
magicLinks map[string]*auth.MagicLink
|
||||
totpSecrets map[string]*auth.TOTPSecret
|
||||
oauthAccounts []auth.OAuthAccount
|
||||
backupCodes map[string]*auth.BackupCode // keyed by code hash
|
||||
magicLinks map[string]*auth.MagicLink
|
||||
totpSecrets map[string]*auth.TOTPSecret
|
||||
oauthAccounts []auth.OAuthAccount
|
||||
backupCodes map[string]*auth.BackupCode // keyed by code hash
|
||||
passwordResetTokens map[string]*auth.PasswordResetToken // keyed by token hash
|
||||
emailVerifyTokens map[string]*auth.EmailVerifyToken // keyed by token hash
|
||||
emailVerifiedUsers map[uuid.UUID]bool
|
||||
|
||||
oauthStates map[string]string // state -> provider
|
||||
consumedTOTP map[string]bool // userID:codeHash -> seen
|
||||
@@ -36,13 +39,16 @@ type fakeRepo struct {
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
sessions: make(map[string]*auth.Session),
|
||||
byRefresh: make(map[string]*auth.Session),
|
||||
magicLinks: make(map[string]*auth.MagicLink),
|
||||
totpSecrets: make(map[string]*auth.TOTPSecret),
|
||||
backupCodes: make(map[string]*auth.BackupCode),
|
||||
oauthStates: make(map[string]string),
|
||||
consumedTOTP: make(map[string]bool),
|
||||
sessions: make(map[string]*auth.Session),
|
||||
byRefresh: make(map[string]*auth.Session),
|
||||
magicLinks: make(map[string]*auth.MagicLink),
|
||||
totpSecrets: make(map[string]*auth.TOTPSecret),
|
||||
backupCodes: make(map[string]*auth.BackupCode),
|
||||
passwordResetTokens: make(map[string]*auth.PasswordResetToken),
|
||||
emailVerifyTokens: make(map[string]*auth.EmailVerifyToken),
|
||||
emailVerifiedUsers: make(map[uuid.UUID]bool),
|
||||
oauthStates: make(map[string]string),
|
||||
consumedTOTP: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +141,8 @@ func (r *fakeRepo) BumpLastUsedAt(_ context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) DeleteUserSessions(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (r *fakeRepo) DeleteUserSessions(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (r *fakeRepo) InvalidateUserSessionCaches(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
|
||||
// Magic link stubs — atomic ConsumeMagicLink mirrors the prod UPDATE...RETURNING
|
||||
// behaviour: exactly one caller wins on a Used=false row.
|
||||
@@ -235,12 +242,18 @@ func newFakeUserRepo(users ...user.User) *fakeUserRepo {
|
||||
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, PasswordHash: &hash}
|
||||
func (r *fakeUserRepo) Create(_ context.Context, email, hash, name, role, status string) (user.User, error) {
|
||||
if role == "" {
|
||||
role = user.RoleUser
|
||||
}
|
||||
if status == "" {
|
||||
status = user.StatusActive
|
||||
}
|
||||
u := user.User{ID: uuid.New(), Email: email, DisplayName: name, PasswordHash: &hash, Role: role, Status: status}
|
||||
r.users[u.ID] = u
|
||||
return u, nil
|
||||
}
|
||||
func (r *fakeUserRepo) CreateOAuthUser(_ context.Context, email, name string, _ bool) (user.User, error) {
|
||||
func (r *fakeUserRepo) CreateOAuthUser(_ context.Context, email, name, _ string, _ bool) (user.User, error) {
|
||||
u := user.User{ID: uuid.New(), Email: email, DisplayName: name}
|
||||
r.users[u.ID] = u
|
||||
return u, nil
|
||||
@@ -270,6 +283,20 @@ 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 (r *fakeUserRepo) GetDashboardStats(_ context.Context, _ uuid.UUID) (user.DashboardStats, error) {
|
||||
return user.DashboardStats{}, nil
|
||||
}
|
||||
|
||||
func makeService(authRepo auth.Repository, userRepo user.Repository) *auth.Service {
|
||||
return auth.NewService(authRepo, userRepo, auth.ServiceConfig{
|
||||
|
||||
@@ -83,6 +83,31 @@ func (r *fakeRepo) RevokeOtherSessions(_ context.Context, userID, exceptID uuid.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CreatePasswordResetToken(_ context.Context, token auth.PasswordResetToken) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
clone := token
|
||||
r.passwordResetTokens[token.TokenHash] = &clone
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ConsumePasswordResetToken(_ context.Context, tokenHash string) (auth.PasswordResetToken, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
t, ok := r.passwordResetTokens[tokenHash]
|
||||
if !ok {
|
||||
return auth.PasswordResetToken{}, auth.ErrPasswordResetNotFound
|
||||
}
|
||||
if t.Used {
|
||||
return auth.PasswordResetToken{}, auth.ErrPasswordResetUsed
|
||||
}
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
return auth.PasswordResetToken{}, auth.ErrPasswordResetExpired
|
||||
}
|
||||
t.Used = true
|
||||
return *t, nil
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestGenerateBackupCodes_ProducesCorrectFormatAndCount(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package favorite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/favorite"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// -- in-memory repository --
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
favorites map[uuid.UUID][]favorite.Favorite // keyed by userID
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
favorites: make(map[uuid.UUID][]favorite.Favorite),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Add(_ context.Context, userID, marketID uuid.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, f := range r.favorites[userID] {
|
||||
if f.MarketID == marketID {
|
||||
return favorite.ErrAlreadyFavorited
|
||||
}
|
||||
}
|
||||
r.favorites[userID] = append(r.favorites[userID], favorite.Favorite{
|
||||
UserID: userID,
|
||||
MarketID: marketID,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Remove(_ context.Context, userID, marketID uuid.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
items := r.favorites[userID]
|
||||
for i, f := range items {
|
||||
if f.MarketID == marketID {
|
||||
r.favorites[userID] = append(items[:i], items[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return favorite.ErrFavoriteNotFound
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]favorite.Favorite, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]favorite.Favorite(nil), r.favorites[userID]...), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListByUserWithMarkets(_ context.Context, userID uuid.UUID) ([]favorite.FavoriteWithMarket, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]favorite.FavoriteWithMarket, 0, len(r.favorites[userID]))
|
||||
for _, f := range r.favorites[userID] {
|
||||
out = append(out, favorite.FavoriteWithMarket{
|
||||
ID: f.MarketID,
|
||||
Slug: "test-slug",
|
||||
Name: "Test Market",
|
||||
City: "Teststadt",
|
||||
FavoritedAt: f.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// -- router helpers --
|
||||
|
||||
func newRouter(repo favorite.Repository, authMiddleware gin.HandlerFunc) *gin.Engine {
|
||||
svc := favorite.NewService(repo)
|
||||
h := favorite.NewHandler(svc)
|
||||
router := gin.New()
|
||||
favorite.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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFavorites_RequireAuth — unauthenticated requests to all favorite endpoints return 401.
|
||||
func TestFavorites_RequireAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newFakeRepo()
|
||||
router := newRouter(repo, noAuth())
|
||||
marketID := uuid.New().String()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/me/favorites"},
|
||||
{http.MethodPost, "/api/v1/me/favorites/" + marketID},
|
||||
{http.MethodDelete, "/api/v1/me/favorites/" + marketID},
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFavorites_CannotReadOtherUser — list only returns the authenticated user's own favorites.
|
||||
func TestFavorites_CannotReadOtherUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userA := uuid.New()
|
||||
userB := uuid.New()
|
||||
marketID := uuid.New()
|
||||
|
||||
repo := newFakeRepo()
|
||||
// Seed a favorite for user A directly.
|
||||
repo.favorites[userA] = []favorite.Favorite{
|
||||
{UserID: userA, MarketID: marketID, CreatedAt: time.Now()},
|
||||
}
|
||||
|
||||
// User B authenticates and calls GET /me/favorites — must get empty list.
|
||||
router := newRouter(repo, stubAuth(userB))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/favorites", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Response should not contain user A's favorite.
|
||||
body := w.Body.String()
|
||||
if len(repo.favorites[userA]) == 0 {
|
||||
t.Error("test setup error: user A has no favorites")
|
||||
}
|
||||
// User B's favorites in the repo are empty; the response data should be [].
|
||||
if len(repo.favorites[userB]) != 0 {
|
||||
t.Error("user B should have no favorites but repo has some")
|
||||
}
|
||||
// The response body should show an empty data array.
|
||||
if body == "" {
|
||||
t.Error("expected non-empty response body")
|
||||
}
|
||||
// Verify user A's market ID is NOT in user B's response.
|
||||
if contains(body, marketID.String()) {
|
||||
t.Errorf("user B's response contains user A's market ID %s — isolation failure", marketID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListFavorites_ReturnsEmbeddedMarketData — GET /me/favorites response includes market fields.
|
||||
func TestListFavorites_ReturnsEmbeddedMarketData(t *testing.T) {
|
||||
t.Parallel()
|
||||
userID := uuid.New()
|
||||
marketID := uuid.New()
|
||||
|
||||
repo := newFakeRepo()
|
||||
repo.favorites[userID] = []favorite.Favorite{
|
||||
{UserID: userID, MarketID: marketID, CreatedAt: time.Now()},
|
||||
}
|
||||
|
||||
router := newRouter(repo, stubAuth(userID))
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/favorites", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, field := range []string{"slug", "name", "city", "favorited_at"} {
|
||||
if !containsStr(body, field) {
|
||||
t.Errorf("response missing field %q: %s", field, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package favorite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *Handler) Add(c *gin.Context) {
|
||||
marketID, ok := parseMarketID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if err := h.svc.Add(c.Request.Context(), userID, marketID); err != nil {
|
||||
if errors.Is(err, ErrAlreadyFavorited) {
|
||||
apiErr := apierror.Conflict("already favorited")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to add favorite")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) Remove(c *gin.Context) {
|
||||
marketID, ok := parseMarketID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if err := h.svc.Remove(c.Request.Context(), userID, marketID); err != nil {
|
||||
if errors.Is(err, ErrFavoriteNotFound) {
|
||||
apiErr := apierror.NotFound("favorite")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to remove favorite")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
userID := getRequesterID(c)
|
||||
favorites, err := h.svc.ListByUserWithMarkets(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to list favorites")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if favorites == nil {
|
||||
favorites = []FavoriteWithMarket{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": favorites})
|
||||
}
|
||||
|
||||
func parseMarketID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("marketId"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_market_id", "invalid market 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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package favorite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Favorite struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
MarketID uuid.UUID `json:"market_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FavoriteWithMarket embeds market summary data from a JOIN so the list endpoint
|
||||
// returns everything the frontend needs to render cards without extra requests.
|
||||
// The ID field is the market_series.id (same value used by add/remove endpoints).
|
||||
type FavoriteWithMarket struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Country string `json:"country"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
ImageURL string `json:"image_url"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
OrganizerName string `json:"organizer_name"`
|
||||
FavoritedAt time.Time `json:"favorited_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAlreadyFavorited = fmt.Errorf("already favorited")
|
||||
ErrFavoriteNotFound = fmt.Errorf("favorite not found")
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
package favorite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/pgerr"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Add(ctx context.Context, userID, marketID uuid.UUID) error
|
||||
Remove(ctx context.Context, userID, marketID uuid.UUID) error
|
||||
ListByUser(ctx context.Context, userID uuid.UUID) ([]Favorite, error)
|
||||
ListByUserWithMarkets(ctx context.Context, userID uuid.UUID) ([]FavoriteWithMarket, error)
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) Add(ctx context.Context, userID, marketID uuid.UUID) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO favorites (user_id, market_id) VALUES ($1, $2)
|
||||
`, userID, marketID)
|
||||
if err != nil {
|
||||
if pgerr.IsDuplicateKey(err) {
|
||||
return ErrAlreadyFavorited
|
||||
}
|
||||
return fmt.Errorf("adding favorite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) Remove(ctx context.Context, userID, marketID uuid.UUID) error {
|
||||
tag, err := r.db.Exec(ctx, `
|
||||
DELETE FROM favorites WHERE user_id = $1 AND market_id = $2
|
||||
`, userID, marketID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing favorite: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrFavoriteNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Favorite, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT user_id, market_id, created_at
|
||||
FROM favorites
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing favorites: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var favorites []Favorite
|
||||
for rows.Next() {
|
||||
var f Favorite
|
||||
if err := rows.Scan(&f.UserID, &f.MarketID, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning favorite: %w", err)
|
||||
}
|
||||
favorites = append(favorites, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating favorites: %w", err)
|
||||
}
|
||||
return favorites, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListByUserWithMarkets(ctx context.Context, userID uuid.UUID) ([]FavoriteWithMarket, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT
|
||||
f.market_id,
|
||||
f.created_at,
|
||||
ms.slug,
|
||||
COALESCE(me.name, ms.name),
|
||||
COALESCE(me.city, ms.city),
|
||||
COALESCE(me.state, ms.state),
|
||||
COALESCE(me.country, ms.country),
|
||||
me.start_date,
|
||||
me.end_date,
|
||||
COALESCE(me.image_url, ''),
|
||||
COALESCE(me.logo_url, ''),
|
||||
CASE WHEN me.location IS NOT NULL THEN ST_Y(me.location::geometry) END,
|
||||
CASE WHEN me.location IS NOT NULL THEN ST_X(me.location::geometry) END,
|
||||
COALESCE(me.organizer_name, '')
|
||||
FROM favorites f
|
||||
JOIN market_series ms ON ms.id = f.market_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT name, city, state, country, start_date, end_date,
|
||||
image_url, logo_url, location, organizer_name
|
||||
FROM market_editions
|
||||
WHERE series_id = ms.id
|
||||
AND status IN ('active', 'confirmed', 'completed')
|
||||
ORDER BY start_date DESC LIMIT 1
|
||||
) me ON true
|
||||
WHERE f.user_id = $1
|
||||
ORDER BY f.created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing favorites with markets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var favorites []FavoriteWithMarket
|
||||
for rows.Next() {
|
||||
var f FavoriteWithMarket
|
||||
if err := rows.Scan(
|
||||
&f.ID, &f.FavoritedAt,
|
||||
&f.Slug, &f.Name, &f.City, &f.State, &f.Country,
|
||||
&f.StartDate, &f.EndDate,
|
||||
&f.ImageURL, &f.LogoURL,
|
||||
&f.Latitude, &f.Longitude,
|
||||
&f.OrganizerName,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning favorite with market: %w", err)
|
||||
}
|
||||
favorites = append(favorites, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating favorites with markets: %w", err)
|
||||
}
|
||||
return favorites, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package favorite
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
|
||||
auth := rg.Group("", requireAuth)
|
||||
{
|
||||
auth.GET("/me/favorites", h.List)
|
||||
auth.POST("/me/favorites/:marketId", h.Add)
|
||||
auth.DELETE("/me/favorites/:marketId", h.Remove)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package favorite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Add(ctx context.Context, userID, marketID uuid.UUID) error {
|
||||
return s.repo.Add(ctx, userID, marketID)
|
||||
}
|
||||
|
||||
func (s *Service) Remove(ctx context.Context, userID, marketID uuid.UUID) error {
|
||||
return s.repo.Remove(ctx, userID, marketID)
|
||||
}
|
||||
|
||||
func (s *Service) ListByUser(ctx context.Context, userID uuid.UUID) ([]Favorite, error) {
|
||||
return s.repo.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) ListByUserWithMarkets(ctx context.Context, userID uuid.UUID) ([]FavoriteWithMarket, error) {
|
||||
return s.repo.ListByUserWithMarkets(ctx, userID)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/pgerr"
|
||||
)
|
||||
|
||||
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 pgerr.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 = $2
|
||||
`, groupID, MemberRoleAdmin).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("counting group admins: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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, body, published, created_at, updated_at
|
||||
FROM lagerleben_articles
|
||||
WHERE slug = $1 AND published = TRUE`,
|
||||
slug)
|
||||
a, err := scanArticleFull(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 scanArticleFull(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.Body, &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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -56,6 +58,17 @@ func (h *Handler) Search(c *gin.Context) { //nolint:dupl // similar structure to
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Stats(c *gin.Context) {
|
||||
stats, err := h.service.Stats(c.Request.Context())
|
||||
if err != nil {
|
||||
slog.ErrorContext(c.Request.Context(), "market stats failed", "error", err)
|
||||
apiErr := apierror.Internal("failed to fetch market stats")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": stats})
|
||||
}
|
||||
|
||||
func (h *Handler) GetBySlug(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
@@ -77,6 +90,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
|
||||
|
||||
@@ -87,3 +87,9 @@ const (
|
||||
StatusArchived = "archived"
|
||||
StatusMerged = "merged"
|
||||
)
|
||||
|
||||
type MarketStats struct {
|
||||
Total int `json:"total"`
|
||||
Countries int `json:"countries"`
|
||||
Regions int `json:"regions"`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
type Repository interface {
|
||||
// Public queries
|
||||
Stats(ctx context.Context) (MarketStats, error)
|
||||
Search(ctx context.Context, params SearchParams) ([]Market, int, error)
|
||||
GetBySlug(ctx context.Context, slug string) (Market, error)
|
||||
GetBySlugAndYear(ctx context.Context, slug string, year int) (Market, error)
|
||||
@@ -51,6 +52,9 @@ type Repository interface {
|
||||
// sets status='merged', merged_into_id, merged_at on the source edition;
|
||||
// reparents discovered_markets.created_edition_id; writes a market_merge_log row.
|
||||
MarkMerged(ctx context.Context, sourceID, targetID, mergedBy uuid.UUID, proposalJSON []byte) error
|
||||
|
||||
// IsOwner reports whether userID is the creator of the market series.
|
||||
IsOwner(ctx context.Context, marketID, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
@@ -146,6 +150,22 @@ func scanReturnedEdition(sc scanner) (Market, error) {
|
||||
|
||||
// --- Public queries ---
|
||||
|
||||
func (r *pgRepository) Stats(ctx context.Context) (MarketStats, error) {
|
||||
var s MarketStats
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(DISTINCT e.series_id),
|
||||
COUNT(DISTINCT e.country),
|
||||
COUNT(DISTINCT e.state)
|
||||
FROM market_editions e
|
||||
WHERE e.is_published = TRUE
|
||||
`).Scan(&s.Total, &s.Countries, &s.Regions)
|
||||
if err != nil {
|
||||
return MarketStats{}, fmt.Errorf("fetching market stats: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) Search(ctx context.Context, params SearchParams) ([]Market, int, error) {
|
||||
var (
|
||||
conditions []string
|
||||
@@ -1132,3 +1152,14 @@ func (r *pgRepository) MarkMerged(ctx context.Context, sourceID, targetID, merge
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *pgRepository) IsOwner(ctx context.Context, marketID, userID uuid.UUID) (bool, error) {
|
||||
var exists bool
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM market_series WHERE id = $1 AND created_by = $2)
|
||||
`, marketID, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking market ownership: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, ge
|
||||
markets := rg.Group("/markets")
|
||||
{
|
||||
markets.GET("", h.Search)
|
||||
markets.GET("/stats", h.Stats)
|
||||
markets.GET("/:slug", h.GetBySlug)
|
||||
markets.POST("/submit", submitLimit, subH.Submit)
|
||||
markets.POST("/:slug/feedback", feedbackLimit, fbH.Submit)
|
||||
|
||||
@@ -39,6 +39,10 @@ func NewServiceFull(repo Repository, emailSender email.Sender, ts turnstile.Veri
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Stats(ctx context.Context) (MarketStats, error) {
|
||||
return s.repo.Stats(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, params SearchParams) ([]Market, int, error) {
|
||||
params.Defaults()
|
||||
return s.repo.Search(ctx, params)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package message
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
type createThreadRequest struct {
|
||||
Subject string `json:"subject" validate:"required,min=1,max=500"`
|
||||
ContextType string `json:"context_type" validate:"required,min=1,max=100"`
|
||||
ContextID *uuid.UUID `json:"context_id"`
|
||||
Participants []participantInput `json:"participants" validate:"omitempty,dive"`
|
||||
}
|
||||
|
||||
type participantInput struct {
|
||||
UserID uuid.UUID `json:"user_id" validate:"required"`
|
||||
Role string `json:"role" validate:"required,oneof=owner replier"`
|
||||
}
|
||||
|
||||
type postMessageRequest struct {
|
||||
Body string `json:"body" validate:"required,min=1,max=10000"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateThread(c *gin.Context) {
|
||||
var req createThreadRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
|
||||
participants := []Participant{
|
||||
{UserID: userID, Role: ParticipantRoleOwner},
|
||||
}
|
||||
for _, p := range req.Participants {
|
||||
if p.UserID == userID {
|
||||
continue
|
||||
}
|
||||
participants = append(participants, Participant{UserID: p.UserID, Role: p.Role})
|
||||
}
|
||||
|
||||
t := Thread{
|
||||
ID: uuid.New(),
|
||||
Subject: req.Subject,
|
||||
ContextType: req.ContextType,
|
||||
ContextID: req.ContextID,
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateThread(c.Request.Context(), t, participants)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to create thread")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *Handler) ListMyThreads(c *gin.Context) {
|
||||
userID := getRequesterID(c)
|
||||
threads, err := h.svc.ListThreadsByUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to list threads")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if threads == nil {
|
||||
threads = []Thread{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": threads})
|
||||
}
|
||||
|
||||
func (h *Handler) GetMessages(c *gin.Context) {
|
||||
threadID, ok := parseThreadID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
ok, err := h.svc.IsParticipant(c.Request.Context(), threadID, userID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to check participation")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
apiErr := apierror.Forbidden("not a participant in this thread")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
messages, err := h.svc.ListMessages(c.Request.Context(), threadID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrThreadNotFound) {
|
||||
apiErr := apierror.NotFound("thread")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to list messages")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if messages == nil {
|
||||
messages = []Message{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": messages})
|
||||
}
|
||||
|
||||
func (h *Handler) PostMessage(c *gin.Context) {
|
||||
threadID, ok := parseThreadID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
isParticipant, err := h.svc.IsParticipant(c.Request.Context(), threadID, userID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to check participation")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
if !isParticipant {
|
||||
apiErr := apierror.Forbidden("not a participant in this thread")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
var req postMessageRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
m := Message{
|
||||
ID: uuid.New(),
|
||||
ThreadID: threadID,
|
||||
SenderID: userID,
|
||||
Body: req.Body,
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateMessage(c.Request.Context(), m)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to post message")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkRead(c *gin.Context) {
|
||||
threadID, ok := parseThreadID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
isParticipant, err := h.svc.IsParticipant(c.Request.Context(), threadID, userID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to check participation")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
if !isParticipant {
|
||||
apiErr := apierror.Forbidden("not a participant in this thread")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.MarkRead(c.Request.Context(), threadID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to mark thread as read")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func parseThreadID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_thread_id", "invalid thread 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
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/message"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// -- in-memory repository --
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
threads map[uuid.UUID]message.Thread
|
||||
participants map[uuid.UUID][]message.Participant // keyed by threadID
|
||||
messages map[uuid.UUID][]message.Message // keyed by threadID
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
threads: make(map[uuid.UUID]message.Thread),
|
||||
participants: make(map[uuid.UUID][]message.Participant),
|
||||
messages: make(map[uuid.UUID][]message.Message),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CreateThread(_ context.Context, t message.Thread, participants []message.Participant) (message.Thread, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
t.CreatedAt = time.Now()
|
||||
t.LastMessageAt = time.Now()
|
||||
r.threads[t.ID] = t
|
||||
r.participants[t.ID] = append(r.participants[t.ID], participants...)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetThread(_ context.Context, id uuid.UUID) (message.Thread, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
t, ok := r.threads[id]
|
||||
if !ok {
|
||||
return message.Thread{}, message.ErrThreadNotFound
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) IsParticipant(_ context.Context, threadID, userID uuid.UUID) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, p := range r.participants[threadID] {
|
||||
if p.UserID == userID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CreateMessage(_ context.Context, m message.Message) (message.Message, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
m.CreatedAt = time.Now()
|
||||
r.messages[m.ThreadID] = append(r.messages[m.ThreadID], m)
|
||||
if t, ok := r.threads[m.ThreadID]; ok {
|
||||
t.LastMessageAt = time.Now()
|
||||
r.threads[m.ThreadID] = t
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListMessages(_ context.Context, threadID uuid.UUID) ([]message.Message, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]message.Message(nil), r.messages[threadID]...), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListThreadsByUser(_ context.Context, userID uuid.UUID) ([]message.Thread, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var out []message.Thread
|
||||
for threadID, parts := range r.participants {
|
||||
for _, p := range parts {
|
||||
if p.UserID == userID {
|
||||
if t, ok := r.threads[threadID]; ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) MarkRead(_ context.Context, threadID, userID uuid.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
parts := r.participants[threadID]
|
||||
now := time.Now()
|
||||
for i, p := range parts {
|
||||
if p.UserID == userID {
|
||||
parts[i].LastReadAt = &now
|
||||
r.participants[threadID] = parts
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -- router helpers --
|
||||
|
||||
func newRouter(repo message.Repository, authMiddleware gin.HandlerFunc) *gin.Engine {
|
||||
svc := message.NewService(repo)
|
||||
h := message.NewHandler(svc)
|
||||
router := gin.New()
|
||||
message.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)
|
||||
}
|
||||
|
||||
// TestMessages_RequireAuth — GET /me/threads returns 401 without auth.
|
||||
func TestMessages_RequireAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newFakeRepo()
|
||||
router := newRouter(repo, noAuth())
|
||||
threadID := uuid.New().String()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/me/threads"},
|
||||
{http.MethodPost, "/api/v1/threads"},
|
||||
{http.MethodGet, "/api/v1/threads/" + threadID + "/messages"},
|
||||
{http.MethodPost, "/api/v1/threads/" + threadID + "/messages"},
|
||||
{http.MethodPost, "/api/v1/threads/" + threadID + "/read"},
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessages_CannotReadOtherThread — user B (not participant) gets 403 on GET /threads/:id/messages.
|
||||
func TestMessages_CannotReadOtherThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userA := uuid.New()
|
||||
userB := uuid.New()
|
||||
|
||||
repo := newFakeRepo()
|
||||
|
||||
// Create thread with user A as participant only.
|
||||
thread := message.Thread{
|
||||
ID: uuid.New(),
|
||||
Subject: "Private thread",
|
||||
ContextType: "direct",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
repo.threads[thread.ID] = thread
|
||||
repo.participants[thread.ID] = []message.Participant{
|
||||
{ThreadID: thread.ID, UserID: userA, Role: message.ParticipantRoleOwner},
|
||||
}
|
||||
|
||||
// User B tries to read the thread.
|
||||
router := newRouter(repo, stubAuth(userB))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/threads/"+thread.ID.String()+"/messages", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessages_CannotPostToOtherThread — user B gets 403 on POST /threads/:id/messages.
|
||||
func TestMessages_CannotPostToOtherThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userA := uuid.New()
|
||||
userB := uuid.New()
|
||||
|
||||
repo := newFakeRepo()
|
||||
|
||||
thread := message.Thread{
|
||||
ID: uuid.New(),
|
||||
Subject: "Private thread",
|
||||
ContextType: "direct",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
repo.threads[thread.ID] = thread
|
||||
repo.participants[thread.ID] = []message.Participant{
|
||||
{ThreadID: thread.ID, UserID: userA, Role: message.ParticipantRoleOwner},
|
||||
}
|
||||
|
||||
router := newRouter(repo, stubAuth(userB))
|
||||
|
||||
body := jsonBody(map[string]string{"body": "hijacked message"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/threads/"+thread.ID.String()+"/messages", 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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Thread struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
ContextType string `json:"context_type"`
|
||||
ContextID *uuid.UUID `json:"context_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastMessageAt time.Time `json:"last_message_at"`
|
||||
}
|
||||
|
||||
type Participant struct {
|
||||
ThreadID uuid.UUID `json:"thread_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
LastReadAt *time.Time `json:"last_read_at,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ThreadID uuid.UUID `json:"thread_id"`
|
||||
SenderID uuid.UUID `json:"sender_id"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrThreadNotFound = fmt.Errorf("thread not found")
|
||||
ErrNotParticipant = fmt.Errorf("not a participant in this thread")
|
||||
ErrMessageNotFound = fmt.Errorf("message not found")
|
||||
)
|
||||
|
||||
const (
|
||||
ParticipantRoleOwner = "owner"
|
||||
ParticipantRoleReplier = "replier"
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CreateThread(ctx context.Context, t Thread, participants []Participant) (Thread, error)
|
||||
GetThread(ctx context.Context, id uuid.UUID) (Thread, error)
|
||||
IsParticipant(ctx context.Context, threadID, userID uuid.UUID) (bool, error)
|
||||
CreateMessage(ctx context.Context, m Message) (Message, error)
|
||||
ListMessages(ctx context.Context, threadID uuid.UUID) ([]Message, error)
|
||||
ListThreadsByUser(ctx context.Context, userID uuid.UUID) ([]Thread, error)
|
||||
MarkRead(ctx context.Context, threadID, userID uuid.UUID) error
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) CreateThread(ctx context.Context, t Thread, participants []Participant) (Thread, error) {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Thread{}, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var out Thread
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO message_threads (id, subject, context_type, context_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, subject, context_type, context_id, created_at, last_message_at
|
||||
`, t.ID, t.Subject, t.ContextType, t.ContextID).Scan(
|
||||
&out.ID, &out.Subject, &out.ContextType, &out.ContextID,
|
||||
&out.CreatedAt, &out.LastMessageAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Thread{}, fmt.Errorf("inserting thread: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range participants {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO message_thread_participants (thread_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
`, out.ID, p.UserID, p.Role)
|
||||
if err != nil {
|
||||
return Thread{}, fmt.Errorf("inserting participant: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Thread{}, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetThread(ctx context.Context, id uuid.UUID) (Thread, error) {
|
||||
var t Thread
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, subject, context_type, context_id, created_at, last_message_at
|
||||
FROM message_threads WHERE id = $1
|
||||
`, id).Scan(&t.ID, &t.Subject, &t.ContextType, &t.ContextID, &t.CreatedAt, &t.LastMessageAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Thread{}, ErrThreadNotFound
|
||||
}
|
||||
return Thread{}, fmt.Errorf("getting thread: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) IsParticipant(ctx context.Context, threadID, userID uuid.UUID) (bool, error) {
|
||||
var exists bool
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM message_thread_participants
|
||||
WHERE thread_id = $1 AND user_id = $2
|
||||
)
|
||||
`, threadID, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking participant: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) CreateMessage(ctx context.Context, m Message) (Message, error) {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var out Message
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO messages (id, thread_id, sender_id, body)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, thread_id, sender_id, body, created_at, edited_at
|
||||
`, m.ID, m.ThreadID, m.SenderID, m.Body).Scan(
|
||||
&out.ID, &out.ThreadID, &out.SenderID, &out.Body, &out.CreatedAt, &out.EditedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("inserting message: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE message_threads SET last_message_at = NOW() WHERE id = $1
|
||||
`, m.ThreadID)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("updating last_message_at: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Message{}, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListMessages(ctx context.Context, threadID uuid.UUID) ([]Message, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, thread_id, sender_id, body, created_at, edited_at
|
||||
FROM messages
|
||||
WHERE thread_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, threadID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []Message
|
||||
for rows.Next() {
|
||||
var m Message
|
||||
if err := rows.Scan(&m.ID, &m.ThreadID, &m.SenderID, &m.Body, &m.CreatedAt, &m.EditedAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning message: %w", err)
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating messages: %w", err)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListThreadsByUser(ctx context.Context, userID uuid.UUID) ([]Thread, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT t.id, t.subject, t.context_type, t.context_id, t.created_at, t.last_message_at
|
||||
FROM message_threads t
|
||||
JOIN message_thread_participants p ON p.thread_id = t.id
|
||||
WHERE p.user_id = $1
|
||||
ORDER BY t.last_message_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing threads: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var threads []Thread
|
||||
for rows.Next() {
|
||||
var t Thread
|
||||
if err := rows.Scan(&t.ID, &t.Subject, &t.ContextType, &t.ContextID, &t.CreatedAt, &t.LastMessageAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning thread: %w", err)
|
||||
}
|
||||
threads = append(threads, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating threads: %w", err)
|
||||
}
|
||||
return threads, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) MarkRead(ctx context.Context, threadID, userID uuid.UUID) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
UPDATE message_thread_participants
|
||||
SET last_read_at = NOW()
|
||||
WHERE thread_id = $1 AND user_id = $2
|
||||
`, threadID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking thread as read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package message
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
|
||||
auth := rg.Group("", requireAuth)
|
||||
{
|
||||
auth.POST("/threads", h.CreateThread)
|
||||
auth.GET("/me/threads", h.ListMyThreads)
|
||||
auth.GET("/threads/:id/messages", h.GetMessages)
|
||||
auth.POST("/threads/:id/messages", h.PostMessage)
|
||||
auth.POST("/threads/:id/read", h.MarkRead)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) CreateThread(ctx context.Context, t Thread, participants []Participant) (Thread, error) {
|
||||
return s.repo.CreateThread(ctx, t, participants)
|
||||
}
|
||||
|
||||
func (s *Service) GetThread(ctx context.Context, id uuid.UUID) (Thread, error) {
|
||||
return s.repo.GetThread(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) IsParticipant(ctx context.Context, threadID, userID uuid.UUID) (bool, error) {
|
||||
return s.repo.IsParticipant(ctx, threadID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateMessage(ctx context.Context, m Message) (Message, error) {
|
||||
return s.repo.CreateMessage(ctx, m)
|
||||
}
|
||||
|
||||
func (s *Service) ListMessages(ctx context.Context, threadID uuid.UUID) ([]Message, error) {
|
||||
return s.repo.ListMessages(ctx, threadID)
|
||||
}
|
||||
|
||||
func (s *Service) ListThreadsByUser(ctx context.Context, userID uuid.UUID) ([]Thread, error) {
|
||||
return s.repo.ListThreadsByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) MarkRead(ctx context.Context, threadID, userID uuid.UUID) error {
|
||||
return s.repo.MarkRead(ctx, threadID, userID)
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package program
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
"marktvogt.de/backend/internal/pkg/validate"
|
||||
)
|
||||
|
||||
// MarketOwnerChecker checks whether a user owns a given market series.
|
||||
// Defined here to avoid importing the market package directly.
|
||||
type MarketOwnerChecker interface {
|
||||
IsOwner(ctx context.Context, marketID, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
checker MarketOwnerChecker
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service, checker MarketOwnerChecker) *Handler {
|
||||
return &Handler{svc: svc, checker: checker}
|
||||
}
|
||||
|
||||
type createProgramRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Description string `json:"description" validate:"omitempty,max=5000"`
|
||||
}
|
||||
|
||||
type updateProgramRequest struct {
|
||||
Title *string `json:"title" validate:"omitempty,min=1,max=500"`
|
||||
Description *string `json:"description" validate:"omitempty,max=5000"`
|
||||
}
|
||||
|
||||
type createStageRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=200"`
|
||||
Position int `json:"position" validate:"min=0"`
|
||||
}
|
||||
|
||||
type updateStageRequest struct {
|
||||
Name *string `json:"name" validate:"omitempty,min=1,max=200"`
|
||||
Position *int `json:"position" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
type createActRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Description string `json:"description" validate:"omitempty,max=5000"`
|
||||
GroupID *uuid.UUID `json:"group_id"`
|
||||
StartsAt *time.Time `json:"starts_at"`
|
||||
EndsAt *time.Time `json:"ends_at"`
|
||||
Position int `json:"position" validate:"min=0"`
|
||||
}
|
||||
|
||||
type updateActRequest struct {
|
||||
Title *string `json:"title" validate:"omitempty,min=1,max=500"`
|
||||
Description *string `json:"description" validate:"omitempty,max=5000"`
|
||||
Position *int `json:"position" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
// ProgramView is the aggregated response for a market's full program.
|
||||
type ProgramView struct {
|
||||
Program Program `json:"program"`
|
||||
Stages []StageView `json:"stages"`
|
||||
}
|
||||
|
||||
type StageView struct {
|
||||
Stage Stage `json:"stage"`
|
||||
Acts []Act `json:"acts"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetProgram(c *gin.Context) {
|
||||
marketID, ok := parseMarketID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
p, err := h.svc.GetByMarket(c.Request.Context(), marketID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrProgramNotFound) {
|
||||
apiErr := apierror.NotFound("program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to get program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
stages, err := h.svc.ListStages(c.Request.Context(), p.ID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to list stages")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
stageViews := make([]StageView, 0, len(stages))
|
||||
for _, stage := range stages {
|
||||
acts, err := h.svc.ListActs(c.Request.Context(), stage.ID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to list acts")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
if acts == nil {
|
||||
acts = []Act{}
|
||||
}
|
||||
stageViews = append(stageViews, StageView{Stage: stage, Acts: acts})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": ProgramView{Program: p, Stages: stageViews}})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateProgram(c *gin.Context) {
|
||||
marketID, ok := parseMarketID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if isOwner, err := h.checker.IsOwner(c.Request.Context(), marketID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to check market ownership")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
} else if !isOwner {
|
||||
apiErr := apierror.Forbidden("not the market owner")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
var req createProgramRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
p := Program{
|
||||
ID: uuid.New(),
|
||||
MarketID: marketID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
created, err := h.svc.Create(c.Request.Context(), p)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrProgramExists) {
|
||||
apiErr := apierror.Conflict("program already exists for this market")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to create program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProgram(c *gin.Context) {
|
||||
programID, ok := parseProgramID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
prog, err := h.svc.GetByID(c.Request.Context(), programID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrProgramNotFound) {
|
||||
apiErr := apierror.NotFound("program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to fetch program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to check market ownership")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
} else if !isOwner {
|
||||
apiErr := apierror.Forbidden("not the market owner")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
var req updateProgramRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if req.Title != nil {
|
||||
updates["title"] = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdateProgram(c.Request.Context(), programID, updates)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to update program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateStage(c *gin.Context) {
|
||||
programID, ok := parseProgramID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
prog, err := h.svc.GetByID(c.Request.Context(), programID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrProgramNotFound) {
|
||||
apiErr := apierror.NotFound("program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to fetch program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to check market ownership")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
} else if !isOwner {
|
||||
apiErr := apierror.Forbidden("not the market owner")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
var req createStageRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
stage := Stage{
|
||||
ID: uuid.New(),
|
||||
ProgramID: programID,
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateStage(c.Request.Context(), stage)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to create stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateStage(c *gin.Context) {
|
||||
stageID, ok := parseStageID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
stage, err := h.svc.GetStage(c.Request.Context(), stageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrStageNotFound) {
|
||||
apiErr := apierror.NotFound("stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to fetch stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to fetch program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to check market ownership")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
} else if !isOwner {
|
||||
apiErr := apierror.Forbidden("not the market owner")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
var req updateStageRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if req.Name != nil {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.Position != nil {
|
||||
updates["position"] = *req.Position
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdateStage(c.Request.Context(), stageID, updates)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to update stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteStage(c *gin.Context) {
|
||||
stageID, ok := parseStageID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
stage, err := h.svc.GetStage(c.Request.Context(), stageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrStageNotFound) {
|
||||
apiErr := apierror.NotFound("stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to fetch stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to fetch program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to check market ownership")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
} else if !isOwner {
|
||||
apiErr := apierror.Forbidden("not the market owner")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteStage(c.Request.Context(), stageID); err != nil {
|
||||
apiErr := apierror.Internal("failed to delete stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAct(c *gin.Context) {
|
||||
stageID, ok := parseStageID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
stage, err := h.svc.GetStage(c.Request.Context(), stageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrStageNotFound) {
|
||||
apiErr := apierror.NotFound("stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to fetch stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to fetch program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to check market ownership")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
} else if !isOwner {
|
||||
apiErr := apierror.Forbidden("not the market owner")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
var req createActRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
act := Act{
|
||||
ID: uuid.New(),
|
||||
StageID: stageID,
|
||||
GroupID: req.GroupID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
StartsAt: req.StartsAt,
|
||||
EndsAt: req.EndsAt,
|
||||
Position: req.Position,
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateAct(c.Request.Context(), act)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to create act")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAct(c *gin.Context) {
|
||||
actID, ok := parseActID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Traverse act → stage → program → market for ownership check.
|
||||
act, err := h.svc.GetAct(c.Request.Context(), actID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrActNotFound) {
|
||||
apiErr := apierror.NotFound("act")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to fetch act")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
stage, err := h.svc.GetStage(c.Request.Context(), act.StageID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to fetch stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to fetch program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to check market ownership")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
} else if !isOwner {
|
||||
apiErr := apierror.Forbidden("not the market owner")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
var req updateActRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if req.Title != nil {
|
||||
updates["title"] = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.Position != nil {
|
||||
updates["position"] = *req.Position
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdateAct(c.Request.Context(), actID, updates)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to update act")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAct(c *gin.Context) {
|
||||
actID, ok := parseActID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
act, err := h.svc.GetAct(c.Request.Context(), actID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrActNotFound) {
|
||||
apiErr := apierror.NotFound("act")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to fetch act")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
stage, err := h.svc.GetStage(c.Request.Context(), act.StageID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to fetch stage")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to fetch program")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
userID := getRequesterID(c)
|
||||
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
|
||||
apiErr := apierror.Internal("failed to check market ownership")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
} else if !isOwner {
|
||||
apiErr := apierror.Forbidden("not the market owner")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAct(c.Request.Context(), actID); err != nil {
|
||||
apiErr := apierror.Internal("failed to delete act")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func parseMarketID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("seriesId"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_market_id", "invalid market id")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseProgramID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("programId"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_program_id", "invalid program id")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseStageID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("stageId"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_stage_id", "invalid stage id")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseActID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("actId"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_act_id", "invalid act 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
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package program
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Program struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
MarketID uuid.UUID `json:"market_id"`
|
||||
EditionID *uuid.UUID `json:"edition_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Stage struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProgramID uuid.UUID `json:"program_id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type Act struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
StageID uuid.UUID `json:"stage_id"`
|
||||
GroupID *uuid.UUID `json:"group_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
StartsAt *time.Time `json:"starts_at,omitempty"`
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrProgramNotFound = fmt.Errorf("program not found")
|
||||
ErrStageNotFound = fmt.Errorf("stage not found")
|
||||
ErrActNotFound = fmt.Errorf("act not found")
|
||||
ErrNotMarketOwner = fmt.Errorf("not the market owner")
|
||||
ErrProgramExists = fmt.Errorf("program already exists for this market")
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
package program
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
GetByMarket(ctx context.Context, marketID uuid.UUID) (Program, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (Program, error)
|
||||
Create(ctx context.Context, p Program) (Program, error)
|
||||
UpdateProgram(ctx context.Context, id uuid.UUID, updates map[string]any) (Program, error)
|
||||
CreateStage(ctx context.Context, s Stage) (Stage, error)
|
||||
UpdateStage(ctx context.Context, id uuid.UUID, updates map[string]any) (Stage, error)
|
||||
DeleteStage(ctx context.Context, id uuid.UUID) error
|
||||
ListStages(ctx context.Context, programID uuid.UUID) ([]Stage, error)
|
||||
GetStage(ctx context.Context, id uuid.UUID) (Stage, error)
|
||||
CreateAct(ctx context.Context, a Act) (Act, error)
|
||||
GetAct(ctx context.Context, id uuid.UUID) (Act, error)
|
||||
UpdateAct(ctx context.Context, id uuid.UUID, updates map[string]any) (Act, error)
|
||||
DeleteAct(ctx context.Context, id uuid.UUID) error
|
||||
ListActs(ctx context.Context, stageID uuid.UUID) ([]Act, error)
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetByMarket(ctx context.Context, marketID uuid.UUID) (Program, error) {
|
||||
var p Program
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, market_id, edition_id, title, description, created_at, updated_at
|
||||
FROM market_programs WHERE market_id = $1
|
||||
LIMIT 1
|
||||
`, marketID).Scan(&p.ID, &p.MarketID, &p.EditionID, &p.Title, &p.Description, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Program{}, ErrProgramNotFound
|
||||
}
|
||||
return Program{}, fmt.Errorf("getting program by market: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Program, error) {
|
||||
var p Program
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, market_id, edition_id, title, description, created_at, updated_at
|
||||
FROM market_programs WHERE id = $1
|
||||
`, id).Scan(&p.ID, &p.MarketID, &p.EditionID, &p.Title, &p.Description, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Program{}, ErrProgramNotFound
|
||||
}
|
||||
return Program{}, fmt.Errorf("getting program by id: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) Create(ctx context.Context, p Program) (Program, error) {
|
||||
var out Program
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO market_programs (id, market_id, edition_id, title, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, market_id, edition_id, title, description, created_at, updated_at
|
||||
`, p.ID, p.MarketID, p.EditionID, p.Title, p.Description).Scan(
|
||||
&out.ID, &out.MarketID, &out.EditionID, &out.Title, &out.Description,
|
||||
&out.CreatedAt, &out.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Program{}, fmt.Errorf("creating program: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) UpdateProgram(ctx context.Context, id uuid.UUID, updates map[string]any) (Program, error) {
|
||||
var p Program
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, market_id, edition_id, title, description, created_at, updated_at
|
||||
FROM market_programs WHERE id = $1
|
||||
`, id).Scan(&p.ID, &p.MarketID, &p.EditionID, &p.Title, &p.Description, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Program{}, ErrProgramNotFound
|
||||
}
|
||||
return Program{}, fmt.Errorf("fetching program: %w", err)
|
||||
}
|
||||
|
||||
if v, ok := updates["title"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
p.Title = s
|
||||
}
|
||||
}
|
||||
if v, ok := updates["description"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
p.Description = s
|
||||
}
|
||||
}
|
||||
|
||||
var out Program
|
||||
err = r.db.QueryRow(ctx, `
|
||||
UPDATE market_programs
|
||||
SET title = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, market_id, edition_id, title, description, created_at, updated_at
|
||||
`, p.Title, p.Description, id).Scan(
|
||||
&out.ID, &out.MarketID, &out.EditionID, &out.Title, &out.Description,
|
||||
&out.CreatedAt, &out.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Program{}, fmt.Errorf("updating program: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) CreateStage(ctx context.Context, s Stage) (Stage, error) {
|
||||
var out Stage
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO program_stages (id, program_id, name, position)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, program_id, name, position
|
||||
`, s.ID, s.ProgramID, s.Name, s.Position).Scan(
|
||||
&out.ID, &out.ProgramID, &out.Name, &out.Position,
|
||||
)
|
||||
if err != nil {
|
||||
return Stage{}, fmt.Errorf("creating stage: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetStage(ctx context.Context, id uuid.UUID) (Stage, error) {
|
||||
var s Stage
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, program_id, name, position FROM program_stages WHERE id = $1
|
||||
`, id).Scan(&s.ID, &s.ProgramID, &s.Name, &s.Position)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Stage{}, ErrStageNotFound
|
||||
}
|
||||
return Stage{}, fmt.Errorf("getting stage: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) UpdateStage(ctx context.Context, id uuid.UUID, updates map[string]any) (Stage, error) {
|
||||
s, err := r.GetStage(ctx, id)
|
||||
if err != nil {
|
||||
return Stage{}, err
|
||||
}
|
||||
|
||||
if v, ok := updates["name"]; ok {
|
||||
if str, ok := v.(string); ok {
|
||||
s.Name = str
|
||||
}
|
||||
}
|
||||
if v, ok := updates["position"]; ok {
|
||||
switch pos := v.(type) {
|
||||
case int:
|
||||
s.Position = pos
|
||||
case float64:
|
||||
s.Position = int(pos)
|
||||
}
|
||||
}
|
||||
|
||||
var out Stage
|
||||
err = r.db.QueryRow(ctx, `
|
||||
UPDATE program_stages SET name = $1, position = $2 WHERE id = $3
|
||||
RETURNING id, program_id, name, position
|
||||
`, s.Name, s.Position, id).Scan(&out.ID, &out.ProgramID, &out.Name, &out.Position)
|
||||
if err != nil {
|
||||
return Stage{}, fmt.Errorf("updating stage: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) DeleteStage(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := r.db.Exec(ctx, `DELETE FROM program_stages WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting stage: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrStageNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListStages(ctx context.Context, programID uuid.UUID) ([]Stage, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, program_id, name, position
|
||||
FROM program_stages WHERE program_id = $1
|
||||
ORDER BY position ASC
|
||||
`, programID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing stages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stages []Stage
|
||||
for rows.Next() {
|
||||
var s Stage
|
||||
if err := rows.Scan(&s.ID, &s.ProgramID, &s.Name, &s.Position); err != nil {
|
||||
return nil, fmt.Errorf("scanning stage: %w", err)
|
||||
}
|
||||
stages = append(stages, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating stages: %w", err)
|
||||
}
|
||||
return stages, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetAct(ctx context.Context, id uuid.UUID) (Act, error) {
|
||||
var a Act
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, stage_id, group_id, title, description, starts_at, ends_at, position
|
||||
FROM program_acts WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.StageID, &a.GroupID, &a.Title, &a.Description, &a.StartsAt, &a.EndsAt, &a.Position)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Act{}, ErrActNotFound
|
||||
}
|
||||
return Act{}, fmt.Errorf("getting act: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) CreateAct(ctx context.Context, a Act) (Act, error) {
|
||||
var out Act
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO program_acts (id, stage_id, group_id, title, description, starts_at, ends_at, position)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, stage_id, group_id, title, description, starts_at, ends_at, position
|
||||
`, a.ID, a.StageID, a.GroupID, a.Title, a.Description, a.StartsAt, a.EndsAt, a.Position).Scan(
|
||||
&out.ID, &out.StageID, &out.GroupID, &out.Title, &out.Description,
|
||||
&out.StartsAt, &out.EndsAt, &out.Position,
|
||||
)
|
||||
if err != nil {
|
||||
return Act{}, fmt.Errorf("creating act: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) UpdateAct(ctx context.Context, id uuid.UUID, updates map[string]any) (Act, error) {
|
||||
a, err := r.GetAct(ctx, id)
|
||||
if err != nil {
|
||||
return Act{}, err
|
||||
}
|
||||
|
||||
if v, ok := updates["title"]; ok {
|
||||
if str, ok := v.(string); ok {
|
||||
a.Title = str
|
||||
}
|
||||
}
|
||||
if v, ok := updates["description"]; ok {
|
||||
if str, ok := v.(string); ok {
|
||||
a.Description = str
|
||||
}
|
||||
}
|
||||
if v, ok := updates["position"]; ok {
|
||||
switch pos := v.(type) {
|
||||
case int:
|
||||
a.Position = pos
|
||||
case float64:
|
||||
a.Position = int(pos)
|
||||
}
|
||||
}
|
||||
|
||||
var out Act
|
||||
err = r.db.QueryRow(ctx, `
|
||||
UPDATE program_acts
|
||||
SET title = $1, description = $2, position = $3
|
||||
WHERE id = $4
|
||||
RETURNING id, stage_id, group_id, title, description, starts_at, ends_at, position
|
||||
`, a.Title, a.Description, a.Position, id).Scan(
|
||||
&out.ID, &out.StageID, &out.GroupID, &out.Title, &out.Description,
|
||||
&out.StartsAt, &out.EndsAt, &out.Position,
|
||||
)
|
||||
if err != nil {
|
||||
return Act{}, fmt.Errorf("updating act: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) DeleteAct(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := r.db.Exec(ctx, `DELETE FROM program_acts WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting act: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrActNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListActs(ctx context.Context, stageID uuid.UUID) ([]Act, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, stage_id, group_id, title, description, starts_at, ends_at, position
|
||||
FROM program_acts WHERE stage_id = $1
|
||||
ORDER BY position ASC
|
||||
`, stageID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing acts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var acts []Act
|
||||
for rows.Next() {
|
||||
var a Act
|
||||
if err := rows.Scan(&a.ID, &a.StageID, &a.GroupID, &a.Title, &a.Description, &a.StartsAt, &a.EndsAt, &a.Position); err != nil {
|
||||
return nil, fmt.Errorf("scanning act: %w", err)
|
||||
}
|
||||
acts = append(acts, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating acts: %w", err)
|
||||
}
|
||||
return acts, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package program
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
|
||||
rg.GET("/series/:seriesId/program", h.GetProgram)
|
||||
|
||||
auth := rg.Group("", requireAuth)
|
||||
{
|
||||
auth.POST("/series/:seriesId/program", h.CreateProgram)
|
||||
auth.PATCH("/programs/:programId", h.UpdateProgram)
|
||||
auth.POST("/programs/:programId/stages", h.CreateStage)
|
||||
auth.PATCH("/stages/:stageId", h.UpdateStage)
|
||||
auth.DELETE("/stages/:stageId", h.DeleteStage)
|
||||
auth.POST("/stages/:stageId/acts", h.CreateAct)
|
||||
auth.PATCH("/acts/:actId", h.UpdateAct)
|
||||
auth.DELETE("/acts/:actId", h.DeleteAct)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package program
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) GetByMarket(ctx context.Context, marketID uuid.UUID) (Program, error) {
|
||||
return s.repo.GetByMarket(ctx, marketID)
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id uuid.UUID) (Program, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, p Program) (Program, error) {
|
||||
_, err := s.repo.GetByMarket(ctx, p.MarketID)
|
||||
if err == nil {
|
||||
return Program{}, ErrProgramExists
|
||||
}
|
||||
if !errors.Is(err, ErrProgramNotFound) {
|
||||
return Program{}, err
|
||||
}
|
||||
return s.repo.Create(ctx, p)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProgram(ctx context.Context, id uuid.UUID, updates map[string]any) (Program, error) {
|
||||
return s.repo.UpdateProgram(ctx, id, updates)
|
||||
}
|
||||
|
||||
func (s *Service) CreateStage(ctx context.Context, stage Stage) (Stage, error) {
|
||||
return s.repo.CreateStage(ctx, stage)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStage(ctx context.Context, id uuid.UUID, updates map[string]any) (Stage, error) {
|
||||
return s.repo.UpdateStage(ctx, id, updates)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteStage(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.DeleteStage(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) ListStages(ctx context.Context, programID uuid.UUID) ([]Stage, error) {
|
||||
return s.repo.ListStages(ctx, programID)
|
||||
}
|
||||
|
||||
func (s *Service) GetStage(ctx context.Context, id uuid.UUID) (Stage, error) {
|
||||
return s.repo.GetStage(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) GetAct(ctx context.Context, id uuid.UUID) (Act, error) {
|
||||
return s.repo.GetAct(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) CreateAct(ctx context.Context, act Act) (Act, error) {
|
||||
return s.repo.CreateAct(ctx, act)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAct(ctx context.Context, id uuid.UUID, updates map[string]any) (Act, error) {
|
||||
return s.repo.UpdateAct(ctx, id, updates)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAct(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.DeleteAct(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) ListActs(ctx context.Context, stageID uuid.UUID) ([]Act, error) {
|
||||
return s.repo.ListActs(ctx, stageID)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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, role, status string) (user.User, error) {
|
||||
if status == "" {
|
||||
status = user.StatusActive
|
||||
}
|
||||
u := user.User{ID: uuid.New(), Email: email, DisplayName: name, Status: status}
|
||||
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
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) GetDashboardStats(_ context.Context, _ uuid.UUID) (user.DashboardStats, error) {
|
||||
return user.DashboardStats{}, 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,10 +13,21 @@ type ProfileData struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
OwnedMarkets int `json:"owned_markets"`
|
||||
PendingApplications int `json:"pending_applications"`
|
||||
UnreadThreads int `json:"unread_threads"`
|
||||
}
|
||||
|
||||
type DashboardStatsResponse struct {
|
||||
Data DashboardStats `json:"data"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
DisplayName *string `json:"display_name" validate:"omitempty,min=1,max=100"`
|
||||
AvatarURL *string `json:"avatar_url" validate:"omitempty,url"`
|
||||
@@ -30,6 +41,7 @@ func ToProfileData(u User) ProfileData {
|
||||
DisplayName: u.DisplayName,
|
||||
AvatarURL: u.AvatarURL,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
HasPassword: u.PasswordHash != nil,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
@@ -83,6 +83,19 @@ func (h *Handler) RestoreProfile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": "account restored"}})
|
||||
}
|
||||
|
||||
func (h *Handler) GetDashboardStats(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
stats, err := h.service.GetDashboardStats(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to get dashboard stats")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, DashboardStatsResponse{Data: stats})
|
||||
}
|
||||
|
||||
func getUserID(c *gin.Context) uuid.UUID {
|
||||
v, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/pgerr"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,14 +19,17 @@ var (
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, email, passwordHash, displayName string) (User, error)
|
||||
CreateOAuthUser(ctx context.Context, email, displayName string, emailVerified bool) (User, error)
|
||||
Create(ctx context.Context, email, passwordHash, displayName, role, status string) (User, error)
|
||||
CreateOAuthUser(ctx context.Context, email, displayName, avatarURL string, emailVerified bool) (User, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (User, error)
|
||||
GetByEmail(ctx context.Context, email string) (User, error)
|
||||
Update(ctx context.Context, id uuid.UUID, fields map[string]any) (User, error)
|
||||
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)
|
||||
GetDashboardStats(ctx context.Context, userID uuid.UUID) (DashboardStats, error)
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
@@ -35,18 +40,18 @@ func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayName string) (User, error) {
|
||||
func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayName, role, status string) (User, error) {
|
||||
var u User
|
||||
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
|
||||
`, email, passwordHash, displayName).Scan(
|
||||
INSERT INTO users (email, password_hash, display_name, role, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
|
||||
`, email, passwordHash, displayName, role, status).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) {
|
||||
if pgerr.IsDuplicateKey(err) {
|
||||
return User{}, ErrEmailAlreadyTaken
|
||||
}
|
||||
return User{}, fmt.Errorf("creating user: %w", err)
|
||||
@@ -54,18 +59,18 @@ func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayN
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) CreateOAuthUser(ctx context.Context, email, displayName string, emailVerified bool) (User, error) {
|
||||
func (r *pgRepository) CreateOAuthUser(ctx context.Context, email, displayName, avatarURL string, emailVerified bool) (User, error) {
|
||||
var u User
|
||||
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
|
||||
`, email, emailVerified, displayName).Scan(
|
||||
INSERT INTO users (email, email_verified, display_name, avatar_url)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
|
||||
`, email, emailVerified, displayName, avatarURL).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) {
|
||||
if pgerr.IsDuplicateKey(err) {
|
||||
return User{}, ErrEmailAlreadyTaken
|
||||
}
|
||||
return User{}, fmt.Errorf("creating oauth user: %w", err)
|
||||
@@ -88,12 +93,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 +130,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 +158,75 @@ 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) GetDashboardStats(ctx context.Context, userID uuid.UUID) (DashboardStats, error) {
|
||||
var s DashboardStats
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int
|
||||
FROM market_series
|
||||
WHERE created_by = $1) AS owned_markets,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM applications a
|
||||
JOIN market_editions e ON a.market_edition_id = e.id
|
||||
JOIN market_series s ON e.series_id = s.id
|
||||
WHERE s.created_by = $1 AND a.status = 'submitted') AS pending_applications,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM message_threads t
|
||||
JOIN message_thread_participants p ON p.thread_id = t.id
|
||||
WHERE p.user_id = $1
|
||||
AND (p.last_read_at IS NULL OR p.last_read_at < t.last_message_at)) AS unread_threads
|
||||
`, userID).Scan(&s.OwnedMarkets, &s.PendingApplications, &s.UnreadThreads)
|
||||
if err != nil {
|
||||
return DashboardStats{}, fmt.Errorf("getting dashboard stats: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) Restore(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := r.db.Exec(ctx, `
|
||||
UPDATE users SET deleted_at = NULL
|
||||
@@ -166,22 +240,3 @@ func (r *pgRepository) Restore(ctx context.Context, id uuid.UUID) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDuplicateKeyError(err error) bool {
|
||||
return err != nil && (fmt.Sprintf("%v", err) == "ERROR: duplicate key value violates unique constraint" ||
|
||||
contains(err.Error(), "duplicate key") ||
|
||||
contains(err.Error(), "23505"))
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
func searchString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -9,5 +9,6 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc
|
||||
users.PATCH("/me", requireAuth, h.UpdateProfile)
|
||||
users.DELETE("/me", requireAuth, h.DeleteProfile)
|
||||
users.POST("/me/restore", requireAuth, h.RestoreProfile)
|
||||
users.GET("/me/stats", requireAuth, h.GetDashboardStats)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,3 +58,7 @@ func (s *Service) Restore(ctx context.Context, userID uuid.UUID) error {
|
||||
|
||||
return s.repo.Restore(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) GetDashboardStats(ctx context.Context, userID uuid.UUID) (DashboardStats, error) {
|
||||
return s.repo.GetDashboardStats(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func RequireAuth(repo SessionLookup, accessTTL time.Duration) gin.HandlerFunc {
|
||||
c.Set("user_id", session.UserID)
|
||||
c.Set("user_email", session.UserEmail)
|
||||
c.Set("user_role", session.UserRole)
|
||||
c.Set("email_verified", session.EmailVerified)
|
||||
c.Set("session_id", session.ID)
|
||||
c.Next()
|
||||
}
|
||||
@@ -51,6 +52,7 @@ func OptionalAuth(repo SessionLookup, accessTTL time.Duration) gin.HandlerFunc {
|
||||
c.Set("user_id", session.UserID)
|
||||
c.Set("user_email", session.UserEmail)
|
||||
c.Set("user_role", session.UserRole)
|
||||
c.Set("email_verified", session.EmailVerified)
|
||||
c.Set("session_id", session.ID)
|
||||
}
|
||||
c.Next()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
)
|
||||
|
||||
// RequireEmailVerified rejects requests from authenticated users whose email
|
||||
// address has not yet been confirmed. Must be applied after RequireAuth.
|
||||
func RequireEmailVerified() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
verified, _ := c.Get("email_verified")
|
||||
if v, ok := verified.(bool); !ok || !v {
|
||||
apiErr := apierror.Forbidden("email address not verified")
|
||||
c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/auth"
|
||||
"marktvogt.de/backend/internal/middleware"
|
||||
)
|
||||
|
||||
func TestRequireEmailVerified_UnverifiedUser_Returns403(t *testing.T) {
|
||||
stub := &stubSessionRepo{
|
||||
session: auth.Session{
|
||||
ID: uuid.New(),
|
||||
UserID: uuid.New(),
|
||||
UserEmail: "a@b.c",
|
||||
UserRole: "user",
|
||||
EmailVerified: false,
|
||||
LastUsedAt: time.Now().Add(-2 * time.Minute),
|
||||
AccessExpiresAt: time.Now().Add(28 * time.Minute),
|
||||
},
|
||||
}
|
||||
noop := func(c *gin.Context) {}
|
||||
r := newRouter(noop, middleware.RequireAuth(stub, 30*time.Minute), middleware.RequireEmailVerified())
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, bearerReq("any-token"))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 Forbidden, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireEmailVerified_VerifiedUser_Passes(t *testing.T) {
|
||||
stub := &stubSessionRepo{
|
||||
session: auth.Session{
|
||||
ID: uuid.New(),
|
||||
UserID: uuid.New(),
|
||||
UserEmail: "a@b.c",
|
||||
UserRole: "user",
|
||||
EmailVerified: true,
|
||||
LastUsedAt: time.Now().Add(-2 * time.Minute),
|
||||
AccessExpiresAt: time.Now().Add(28 * time.Minute),
|
||||
},
|
||||
}
|
||||
reached := false
|
||||
handler := func(c *gin.Context) { reached = true }
|
||||
r := newRouter(handler, middleware.RequireAuth(stub, 30*time.Minute), middleware.RequireEmailVerified())
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, bearerReq("any-token"))
|
||||
if !reached {
|
||||
t.Fatal("handler not reached for verified user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireEmailVerified_Unauthenticated_Returns401(t *testing.T) {
|
||||
stub := &stubSessionRepo{err: auth.ErrSessionNotFound}
|
||||
noop := func(c *gin.Context) {}
|
||||
r := newRouter(noop, middleware.RequireAuth(stub, 30*time.Minute), middleware.RequireEmailVerified())
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, bearerReq("bad-token"))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ func init() {
|
||||
contentFiles := []string{
|
||||
"market_submission",
|
||||
"magic_link",
|
||||
"password_reset",
|
||||
"email_verify",
|
||||
}
|
||||
|
||||
templates = make(map[string]*template.Template, len(contentFiles))
|
||||
@@ -67,6 +69,14 @@ type MagicLinkData struct {
|
||||
ExpiresMin int
|
||||
}
|
||||
|
||||
type PasswordResetData struct {
|
||||
ResetURL string
|
||||
}
|
||||
|
||||
type EmailVerifyData struct {
|
||||
VerifyURL string
|
||||
}
|
||||
|
||||
func Render(name string, data TemplateData) (string, error) {
|
||||
tmpl, ok := templates[name]
|
||||
if !ok {
|
||||
|
||||
@@ -6,32 +6,21 @@
|
||||
<title>Marktvogt</title>
|
||||
<!--[if mso]><style>table,td{font-family:Georgia,'Times New Roman',serif!important}</style><![endif]-->
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f5f0e8;font-family:Georgia,'Times New Roman',serif;">
|
||||
<body style="margin:0;padding:0;background-color:#f5efe4;font-family:Georgia,'Times New Roman',serif;">
|
||||
{{if .PreheaderText}}<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">{{.PreheaderText}}</div>{{end}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f5f0e8;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f5efe4;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 16px;">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="background-color:#14472a;padding:28px 24px;border-radius:8px 8px 0 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:12px;">
|
||||
<div style="width:48px;height:48px;border-radius:50%;background-color:#d4a63a;text-align:center;line-height:48px;font-size:22px;color:#14472a;">⚔</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<span style="font-size:26px;font-weight:bold;color:#d4a63a;letter-spacing:1px;">Marktvogt</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<td align="center" style="background-color:#f5efe4;padding:28px 24px 20px;">
|
||||
<span style="font-size:13px;font-weight:700;color:#181410;letter-spacing:0.18em;font-family:Georgia,'Times New Roman',serif;text-transform:uppercase;">Marktvogt</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Gold accent line -->
|
||||
<!-- Rule -->
|
||||
<tr>
|
||||
<td style="background-color:#d4a63a;height:3px;font-size:1px;line-height:1px;"> </td>
|
||||
<td style="background-color:#c9b58c;height:1px;font-size:1px;line-height:1px;"> </td>
|
||||
</tr>
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
@@ -41,12 +30,12 @@
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:#f5f3f0;padding:20px 28px;border-radius:0 0 8px 8px;border-top:1px solid #e8e5e0;">
|
||||
<td style="background-color:#f5efe4;padding:20px 28px;border-top:1px solid #c9b58c;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="font-size:13px;color:#8a8580;line-height:1.5;">
|
||||
<td align="center" style="font-size:12px;color:#6e6253;letter-spacing:0.1em;text-transform:uppercase;font-family:Georgia,'Times New Roman',serif;">
|
||||
© {{.Year}} Marktvogt ·
|
||||
<a href="{{.BaseURL}}" style="color:#1a6b3a;text-decoration:none;">marktvogt.de</a>
|
||||
<a href="{{.BaseURL}}" style="color:#9a1e2c;text-decoration:none;">marktvogt.de</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{{define "content"}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-size:20px;font-weight:bold;color:#181410;padding-bottom:8px;">
|
||||
E-Mail-Adresse bestätigen
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;color:#3a322a;padding-bottom:24px;line-height:1.6;">
|
||||
Klicke auf den Button, um deine E-Mail-Adresse zu bestätigen. Der Link ist 48 Stunden gültig.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="background-color:#9a1e2c;">
|
||||
<a href="{{.Content.VerifyURL}}" style="display:inline-block;padding:14px 32px;font-size:15px;font-weight:bold;color:#f5efe4;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">E-Mail bestätigen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6e6253;line-height:1.5;border-top:1px solid #c9b58c;padding-top:16px;">
|
||||
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br/>
|
||||
<a href="{{.Content.VerifyURL}}" style="color:#9a1e2c;word-break:break-all;text-decoration:none;">{{.Content.VerifyURL}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6e6253;padding-top:16px;line-height:1.5;">
|
||||
Du hast kein Konto bei Marktvogt angelegt? Dann kannst du diese E-Mail ignorieren.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{end}}
|
||||
@@ -1,12 +1,12 @@
|
||||
{{define "content"}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-size:20px;font-weight:bold;color:#14472a;padding-bottom:8px;">
|
||||
<td style="font-size:20px;font-weight:bold;color:#181410;padding-bottom:8px;">
|
||||
Dein Anmeldelink
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;color:#4a4745;padding-bottom:24px;line-height:1.5;">
|
||||
<td style="font-size:15px;color:#3a322a;padding-bottom:24px;line-height:1.6;">
|
||||
Klicke auf den Button, um dich bei Marktvogt anzumelden. Der Link ist {{.Content.ExpiresMin}} Minuten gültig.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -14,21 +14,21 @@
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="background-color:#1a6b3a;border-radius:6px;">
|
||||
<a href="{{.Content.MagicURL}}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:bold;color:#ffffff;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Jetzt anmelden</a>
|
||||
<td align="center" style="background-color:#9a1e2c;">
|
||||
<a href="{{.Content.MagicURL}}" style="display:inline-block;padding:14px 32px;font-size:15px;font-weight:bold;color:#f5efe4;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Jetzt anmelden</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#8a8580;line-height:1.5;border-top:1px solid #e8e5e0;padding-top:16px;">
|
||||
<td style="font-size:13px;color:#6e6253;line-height:1.5;border-top:1px solid #c9b58c;padding-top:16px;">
|
||||
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br/>
|
||||
<a href="{{.Content.MagicURL}}" style="color:#1a6b3a;word-break:break-all;text-decoration:none;">{{.Content.MagicURL}}</a>
|
||||
<a href="{{.Content.MagicURL}}" style="color:#9a1e2c;word-break:break-all;text-decoration:none;">{{.Content.MagicURL}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#8a8580;padding-top:16px;line-height:1.5;">
|
||||
<td style="font-size:13px;color:#6e6253;padding-top:16px;line-height:1.5;">
|
||||
Du hast diese E-Mail nicht angefordert? Dann kannst du sie ignorieren.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
{{define "content"}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-size:20px;font-weight:bold;color:#14472a;padding-bottom:8px;">
|
||||
<td style="font-size:20px;font-weight:bold;color:#181410;padding-bottom:8px;">
|
||||
Neuer Markt eingereicht
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;color:#4a4745;padding-bottom:20px;line-height:1.5;">
|
||||
<td style="font-size:15px;color:#3a322a;padding-bottom:20px;line-height:1.6;">
|
||||
Ein neuer Markt wurde zur Prüfung eingereicht.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #e8e5e0;border-radius:6px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #c9b58c;">
|
||||
<tr>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;width:140px;">Marktname</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;font-weight:bold;">{{.Content.MarketName}}</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;width:140px;">Marktname</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:15px;color:#181410;font-weight:bold;">{{.Content.MarketName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Stadt</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.City}}</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;">Stadt</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:15px;color:#3a322a;">{{.Content.City}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Zeitraum</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.StartDate}} – {{.Content.EndDate}}</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;">Zeitraum</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:15px;color:#3a322a;">{{.Content.StartDate}} – {{.Content.EndDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Eingereicht von</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.SubmitterName}}</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;">Eingereicht von</td>
|
||||
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:15px;color:#3a322a;">{{.Content.SubmitterName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 16px;font-size:13px;color:#8a8580;">E-Mail</td>
|
||||
<td style="padding:12px 16px;font-size:15px;color:#3a3836;">
|
||||
<a href="mailto:{{.Content.SubmitterEmail}}" style="color:#1a6b3a;text-decoration:none;">{{.Content.SubmitterEmail}}</a>
|
||||
<td style="padding:12px 16px;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;">E-Mail</td>
|
||||
<td style="padding:12px 16px;font-size:15px;color:#3a322a;">
|
||||
<a href="mailto:{{.Content.SubmitterEmail}}" style="color:#9a1e2c;text-decoration:none;">{{.Content.SubmitterEmail}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -42,8 +42,8 @@
|
||||
<td align="center" style="padding-top:24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="background-color:#1a6b3a;border-radius:6px;">
|
||||
<a href="{{.Content.AdminURL}}" style="display:inline-block;padding:12px 28px;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Im Admin-Panel prüfen</a>
|
||||
<td align="center" style="background-color:#9a1e2c;">
|
||||
<a href="{{.Content.AdminURL}}" style="display:inline-block;padding:12px 28px;font-size:15px;font-weight:bold;color:#f5efe4;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Im Admin-Panel prüfen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{{define "content"}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-size:20px;font-weight:bold;color:#181410;padding-bottom:8px;">
|
||||
Passwort zurücksetzen
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;color:#3a322a;padding-bottom:24px;line-height:1.6;">
|
||||
Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt. Klicke auf den Button, um ein neues Passwort zu wählen. Der Link ist 1 Stunde gültig.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="background-color:#9a1e2c;">
|
||||
<a href="{{.Content.ResetURL}}" style="display:inline-block;padding:14px 32px;font-size:15px;font-weight:bold;color:#f5efe4;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Passwort zurücksetzen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6e6253;line-height:1.5;border-top:1px solid #c9b58c;padding-top:16px;">
|
||||
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br/>
|
||||
<a href="{{.Content.ResetURL}}" style="color:#9a1e2c;word-break:break-all;text-decoration:none;">{{.Content.ResetURL}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6e6253;padding-top:16px;line-height:1.5;">
|
||||
Du hast keine Passwortzurücksetzung angefordert? Dann kannst du diese E-Mail ignorieren. Dein Passwort bleibt unverändert.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
package pgerr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// IsDuplicateKey reports whether err is a PostgreSQL unique constraint violation (SQLSTATE 23505).
|
||||
func IsDuplicateKey(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
@@ -2,16 +2,24 @@ 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/favorite"
|
||||
"marktvogt.de/backend/internal/domain/group"
|
||||
"marktvogt.de/backend/internal/domain/lagerleben"
|
||||
"marktvogt.de/backend/internal/domain/market"
|
||||
"marktvogt.de/backend/internal/domain/message"
|
||||
"marktvogt.de/backend/internal/domain/program"
|
||||
"marktvogt.de/backend/internal/domain/settings"
|
||||
"marktvogt.de/backend/internal/domain/user"
|
||||
"marktvogt.de/backend/internal/middleware"
|
||||
@@ -50,6 +58,7 @@ func (s *Server) registerRoutes() {
|
||||
})
|
||||
authHandler := auth.NewHandler(authSvc, userRepo)
|
||||
requireAuth := middleware.RequireAuth(authRepo, s.cfg.Auth.AccessTTL)
|
||||
requireEmailVerified := middleware.RequireEmailVerified()
|
||||
|
||||
// Per-route auth rate limiters (keyed by IP; user_id unavailable before auth completes)
|
||||
userIDKey := func(c *gin.Context) string {
|
||||
@@ -58,17 +67,17 @@ func (s *Server) registerRoutes() {
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
loginLimit := middleware.RateLimitByKey(0.2, 5, middleware.IPKey) // 1 per 5s, burst 5
|
||||
refreshLimit := middleware.RateLimitByKey(1, 10, middleware.IPKey) // 1/s, burst 10
|
||||
twoFALimit := middleware.RateLimitByKey(0.2, 5, middleware.IPKey) // 1 per 5s, burst 5
|
||||
passwordLimit := middleware.RateLimitByKey(0.1, 3, userIDKey) // 1 per 10s, burst 3
|
||||
magicLinkLimit := middleware.RateLimitByKey(0.1, 3, middleware.IPKey) // 1 per 10s, burst 3
|
||||
loginLimit := middleware.RateLimitByKey(0.2, 5, middleware.IPKey) // 1 per 5s, burst 5
|
||||
refreshLimit := middleware.RateLimitByKey(1, 10, middleware.IPKey) // 1/s, burst 10
|
||||
twoFALimit := middleware.RateLimitByKey(0.2, 5, middleware.IPKey) // 1 per 5s, burst 5
|
||||
passwordLimit := middleware.RateLimitByKey(0.1, 3, userIDKey) // 1 per 10s, burst 3
|
||||
magicLinkLimit := middleware.RateLimitByKey(0.1, 3, middleware.IPKey) // 1 per 10s, burst 3
|
||||
emailVerifyLimit := middleware.RateLimitByKey(1.0/20.0/60.0, 3, userIDKey) // 1 per 20min, burst 3
|
||||
|
||||
auth.RegisterRoutes(v1, authHandler, requireAuth, loginLimit, refreshLimit, twoFALimit, passwordLimit)
|
||||
|
||||
// OAuth routes — disabled until provider apps are configured
|
||||
// oauthHandler := auth.NewOAuthHandler(s.cfg.OAuth, authSvc, userRepo, authRepo)
|
||||
// auth.RegisterOAuthRoutes(v1, oauthHandler)
|
||||
oauthHandler := auth.NewOAuthHandler(s.cfg.OAuth, s.cfg.Notification.FrontendURL, authSvc, userRepo, authRepo)
|
||||
auth.RegisterOAuthRoutes(v1, oauthHandler)
|
||||
|
||||
// Shared email sender
|
||||
emailSender := email.New(
|
||||
@@ -80,11 +89,46 @@ func (s *Server) registerRoutes() {
|
||||
magicLinkHandler := auth.NewMagicLinkHandler(authRepo, userRepo, authSvc, s.cfg.Magic, emailSender, s.cfg.Notification.FrontendURL)
|
||||
auth.RegisterMagicLinkRoutes(v1, magicLinkHandler, magicLinkLimit)
|
||||
|
||||
// Password reset routes
|
||||
pwResetHandler := auth.NewPasswordResetHandler(authRepo, userRepo, emailSender, s.cfg.Notification.FrontendURL)
|
||||
auth.RegisterPasswordResetRoutes(v1, pwResetHandler, magicLinkLimit)
|
||||
|
||||
// Email verification routes
|
||||
emailVerifyHandler := auth.NewEmailVerifyHandler(authRepo, userRepo, emailSender, s.cfg.Notification.FrontendURL)
|
||||
auth.RegisterEmailVerifyRoutes(v1, emailVerifyHandler, requireAuth, emailVerifyLimit)
|
||||
// Wire the verify-send function into the auth service so Register() dispatches it.
|
||||
authSvc.SetEmailVerifySender(emailVerifyHandler.SendVerifyEmail)
|
||||
|
||||
// User profile routes
|
||||
userSvc := user.NewService(userRepo)
|
||||
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, requireEmailVerified)
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -144,6 +188,61 @@ func (s *Server) registerRoutes() {
|
||||
// AI settings routes
|
||||
settingsHandler := settings.NewHandler(aiProvider, settingsStore, usageRepo)
|
||||
settings.RegisterRoutes(v1, settingsHandler, requireAuth, requireAdmin)
|
||||
|
||||
// Favorite routes
|
||||
favoriteRepo := favorite.NewRepository(s.db)
|
||||
favoriteSvc := favorite.NewService(favoriteRepo)
|
||||
favoriteHandler := favorite.NewHandler(favoriteSvc)
|
||||
favorite.RegisterRoutes(v1, favoriteHandler, requireAuth)
|
||||
|
||||
// Message routes
|
||||
messageRepo := message.NewRepository(s.db)
|
||||
messageSvc := message.NewService(messageRepo)
|
||||
messageHandler := message.NewHandler(messageSvc)
|
||||
message.RegisterRoutes(v1, messageHandler, requireAuth)
|
||||
|
||||
// Program routes — MarketOwnerChecker is adapted from marketRepo
|
||||
// to avoid a direct import of the market package from the program package.
|
||||
marketOwnerChecker := &marketOwnerAdapter{repo: marketRepo}
|
||||
programRepo := program.NewRepository(s.db)
|
||||
programSvc := program.NewService(programRepo)
|
||||
programHandler := program.NewHandler(programSvc, marketOwnerChecker)
|
||||
program.RegisterRoutes(v1, programHandler, requireAuth)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// marketOwnerAdapter adapts market.Repository to program.MarketOwnerChecker
|
||||
// so the program package does not import the market package directly.
|
||||
type marketOwnerAdapter struct {
|
||||
repo market.Repository
|
||||
}
|
||||
|
||||
func (a *marketOwnerAdapter) IsOwner(ctx context.Context, marketID, userID uuid.UUID) (bool, error) {
|
||||
return a.repo.IsOwner(ctx, marketID, userID)
|
||||
}
|
||||
|
||||
func (s *Server) healthz(c *gin.Context) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS lagerleben_camps;
|
||||
DROP TABLE IF EXISTS lagerleben_articles;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS password_reset_tokens;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_prt_token_hash ON password_reset_tokens(token_hash);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS email_verify_tokens;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE email_verify_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_evt_token_hash ON email_verify_tokens(token_hash);
|
||||
CREATE INDEX idx_evt_user_id ON email_verify_tokens(user_id);
|
||||
|
||||
-- Existing accounts pre-date the verify gate; mark them verified so they
|
||||
-- are not locked out when RequireEmailVerified is applied to POST /applications.
|
||||
UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_favorites_user_created;
|
||||
DROP TABLE IF EXISTS favorites;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE favorites (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
market_id UUID NOT NULL REFERENCES market_series(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, market_id)
|
||||
);
|
||||
CREATE INDEX idx_favorites_user_created ON favorites(user_id, created_at DESC);
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS idx_thread_participants_user;
|
||||
DROP INDEX IF EXISTS idx_messages_thread_created;
|
||||
DROP TABLE IF EXISTS messages;
|
||||
DROP TABLE IF EXISTS message_thread_participants;
|
||||
DROP TABLE IF EXISTS message_threads;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE message_threads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject TEXT NOT NULL,
|
||||
context_type TEXT NOT NULL,
|
||||
context_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_message_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE message_thread_participants (
|
||||
thread_id UUID NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
last_read_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (thread_id, user_id)
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
thread_id UUID NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE,
|
||||
sender_id UUID NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
edited_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX idx_messages_thread_created ON messages(thread_id, created_at DESC);
|
||||
CREATE INDEX idx_thread_participants_user ON message_thread_participants(user_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS idx_program_acts_stage;
|
||||
DROP INDEX IF EXISTS idx_market_programs_market;
|
||||
DROP TABLE IF EXISTS program_acts;
|
||||
DROP TABLE IF EXISTS program_stages;
|
||||
DROP TABLE IF EXISTS market_programs;
|
||||
ALTER TABLE market_series DROP COLUMN IF EXISTS created_by;
|
||||
@@ -0,0 +1,29 @@
|
||||
ALTER TABLE market_series ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id);
|
||||
|
||||
CREATE TABLE market_programs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
market_id UUID NOT NULL REFERENCES market_series(id) ON DELETE CASCADE,
|
||||
edition_id UUID REFERENCES market_editions(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE program_stages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
program_id UUID NOT NULL REFERENCES market_programs(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE program_acts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
stage_id UUID NOT NULL REFERENCES program_stages(id) ON DELETE CASCADE,
|
||||
group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_market_programs_market ON market_programs(market_id);
|
||||
CREATE INDEX idx_program_acts_stage ON program_acts(stage_id);
|
||||
@@ -237,6 +237,7 @@ web:
|
||||
HOST: "0.0.0.0"
|
||||
PUBLIC_TURNSTILE_SITE_KEY: "0x4AAAAAACjLCV-78Ql1oTPz"
|
||||
PRIVATE_API_BASE_URL: "http://marktvogt-backend"
|
||||
PUBLIC_OAUTH_GOOGLE: "true"
|
||||
|
||||
networkPolicy:
|
||||
# Audit H16: web has no NetworkPolicy template historically; this enables
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Marktvogt -- Newsletter (Saisonbrief)
|
||||
|
||||
## Scope & Activation Gate
|
||||
|
||||
The public opt-in form on the homepage is **disabled** until the full
|
||||
newsletter lifecycle is in place. Enabling opt-in without a send flow
|
||||
would collect addresses into a dead-end table -- not acceptable.
|
||||
|
||||
**Activation order:**
|
||||
1. Backend: subscriber table + subscribe/confirm/unsubscribe endpoints
|
||||
2. Backend: newsletter table + admin send flow
|
||||
3. Frontend admin: newsletter creation + send UI (proper planning required)
|
||||
4. Frontend public: enable opt-in form
|
||||
5. New subscriber welcome: last sent newsletter is mailed on confirm
|
||||
|
||||
---
|
||||
|
||||
## Subscriber Lifecycle
|
||||
|
||||
```
|
||||
anonymous -> POST /newsletter/subscribe (Turnstile) -> insert unconfirmed
|
||||
-> send confirm email (double opt-in) -> GET /newsletter/confirm/:token
|
||||
-> status=confirmed -> optionally send last newsletter
|
||||
-> ... receives newsletters ...
|
||||
-> GET /newsletter/unsubscribe/:token -> confirm-page (one-click with visual confirm)
|
||||
-> status=unsubscribed (row kept for audit, never re-added without fresh opt-in)
|
||||
```
|
||||
|
||||
### Why double opt-in
|
||||
- GDPR: legitimate interest requires explicit consent; confirmed opt-in is
|
||||
the cleanest proof
|
||||
- Prevents spam/typo additions (someone entering another person's email)
|
||||
|
||||
### Unsubscribe UX
|
||||
- Every newsletter email contains a prominent **"Abbestellen"** link,
|
||||
not a small gray footer line
|
||||
- The link is a signed, expiring URL with the subscriber's token
|
||||
- Clicking opens a simple page: "Saisonbrief abbestellen?" + one confirm button
|
||||
- No login required, no re-entry of email
|
||||
- After confirm: friendly "Du wirst nicht mehr kontaktiert." page + homepage link
|
||||
- Unsubscribe is permanent; subscriber must re-opt-in from scratch if they
|
||||
change their mind
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### newsletter_subscribers
|
||||
|
||||
```sql
|
||||
CREATE TABLE newsletter_subscribers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | confirmed | unsubscribed
|
||||
confirm_token_hash TEXT UNIQUE,
|
||||
confirm_expires_at TIMESTAMPTZ,
|
||||
unsubscribe_token_hash TEXT NOT NULL UNIQUE, -- long-lived, rotated on each send
|
||||
subscribed_at TIMESTAMPTZ,
|
||||
unsubscribed_at TIMESTAMPTZ,
|
||||
last_newsletter_id UUID, -- FK newsletters(id), last delivered
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_ns_status ON newsletter_subscribers(status);
|
||||
CREATE INDEX idx_ns_confirm_token ON newsletter_subscribers(confirm_token_hash);
|
||||
CREATE INDEX idx_ns_unsub_token ON newsletter_subscribers(unsubscribe_token_hash);
|
||||
```
|
||||
|
||||
### newsletters
|
||||
|
||||
```sql
|
||||
CREATE TABLE newsletters (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL, -- Burgund-styled, stored as rendered HTML
|
||||
body_text TEXT NOT NULL, -- plain-text fallback
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft | sending | sent
|
||||
sent_at TIMESTAMPTZ,
|
||||
sent_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Public
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | /newsletter/subscribe | Turnstile | Insert pending subscriber, send confirm email |
|
||||
| GET | /newsletter/confirm/:token | none | Confirm subscription, optionally send last newsletter |
|
||||
| GET | /newsletter/unsubscribe/:token | none | Show confirm page |
|
||||
| POST | /newsletter/unsubscribe/:token | none | Execute unsubscribe |
|
||||
|
||||
### Admin (requires admin role)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | /admin/newsletters | admin | List all newsletters (paginated) |
|
||||
| POST | /admin/newsletters | admin | Create draft |
|
||||
| GET | /admin/newsletters/:id | admin | View newsletter |
|
||||
| PATCH | /admin/newsletters/:id | admin | Update draft (subject, body) |
|
||||
| POST | /admin/newsletters/:id/send | admin | Send to all confirmed subscribers |
|
||||
| GET | /admin/newsletter-subscribers | admin | List subscribers with status filter |
|
||||
| DELETE | /admin/newsletter-subscribers/:id | admin | Hard-delete a subscriber (GDPR erasure) |
|
||||
|
||||
---
|
||||
|
||||
## Admin UI (requires planning before build)
|
||||
|
||||
This is a mini editor, not just a form. Needs proper planning session.
|
||||
|
||||
Open questions to resolve before building:
|
||||
1. **Editor type**: raw HTML (risky, but full control) vs. markdown-to-HTML
|
||||
vs. block editor (simplest for non-devs). Recommendation: Markdown + live
|
||||
preview rendered with Burgund styles. Store both raw Markdown and rendered HTML.
|
||||
2. **Preview / test send**: admin should be able to send to themselves before
|
||||
sending to all subscribers
|
||||
3. **Send confirmation**: explicit "send to N subscribers?" confirmation modal
|
||||
with subscriber count before firing
|
||||
4. **Scheduling**: send now vs. scheduled send (nice-to-have; defer to v2)
|
||||
5. **Unsubscribe token rotation**: rotate per send (more privacy, harder to
|
||||
extract list from leaked tokens) vs. per subscriber (simpler)
|
||||
-- Recommendation: per send (fresh token in each email, old tokens invalidated)
|
||||
6. **Bounce handling**: no infrastructure for this yet; v1 ignores bounces,
|
||||
v2 integrates bounce webhooks from SMTP provider
|
||||
|
||||
---
|
||||
|
||||
## Email Template
|
||||
|
||||
Newsletter emails use the Burgund base template but with a full-width
|
||||
content area and a more article-like layout:
|
||||
|
||||
- Display-font headline (subject)
|
||||
- Optional intro paragraph
|
||||
- Body content (markdown-rendered sections)
|
||||
- Prominent "Abbestellen" button/link above the footer rule
|
||||
(not buried in footer text)
|
||||
- Footer: copyright + marktvogt.de link
|
||||
|
||||
The unsubscribe link placement is a first-class design element, not an
|
||||
afterthought. A user who wants to stop receiving email should find the
|
||||
link immediately without hunting.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- Double opt-in: confirm token, 48h TTL
|
||||
- Unsubscribe: signed token, no auth required, no re-entry of email
|
||||
- Admin send: rate-limited, requires 2FA if admin account has it enabled
|
||||
- No subscriber email addresses exposed in any public response
|
||||
- Turnstile on subscribe form (bot protection)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Migration: newsletter_subscribers + newsletters tables
|
||||
2. Backend domain: subscriber CRUD + confirm/unsubscribe handlers
|
||||
3. Email templates: confirm + newsletter (Burgund style) + unsubscribe CTA
|
||||
4. Admin API: newsletter CRUD + send endpoint
|
||||
5. Admin UI: plan separately, then build (see open questions above)
|
||||
6. Frontend: enable opt-in form, integrate Turnstile
|
||||
7. On confirm: fetch and send latest newsletter if one exists
|
||||
|
||||
**Do not enable the opt-in form until step 4 (admin send) is complete.**
|
||||
+12
-1
@@ -1,4 +1,15 @@
|
||||
PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# Cloudflare Turnstile (site key - public, safe to expose)
|
||||
PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# Local dev test keys (always pass): use 1x00000000000000000000AA
|
||||
# Always-block test key: 2x00000000000000000000AB
|
||||
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
|
||||
# Cloudflare Turnstile secret key (server-side verification, never expose to client)
|
||||
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
|
||||
# OAuth providers — set to any non-empty value to enable the button in the UI
|
||||
# Must match backend OAUTH_*_CLIENT_ID being configured
|
||||
PUBLIC_OAUTH_GOOGLE=
|
||||
PUBLIC_OAUTH_GITHUB=
|
||||
PUBLIC_OAUTH_FACEBOOK=
|
||||
|
||||
@@ -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
|
||||
<MarktvogtMark size={36} />
|
||||
```
|
||||
|
||||
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
|
||||
<Caps size={10}>Hessen · Nr. 015</Caps>
|
||||
```
|
||||
|
||||
### `Tag`
|
||||
|
||||
Pill/badge. Props: `accent` (boolean, default false). Accent variant: bg + border in accent color, on-accent foreground.
|
||||
|
||||
```svelte
|
||||
<Tag accent>Empfohlen</Tag>
|
||||
<Tag>Burg</Tag>
|
||||
```
|
||||
|
||||
### `Rule`
|
||||
|
||||
Section divider. Props: `kind`: `'thin'` | `'double'` | `'ornament'` (default `'thin'`).
|
||||
|
||||
```svelte
|
||||
<Rule kind="ornament" />
|
||||
<!-- ─────── ✦ ─────── -->
|
||||
<Rule kind="double" />
|
||||
<!-- thin + thin gap -->
|
||||
<Rule />
|
||||
<!-- single rule-soft border -->
|
||||
```
|
||||
|
||||
### `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
|
||||
<Heraldry seed={market.slug} />
|
||||
```
|
||||
|
||||
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 `<html>` (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 |
|
||||
+4
-4
@@ -5,8 +5,8 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"dev": "NODE_OPTIONS='--disable-warning=DEP0205' vite dev",
|
||||
"build": "NODE_OPTIONS='--disable-warning=DEP0205' vite build",
|
||||
"bundle": "node scripts/bundle.mjs",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
@@ -22,7 +22,7 @@
|
||||
"@sveltejs/adapter-node": "^5.5.3",
|
||||
"@sveltejs/kit": "^2.57.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/leaflet": "^1.9.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260428.1",
|
||||
"esbuild": "^0.27.4",
|
||||
@@ -35,7 +35,7 @@
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.49.2",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.3.2"
|
||||
|
||||
Generated
+136
-136
@@ -17,19 +17,19 @@ importers:
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(eslint@10.1.0(jiti@2.6.1))
|
||||
version: 10.0.1(eslint@10.1.0(jiti@2.7.0))
|
||||
'@sveltejs/adapter-node':
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))
|
||||
version: 5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.57.1
|
||||
version: 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
version: 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^6.2.4
|
||||
version: 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
version: 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.0.0
|
||||
version: 4.2.2(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
'@types/leaflet':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.21
|
||||
@@ -41,13 +41,13 @@ importers:
|
||||
version: 0.27.4
|
||||
eslint:
|
||||
specifier: ^10.0.1
|
||||
version: 10.1.0(jiti@2.6.1)
|
||||
version: 10.1.0(jiti@2.7.0)
|
||||
eslint-config-prettier:
|
||||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@10.1.0(jiti@2.6.1))
|
||||
version: 10.1.8(eslint@10.1.0(jiti@2.7.0))
|
||||
eslint-plugin-svelte:
|
||||
specifier: ^3.15.0
|
||||
version: 3.16.0(eslint@10.1.0(jiti@2.6.1))(svelte@5.55.1)
|
||||
version: 3.16.0(eslint@10.1.0(jiti@2.7.0))(svelte@5.55.1)
|
||||
globals:
|
||||
specifier: ^17.3.0
|
||||
version: 17.4.0
|
||||
@@ -67,17 +67,17 @@ importers:
|
||||
specifier: ^4.3.6
|
||||
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.9.3)
|
||||
tailwindcss:
|
||||
specifier: ^4.0.0
|
||||
version: 4.2.2
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.56.0
|
||||
version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
version: 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
version: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -529,69 +529,69 @@ packages:
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.3.0 || ^7.0.0
|
||||
|
||||
'@tailwindcss/node@4.2.2':
|
||||
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
|
||||
'@tailwindcss/node@4.3.0':
|
||||
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.2.2':
|
||||
resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
|
||||
'@tailwindcss/oxide-android-arm64@4.3.0':
|
||||
resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.2.2':
|
||||
resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
|
||||
'@tailwindcss/oxide-darwin-arm64@4.3.0':
|
||||
resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.2.2':
|
||||
resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
|
||||
'@tailwindcss/oxide-darwin-x64@4.3.0':
|
||||
resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.2.2':
|
||||
resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
|
||||
'@tailwindcss/oxide-freebsd-x64@4.3.0':
|
||||
resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
|
||||
resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
|
||||
resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
|
||||
resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
|
||||
resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
|
||||
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
|
||||
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
|
||||
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
|
||||
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
||||
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
bundledDependencies:
|
||||
@@ -602,24 +602,24 @@ packages:
|
||||
- '@emnapi/wasi-threads'
|
||||
- tslib
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
|
||||
resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
|
||||
resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.2.2':
|
||||
resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.3.0':
|
||||
resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide@4.2.2':
|
||||
resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
|
||||
'@tailwindcss/oxide@4.3.0':
|
||||
resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@tailwindcss/vite@4.2.2':
|
||||
resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==}
|
||||
'@tailwindcss/vite@4.3.0':
|
||||
resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==}
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
@@ -829,8 +829,8 @@ packages:
|
||||
devalue@5.6.4:
|
||||
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
|
||||
|
||||
enhanced-resolve@5.20.1:
|
||||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||
enhanced-resolve@5.21.2:
|
||||
resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
esbuild@0.27.4:
|
||||
@@ -1017,8 +1017,8 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
json-buffer@3.0.1:
|
||||
@@ -1364,11 +1364,11 @@ packages:
|
||||
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tailwindcss@4.2.2:
|
||||
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
|
||||
tailwindcss@4.3.0:
|
||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||
|
||||
tapable@2.3.2:
|
||||
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
|
||||
tapable@2.3.3:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
@@ -1555,9 +1555,9 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))':
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.7.0))':
|
||||
dependencies:
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/regexpp@4.12.2': {}
|
||||
@@ -1578,9 +1578,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))':
|
||||
'@eslint/js@10.0.1(eslint@10.1.0(jiti@2.7.0))':
|
||||
optionalDependencies:
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
|
||||
'@eslint/object-schema@3.0.3': {}
|
||||
|
||||
@@ -1738,19 +1738,19 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))':
|
||||
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))':
|
||||
dependencies:
|
||||
'@rollup/plugin-commonjs': 29.0.2(rollup@4.60.1)
|
||||
'@rollup/plugin-json': 6.1.0(rollup@4.60.1)
|
||||
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.1)
|
||||
'@sveltejs/kit': 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@sveltejs/kit': 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
rollup: 4.60.1
|
||||
|
||||
'@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
'@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
'@types/cookie': 0.6.0
|
||||
acorn: 8.16.0
|
||||
cookie: 0.7.2
|
||||
@@ -1762,94 +1762,94 @@ snapshots:
|
||||
set-cookie-parser: 3.1.0
|
||||
sirv: 3.0.2
|
||||
svelte: 5.55.1
|
||||
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
obug: 2.1.1
|
||||
svelte: 5.55.1
|
||||
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
deepmerge: 4.3.1
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
svelte: 5.55.1
|
||||
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vitefu: 1.1.3(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
vitefu: 1.1.3(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
|
||||
|
||||
'@tailwindcss/node@4.2.2':
|
||||
'@tailwindcss/node@4.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
enhanced-resolve: 5.20.1
|
||||
jiti: 2.6.1
|
||||
enhanced-resolve: 5.21.2
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.2.2
|
||||
tailwindcss: 4.3.0
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.2.2':
|
||||
'@tailwindcss/oxide-android-arm64@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.2.2':
|
||||
'@tailwindcss/oxide-darwin-arm64@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.2.2':
|
||||
'@tailwindcss/oxide-darwin-x64@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.2.2':
|
||||
'@tailwindcss/oxide-freebsd-x64@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.2.2':
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.3.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide@4.2.2':
|
||||
'@tailwindcss/oxide@4.3.0':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.2.2
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.2.2
|
||||
'@tailwindcss/oxide-darwin-x64': 4.2.2
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.2.2
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.2.2
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.2.2
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.2.2
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.2.2
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.2.2
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
|
||||
'@tailwindcss/oxide-android-arm64': 4.3.0
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.3.0
|
||||
'@tailwindcss/oxide-darwin-x64': 4.3.0
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.3.0
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.0
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.3.0
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.3.0
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.3.0
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.3.0
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.0
|
||||
|
||||
'@tailwindcss/vite@4.2.2(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
'@tailwindcss/vite@4.3.0(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.2
|
||||
'@tailwindcss/oxide': 4.2.2
|
||||
tailwindcss: 4.2.2
|
||||
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
'@tailwindcss/node': 4.3.0
|
||||
'@tailwindcss/oxide': 4.3.0
|
||||
tailwindcss: 4.3.0
|
||||
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
|
||||
'@types/cookie@0.6.0': {}
|
||||
|
||||
@@ -1869,15 +1869,15 @@ snapshots:
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.58.0
|
||||
'@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.58.0
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
@@ -1885,14 +1885,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.58.0
|
||||
'@typescript-eslint/types': 8.58.0
|
||||
'@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.58.0
|
||||
debug: 4.4.3
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -1915,13 +1915,13 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.58.0
|
||||
'@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -1944,13 +1944,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1))
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.7.0))
|
||||
'@typescript-eslint/scope-manager': 8.58.0
|
||||
'@typescript-eslint/types': 8.58.0
|
||||
'@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3)
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2044,10 +2044,10 @@ snapshots:
|
||||
|
||||
devalue@5.6.4: {}
|
||||
|
||||
enhanced-resolve@5.20.1:
|
||||
enhanced-resolve@5.21.2:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.2
|
||||
tapable: 2.3.3
|
||||
|
||||
esbuild@0.27.4:
|
||||
optionalDependencies:
|
||||
@@ -2080,15 +2080,15 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)):
|
||||
eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.7.0)):
|
||||
dependencies:
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
|
||||
eslint-plugin-svelte@3.16.0(eslint@10.1.0(jiti@2.6.1))(svelte@5.55.1):
|
||||
eslint-plugin-svelte@3.16.0(eslint@10.1.0(jiti@2.7.0))(svelte@5.55.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1))
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.7.0))
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
esutils: 2.0.3
|
||||
globals: 16.5.0
|
||||
known-css-properties: 0.37.0
|
||||
@@ -2120,9 +2120,9 @@ snapshots:
|
||||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@10.1.0(jiti@2.6.1):
|
||||
eslint@10.1.0(jiti@2.7.0):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1))
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.7.0))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.23.3
|
||||
'@eslint/config-helpers': 0.5.3
|
||||
@@ -2153,7 +2153,7 @@ snapshots:
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 2.6.1
|
||||
jiti: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2263,7 +2263,7 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
jiti@2.7.0: {}
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
@@ -2539,9 +2539,9 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
zimmerframe: 1.1.4
|
||||
|
||||
tailwindcss@4.2.2: {}
|
||||
tailwindcss@4.3.0: {}
|
||||
|
||||
tapable@2.3.2: {}
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
@@ -2558,13 +2558,13 @@ snapshots:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3):
|
||||
typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 10.1.0(jiti@2.6.1)
|
||||
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
|
||||
eslint: 10.1.0(jiti@2.7.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2577,7 +2577,7 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0):
|
||||
vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@@ -2587,12 +2587,12 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
|
||||
vitefu@1.1.3(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)):
|
||||
vitefu@1.1.3(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)):
|
||||
optionalDependencies:
|
||||
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user