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}