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

u-wave / core / 11980840475

22 Nov 2024 10:04PM UTC coverage: 78.492% (-1.7%) from 80.158%
11980840475

Pull #637

github

goto-bus-stop
ci: add node 22
Pull Request #637: Switch to a relational database

757 of 912 branches covered (83.0%)

Branch coverage included in aggregate %.

2001 of 2791 new or added lines in 52 files covered. (71.69%)

9 existing lines in 7 files now uncovered.

8666 of 11093 relevant lines covered (78.12%)

70.72 hits per line

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

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

1✔
15
/**
1✔
16
 * @typedef {import('../schema').UserID} UserID
1✔
17
 * @typedef {import('../schema').MediaID} MediaID
1✔
18
 * @typedef {import('../schema').PlaylistID} PlaylistID
1✔
19
 * @typedef {import('../schema').HistoryEntryID} HistoryEntryID
1✔
20
 */
1✔
21

1✔
22
const REDIS_HISTORY_ID = 'booth:historyID';
1✔
23
const REDIS_CURRENT_DJ_ID = 'booth:currentDJ';
1✔
24

1✔
25
/**
1✔
26
 * @param {import('../Uwave.js').default} uw
1✔
27
 */
1✔
28
async function getBoothData(uw) {
5✔
29
  const { booth } = uw;
5✔
30

5✔
31
  const state = await booth.getCurrentEntry();
5✔
32
  if (state == null) {
5✔
33
    return null;
3✔
34
  }
3✔
35

2✔
36
  // @ts-expect-error TS2322: We just populated historyEntry.media.media
2✔
37
  const media = booth.getMediaForPlayback(state);
2✔
38

2✔
39
  return {
2✔
40
    historyID: state.historyEntry.id,
2✔
41
    // playlistID: state.playlist.id,
2✔
42
    playedAt: state.historyEntry.createdAt.getTime(),
2✔
43
    userID: state.user.id,
2✔
44
    media,
2✔
45
    stats: {
2✔
46
      upvotes: state.upvotes,
2✔
47
      downvotes: state.downvotes,
2✔
48
      favorites: state.favorites,
2✔
49
    },
2✔
50
  };
2✔
51
}
5✔
52

1✔
53
/**
1✔
54
 * @type {import('../types.js').Controller}
1✔
55
 */
1✔
56
async function getBooth(req) {
1✔
57
  const uw = req.uwave;
1✔
58

1✔
59
  const data = await getBoothData(uw);
1✔
60
  if (data && req.user && data.userID === req.user.id) {
1✔
61
    return toItemResponse({
1✔
62
      ...data,
1✔
63
      autoLeave: await uw.booth.getRemoveAfterCurrentPlay(req.user),
1✔
64
    }, { url: req.fullUrl });
1✔
65
  }
1✔
66

×
67
  return toItemResponse(data, { url: req.fullUrl });
×
68
}
1✔
69

1✔
70
/**
1✔
71
 * @param {import('../Uwave.js').default} uw
1✔
72
 */
1✔
73
function getCurrentDJ(uw) {
5✔
74
  return /** @type {Promise<UserID|null>} */ (uw.redis.get(REDIS_CURRENT_DJ_ID));
5✔
75
}
5✔
76

1✔
77
/**
1✔
78
 * @param {import('../Uwave.js').default} uw
1✔
79
 */
1✔
80
function getCurrentHistoryID(uw) {
5✔
81
  return /** @type {Promise<HistoryEntryID|null>} */ (uw.redis.get(REDIS_HISTORY_ID));
5✔
82
}
5✔
83

1✔
84
/**
1✔
85
 * @param {import('../Uwave.js').default} uw
1✔
86
 * @param {UserID|null} moderatorID - `null` if a user is skipping their own turn.
1✔
87
 * @param {UserID} userID
1✔
88
 * @param {string|null} reason
1✔
89
 * @param {{ remove?: boolean }} [opts]
1✔
90
 */
1✔
91
async function doSkip(uw, moderatorID, userID, reason, opts = {}) {
×
92
  uw.publish('booth:skip', {
×
93
    moderatorID,
×
94
    userID,
×
95
    reason,
×
96
  });
×
97

×
98
  await uw.booth.advance({
×
99
    remove: opts.remove === true,
×
100
  });
×
101
}
×
102

1✔
103
/**
1✔
104
 * @typedef {object} SkipUserAndReason
1✔
105
 * @prop {UserID} userID
1✔
106
 * @prop {string} reason
1✔
107
 * @typedef {{
1✔
108
 *   remove?: boolean,
1✔
109
 *   userID?: UserID,
1✔
110
 *   reason?: string,
1✔
111
 * } & (SkipUserAndReason | {})} SkipBoothBody
1✔
112
 */
1✔
113

1✔
114
/**
1✔
115
 * @type {import('../types.js').AuthenticatedController<{}, {}, SkipBoothBody>}
1✔
116
 */
1✔
117
async function skipBooth(req) {
×
118
  const { user } = req;
×
119
  const { userID, reason, remove } = req.body;
×
120
  const { acl } = req.uwave;
×
121

×
122
  const skippingSelf = (!userID && !reason) || userID === user.id;
×
123
  const opts = { remove: !!remove };
×
124

×
125
  if (skippingSelf) {
×
126
    const currentDJ = await getCurrentDJ(req.uwave);
×
127
    if (!currentDJ || currentDJ !== req.user.id) {
×
128
      throw new HTTPError(412, 'You are not currently playing');
×
129
    }
×
130

×
131
    await doSkip(req.uwave, null, req.user.id, null, opts);
×
132

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

×
NEW
136
  if (!await acl.isAllowed(user, Permissions.SkipOther)) {
×
NEW
137
    throw new PermissionError({ requiredRole: Permissions.SkipOther });
×
138
  }
×
139

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

×
144
  return toItemResponse({});
×
145
}
×
146

1✔
147
/** @typedef {{ userID: UserID, autoLeave: boolean }} LeaveBoothBody */
1✔
148

1✔
149
/**
1✔
150
 * @type {import('../types.js').AuthenticatedController<{}, {}, LeaveBoothBody>}
1✔
151
 */
1✔
152
async function leaveBooth(req) {
×
153
  const { user: self } = req;
×
154
  const { userID, autoLeave } = req.body;
×
155
  const { acl, booth, users } = req.uwave;
×
156

×
157
  const skippingSelf = userID === self.id;
×
158

×
159
  if (skippingSelf) {
×
160
    const value = await booth.setRemoveAfterCurrentPlay(self, autoLeave);
×
161
    return toItemResponse({ autoLeave: value });
×
162
  }
×
163

×
NEW
164
  if (!await acl.isAllowed(self, Permissions.SkipOther)) {
×
NEW
165
    throw new PermissionError({ requiredRole: Permissions.SkipOther });
×
166
  }
×
167

×
168
  const user = await users.getUser(userID);
×
169
  if (!user) {
×
170
    throw new UserNotFoundError({ id: userID });
×
171
  }
×
172

×
173
  const value = await booth.setRemoveAfterCurrentPlay(user, autoLeave);
×
174
  return toItemResponse({ autoLeave: value });
×
175
}
×
176

1✔
177
/**
1✔
178
 * @typedef {object} ReplaceBoothBody
1✔
179
 * @prop {UserID} userID
1✔
180
 */
1✔
181

1✔
182
/**
1✔
183
 * @type {import('../types.js').AuthenticatedController<{}, {}, ReplaceBoothBody>}
1✔
184
 */
1✔
185
async function replaceBooth(req) {
×
186
  const uw = req.uwave;
×
187
  const moderatorID = req.user.id;
×
188
  const { userID } = req.body;
×
189
  let waitlist = await uw.redis.lrange('waitlist', 0, -1);
×
190

×
191
  if (!waitlist.length) {
×
192
    throw new HTTPError(404, 'Waitlist is empty.');
×
193
  }
×
194

×
195
  if (waitlist.includes(userID)) {
×
196
    uw.redis.lrem('waitlist', 1, userID);
×
197
    await uw.redis.lpush('waitlist', userID);
×
198
    waitlist = await uw.redis.lrange('waitlist', 0, -1);
×
199
  }
×
200

×
201
  uw.publish('booth:replace', {
×
202
    moderatorID,
×
203
    userID,
×
204
  });
×
205

×
206
  await uw.booth.advance();
×
207

×
208
  return toItemResponse({});
×
209
}
×
210

1✔
211
/**
1✔
212
 * @param {import('../Uwave.js').default} uw
1✔
213
 * @param {HistoryEntryID} historyEntryID
1✔
214
 * @param {UserID} userID
1✔
215
 * @param {1|-1} direction
1✔
216
 */
1✔
217
async function addVote(uw, historyEntryID, userID, direction) {
3✔
218
  const result = await uw.db.insertInto('feedback')
3✔
219
    .values({
3✔
220
      historyEntryID,
3✔
221
      userID,
3✔
222
      vote: direction,
3✔
223
    })
3✔
224
    // We should only broadcast the vote if it changed,
3✔
225
    // so we make sure not to update the vote if the value is the same.
3✔
226
    .onConflict((oc) => oc
3✔
227
      .columns(['historyEntryID', 'userID'])
3✔
228
      .doUpdateSet({ vote: direction })
3✔
229
      .where('vote', '!=', direction))
3✔
230
    .executeTakeFirst();
3✔
231

3✔
232
  if (result != null && result.numInsertedOrUpdatedRows != null
3✔
233
      && result.numInsertedOrUpdatedRows > 0n) {
3✔
234
    uw.publish('booth:vote', {
2✔
235
      userID, direction,
2✔
236
    });
2✔
237
  }
2✔
238
}
3✔
239

1✔
240
/**
1✔
241
 * Old way of voting: over the WebSocket
1✔
242
 *
1✔
243
 * @param {import('../Uwave.js').default} uw
1✔
244
 * @param {UserID} userID
1✔
245
 * @param {1|-1} direction
1✔
246
 */
1✔
247
async function socketVote(uw, userID, direction) {
×
248
  const currentDJ = await getCurrentDJ(uw);
×
NEW
249
  if (currentDJ != null && currentDJ !== userID) {
×
NEW
250
    const historyEntryID = await getCurrentHistoryID(uw);
×
NEW
251
    if (historyEntryID == null) {
×
NEW
252
      return;
×
NEW
253
    }
×
254
    if (direction > 0) {
×
NEW
255
      await addVote(uw, historyEntryID, userID, 1);
×
256
    } else {
×
NEW
257
      await addVote(uw, historyEntryID, userID, -1);
×
258
    }
×
259
  }
×
260
}
×
261

1✔
262
/**
1✔
263
 * @typedef {object} GetVoteParams
1✔
264
 * @prop {HistoryEntryID} historyID
1✔
265
 */
1✔
266

1✔
267
/**
1✔
268
 * @type {import('../types.js').AuthenticatedController<GetVoteParams>}
1✔
269
 */
1✔
270
async function getVote(req) {
×
271
  const { uwave: uw, user } = req;
×
272
  const { historyID } = req.params;
×
273

×
NEW
274
  const currentHistoryID = await getCurrentHistoryID(uw);
×
NEW
275
  if (currentHistoryID == null) {
×
276
    throw new HTTPError(412, 'Nobody is playing');
×
277
  }
×
278
  if (historyID && historyID !== currentHistoryID) {
×
279
    throw new HTTPError(412, 'Cannot get vote for media that is not currently playing');
×
280
  }
×
281

×
NEW
282
  const feedback = await uw.db.selectFrom('feedback')
×
NEW
283
    .where('historyEntryID', '=', historyID)
×
NEW
284
    .where('userID', '=', user.id)
×
NEW
285
    .select('vote')
×
NEW
286
    .executeTakeFirst();
×
287

×
NEW
288
  const direction = feedback?.vote ?? 0;
×
289
  return toItemResponse({ direction });
×
290
}
×
291

1✔
292
/**
1✔
293
 * @typedef {object} VoteParams
1✔
294
 * @prop {HistoryEntryID} historyID
1✔
295
 * @typedef {object} VoteBody
1✔
296
 * @prop {1|-1} direction
1✔
297
 */
1✔
298

1✔
299
/**
1✔
300
 * @type {import('../types.js').AuthenticatedController<VoteParams, {}, VoteBody>}
1✔
301
 */
1✔
302
async function vote(req) {
5✔
303
  const { uwave: uw, user } = req;
5✔
304
  const { historyID } = req.params;
5✔
305
  const { direction } = req.body;
5✔
306

5✔
307
  const [currentDJ, currentHistoryID] = await Promise.all([
5✔
308
    getCurrentDJ(uw),
5✔
309
    getCurrentHistoryID(uw),
5✔
310
  ]);
5✔
311
  if (currentDJ == null || currentHistoryID == null) {
5✔
312
    throw new HTTPError(412, 'Nobody is playing');
2✔
313
  }
2✔
314
  if (currentDJ === user.id) {
5!
315
    throw new HTTPError(412, 'Cannot vote for your own plays');
×
316
  }
×
317
  if (historyID && historyID !== currentHistoryID) {
5!
318
    throw new HTTPError(412, 'Cannot vote for media that is not currently playing');
×
319
  }
×
320

3✔
321
  if (direction > 0) {
5✔
322
    await addVote(uw, historyID, user.id, 1);
1✔
323
  } else {
5✔
324
    await addVote(uw, historyID, user.id, -1);
2✔
325
  }
2✔
326

3✔
327
  return toItemResponse({});
3✔
328
}
5✔
329

1✔
330
/**
1✔
331
 * @typedef {object} FavoriteBody
1✔
332
 * @prop {PlaylistID} playlistID
1✔
333
 * @prop {HistoryEntryID} historyID
1✔
334
 */
1✔
335

1✔
336
/**
1✔
337
 * @type {import('../types.js').AuthenticatedController<{}, {}, FavoriteBody>}
1✔
338
 */
1✔
339
async function favorite(req) {
×
340
  const { user } = req;
×
341
  const { playlistID, historyID } = req.body;
×
NEW
342
  const { db, history, playlists } = req.uwave;
×
343
  const uw = req.uwave;
×
344

×
NEW
345
  const historyEntry = await history.getEntry(historyID);
×
346

×
347
  if (!historyEntry) {
×
348
    throw new HistoryEntryNotFoundError({ id: historyID });
×
349
  }
×
NEW
350
  if (historyEntry.user._id === user.id) {
×
351
    throw new CannotSelfFavoriteError();
×
352
  }
×
353

×
NEW
354
  const playlist = await playlists.getUserPlaylist(user, playlistID);
×
355
  if (!playlist) {
×
356
    throw new PlaylistNotFoundError({ id: playlistID });
×
357
  }
×
358

×
NEW
359
  const result = await playlists.addPlaylistItems(
×
NEW
360
    playlist,
×
NEW
361
    [{
×
NEW
362
      sourceType: historyEntry.media.media.sourceType,
×
NEW
363
      sourceID: historyEntry.media.media.sourceID,
×
NEW
364
      artist: historyEntry.media.artist,
×
NEW
365
      title: historyEntry.media.title,
×
NEW
366
      start: historyEntry.media.start,
×
NEW
367
      end: historyEntry.media.end,
×
NEW
368
    }],
×
NEW
369
    { at: 'end' },
×
NEW
370
  );
×
NEW
371

×
NEW
372
  await db.insertInto('feedback')
×
NEW
373
    .values({ userID: user.id, historyEntryID: historyID, favorite: 1 })
×
NEW
374
    .onConflict((oc) => oc.columns(['userID', 'historyEntryID']).doUpdateSet({ favorite: 1 }))
×
NEW
375
    .execute();
×
376

×
377
  uw.publish('booth:favorite', {
×
378
    userID: user.id,
×
379
    playlistID,
×
380
  });
×
381

×
NEW
382
  return toListResponse(result.added, {
×
383
    meta: {
×
NEW
384
      playlistSize: result.playlistSize,
×
385
    },
×
386
    included: {
×
387
      media: ['media'],
×
388
    },
×
389
  });
×
390
}
×
391

1✔
392
/**
1✔
393
 * @typedef {object} GetRoomHistoryQuery
1✔
394
 * @prop {import('../types.js').PaginationQuery & { media?: MediaID }} [filter]
1✔
395
 */
1✔
396
/**
1✔
397
 * @type {import('../types.js').Controller<never, GetRoomHistoryQuery, never>}
1✔
398
 */
1✔
399
async function getHistory(req) {
×
400
  const filter = {};
×
401
  const pagination = getOffsetPagination(req.query, {
×
402
    defaultSize: 25,
×
403
    maxSize: 100,
×
404
  });
×
405
  const { history } = req.uwave;
×
406

×
407
  if (req.query.filter && req.query.filter.media) {
×
408
    filter['media.media'] = req.query.filter.media;
×
409
  }
×
410

×
NEW
411
  // TODO: Support filter?
×
NEW
412

×
NEW
413
  const roomHistory = await history.getRoomHistory(pagination);
×
414

×
415
  return toPaginatedResponse(roomHistory, {
×
416
    baseUrl: req.fullUrl,
×
417
    included: {
×
418
      media: ['media.media'],
×
419
      user: ['user'],
×
420
    },
×
421
  });
×
422
}
×
423

1✔
424
export {
1✔
425
  favorite,
1✔
426
  getBooth,
1✔
427
  getBoothData,
1✔
428
  getHistory,
1✔
429
  getVote,
1✔
430
  leaveBooth,
1✔
431
  replaceBooth,
1✔
432
  skipBooth,
1✔
433
  socketVote,
1✔
434
  vote,
1✔
435
};
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