From 2a16dc220001a30d2af23cdf334c17c4617e92b9 Mon Sep 17 00:00:00 2001 From: noroot Date: Thu, 27 Aug 2026 15:48:40 +0200 Subject: [PATCH] feat: add auth and multi-tenancy (Schritt 2, part 1/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/auth is pure logic (bcrypt hashing, session token generation) with no DB access — persistence for account/app_user/session lives in internal/store like everything else, via migration 0003. account is the tenant (Mandant); app_user is a login inside one account; session is a real server-side row (not a signed stateless token) so logout can actually end a session rather than the client just forgetting a JWT. submission.account_id is NOT NULL — added directly rather than the nullable-then-backfill dance, since no submission rows exist anywhere yet (verified empty on the test server before writing the migration). Added as migration 0003 (new file), not folded into an earlier one, since 0001/0002 are already applied on the test server. store.ErrNotFound lets callers distinguish "wrong email" / "unknown session" from a genuine DB error — matters for login, where those two cases should both fail closed but for different reasons. Not yet wired into internal/web — that's the next commit. All of this is tested against real Postgres (14 store tests green) but isn't reachable from any HTTP handler yet. Co-Authored-By: Claude Sonnet 5 --- go.mod | 5 +- go.sum | 7 ++ internal/auth/auth.go | 56 +++++++++ internal/auth/auth_test.go | 50 ++++++++ internal/store/account.go | 40 +++++++ internal/store/auth_test.go | 116 +++++++++++++++++++ internal/store/crud_test.go | 23 +++- internal/store/migrations/0003_auth.down.sql | 4 + internal/store/migrations/0003_auth.up.sql | 33 ++++++ internal/store/session.go | 62 ++++++++++ internal/store/store_test.go | 9 +- internal/store/submission.go | 35 ++++-- internal/store/user.go | 77 ++++++++++++ 13 files changed, 498 insertions(+), 19 deletions(-) create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/store/account.go create mode 100644 internal/store/auth_test.go create mode 100644 internal/store/migrations/0003_auth.down.sql create mode 100644 internal/store/migrations/0003_auth.up.sql create mode 100644 internal/store/session.go create mode 100644 internal/store/user.go 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 +}