package storage import ( "context" "database/sql" "errors" "fmt" "time" ) func (s *Store) CreateInvitation(ctx context.Context, input CreateInvitationInput) (Invitation, error) { const operation = "create invitation" if err := s.checkOpen(operation); err != nil { return Invitation{}, err } invitation, err := s.prepareInvitation(operation, input, canonicalTime(s.now())) if err != nil { return Invitation{}, err } if err := insertInvitation(ctx, s.sql, operation, invitation); err != nil { return Invitation{}, err } return invitation, nil } func (s *Store) prepareInvitation(operation string, input CreateInvitationInput, createdAt time.Time) (Invitation, error) { if len(input.TokenHash) == 0 { return Invitation{}, invalid(operation, fmt.Errorf("token hash is empty")) } var copiedCreatedBy *string if input.CreatedBy != nil { if err := requireValue("creator id", *input.CreatedBy); err != nil { return Invitation{}, invalid(operation, err) } value := *input.CreatedBy copiedCreatedBy = &value } expiresAt := canonicalTime(input.ExpiresAt) if !expiresAt.After(createdAt) { return Invitation{}, invalid(operation, fmt.Errorf("expiry is not after creation")) } id, err := s.newID() if err != nil { return Invitation{}, fmt.Errorf("%s: %w", operation, err) } return Invitation{ ID: id, TokenHash: append([]byte(nil), input.TokenHash...), CreatedBy: copiedCreatedBy, CreatedAt: createdAt, ExpiresAt: expiresAt, }, nil } func insertInvitation(ctx context.Context, q execer, operation string, invitation Invitation) error { var createdBy any if invitation.CreatedBy != nil { createdBy = *invitation.CreatedBy } if _, err := q.ExecContext(ctx, ` INSERT INTO invitations ( id, token_hash, created_by, created_at, expires_at, used_by, used_at, revoked_at ) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL)`, invitation.ID, invitation.TokenHash, createdBy, timeToInt(invitation.CreatedAt), timeToInt(invitation.ExpiresAt)); err != nil { return classify(operation, err) } return nil } func (s *Store) InvitationByID(ctx context.Context, id string) (Invitation, error) { const operation = "get invitation" if err := s.checkOpen(operation); err != nil { return Invitation{}, err } invitation, err := scanInvitation(s.sql.QueryRowContext(ctx, ` SELECT id, token_hash, created_by, created_at, expires_at, used_by, used_at, revoked_at FROM invitations WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return Invitation{}, fmt.Errorf("%s: %w", operation, ErrNotFound) } if err != nil { return Invitation{}, classify(operation, err) } return invitation, nil } func (s *Store) ListInvitations(ctx context.Context) ([]Invitation, error) { const operation = "list invitations" if err := s.checkOpen(operation); err != nil { return nil, err } rows, err := s.sql.QueryContext(ctx, ` SELECT id, token_hash, created_by, created_at, expires_at, used_by, used_at, revoked_at FROM invitations ORDER BY created_at DESC, id`) if err != nil { return nil, classify(operation, err) } defer rows.Close() var invitations []Invitation for rows.Next() { invitation, err := scanInvitation(rows) if err != nil { return nil, classify(operation, err) } invitations = append(invitations, invitation) } if err := rows.Err(); err != nil { return nil, classify(operation, err) } return invitations, nil } func (s *Store) MarkInvitationUsed(ctx context.Context, id, userID string) error { const operation = "mark invitation used" if err := s.checkOpen(operation); err != nil { return err } if err := requireValue("user id", userID); err != nil { return invalid(operation, err) } result, err := s.sql.ExecContext(ctx, ` UPDATE invitations SET used_by = ?, used_at = ? WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL`, userID, timeToInt(s.now()), id) if err != nil { return classify(operation, err) } return requireInvitationTransition(ctx, s.sql, operation, id, result) } func (s *Store) RevokeInvitation(ctx context.Context, id string) error { const operation = "revoke invitation" if err := s.checkOpen(operation); err != nil { return err } result, err := s.sql.ExecContext(ctx, ` UPDATE invitations SET revoked_at = ? WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL`, timeToInt(s.now()), id) if err != nil { return classify(operation, err) } return requireInvitationTransition(ctx, s.sql, operation, id, result) } func requireInvitationTransition(ctx context.Context, q execer, operation, id string, result sql.Result) error { count, err := result.RowsAffected() if err != nil { return classify(operation+" rows affected", err) } if count != 0 { return nil } var exists int err = q.QueryRowContext(ctx, `SELECT 1 FROM invitations WHERE id = ?`, id).Scan(&exists) if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("%s: %w", operation, ErrNotFound) } if err != nil { return classify(operation+" state", err) } return fmt.Errorf("%s: %w", operation, ErrConflict) } func scanInvitation(row rowScanner) (Invitation, error) { var invitation Invitation var createdBy sql.NullString var usedBy sql.NullString var createdAt int64 var expiresAt int64 var usedAt sql.NullInt64 var revokedAt sql.NullInt64 if err := row.Scan(&invitation.ID, &invitation.TokenHash, &createdBy, &createdAt, &expiresAt, &usedBy, &usedAt, &revokedAt); err != nil { return Invitation{}, err } invitation.TokenHash = append([]byte(nil), invitation.TokenHash...) invitation.CreatedAt = intToTime(createdAt) invitation.ExpiresAt = intToTime(expiresAt) if createdBy.Valid { value := createdBy.String invitation.CreatedBy = &value } if usedBy.Valid { value := usedBy.String invitation.UsedBy = &value } if usedAt.Valid { value := intToTime(usedAt.Int64) invitation.UsedAt = &value } if revokedAt.Valid { value := intToTime(revokedAt.Int64) invitation.RevokedAt = &value } return invitation, nil }