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

u-wave / core / 11980470338

22 Nov 2024 09:32PM UTC coverage: 78.436% (-1.7%) from 80.16%
11980470338

Pull #637

github

goto-bus-stop
explicitly store UTC in sqlite
Pull Request #637: Switch to a relational database

757 of 915 branches covered (82.73%)

Branch coverage included in aggregate %.

1977 of 2768 new or added lines in 52 files covered. (71.42%)

9 existing lines in 7 files now uncovered.

8653 of 11082 relevant lines covered (78.08%)

70.79 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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