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

u-wave / core / 10530495771

23 Aug 2024 06:22PM UTC coverage: 80.129% (-0.1%) from 80.244%
10530495771

push

github

web-flow
Add booth auto-leave after current play  (#600)

* Add booth auto-leave after current play

The current DJ can enable auto-leave to leave the waitlist *after* their
current play is over.

* Allow disabling auto-leave

643 of 782 branches covered (82.23%)

Branch coverage included in aggregate %.

98 of 138 new or added lines in 4 files covered. (71.01%)

2 existing lines in 1 file now uncovered.

8394 of 10496 relevant lines covered (79.97%)

44.3 hits per line

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

53.6
/src/controllers/booth.js
1
import assert from 'node:assert';
1✔
2
import mongoose from 'mongoose';
1✔
3
import {
1✔
4
  HTTPError,
1✔
5
  PermissionError,
1✔
6
  HistoryEntryNotFoundError,
1✔
7
  PlaylistNotFoundError,
1✔
8
  CannotSelfFavoriteError,
1✔
9
  UserNotFoundError,
1✔
10
} from '../errors/index.js';
1✔
11
import getOffsetPagination from '../utils/getOffsetPagination.js';
1✔
12
import toItemResponse from '../utils/toItemResponse.js';
1✔
13
import toListResponse from '../utils/toListResponse.js';
1✔
14
import toPaginatedResponse from '../utils/toPaginatedResponse.js';
1✔
15

1✔
16
const { ObjectId } = mongoose.Types;
1✔
17

1✔
18
/**
1✔
19
 * @param {import('../Uwave.js').default} uw
1✔
20
 */
1✔
21
async function getBoothData(uw) {
4✔
22
  const { booth } = uw;
4✔
23

4✔
24
  const historyEntry = await booth.getCurrentEntry();
4✔
25

4✔
26
  if (!historyEntry || !historyEntry.user) {
4✔
27
    return null;
3✔
28
  }
3✔
29

1✔
30
  await historyEntry.populate('media.media');
1✔
31
  // @ts-expect-error TS2322: We just populated historyEntry.media.media
1✔
32
  const media = booth.getMediaForPlayback(historyEntry);
1✔
33

1✔
34
  const stats = await booth.getCurrentVoteStats();
1✔
35

1✔
36
  return {
1✔
37
    historyID: historyEntry.id,
1✔
38
    playlistID: `${historyEntry.playlist}`,
1✔
39
    playedAt: historyEntry.playedAt.getTime(),
1✔
40
    userID: `${historyEntry.user}`,
1✔
41
    media,
1✔
42
    stats,
1✔
43
  };
1✔
44
}
4✔
45

1✔
46
/**
1✔
47
 * @type {import('../types.js').Controller}
1✔
48
 */
1✔
49
async function getBooth(req) {
1✔
50
  const uw = req.uwave;
1✔
51

1✔
52
  const data = await getBoothData(uw);
1✔
53
  if (data && req.user && data.userID === req.user.id) {
1✔
54
    return toItemResponse({
1✔
55
      ...data,
1✔
56
      autoLeave: await uw.booth.getRemoveAfterCurrentPlay(req.user),
1✔
57
    }, { url: req.fullUrl });
1✔
58
  }
1✔
UNCOV
59

×
UNCOV
60
  return toItemResponse(data, { url: req.fullUrl });
×
61
}
1✔
62

1✔
63
/**
1✔
64
 * @param {import('../Uwave.js').default} uw
1✔
65
 * @returns {Promise<string|null>}
1✔
66
 */
1✔
67
function getCurrentDJ(uw) {
5✔
68
  return uw.redis.get('booth:currentDJ');
5✔
69
}
5✔
70

1✔
71
/**
1✔
72
 * @param {import('../Uwave.js').default} uw
1✔
73
 * @param {string|null} moderatorID - `null` if a user is skipping their own turn.
1✔
74
 * @param {string} userID
1✔
75
 * @param {string|null} reason
1✔
76
 * @param {{ remove?: boolean }} [opts]
1✔
77
 */
1✔
78
async function doSkip(uw, moderatorID, userID, reason, opts = {}) {
×
79
  uw.publish('booth:skip', {
×
80
    moderatorID,
×
81
    userID,
×
82
    reason,
×
83
  });
×
84

×
85
  await uw.booth.advance({
×
86
    remove: opts.remove === true,
×
87
  });
×
88
}
×
89

1✔
90
/**
1✔
91
 * @typedef {object} SkipUserAndReason
1✔
92
 * @prop {string} userID
1✔
93
 * @prop {string} reason
1✔
94
 *
1✔
95
 * @typedef {{
1✔
96
 *   remove?: boolean,
1✔
97
 *   userID?: string,
1✔
98
 *   reason?: string,
1✔
99
 * } & (SkipUserAndReason | {})} SkipBoothBody
1✔
100
 */
1✔
101

1✔
102
/**
1✔
103
 * @type {import('../types.js').AuthenticatedController<{}, {}, SkipBoothBody>}
1✔
104
 */
1✔
105
async function skipBooth(req) {
×
106
  const { user } = req;
×
107
  const { userID, reason, remove } = req.body;
×
108
  const { acl } = req.uwave;
×
109

×
110
  const skippingSelf = (!userID && !reason) || userID === user.id;
×
111
  const opts = { remove: !!remove };
×
112

×
113
  if (skippingSelf) {
×
114
    const currentDJ = await getCurrentDJ(req.uwave);
×
115
    if (!currentDJ || currentDJ !== req.user.id) {
×
116
      throw new HTTPError(412, 'You are not currently playing');
×
117
    }
×
118

×
119
    await doSkip(req.uwave, null, req.user.id, null, opts);
×
120

×
121
    return toItemResponse({});
×
122
  }
×
123

×
124
  if (!await acl.isAllowed(user, 'booth.skip.other')) {
×
125
    throw new PermissionError({ requiredRole: 'booth.skip.other' });
×
126
  }
×
127

×
128
  // @ts-expect-error TS2345 pretending like `userID` is definitely defined here
×
129
  // TODO I think the typescript error is actually correct so we should fix this
×
130
  await doSkip(req.uwave, user.id, userID, reason, opts);
×
131

×
132
  return toItemResponse({});
×
133
}
×
134

1✔
135
/** @typedef {{ userID: string, autoLeave: boolean }} LeaveBoothBody */
1✔
136

1✔
137
/**
1✔
138
 * @type {import('../types.js').AuthenticatedController<{}, {}, LeaveBoothBody>}
1✔
139
 */
1✔
NEW
140
async function leaveBooth(req) {
×
NEW
141
  const { user: self } = req;
×
NEW
142
  const { userID, autoLeave } = req.body;
×
NEW
143
  const { acl, booth, users } = req.uwave;
×
NEW
144

×
NEW
145
  const skippingSelf = userID === self.id;
×
NEW
146

×
NEW
147
  if (skippingSelf) {
×
NEW
148
    const value = await booth.setRemoveAfterCurrentPlay(self, autoLeave);
×
NEW
149
    return toItemResponse({ autoLeave: value });
×
NEW
150
  }
×
NEW
151

×
NEW
152
  if (!await acl.isAllowed(self, 'booth.skip.other')) {
×
NEW
153
    throw new PermissionError({ requiredRole: 'booth.skip.other' });
×
NEW
154
  }
×
NEW
155

×
NEW
156
  const user = await users.getUser(userID);
×
NEW
157
  if (!user) {
×
NEW
158
    throw new UserNotFoundError({ id: userID });
×
NEW
159
  }
×
NEW
160

×
NEW
161
  const value = await booth.setRemoveAfterCurrentPlay(user, autoLeave);
×
NEW
162
  return toItemResponse({ autoLeave: value });
×
NEW
163
}
×
164

1✔
165
/**
1✔
166
 * @typedef {object} ReplaceBoothBody
1✔
167
 * @prop {string} userID
1✔
168
 */
1✔
169

1✔
170
/**
1✔
171
 * @type {import('../types.js').AuthenticatedController<{}, {}, ReplaceBoothBody>}
1✔
172
 */
1✔
173
async function replaceBooth(req) {
×
174
  const uw = req.uwave;
×
175
  const moderatorID = req.user.id;
×
176
  const { userID } = req.body;
×
177
  let waitlist = await uw.redis.lrange('waitlist', 0, -1);
×
178

×
179
  if (!waitlist.length) {
×
180
    throw new HTTPError(404, 'Waitlist is empty.');
×
181
  }
×
182

×
183
  if (waitlist.includes(userID)) {
×
184
    uw.redis.lrem('waitlist', 1, userID);
×
185
    await uw.redis.lpush('waitlist', userID);
×
186
    waitlist = await uw.redis.lrange('waitlist', 0, -1);
×
187
  }
×
188

×
189
  uw.publish('booth:replace', {
×
190
    moderatorID,
×
191
    userID,
×
192
  });
×
193

×
194
  await uw.booth.advance();
×
195

×
196
  return toItemResponse({});
×
197
}
×
198

1✔
199
/**
1✔
200
 * @param {import('../Uwave.js').default} uw
1✔
201
 * @param {string} userID
1✔
202
 * @param {1|-1} direction
1✔
203
 */
1✔
204
async function addVote(uw, userID, direction) {
3✔
205
  const results = await uw.redis.multi()
3✔
206
    .srem('booth:upvotes', userID)
3✔
207
    .srem('booth:downvotes', userID)
3✔
208
    .sadd(direction > 0 ? 'booth:upvotes' : 'booth:downvotes', userID)
3✔
209
    .exec();
3✔
210
  assert(results);
3✔
211

3✔
212
  const replacedUpvote = results[0][1] !== 0;
3✔
213
  const replacedDownvote = results[1][1] !== 0;
3✔
214

3✔
215
  // Replaced an upvote by an upvote or a downvote by a downvote: the vote didn't change.
3✔
216
  // We don't need to broadcast the non-change to everyone.
3✔
217
  if ((replacedUpvote && direction > 0) || (replacedDownvote && direction < 0)) {
3!
218
    return;
1✔
219
  }
1✔
220

2✔
221
  uw.publish('booth:vote', {
2✔
222
    userID, direction,
2✔
223
  });
2✔
224
}
3✔
225

1✔
226
/**
1✔
227
 * Old way of voting: over the WebSocket
1✔
228
 *
1✔
229
 * @param {import('../Uwave.js').default} uw
1✔
230
 * @param {string} userID
1✔
231
 * @param {1|-1} direction
1✔
232
 */
1✔
233
async function socketVote(uw, userID, direction) {
×
234
  const currentDJ = await getCurrentDJ(uw);
×
235
  if (currentDJ !== null && currentDJ !== userID) {
×
236
    const historyID = await uw.redis.get('booth:historyID');
×
237
    if (historyID === null) return;
×
238
    if (direction > 0) {
×
239
      await addVote(uw, userID, 1);
×
240
    } else {
×
241
      await addVote(uw, userID, -1);
×
242
    }
×
243
  }
×
244
}
×
245

1✔
246
/**
1✔
247
 * @typedef {object} GetVoteParams
1✔
248
 * @prop {string} historyID
1✔
249
 */
1✔
250

1✔
251
/**
1✔
252
 * @type {import('../types.js').AuthenticatedController<GetVoteParams>}
1✔
253
 */
1✔
254
async function getVote(req) {
×
255
  const { uwave: uw, user } = req;
×
256
  const { historyID } = req.params;
×
257

×
258
  const [currentDJ, currentHistoryID] = await Promise.all([
×
259
    getCurrentDJ(uw),
×
260
    uw.redis.get('booth:historyID'),
×
261
  ]);
×
262
  if (currentDJ === null || currentHistoryID === null) {
×
263
    throw new HTTPError(412, 'Nobody is playing');
×
264
  }
×
265
  if (historyID && historyID !== currentHistoryID) {
×
266
    throw new HTTPError(412, 'Cannot get vote for media that is not currently playing');
×
267
  }
×
268

×
269
  const [upvoted, downvoted] = await Promise.all([
×
270
    uw.redis.sismember('booth:upvotes', user.id),
×
271
    uw.redis.sismember('booth:downvotes', user.id),
×
272
  ]);
×
273

×
274
  let direction = 0;
×
275
  if (upvoted) {
×
276
    direction = 1;
×
277
  } else if (downvoted) {
×
278
    direction = -1;
×
279
  }
×
280

×
281
  return toItemResponse({ direction });
×
282
}
×
283

1✔
284
/**
1✔
285
 * @typedef {object} VoteParams
1✔
286
 * @prop {string} historyID
1✔
287
 *
1✔
288
 * @typedef {object} VoteBody
1✔
289
 * @prop {1|-1} direction
1✔
290
 */
1✔
291

1✔
292
/**
1✔
293
 * @type {import('../types.js').AuthenticatedController<VoteParams, {}, VoteBody>}
1✔
294
 */
1✔
295
async function vote(req) {
5✔
296
  const { uwave: uw, user } = req;
5✔
297
  const { historyID } = req.params;
5✔
298
  const { direction } = req.body;
5✔
299

5✔
300
  const [currentDJ, currentHistoryID] = await Promise.all([
5✔
301
    getCurrentDJ(uw),
5✔
302
    uw.redis.get('booth:historyID'),
5✔
303
  ]);
5✔
304
  if (currentDJ === null || currentHistoryID === null) {
5✔
305
    throw new HTTPError(412, 'Nobody is playing');
2✔
306
  }
2✔
307
  if (currentDJ === user.id) {
5!
308
    throw new HTTPError(412, 'Cannot vote for your own plays');
×
309
  }
×
310
  if (historyID && historyID !== currentHistoryID) {
5!
311
    throw new HTTPError(412, 'Cannot vote for media that is not currently playing');
×
312
  }
×
313

3✔
314
  if (direction > 0) {
5✔
315
    await addVote(uw, user.id, 1);
1✔
316
  } else {
5✔
317
    await addVote(uw, user.id, -1);
2✔
318
  }
2✔
319

3✔
320
  return toItemResponse({});
3✔
321
}
5✔
322

1✔
323
/**
1✔
324
 * @typedef {object} FavoriteBody
1✔
325
 * @prop {string} playlistID
1✔
326
 * @prop {string} historyID
1✔
327
 */
1✔
328

1✔
329
/**
1✔
330
 * @type {import('../types.js').AuthenticatedController<{}, {}, FavoriteBody>}
1✔
331
 */
1✔
332
async function favorite(req) {
×
333
  const { user } = req;
×
334
  const { playlistID, historyID } = req.body;
×
335
  const uw = req.uwave;
×
336
  const { PlaylistItem, HistoryEntry } = uw.models;
×
337

×
338
  const historyEntry = await HistoryEntry.findById(historyID);
×
339

×
340
  if (!historyEntry) {
×
341
    throw new HistoryEntryNotFoundError({ id: historyID });
×
342
  }
×
343
  if (`${historyEntry.user}` === user.id) {
×
344
    throw new CannotSelfFavoriteError();
×
345
  }
×
346

×
347
  const playlist = await uw.playlists.getUserPlaylist(user, new ObjectId(playlistID));
×
348
  if (!playlist) {
×
349
    throw new PlaylistNotFoundError({ id: playlistID });
×
350
  }
×
351

×
352
  // `.media` has the same shape as `.item`, but is guaranteed to exist and have
×
353
  // the same properties as when the playlist item was actually played.
×
354
  const itemProps = historyEntry.media.toJSON();
×
355
  const playlistItem = await PlaylistItem.create(itemProps);
×
356

×
357
  playlist.media.push(playlistItem.id);
×
358

×
359
  await uw.redis.sadd('booth:favorites', user.id);
×
360
  uw.publish('booth:favorite', {
×
361
    userID: user.id,
×
362
    playlistID,
×
363
  });
×
364

×
365
  await playlist.save();
×
366

×
367
  return toListResponse([playlistItem], {
×
368
    meta: {
×
369
      playlistSize: playlist.media.length,
×
370
    },
×
371
    included: {
×
372
      media: ['media'],
×
373
    },
×
374
  });
×
375
}
×
376

1✔
377
/**
1✔
378
 * @typedef {object} GetRoomHistoryQuery
1✔
379
 * @prop {import('../types.js').PaginationQuery & { media?: string }} [filter]
1✔
380
 */
1✔
381
/**
1✔
382
 * @type {import('../types.js').Controller<never, GetRoomHistoryQuery, never>}
1✔
383
 */
1✔
384
async function getHistory(req) {
×
385
  const filter = {};
×
386
  const pagination = getOffsetPagination(req.query, {
×
387
    defaultSize: 25,
×
388
    maxSize: 100,
×
389
  });
×
390
  const { history } = req.uwave;
×
391

×
392
  if (req.query.filter && req.query.filter.media) {
×
393
    filter['media.media'] = req.query.filter.media;
×
394
  }
×
395

×
396
  const roomHistory = await history.getHistory(filter, pagination);
×
397

×
398
  return toPaginatedResponse(roomHistory, {
×
399
    baseUrl: req.fullUrl,
×
400
    included: {
×
401
      media: ['media.media'],
×
402
      user: ['user'],
×
403
    },
×
404
  });
×
405
}
×
406

1✔
407
export {
1✔
408
  favorite,
1✔
409
  getBooth,
1✔
410
  getBoothData,
1✔
411
  getHistory,
1✔
412
  getVote,
1✔
413
  leaveBooth,
1✔
414
  replaceBooth,
1✔
415
  skipBooth,
1✔
416
  socketVote,
1✔
417
  vote,
1✔
418
};
1✔
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

© 2026 Coveralls, Inc