Raw
  1package storage
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"errors"
  7	"fmt"
  8	"time"
  9)
 10
 11func (s *Store) CreateInvitation(ctx context.Context, input CreateInvitationInput) (Invitation, error) {
 12	const operation = "create invitation"
 13	if err := s.checkOpen(operation); err != nil {
 14		return Invitation{}, err
 15	}
 16	invitation, err := s.prepareInvitation(operation, input, canonicalTime(s.now()))
 17	if err != nil {
 18		return Invitation{}, err
 19	}
 20	if err := insertInvitation(ctx, s.sql, operation, invitation); err != nil {
 21		return Invitation{}, err
 22	}
 23	return invitation, nil
 24}
 25
 26func (s *Store) prepareInvitation(operation string, input CreateInvitationInput, createdAt time.Time) (Invitation, error) {
 27	if len(input.TokenHash) == 0 {
 28		return Invitation{}, invalid(operation, fmt.Errorf("token hash is empty"))
 29	}
 30	var copiedCreatedBy *string
 31	if input.CreatedBy != nil {
 32		if err := requireValue("creator id", *input.CreatedBy); err != nil {
 33			return Invitation{}, invalid(operation, err)
 34		}
 35		value := *input.CreatedBy
 36		copiedCreatedBy = &value
 37	}
 38	expiresAt := canonicalTime(input.ExpiresAt)
 39	if !expiresAt.After(createdAt) {
 40		return Invitation{}, invalid(operation, fmt.Errorf("expiry is not after creation"))
 41	}
 42	id, err := s.newID()
 43	if err != nil {
 44		return Invitation{}, fmt.Errorf("%s: %w", operation, err)
 45	}
 46	return Invitation{
 47		ID:        id,
 48		TokenHash: append([]byte(nil), input.TokenHash...),
 49		CreatedBy: copiedCreatedBy,
 50		CreatedAt: createdAt,
 51		ExpiresAt: expiresAt,
 52	}, nil
 53}
 54
 55func insertInvitation(ctx context.Context, q execer, operation string, invitation Invitation) error {
 56	var createdBy any
 57	if invitation.CreatedBy != nil {
 58		createdBy = *invitation.CreatedBy
 59	}
 60	if _, err := q.ExecContext(ctx, `
 61INSERT INTO invitations (
 62    id, token_hash, created_by, created_at, expires_at,
 63    used_by, used_at, revoked_at
 64) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL)`, invitation.ID,
 65		invitation.TokenHash, createdBy,
 66		timeToInt(invitation.CreatedAt), timeToInt(invitation.ExpiresAt)); err != nil {
 67		return classify(operation, err)
 68	}
 69	return nil
 70}
 71
 72func (s *Store) InvitationByID(ctx context.Context, id string) (Invitation, error) {
 73	const operation = "get invitation"
 74	if err := s.checkOpen(operation); err != nil {
 75		return Invitation{}, err
 76	}
 77	invitation, err := scanInvitation(s.sql.QueryRowContext(ctx, `
 78SELECT id, token_hash, created_by, created_at, expires_at,
 79       used_by, used_at, revoked_at
 80FROM invitations WHERE id = ?`, id))
 81	if errors.Is(err, sql.ErrNoRows) {
 82		return Invitation{}, fmt.Errorf("%s: %w", operation, ErrNotFound)
 83	}
 84	if err != nil {
 85		return Invitation{}, classify(operation, err)
 86	}
 87	return invitation, nil
 88}
 89
 90func (s *Store) ListInvitations(ctx context.Context) ([]Invitation, error) {
 91	const operation = "list invitations"
 92	if err := s.checkOpen(operation); err != nil {
 93		return nil, err
 94	}
 95	rows, err := s.sql.QueryContext(ctx, `
 96SELECT id, token_hash, created_by, created_at, expires_at,
 97       used_by, used_at, revoked_at
 98FROM invitations ORDER BY created_at DESC, id`)
 99	if err != nil {
100		return nil, classify(operation, err)
101	}
102	defer rows.Close()
103	var invitations []Invitation
104	for rows.Next() {
105		invitation, err := scanInvitation(rows)
106		if err != nil {
107			return nil, classify(operation, err)
108		}
109		invitations = append(invitations, invitation)
110	}
111	if err := rows.Err(); err != nil {
112		return nil, classify(operation, err)
113	}
114	return invitations, nil
115}
116
117func (s *Store) MarkInvitationUsed(ctx context.Context, id, userID string) error {
118	const operation = "mark invitation used"
119	if err := s.checkOpen(operation); err != nil {
120		return err
121	}
122	if err := requireValue("user id", userID); err != nil {
123		return invalid(operation, err)
124	}
125	result, err := s.sql.ExecContext(ctx, `
126UPDATE invitations
127SET used_by = ?, used_at = ?
128WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL`,
129		userID, timeToInt(s.now()), id)
130	if err != nil {
131		return classify(operation, err)
132	}
133	return requireInvitationTransition(ctx, s.sql, operation, id, result)
134}
135
136func (s *Store) RevokeInvitation(ctx context.Context, id string) error {
137	const operation = "revoke invitation"
138	if err := s.checkOpen(operation); err != nil {
139		return err
140	}
141	result, err := s.sql.ExecContext(ctx, `
142UPDATE invitations
143SET revoked_at = ?
144WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL`,
145		timeToInt(s.now()), id)
146	if err != nil {
147		return classify(operation, err)
148	}
149	return requireInvitationTransition(ctx, s.sql, operation, id, result)
150}
151
152func requireInvitationTransition(ctx context.Context, q execer, operation, id string, result sql.Result) error {
153	count, err := result.RowsAffected()
154	if err != nil {
155		return classify(operation+" rows affected", err)
156	}
157	if count != 0 {
158		return nil
159	}
160	var exists int
161	err = q.QueryRowContext(ctx, `SELECT 1 FROM invitations WHERE id = ?`, id).Scan(&exists)
162	if errors.Is(err, sql.ErrNoRows) {
163		return fmt.Errorf("%s: %w", operation, ErrNotFound)
164	}
165	if err != nil {
166		return classify(operation+" state", err)
167	}
168	return fmt.Errorf("%s: %w", operation, ErrConflict)
169}
170
171func scanInvitation(row rowScanner) (Invitation, error) {
172	var invitation Invitation
173	var createdBy sql.NullString
174	var usedBy sql.NullString
175	var createdAt int64
176	var expiresAt int64
177	var usedAt sql.NullInt64
178	var revokedAt sql.NullInt64
179	if err := row.Scan(&invitation.ID, &invitation.TokenHash, &createdBy,
180		&createdAt, &expiresAt, &usedBy, &usedAt, &revokedAt); err != nil {
181		return Invitation{}, err
182	}
183	invitation.TokenHash = append([]byte(nil), invitation.TokenHash...)
184	invitation.CreatedAt = intToTime(createdAt)
185	invitation.ExpiresAt = intToTime(expiresAt)
186	if createdBy.Valid {
187		value := createdBy.String
188		invitation.CreatedBy = &value
189	}
190	if usedBy.Valid {
191		value := usedBy.String
192		invitation.UsedBy = &value
193	}
194	if usedAt.Valid {
195		value := intToTime(usedAt.Int64)
196		invitation.UsedAt = &value
197	}
198	if revokedAt.Valid {
199		value := intToTime(revokedAt.Int64)
200		invitation.RevokedAt = &value
201	}
202	return invitation, nil
203}