package userstore import ( "context" "database/sql" "errors" "fmt" "time" pgtable "galaxy/user/internal/adapters/postgres/jet/user/table" "galaxy/user/internal/domain/authblock" "galaxy/user/internal/domain/common" "galaxy/user/internal/ports" pg "github.com/go-jet/jet/v2/postgres" ) // blockedEmailSelectColumns is the canonical SELECT list for blocked_emails. var blockedEmailSelectColumns = pg.ColumnList{ pgtable.BlockedEmails.Email, pgtable.BlockedEmails.ReasonCode, pgtable.BlockedEmails.BlockedAt, pgtable.BlockedEmails.ActorType, pgtable.BlockedEmails.ActorID, pgtable.BlockedEmails.ResolvedUserID, } // GetBlockedEmail returns the blocked-email subject for email. func (store *Store) GetBlockedEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error) { if err := email.Validate(); err != nil { return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject from postgres: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "get blocked email subject from postgres") if err != nil { return authblock.BlockedEmailSubject{}, err } defer cancel() record, err := scanBlockedEmail(operationCtx, store.db, email, false) switch { case errors.Is(err, ports.ErrNotFound): return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject %q from postgres: %w", email, ports.ErrNotFound) case err != nil: return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject %q from postgres: %w", email, err) } return record, nil } // PutBlockedEmail stores or replaces the blocked-email subject for // record.Email. The schema's PRIMARY KEY on (email) makes this an UPSERT via // `INSERT … ON CONFLICT (email) DO UPDATE`. func (store *Store) PutBlockedEmail(ctx context.Context, record authblock.BlockedEmailSubject) error { if err := record.Validate(); err != nil { return fmt.Errorf("upsert blocked email subject in postgres: %w", err) } operationCtx, cancel, err := store.operationContext(ctx, "upsert blocked email subject in postgres") if err != nil { return err } defer cancel() if err := upsertBlockedEmail(operationCtx, store.db, record); err != nil { return err } return nil } // upsertBlockedEmail centralises the UPSERT used by PutBlockedEmail and the // composite block flows. q is a *sql.DB or *sql.Tx so it can run inside an // auth-directory transaction. func upsertBlockedEmail(ctx context.Context, q queryer, record authblock.BlockedEmailSubject) error { stmt := pgtable.BlockedEmails.INSERT( pgtable.BlockedEmails.Email, pgtable.BlockedEmails.ReasonCode, pgtable.BlockedEmails.BlockedAt, pgtable.BlockedEmails.ActorType, pgtable.BlockedEmails.ActorID, pgtable.BlockedEmails.ResolvedUserID, ).VALUES( record.Email.String(), record.ReasonCode.String(), record.BlockedAt.UTC(), nullableActorType(record.Actor.Type), nullableActorID(record.Actor.ID), nullableUserID(record.ResolvedUserID), ).ON_CONFLICT(pgtable.BlockedEmails.Email).DO_UPDATE( pg.SET( pgtable.BlockedEmails.ReasonCode.SET(pgtable.BlockedEmails.EXCLUDED.ReasonCode), pgtable.BlockedEmails.BlockedAt.SET(pgtable.BlockedEmails.EXCLUDED.BlockedAt), pgtable.BlockedEmails.ActorType.SET(pgtable.BlockedEmails.EXCLUDED.ActorType), pgtable.BlockedEmails.ActorID.SET(pgtable.BlockedEmails.EXCLUDED.ActorID), pgtable.BlockedEmails.ResolvedUserID.SET(pgtable.BlockedEmails.EXCLUDED.ResolvedUserID), ), ) query, args := stmt.Sql() if _, err := q.ExecContext(ctx, query, args...); err != nil { return fmt.Errorf("upsert blocked email subject %q in postgres: %w", record.Email, err) } return nil } // scanBlockedEmail loads one blocked-email row. forUpdate selects the // `FOR UPDATE` lock variant used inside the auth-directory transaction. func scanBlockedEmail(ctx context.Context, q queryer, email common.Email, forUpdate bool) (authblock.BlockedEmailSubject, error) { stmt := pg.SELECT(blockedEmailSelectColumns). FROM(pgtable.BlockedEmails). WHERE(pgtable.BlockedEmails.Email.EQ(pg.String(email.String()))) if forUpdate { stmt = stmt.FOR(pg.UPDATE()) } query, args := stmt.Sql() row := q.QueryRowContext(ctx, query, args...) return scanBlockedEmailRow(row) } func scanBlockedEmailRow(row *sql.Row) (authblock.BlockedEmailSubject, error) { var ( record authblock.BlockedEmailSubject emailValue string reasonCode string blockedAt time.Time actorType *string actorID *string resolvedUserID *string ) if err := row.Scan( &emailValue, &reasonCode, &blockedAt, &actorType, &actorID, &resolvedUserID, ); err != nil { return authblock.BlockedEmailSubject{}, mapNotFound(err) } record.Email = common.Email(emailValue) record.ReasonCode = common.ReasonCode(reasonCode) record.BlockedAt = blockedAt.UTC() if actorType != nil { record.Actor.Type = common.ActorType(*actorType) } if actorID != nil { record.Actor.ID = common.ActorID(*actorID) } if resolvedUserID != nil { record.ResolvedUserID = common.UserID(*resolvedUserID) } return record, nil } // BlockedEmailStore adapts Store to the BlockedEmailStore port. type BlockedEmailStore struct { store *Store } // BlockedEmails returns one adapter that exposes the blocked-email store // port over Store. func (store *Store) BlockedEmails() *BlockedEmailStore { if store == nil { return nil } return &BlockedEmailStore{store: store} } // GetByEmail returns the blocked-email subject for email. func (adapter *BlockedEmailStore) GetByEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error) { return adapter.store.GetBlockedEmail(ctx, email) } // Upsert stores or replaces the blocked-email subject for record.Email. func (adapter *BlockedEmailStore) Upsert(ctx context.Context, record authblock.BlockedEmailSubject) error { return adapter.store.PutBlockedEmail(ctx, record) } var _ ports.BlockedEmailStore = (*BlockedEmailStore)(nil)