Files
deklarix/internal/store/submission.go
noroot 2a16dc2200 feat: add auth and multi-tenancy (Schritt 2, part 1/2)
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 <noreply@anthropic.com>
2026-08-27 15:48:40 +02:00

80 lines
2.6 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// 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
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
// 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 (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)
}
return sub, nil
}
// 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, account_id, platform, post_type, caption, status, created_at, updated_at
FROM submission WHERE id = $1
`, id).Scan(
&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)
}
return sub, nil
}
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
// anders als extraction/finding/evidence_package, NICHT append-only —
// der Lebenszyklus draft → checked → published → archived ist eine
// normale Zustandsänderung, kein Beweis-Eintrag).
func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error {
tag, err := s.Pool.Exec(ctx, `
UPDATE submission SET status = $2, updated_at = now() WHERE id = $1
`, id, status)
if err != nil {
return fmt.Errorf("store: set submission status: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id)
}
return nil
}