diff --git a/go.mod b/go.mod index 54eb167..1808a02 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 87feb21..6123a24 100644 --- a/go.sum +++ b/go.sum @@ -84,12 +84,19 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..03eebe7 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,56 @@ +// Package auth enthält die reine Logik für Anmeldung: Passwort-Hashing, +// Session-Token-Erzeugung. Es fasst keine Datenbank an — das macht +// internal/store (account, app_user, session), damit Persistenz an +// einer Stelle im Projekt gebündelt bleibt. +package auth + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// MinPasswordLength ist die einzige Passwort-Regel, die wir prüfen — +// keine erzwungenen Sonderzeichen/Ziffern-Vorgaben, die Nutzer nur zu +// vorhersehbaren Mustern verleiten. +const MinPasswordLength = 8 + +// SessionDuration ist die Gültigkeitsdauer einer neu erstellten Sitzung. +const SessionDuration = 30 * 24 * time.Hour + +// HashPassword hasht ein Klartext-Passwort für die Speicherung. Lehnt +// zu kurze Passwörter ab, statt sie stillschweigend zu hashen. +func HashPassword(password string) (string, error) { + if len(password) < MinPasswordLength { + return "", fmt.Errorf("auth: passwort muss mindestens %d Zeichen haben", MinPasswordLength) + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("auth: passwort hashen: %w", err) + } + return string(hash), nil +} + +// VerifyPassword prüft ein Klartext-Passwort gegen einen gespeicherten +// Hash. Ein Fehler bedeutet: falsches Passwort oder ungültiger Hash — +// beides führt zu "Anmeldung abgelehnt", nie zu einem Unterschied, den +// ein Angreifer für User-Enumeration nutzen könnte. +func VerifyPassword(hash, password string) error { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return fmt.Errorf("auth: passwort ungültig: %w", err) + } + return nil +} + +// NewSessionToken erzeugt ein kryptographisch zufälliges Session-Token +// (32 Byte, hex-kodiert). +func NewSessionToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("auth: token generieren: %w", err) + } + return hex.EncodeToString(buf), nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..25f58d5 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,50 @@ +package auth_test + +import ( + "testing" + + "github.com/netcell-it/deklarix/internal/auth" +) + +func TestHashPasswordRejectsShortPassword(t *testing.T) { + if _, err := auth.HashPassword("kurz"); err == nil { + t.Fatal("expected error for a too-short password, got nil") + } +} + +func TestHashAndVerifyRoundTrip(t *testing.T) { + hash, err := auth.HashPassword("ein-langes-passwort") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if err := auth.VerifyPassword(hash, "ein-langes-passwort"); err != nil { + t.Fatalf("VerifyPassword: %v", err) + } +} + +func TestVerifyPasswordRejectsWrongPassword(t *testing.T) { + hash, err := auth.HashPassword("ein-langes-passwort") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if err := auth.VerifyPassword(hash, "falsches-passwort"); err == nil { + t.Fatal("expected error for a wrong password, got nil") + } +} + +func TestNewSessionTokenIsRandomAndCorrectLength(t *testing.T) { + a, err := auth.NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + b, err := auth.NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + if a == b { + t.Fatal("expected two distinct tokens, got the same value twice") + } + if len(a) != 64 { + t.Fatalf("token length = %d, want 64 (hex of 32 random bytes)", len(a)) + } +} diff --git a/internal/store/account.go b/internal/store/account.go new file mode 100644 index 0000000..48f8120 --- /dev/null +++ b/internal/store/account.go @@ -0,0 +1,40 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als +// eigene Organisation). Jeder Beitrag gehört genau einem Account. +type Account struct { + ID string + Name string + CreatedAt time.Time +} + +// CreateAccount legt einen neuen Mandanten an. +func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) { + var a Account + err := s.Pool.QueryRow(ctx, ` + INSERT INTO account (name) VALUES ($1) + RETURNING id, name, created_at + `, name).Scan(&a.ID, &a.Name, &a.CreatedAt) + if err != nil { + return Account{}, fmt.Errorf("store: create account: %w", err) + } + return a, nil +} + +// GetAccount liest einen Mandanten anhand seiner ID. +func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { + var a Account + err := s.Pool.QueryRow(ctx, ` + SELECT id, name, created_at FROM account WHERE id = $1 + `, id).Scan(&a.ID, &a.Name, &a.CreatedAt) + if err != nil { + return Account{}, fmt.Errorf("store: get account: %w", err) + } + return a, nil +} diff --git a/internal/store/auth_test.go b/internal/store/auth_test.go new file mode 100644 index 0000000..42876a3 --- /dev/null +++ b/internal/store/auth_test.go @@ -0,0 +1,116 @@ +package store_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +func TestAccountCRUD(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + got, err := s.GetAccount(ctx, acc.ID) + if err != nil { + t.Fatalf("GetAccount: %v", err) + } + if got.Name != "Beispiel Agentur GmbH" { + t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name) + } +} + +func TestUserCRUD(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "agentur") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + byEmail, err := s.GetUserByEmail(ctx, "team@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + if byEmail.ID != user.ID { + t.Fatalf("GetUserByEmail returned a different user than CreateUser") + } + + byID, err := s.GetUser(ctx, user.ID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if byID.Email != "team@example.com" || byID.AccountID != accID { + t.Fatalf("GetUser = %+v, unerwartete Werte", byID) + } +} + +func TestGetUserByEmailNotFound(t *testing.T) { + s := openTestStore(t) + _, err := s.GetUserByEmail(context.Background(), "nichtvorhanden@example.com") + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound", err) + } +} + +func TestUserEmailIsUnique(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "creator"); err != nil { + t.Fatalf("CreateUser (1): %v", err) + } + if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "creator"); err == nil { + t.Fatal("expected error for a duplicate email, got nil") + } +} + +func TestSessionCRUD(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "marke") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond) + sess, err := s.CreateSession(ctx, "test-token-123", user.ID, expiresAt) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + got, err := s.GetSession(ctx, sess.Token) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if got.UserID != user.ID { + t.Fatalf("UserID = %q, want %q", got.UserID, user.ID) + } + + if err := s.DeleteSession(ctx, sess.Token); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + if _, err := s.GetSession(ctx, sess.Token); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err after delete = %v, want store.ErrNotFound", err) + } +} + +func TestGetSessionNotFound(t *testing.T) { + s := openTestStore(t) + _, err := s.GetSession(context.Background(), "unbekanntes-token") + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound", err) + } +} diff --git a/internal/store/crud_test.go b/internal/store/crud_test.go index bacffa9..ec535a1 100644 --- a/internal/store/crud_test.go +++ b/internal/store/crud_test.go @@ -22,11 +22,23 @@ func openTestStore(t *testing.T) *store.Store { return s } +// testAccountID legt einen Mandanten an und liefert dessen ID — jede +// Submission braucht seit der Auth-Migration einen Account. +func testAccountID(t *testing.T, s *store.Store) string { + t.Helper() + acc, err := s.CreateAccount(context.Background(), "Test-Mandant") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + return acc.ID +} + func TestSubmissionCRUD(t *testing.T) { s := openTestStore(t) ctx := context.Background() - created, err := s.CreateSubmission(ctx, "instagram", "reel", "Werbung fuer ein Produkt") + accID := testAccountID(t, s) + created, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "Werbung fuer ein Produkt") if err != nil { t.Fatalf("CreateSubmission: %v", err) } @@ -73,7 +85,8 @@ func TestExtractionCRUD(t *testing.T) { s := openTestStore(t) ctx := context.Background() - sub, err := s.CreateSubmission(ctx, "tiktok", "video", "...") + accID := testAccountID(t, s) + sub, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "...") if err != nil { t.Fatalf("CreateSubmission: %v", err) } @@ -123,7 +136,8 @@ func TestFindingCRUDAndSupersedes(t *testing.T) { s := openTestStore(t) ctx := context.Background() - sub, err := s.CreateSubmission(ctx, "instagram", "reel", "...") + accID := testAccountID(t, s) + sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...") if err != nil { t.Fatalf("CreateSubmission: %v", err) } @@ -169,7 +183,8 @@ func TestEvidencePackageCRUD(t *testing.T) { s := openTestStore(t) ctx := context.Background() - sub, err := s.CreateSubmission(ctx, "instagram", "reel", "...") + accID := testAccountID(t, s) + sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...") if err != nil { t.Fatalf("CreateSubmission: %v", err) } diff --git a/internal/store/migrations/0003_auth.down.sql b/internal/store/migrations/0003_auth.down.sql new file mode 100644 index 0000000..9ca73f7 --- /dev/null +++ b/internal/store/migrations/0003_auth.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE submission DROP COLUMN account_id; +DROP TABLE session; +DROP TABLE app_user; +DROP TABLE account; diff --git a/internal/store/migrations/0003_auth.up.sql b/internal/store/migrations/0003_auth.up.sql new file mode 100644 index 0000000..bebbf6c --- /dev/null +++ b/internal/store/migrations/0003_auth.up.sql @@ -0,0 +1,33 @@ +-- Mandantentrennung: ein account ist der Mandant (Creator, Agentur, +-- Marke oder Kanzlei als eigene Organisation), app_user ist ein Login +-- innerhalb eines accounts. session ist bewusst eine eigene Tabelle +-- statt signierter, zustandsloser Tokens — ein Logout muss eine +-- Sitzung wirklich beenden können, nicht nur clientseitig "vergessen" +-- werden. +CREATE TABLE account ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- "app_user" statt "user", da user ein reserviertes Wort in SQL ist. +CREATE TABLE app_user ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES account (id), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE session ( + token TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES app_user (id), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Jeder Beitrag gehört ab jetzt zu genau einem Mandanten. NOT NULL ohne +-- Default ist hier sicher, weil noch keine Submission-Zeilen aus der +-- Zeit vor Auth existieren (kein Kunde live). +ALTER TABLE submission ADD COLUMN account_id UUID NOT NULL REFERENCES account (id); diff --git a/internal/store/session.go b/internal/store/session.go new file mode 100644 index 0000000..8fed702 --- /dev/null +++ b/internal/store/session.go @@ -0,0 +1,62 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel +// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID, +// eine Session wird immer über ihren Token nachgeschlagen. +type Session struct { + Token string + UserID string + ExpiresAt time.Time + CreatedAt time.Time +} + +// CreateSession speichert eine neue Sitzung. token muss bereits ein +// kryptographisch zufälliges Geheimnis sein (siehe internal/auth). +func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) { + var sess Session + err := s.Pool.QueryRow(ctx, ` + INSERT INTO session (token, user_id, expires_at) + VALUES ($1, $2, $3) + RETURNING token, user_id, expires_at, created_at + `, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt) + if err != nil { + return Session{}, fmt.Errorf("store: create session: %w", err) + } + return sess, nil +} + +// GetSession liest eine Sitzung anhand ihres Tokens. Liefert +// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen +// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft +// der Aufrufer über ExpiresAt (siehe internal/auth), damit die +// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht. +func (s *Store) GetSession(ctx context.Context, token string) (Session, error) { + var sess Session + err := s.Pool.QueryRow(ctx, ` + SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1 + `, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrNotFound + } + if err != nil { + return Session{}, fmt.Errorf("store: get session: %w", err) + } + return sess, nil +} + +// DeleteSession beendet eine Sitzung (Logout). +func (s *Store) DeleteSession(ctx context.Context, token string) error { + if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil { + return fmt.Errorf("store: delete session: %w", err) + } + return nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7d1b3cd..7f9bd7e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -60,11 +60,16 @@ func TestFindingIsAppendOnly(t *testing.T) { ctx := context.Background() + acc, err := s.CreateAccount(ctx, "Test-Mandant") + if err != nil { + t.Fatalf("create account: %v", err) + } + var submissionID string err = s.Pool.QueryRow(ctx, ` - INSERT INTO submission (platform, post_type) VALUES ('instagram', 'reel') + INSERT INTO submission (account_id, platform, post_type) VALUES ($1, 'instagram', 'reel') RETURNING id - `).Scan(&submissionID) + `, acc.ID).Scan(&submissionID) if err != nil { t.Fatalf("insert submission: %v", err) } diff --git a/internal/store/submission.go b/internal/store/submission.go index 296e300..3415be3 100644 --- a/internal/store/submission.go +++ b/internal/store/submission.go @@ -2,13 +2,21 @@ package store import ( "context" + "errors" "fmt" "time" + + "github.com/jackc/pgx/v5" ) -// Submission ist ein eingereichter Beitrag. +// Submission ist ein eingereichter Beitrag. AccountID ist der Mandant, +// dem der Beitrag gehört (Mandantentrennung) — jede Abfrage, die einen +// Beitrag ausliefert, muss AccountID gegen den angemeldeten Account +// prüfen (siehe internal/web-Middleware), store selbst erzwingt das +// nicht auf Zeilenebene. type Submission struct { ID string + AccountID string Platform string PostType string Caption string @@ -17,15 +25,16 @@ type Submission struct { UpdatedAt time.Time } -// CreateSubmission legt einen neuen Beitrag an (Status "draft"). -func (s *Store) CreateSubmission(ctx context.Context, platform, postType, caption string) (Submission, error) { +// CreateSubmission legt einen neuen Beitrag für einen Mandanten an +// (Status "draft"). +func (s *Store) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (Submission, error) { var sub Submission err := s.Pool.QueryRow(ctx, ` - INSERT INTO submission (platform, post_type, caption) - VALUES ($1, $2, $3) - RETURNING id, platform, post_type, caption, status, created_at, updated_at - `, platform, postType, caption).Scan( - &sub.ID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, + INSERT INTO submission (account_id, platform, post_type, caption) + VALUES ($1, $2, $3, $4) + RETURNING id, account_id, platform, post_type, caption, status, created_at, updated_at + `, accountID, platform, postType, caption).Scan( + &sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, ) if err != nil { return Submission{}, fmt.Errorf("store: create submission: %w", err) @@ -33,15 +42,19 @@ func (s *Store) CreateSubmission(ctx context.Context, platform, postType, captio return sub, nil } -// GetSubmission liest einen Beitrag anhand seiner ID. +// GetSubmission liest einen Beitrag anhand seiner ID — ohne +// Mandanten-Prüfung, das ist Sache des Aufrufers (siehe Submission.AccountID). func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) { var sub Submission err := s.Pool.QueryRow(ctx, ` - SELECT id, platform, post_type, caption, status, created_at, updated_at + SELECT id, account_id, platform, post_type, caption, status, created_at, updated_at FROM submission WHERE id = $1 `, id).Scan( - &sub.ID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, + &sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, ) + if errors.Is(err, pgx.ErrNoRows) { + return Submission{}, ErrNotFound + } if err != nil { return Submission{}, fmt.Errorf("store: get submission: %w", err) } diff --git a/internal/store/user.go b/internal/store/user.go new file mode 100644 index 0000000..9dfe27b --- /dev/null +++ b/internal/store/user.go @@ -0,0 +1,77 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat — +// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit +// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei +// Login: falsche E-Mail vs. Datenbankfehler). +var ErrNotFound = errors.New("store: nicht gefunden") + +// User ist ein Login innerhalb eines Account (Mandanten). +type User struct { + ID string + AccountID string + Email string + PasswordHash string + Role string + CreatedAt time.Time +} + +// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an. +// passwordHash muss bereits gehasht sein (siehe internal/auth) — store +// speichert nur, es hasht nicht selbst. +func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) { + var u User + err := s.Pool.QueryRow(ctx, ` + INSERT INTO app_user (account_id, email, password_hash, role) + VALUES ($1, $2, $3, $4) + RETURNING id, account_id, email, password_hash, role, created_at + `, accountID, email, passwordHash, role).Scan( + &u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, + ) + if err != nil { + return User{}, fmt.Errorf("store: create user: %w", err) + } + return u, nil +} + +// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse. +// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler). +func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { + var u User + err := s.Pool.QueryRow(ctx, ` + SELECT id, account_id, email, password_hash, role, created_at + FROM app_user WHERE email = $1 + `, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, fmt.Errorf("store: get user by email: %w", err) + } + return u, nil +} + +// GetUser liest einen Nutzer anhand seiner ID. +func (s *Store) GetUser(ctx context.Context, id string) (User, error) { + var u User + err := s.Pool.QueryRow(ctx, ` + SELECT id, account_id, email, password_hash, role, created_at + FROM app_user WHERE id = $1 + `, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, fmt.Errorf("store: get user: %w", err) + } + return u, nil +}