package store import ( "context" "errors" "time" "github.com/jackc/pgx/v5" ) type User struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` IsAdmin bool `json:"isAdmin"` } // UpsertUser finds or creates the account behind an email address. Requesting // a magic link is what creates an account; there is no separate signup. func (s *Store) UpsertUser(ctx context.Context, email string) (*User, error) { var u User err := s.pool.QueryRow(ctx, ` INSERT INTO "User" ("id","email","emailVerified") VALUES ($1,$2,true) ON CONFLICT ("email") DO UPDATE SET "emailVerified" = true, "updatedAt" = now() RETURNING "id","email","name","isAdmin"`, NewID(), email).Scan(&u.ID, &u.Email, &u.Name, &u.IsAdmin) return &u, err } // CreateVerification stores a hashed magic-link token. The plaintext never // reaches the database, so a dump cannot be replayed into a login. func (s *Store) CreateVerification(ctx context.Context, identifier, hashed string, expiresAt time.Time) error { _, err := s.pool.Exec(ctx, ` INSERT INTO "Verification" ("id","identifier","value","expiresAt") VALUES ($1,$2,$3,$4)`, NewID(), identifier, hashed, expiresAt) return err } // ConsumeVerification redeems a token exactly once. The single UPDATE is what // makes it single-use: a second request finds consumedAt already set and gets // no rows back, even if both arrive at the same instant. func (s *Store) ConsumeVerification(ctx context.Context, hashed string) (string, error) { var identifier string err := s.pool.QueryRow(ctx, ` UPDATE "Verification" SET "consumedAt" = now(), "updatedAt" = now() WHERE "value" = $1 AND "consumedAt" IS NULL AND "expiresAt" > now() RETURNING "identifier"`, hashed).Scan(&identifier) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } return identifier, err } func (s *Store) CreateSession(ctx context.Context, userID, hashed string, expiresAt time.Time, ip, ua string) error { _, err := s.pool.Exec(ctx, ` INSERT INTO "Session" ("id","token","expiresAt","userId","ipAddress","userAgent") VALUES ($1,$2,$3,$4,$5,$6)`, NewID(), hashed, expiresAt, userID, nullable(ip), nullable(ua)) return err } func (s *Store) UserBySessionToken(ctx context.Context, hashed string) (*User, error) { var u User err := s.pool.QueryRow(ctx, ` SELECT u."id", u."email", u."name", u."isAdmin" FROM "Session" s JOIN "User" u ON u."id" = s."userId" WHERE s."token" = $1 AND s."expiresAt" > now()`, hashed).Scan( &u.ID, &u.Email, &u.Name, &u.IsAdmin) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return &u, err } func (s *Store) DeleteSession(ctx context.Context, hashed string) error { _, err := s.pool.Exec(ctx, `DELETE FROM "Session" WHERE "token" = $1`, hashed) return err } // PurgeExpiredAuth keeps the two token tables from growing without bound. // Expired rows are already refused on read, so this is housekeeping only. func (s *Store) PurgeExpiredAuth(ctx context.Context) error { if _, err := s.pool.Exec(ctx, `DELETE FROM "Session" WHERE "expiresAt" < now()`); err != nil { return err } _, err := s.pool.Exec(ctx, `DELETE FROM "Verification" WHERE "expiresAt" < now() - interval '1 day'`) return err } func nullable(s string) *string { if s == "" { return nil } return &s }