• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 19337142747

13 Nov 2025 03:43PM UTC coverage: 85.319%. First build
19337142747

Pull #657

github

web-flow
Merge 2ae249b03 into cc369fe03
Pull Request #657: Store waitlist and booth state in SQLite instead of Redis

954 of 1135 branches covered (84.05%)

Branch coverage included in aggregate %.

194 of 230 new or added lines in 7 files covered. (84.35%)

10047 of 11759 relevant lines covered (85.44%)

192.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.86
/src/plugins/waitlist.js
1
import fs from 'node:fs';
2✔
2
import {
2✔
3
  PermissionError,
2✔
4
  UserNotFoundError,
2✔
5
  EmptyPlaylistError,
2✔
6
  WaitlistLockedError,
2✔
7
  AlreadyInWaitlistError,
2✔
8
  UserNotInWaitlistError,
2✔
9
  UserIsPlayingError,
2✔
10
} from '../errors/index.js';
2✔
11
import routes from '../routes/waitlist.js';
2✔
12
import { Permissions } from './acl.js';
2✔
13

2✔
14
const schema = JSON.parse(
2✔
15
  fs.readFileSync(new URL('../schemas/waitlist.json', import.meta.url), 'utf8'),
2✔
16
);
2✔
17

2✔
18
const KEY_WAITLIST = 'waitlist';
2✔
19
const KEY_CURRENT_DJ_ID = 'booth:currentDJ';
2✔
20
const KEY_HISTORY_ID = 'booth:historyID';
2✔
21

2✔
22
/**
2✔
23
 * @typedef {import('../schema.js').UserID} UserID
2✔
24
 * @typedef {import('../schema.js').User} User
2✔
25
 * @typedef {{ cycle: boolean, locked: boolean }} WaitlistSettings
2✔
26
 */
2✔
27

2✔
28
class Waitlist {
2✔
29
  #uw;
298✔
30

298✔
31
  /**
298✔
32
   * @param {import('../Uwave.js').Boot} uw
298✔
33
   */
298✔
34
  constructor(uw) {
298✔
35
    this.#uw = uw;
298✔
36

298✔
37
    uw.config.register(schema['uw:key'], schema);
298✔
38

298✔
39
    const unsubscribe = uw.config.subscribe(
298✔
40
      schema['uw:key'],
298✔
41
      /**
298✔
42
       * @param {WaitlistSettings} _settings
298✔
43
       * @param {UserID|null} userID
298✔
44
       * @param {Partial<WaitlistSettings>} patch
298✔
45
       */
298✔
46
      (_settings, userID, patch) => {
298✔
47
        // TODO This userID != null check is wrong. It should always pass as
12✔
48
        // long as all the cases where waitlist settings can be updated provide
12✔
49
        // the moderator's user ID. There's no type level guarantee of that happening
12✔
50
        // though and if it doesn't, clients will get out of sync because of this check.
12✔
51
        if ('locked' in patch && patch.locked != null && userID != null) {
12✔
52
          this.#uw.publish('waitlist:lock', {
12✔
53
            moderatorID: userID,
12✔
54
            locked: patch.locked,
12✔
55
          });
12✔
56
        }
12✔
57
      },
298✔
58
    );
298✔
59
    uw.onClose(unsubscribe);
298✔
60
  }
298✔
61

298✔
62
  async #isBoothEmpty() {
298✔
63
    return !(await this.#uw.keyv.get(KEY_HISTORY_ID));
64✔
64
  }
64✔
65

298✔
66
  /**
298✔
67
   * @param {User} user
298✔
68
   * @returns {Promise<boolean>}
298✔
69
   */
298✔
70
  async #hasPlayablePlaylist(user, tx = this.#uw.db) {
298✔
71
    const { playlists } = this.#uw;
76✔
72
    if (!user.activePlaylistID) {
76✔
73
      return false;
2✔
74
    }
2✔
75

74✔
76
    const playlist = await playlists.getUserPlaylist(user, user.activePlaylistID, tx);
74✔
77
    return playlist && playlist.size > 0;
76✔
78
  }
76✔
79

298✔
80
  /**
298✔
81
   * @returns {Promise<WaitlistSettings>}
298✔
82
   */
298✔
83
  async #getSettings() {
298✔
84
    const { config } = this.#uw;
128✔
85

128✔
86
    const settings = /** @type {WaitlistSettings} */ (await config.get(schema['uw:key']));
128✔
87
    return settings;
128✔
88
  }
128✔
89

298✔
90
  /**
298✔
91
   * @returns {Promise<boolean>}
298✔
92
   */
298✔
93
  async isLocked() {
298✔
94
    const settings = await this.#getSettings();
80✔
95
    return settings.locked;
80✔
96
  }
80✔
97

298✔
98
  /**
298✔
99
   * @returns {Promise<boolean>}
298✔
100
   */
298✔
101
  async isCycleEnabled() {
298✔
102
    const settings = await this.#getSettings();
36✔
103
    return settings.cycle;
36✔
104
  }
36✔
105

298✔
106
  /**
298✔
107
   * @returns {Promise<UserID[]>}
298✔
108
   */
298✔
109
  async getUserIDs(tx = this.#uw.db) {
298✔
110
    const userIDs = /** @type {UserID[] | null} */ (await this.#uw.keyv.get(KEY_WAITLIST, tx));
240✔
111
    return userIDs ?? [];
240✔
112
  }
240✔
113

298✔
114
  /**
298✔
115
   * @param {UserID|null} previous
298✔
116
   * @param {{ remove?: boolean }} options
298✔
117
   */
298✔
118
  async cycle(previous, options) {
298✔
119
    // TODO: This must happen in a transaction
36✔
120
    const waitlist = await this.getUserIDs();
36✔
121
    if (waitlist.length > 0) {
36✔
122
      waitlist.shift();
36✔
123
      if (previous && !options.remove) {
36!
NEW
124
        // The previous DJ should only be added to the waitlist again if it was
×
NEW
125
        // not empty. If it was empty, the previous DJ is already in the booth.
×
NEW
126
        waitlist.push(previous);
×
NEW
127
      }
×
128

36✔
129
      await this.#uw.keyv.set(KEY_WAITLIST, waitlist);
36✔
130
    }
36✔
131
  }
36✔
132

298✔
133
  /**
298✔
134
   * Add a user to the waitlist.
298✔
135
   *
298✔
136
   * @param {UserID} userID
298✔
137
   * @param {{moderator?: User}} [options]
298✔
138
   */
298✔
139
  async addUser(userID, options = {}) {
298✔
140
    const { moderator } = options;
76✔
141
    const { acl, users } = this.#uw;
76✔
142

76✔
143
    const user = await users.getUser(userID);
76✔
144
    if (!user) throw new UserNotFoundError({ id: userID });
76!
145

76✔
146
    const isAddingOtherUser = moderator && user.id !== moderator.id;
76✔
147
    if (isAddingOtherUser) {
76✔
148
      if (!(await acl.isAllowed(moderator, Permissions.WaitlistAdd))) {
4✔
149
        throw new PermissionError({
2✔
150
          requiredRole: 'waitlist.add',
2✔
151
        });
2✔
152
      }
2✔
153
    }
4✔
154

74✔
155
    const canForceJoin = await acl.isAllowed(user, Permissions.WaitlistJoinLocked);
74✔
156
    if (!isAddingOtherUser && !canForceJoin && await this.isLocked()) {
76✔
157
      throw new WaitlistLockedError();
4✔
158
    }
4✔
159

70✔
160
    if (!(await this.#hasPlayablePlaylist(user))) {
76✔
161
      throw new EmptyPlaylistError();
4✔
162
    }
4✔
163

66✔
164
    const waitlist = await this.getUserIDs();
66✔
165
    const isInWaitlist = waitlist.includes(user.id);
66✔
166
    const currentDJ = /** @type {UserID|null} */ (
66✔
167
      await this.#uw.keyv.get(KEY_CURRENT_DJ_ID)
66✔
168
    );
66✔
169
    if (isInWaitlist || currentDJ === user.id) {
76✔
170
      throw new AlreadyInWaitlistError();
2✔
171
    }
2✔
172

64✔
173
    waitlist.push(user.id);
64✔
174

64✔
175
    await this.#uw.keyv.set(KEY_WAITLIST, waitlist);
64✔
176

64✔
177
    if (isAddingOtherUser) {
76✔
178
      this.#uw.publish('waitlist:add', {
2✔
179
        userID: user.id,
2✔
180
        moderatorID: moderator.id,
2✔
181
        position: waitlist.indexOf(user.id),
2✔
182
        waitlist,
2✔
183
      });
2✔
184
    } else {
76✔
185
      this.#uw.publish('waitlist:join', {
62✔
186
        userID: user.id,
62✔
187
        waitlist,
62✔
188
      });
62✔
189
    }
62✔
190

64✔
191
    if (await this.#isBoothEmpty()) {
76✔
192
      await this.#uw.booth.advance();
36✔
193
    }
36✔
194
  }
76✔
195

298✔
196
  /**
298✔
197
   * @param {UserID} userID
298✔
198
   * @param {number} position
298✔
199
   * @param {{moderator: User}} options
298✔
200
   * @returns {Promise<void>}
298✔
201
   */
298✔
202
  async moveUser(userID, position, { moderator }) {
298✔
203
    const { users } = this.#uw;
6✔
204

6✔
205
    const user = await users.getUser(userID);
6✔
206
    if (!user) {
6!
207
      throw new UserNotFoundError({ id: userID });
×
208
    }
×
209

6✔
210
    if (!(await this.#hasPlayablePlaylist(user))) {
6!
211
      throw new EmptyPlaylistError();
×
212
    }
×
213

6✔
214
    const waitlist = await this.getUserIDs();
6✔
215
    const previousPosition = waitlist.indexOf(user.id);
6✔
216
    if (previousPosition === -1) {
6!
NEW
217
      throw new UserNotInWaitlistError({ id: user.id });
×
NEW
218
    }
×
219
    const currentDJ = /** @type {UserID|null} */ (
6✔
220
      await this.#uw.keyv.get(KEY_CURRENT_DJ_ID)
6✔
221
    );
6✔
222
    if (currentDJ === user.id) {
6!
NEW
223
      throw new UserIsPlayingError({ id: user.id });
×
224
    }
×
225

6✔
226
    waitlist.splice(previousPosition, 1);
6✔
227
    // `position` might be _past_ the end of the array,
6✔
228
    // in which case this is equivalent to a `.push`.
6✔
229
    waitlist.splice(position, 0, user.id);
6✔
230

6✔
231
    await this.#uw.keyv.set(KEY_WAITLIST, waitlist);
6✔
232

6✔
233
    this.#uw.publish('waitlist:move', {
6✔
234
      userID: user.id,
6✔
235
      moderatorID: moderator.id,
6✔
236
      position: waitlist.indexOf(user.id),
6✔
237
      waitlist,
6✔
238
    });
6✔
239
  }
6✔
240

298✔
241
  /**
298✔
242
   * @param {UserID} userID
298✔
243
   * @param {{moderator?: User}} [options]
298✔
244
   * @returns {Promise<void>}
298✔
245
   */
298✔
246
  async removeUser(userID, { moderator } = {}) {
298✔
247
    const { acl, users } = this.#uw;
4✔
248
    const user = await users.getUser(userID);
4✔
249
    if (!user) {
4!
250
      throw new UserNotFoundError({ id: userID });
×
251
    }
×
252

4✔
253
    const isRemoving = moderator != null && user.id !== moderator.id;
4✔
254
    if (isRemoving && !(await acl.isAllowed(moderator, Permissions.WaitlistRemove))) {
4!
255
      throw new PermissionError({
×
256
        requiredRole: 'waitlist.remove',
×
257
      });
×
258
    }
×
259

4✔
260
    const waitlist = await this.#uw.db.transaction().execute(async (tx) => {
4✔
261
      const waitlist = await this.getUserIDs(tx);
4✔
262
      let index;
4✔
263
      let removedCount = 0;
4✔
264
      while ((index = waitlist.indexOf(user.id)) !== -1) {
4✔
265
        waitlist.splice(index, 1);
2✔
266
        removedCount += 1;
2✔
267
      }
2✔
268

4✔
269
      if (removedCount === 0) {
4✔
270
        throw new UserNotInWaitlistError({ id: user.id });
2✔
271
      }
2✔
272

2✔
273
      await this.#uw.keyv.set(KEY_WAITLIST, waitlist, tx);
2✔
274
      return waitlist;
2✔
275
    });
4✔
276

2✔
277
    if (isRemoving) {
4!
278
      this.#uw.publish('waitlist:remove', {
×
279
        userID: user.id,
×
280
        moderatorID: moderator.id,
×
281
        waitlist,
×
282
      });
×
283
    } else {
4✔
284
      this.#uw.publish('waitlist:leave', {
2✔
285
        userID: user.id,
2✔
286
        waitlist,
2✔
287
      });
2✔
288
    }
2✔
289
  }
4✔
290

298✔
291
  /**
298✔
292
   * @param {{moderator: User}} options
298✔
293
   * @returns {Promise<void>}
298✔
294
   */
298✔
295
  async clear({ moderator }) {
298✔
NEW
296
    await this.#uw.keyv.delete(KEY_WAITLIST);
×
297

×
298
    const waitlist = await this.getUserIDs();
×
299
    if (waitlist.length !== 0) {
×
300
      throw new Error('Could not clear the waitlist. Please try again.');
×
301
    }
×
302

×
303
    this.#uw.publish('waitlist:clear', {
×
304
      moderatorID: moderator.id,
×
305
    });
×
306
  }
×
307

298✔
308
  /**
298✔
309
   * @param {boolean} lock
298✔
310
   * @param {User} moderator
298✔
311
   * @returns {Promise<void>}
298✔
312
   */
298✔
313
  async #setWaitlistLocked(lock, moderator) {
298✔
314
    const settings = await this.#getSettings();
12✔
315
    await this.#uw.config.set(schema['uw:key'], { ...settings, locked: lock }, { user: moderator });
12✔
316
  }
12✔
317

298✔
318
  /**
298✔
319
   * Lock the waitlist. Only users with the `waitlist.join.locked` permission
298✔
320
   * will be able to join.
298✔
321
   *
298✔
322
   * @param {{moderator: User}} options
298✔
323
   * @returns {Promise<void>}
298✔
324
   */
298✔
325
  lock({ moderator }) {
298✔
326
    return this.#setWaitlistLocked(true, moderator);
8✔
327
  }
8✔
328

298✔
329
  /**
298✔
330
   * Unlock the waitlist. All users with the `waitlist.join` permission
298✔
331
   * will be able to join.
298✔
332
   *
298✔
333
   * @param {{moderator: User}} options
298✔
334
   * @returns {Promise<void>}
298✔
335
   */
298✔
336
  unlock({ moderator }) {
298✔
337
    return this.#setWaitlistLocked(false, moderator);
4✔
338
  }
4✔
339
}
298✔
340

2✔
341
/**
2✔
342
 * @param {import('../Uwave.js').Boot} uw
2✔
343
 * @returns {Promise<void>}
2✔
344
 */
2✔
345
async function waitlistPlugin(uw) {
298✔
346
  uw.waitlist = new Waitlist(uw);
298✔
347
  uw.httpApi.use('/waitlist', routes());
298✔
348
}
298✔
349

2✔
350
export default waitlistPlugin;
2✔
351
export { Waitlist };
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc