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

GEWIS / sudosos-backend / 27283082186

10 Jun 2026 02:13PM UTC coverage: 91.996% (+0.04%) from 91.956%
27283082186

push

github

web-flow
chore(deps): bump bullmq from 5.77.6 to 5.78.0 (#949)

Bumps [bullmq](https://github.com/taskforcesh/bullmq) from 5.77.6 to 5.78.0.
- [Release notes](https://github.com/taskforcesh/bullmq/releases)
- [Commits](https://github.com/taskforcesh/bullmq/compare/v5.77.6...v5.78.0)

---
updated-dependencies:
- dependency-name: bullmq
  dependency-version: 5.78.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

4199 of 4800 branches covered (87.48%)

Branch coverage included in aggregate %.

21593 of 23236 relevant lines covered (92.93%)

847.01 hits per line

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

83.19
/src/controller/debtor-controller.ts
1
/**
1✔
2
 *  SudoSOS back-end API service.
3
 *  Copyright (C) 2026 Study association GEWIS
4
 *
5
 *  This program is free software: you can redistribute it and/or modify
6
 *  it under the terms of the GNU Affero General Public License as published
7
 *  by the Free Software Foundation, either version 3 of the License, or
8
 *  (at your option) any later version.
9
 *
10
 *  This program is distributed in the hope that it will be useful,
11
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 *  GNU Affero General Public License for more details.
14
 *
15
 *  You should have received a copy of the GNU Affero General Public License
16
 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
17
 *
18
 *  @license
19
 */
1✔
20

21
/**
1✔
22
 * @module debtors
23
 */
1✔
24

25
import BaseController, { BaseControllerOptions } from './base-controller';
26
import { Response } from 'express';
27
import log4js, { Logger } from 'log4js';
28
import Policy from './policy';
29
import { RequestWithToken } from '../middleware/token-middleware';
30
import { parseRequestPagination, toResponse } from '../helpers/pagination';
31
import DebtorService from '../service/debtor-service';
32
import User from '../entity/user/user';
33
import { asArrayOfDates, asArrayOfUserTypes, asDate, asFromAndTillDate, asReturnFileType } from '../helpers/validators';
34
import { In } from 'typeorm';
35
import { HandoutFinesRequest } from './request/debtor-request';
36
import Fine from '../entity/fine/fine';
37
import { ReturnFileType } from 'pdf-generator-client';
38
import { PdfError } from '../errors';
39
import FineHandoutEvent from '../entity/fine/fineHandoutEvent';
40

41
/**
1✔
42
 * Controller for the `/fines` endpoints in the {@link debtors | debtors} module. Covers
43
 * the full handout pipeline (eligibility, warning, batch creation, undo) and the
44
 * treasurer-facing fine reports. Waiving lives on the user controller; see
45
 * {@link users!UserController.waiveUserFines | waiveUserFines} (`POST /users/<id>/fines/waive`).
46
 */
1✔
47
export default class DebtorController extends BaseController {
1✔
48
  private logger: Logger = log4js.getLogger(' DebtorController');
1✔
49

50
  public constructor(options: BaseControllerOptions) {
1✔
51
    super(options);
2✔
52
    this.configureLogger(this.logger);
2✔
53
  }
2✔
54

55
  public getPolicy(): Policy {
1✔
56
    return {
2✔
57
      '/': {
2✔
58
        GET: {
2✔
59
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
60
          handler: this.returnAllFineHandoutEvents.bind(this),
2✔
61
        },
2✔
62
      },
2✔
63
      '/:id(\\d+)': {
2✔
64
        GET: {
2✔
65
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
66
          handler: this.returnSingleFineHandoutEvent.bind(this),
2✔
67
        },
2✔
68
      },
2✔
69
      '/single/:id(\\d+)': {
2✔
70
        DELETE: {
2✔
71
          policy: async (req) => this.roleManager.can(req.token.roles, 'delete', 'all', 'Fine', ['*']),
2✔
72
          handler: this.deleteFine.bind(this),
2✔
73
        },
2✔
74
      },
2✔
75
      '/eligible': {
2✔
76
        GET: {
2✔
77
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
78
          handler: this.calculateFines.bind(this),
2✔
79
        },
2✔
80
      },
2✔
81
      '/handout': {
2✔
82
        POST: {
2✔
83
          policy: async (req) => this.roleManager.can(req.token.roles, 'create', 'all', 'Fine', ['*']),
2✔
84
          handler: this.handoutFines.bind(this),
2✔
85
          body: { modelName: 'HandoutFinesRequest' },
2✔
86
        },
2✔
87
      },
2✔
88
      '/handout/:id(\\d+)': {
2✔
89
        DELETE: {
2✔
90
          policy: async (req) => this.roleManager.can(req.token.roles, 'delete', 'all', 'Fine', ['*']),
2✔
91
          handler: this.deleteFineHandout.bind(this),
2✔
92
        },
2✔
93
      },
2✔
94
      '/notify': {
2✔
95
        POST: {
2✔
96
          policy: async (req) => this.roleManager.can(req.token.roles, 'notify', 'all', 'Fine', ['*']),
2✔
97
          handler: this.notifyAboutFutureFines.bind(this),
2✔
98
          body: { modelName: 'HandoutFinesRequest' },
2✔
99
        },
2✔
100
      },
2✔
101
      '/report': {
2✔
102
        GET: {
2✔
103
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
104
          handler: this.getFineReport.bind(this),
2✔
105
        },
2✔
106
      },
2✔
107
      '/report/pdf': {
2✔
108
        GET: {
2✔
109
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Fine', ['*']),
2✔
110
          handler: this.getFineReportPdf.bind(this),
2✔
111
        },
2✔
112
      },
2✔
113
    };
2✔
114
  }
2✔
115

116
  /**
1✔
117
   * GET /fines
118
   * @summary Get all fine handout events
119
   * @tags debtors - Operations of the debtor controller
120
   * @operationId returnAllFineHandoutEvents
121
   * @security JWT
122
   * @param {integer} take.query - How many entries the endpoint should return
123
   * @param {integer} skip.query - How many entries should be skipped (for pagination)
124
   * @return {PaginatedFineHandoutEventResponse} 200 - All existing fine handout events
125
   * @return {string} 400 - Validation error
126
   * @return {string} 500 - Internal server error
127
   */
1✔
128
  public async returnAllFineHandoutEvents(req: RequestWithToken, res: Response): Promise<void> {
1✔
129
    this.logger.trace('Get all fine handout events by ', req.token.user);
2✔
130

131
    let take;
2✔
132
    let skip;
2✔
133
    try {
2✔
134
      const pagination = parseRequestPagination(req);
2✔
135
      take = pagination.take;
2✔
136
      skip = pagination.skip;
2✔
137
    } catch (e) {
2!
138
      res.status(400).json(e.message);
×
139
      return;
×
140
    }
×
141

142
    try {
2✔
143
      const [events, count] = await new DebtorService().getFineHandoutEvents({ take, skip });
2✔
144
      const records = events.map((e) => DebtorService.asBaseFineHandoutEventResponse(e));
2✔
145
      res.json(toResponse(records, count, { take, skip }));
2✔
146
    } catch (error) {
2!
147
      this.logger.error('Could not return all fine handout event:', error);
×
148
      res.status(500).json('Internal server error.');
×
149
    }
×
150
  }
2✔
151

152
  /**
1✔
153
   * GET /fines/{id}
154
   * @summary Get all fine handout events
155
   * @tags debtors - Operations of the debtor controller
156
   * @operationId returnSingleFineHandoutEvent
157
   * @security JWT
158
   * @param {integer} id.path.required - The id of the fine handout event which should be returned
159
   * @return {FineHandoutEventResponse} 200 - Requested fine handout event with corresponding fines
160
   * @return {string} 400 - Validation error
161
   * @return {string} 500 - Internal server error
162
   */
1✔
163
  public async returnSingleFineHandoutEvent(req: RequestWithToken, res: Response): Promise<void> {
1✔
164
    const { id } = req.params;
2✔
165
    this.logger.trace('Get fine handout event', id, 'by', req.token.user);
2✔
166

167
    try {
2✔
168
      const event = await new DebtorService().getSingleFineHandoutEvent(Number.parseInt(id, 10));
2✔
169
      if (!event) {
2!
170
        res.status(404).json('Fine handout event not found.');
×
171
        return;
×
172
      }
×
173
      res.json(DebtorService.asFineHandoutEventResponse(event));
2✔
174
    } catch (error) {
2!
175
      this.logger.error('Could not return fine handout event:', error);
×
176
      res.status(500).json('Internal server error.');
×
177
    }
×
178
  }
2✔
179

180
  /**
1✔
181
   * DELETE /fines/single/{id}
182
   * @summary Delete a fine
183
   * @tags debtors - Operations of the debtor controller
184
   * @operationId deleteFine
185
   * @security JWT
186
   * @param {integer} id.path.required - The id of the fine which should be deleted
187
   * @return 204 - Success
188
   * @return {string} 400 - Validation error
189
   * @return {string} 500 - Internal server error
190
   */
1✔
191
  public async deleteFine(req: RequestWithToken, res: Response): Promise<void> {
1✔
192
    const { id } = req.params;
2✔
193
    this.logger.trace('Delete fine', id, 'by', req.token.user);
2✔
194

195
    try {
2✔
196
      const parsedId = Number.parseInt(id, 10);
2✔
197
      const fine = await Fine.findOne({ where: { id: parsedId } });
2✔
198
      if (fine == null) {
2✔
199
        res.status(404).send();
1✔
200
        return;
1✔
201
      }
1✔
202

203
      await new DebtorService().deleteFine(parsedId);
1✔
204
      res.status(204).send();
1✔
205
    } catch (error) {
2!
206
      this.logger.error('Could not return fine handout event:', error);
×
207
      res.status(500).json('Internal server error.');
×
208
    }
×
209
  }
2✔
210

211
  /**
1✔
212
   * GET /fines/eligible
213
   * @summary Return all users that had at most -5 euros balance both now and on the reference date.
214
   *    For all these users, also return their fine based on the reference date.
215
   * @tags debtors - Operations of the debtor controller
216
   * @operationId calculateFines
217
   * @security JWT
218
   * @param {Array<string>} userTypes.query - List of all user types fines should be calculated for (MEMBER, ORGAN, VOUCHER, LOCAL_USER, LOCAL_ADMIN, INVOICE, AUTOMATIC_INVOICE).
219
   * @param {Array<string>} referenceDates.query.required - Dates to base the fines on. Every returned user has at
220
   *    least five euros debt on every reference date. The height of the fine is based on the first date in the array.
221
   * @return {Array<UserToFineResponse>} 200 - List of eligible fines
222
   * @return {string} 400 - Validation error
223
   * @return {string} 500 - Internal server error
224
   */
1✔
225
  public async calculateFines(req: RequestWithToken, res: Response): Promise<void> {
1✔
226
    this.logger.trace('Get all possible fines by ', req.token.user);
6✔
227

228
    let params;
6✔
229
    try {
6✔
230
      if (req.query.referenceDates === undefined) throw new Error('referenceDates is required');
6✔
231
      const referenceDates = asArrayOfDates(req.query.referenceDates);
3✔
232
      if (referenceDates === undefined) throw new Error('referenceDates is not a valid array');
6!
233
      params = {
3✔
234
        userTypes: asArrayOfUserTypes(req.query.userTypes),
3✔
235
        referenceDates,
3✔
236
      };
3✔
237
      if (params.userTypes === undefined && req.query.userTypes !== undefined) throw new Error('userTypes is not a valid array of UserTypes');
6!
238
    } catch (e) {
6✔
239
      res.status(400).json(e.message);
3✔
240
      return;
3✔
241
    }
3✔
242

243
    try {
3✔
244
      res.json(await new DebtorService().calculateFinesOnDate(params));
3✔
245
    } catch (error) {
6!
246
      this.logger.error('Could not calculate fines:', error);
×
247
      res.status(500).json('Internal server error.');
×
248
    }
×
249
  }
6✔
250

251
  /**
1✔
252
   * POST /fines/handout
253
   * @summary Handout fines to all given users. Fines will be handed out "now" to prevent rewriting history.
254
   * @tags debtors - Operations of the debtor controller
255
   * @operationId handoutFines
256
   * @security JWT
257
   * @param {HandoutFinesRequest} request.body.required
258
   * @return {FineHandoutEventResponse} 200 - Created fine handout event with corresponding fines
259
   * @return {string} 400 - Validation error
260
   * @return {string} 500 - Internal server error
261
   */
1✔
262
  public async handoutFines(req: RequestWithToken, res: Response): Promise<void> {
1✔
263
    const body = req.body as HandoutFinesRequest;
4✔
264
    this.logger.trace('Handout fines', body, 'by user', req.token.user);
4✔
265

266
    let referenceDate: Date;
4✔
267
    try {
4✔
268
      // Todo: write code-consistent validator (either /src/controller/request/validators or custom validator.js function)
4✔
269
      if (!Array.isArray(body.userIds)) throw new Error('userIds is not an array');
4!
270
      const users = await User.find({ where: { id: In(body.userIds) } });
4✔
271
      if (users.length !== body.userIds.length) throw new Error('userIds is not a valid array of user IDs');
4!
272

273
      if (body.referenceDate !== undefined) {
4✔
274
        referenceDate = asDate(body.referenceDate);
4✔
275
      }
4✔
276
    } catch (e) {
4✔
277
      res.status(400).json(e.message);
1✔
278
      return;
1✔
279
    }
1✔
280

281
    try {
3✔
282
      const event = await new DebtorService().handOutFines({ referenceDate, userIds: body.userIds }, req.token.user);
3✔
283
      res.json(DebtorService.asFineHandoutEventResponse(event));
3✔
284
    } catch (error) {
4!
285
      this.logger.error('Could not handout fines:', error);
×
286
      res.status(500).json('Internal server error.');
×
287
    }
×
288
  }
4✔
289

290
  /**
1✔
291
   * DELETE /fines/handout/{id}
292
   * @summary Delete a fine handout event
293
   * @tags debtors - Operations of the debtor controller
294
   * @operationId deleteFineHandout
295
   * @security JWT
296
   * @param {integer} id.path.required - The id of the fine handout event which should be deleted
297
   * @return 204 - Success
298
   * @return {string} 400 - Validation error
299
   * @return {string} 404 - Not found error
300
   * @return {string} 500 - Internal server error
301
   */
1✔
302
  public async deleteFineHandout(req: RequestWithToken, res: Response): Promise<void> {
1✔
303
    const { id } = req.params;
2✔
304
    this.logger.trace('Delete fine handout', id, 'by', req.token.user);
2✔
305

306
    try {
2✔
307
      const parsedId = Number.parseInt(id, 10);
2✔
308
      const event = await FineHandoutEvent.findOne({ where: { id: parsedId }, relations: {
2✔
309
        fines: true,
2✔
310
      } });
2✔
311
      if (event == null) {
2✔
312
        res.status(404).send();
1✔
313
        return;
1✔
314
      }
1✔
315

316
      await new DebtorService().deleteFineHandout(event);
1✔
317
      res.status(204).send();
1✔
318
    } catch (error) {
2!
319
      this.logger.error('Could not delete fine handout:', error);
×
320
      res.status(500).json('Internal server error.');
×
321
    }
×
322
  }
2✔
323

324
  /**
1✔
325
   * POST /fines/notify
326
   * @summary Send an email to all given users about their possible future fine.
327
   * @tags debtors - Operations of the debtor controller
328
   * @operationId notifyAboutFutureFines
329
   * @security JWT
330
   * @param {HandoutFinesRequest} request.body.required
331
   * @return 204 - Success
332
   * @return {string} 400 - Validation error
333
   * @return {string} 500 - Internal server error
334
   */
1✔
335
  public async notifyAboutFutureFines(req: RequestWithToken, res: Response): Promise<void> {
1✔
336
    const body = req.body as HandoutFinesRequest;
2✔
337
    this.logger.trace('Send future fine notification emails', body, 'by user', req.token.user);
2✔
338

339
    let referenceDate: Date;
2✔
340
    try {
2✔
341
      // Todo: write code-consistent validator (either /src/controller/request/validators or custom validator.js function)
2✔
342
      if (!Array.isArray(body.userIds)) throw new Error('userIds is not an array');
2!
343
      const users = await User.find({ where: { id: In(body.userIds) } });
2✔
344
      if (users.length !== body.userIds.length) throw new Error('userIds is not a valid array of user IDs');
2!
345

346
      if (body.referenceDate !== undefined) {
2✔
347
        referenceDate = asDate(body.referenceDate);
2✔
348
      }
2✔
349
    } catch (e) {
2✔
350
      res.status(400).json(e.message);
1✔
351
      return;
1✔
352
    }
1✔
353

354
    try {
1✔
355
      await new DebtorService().sendFineWarnings({ referenceDate, userIds: body.userIds });
1✔
356
      res.status(204).send();
1✔
357
    } catch (error) {
2!
358
      this.logger.error('Could not send future fine notification emails:', error);
×
359
      res.status(500).json('Internal server error.');
×
360
    }
×
361
  }
2✔
362

363
  /**
1✔
364
   * GET /fines/report
365
   * @summary Get a report of all fines
366
   * @tags debtors - Operations of the debtor controller
367
   * @operationId getFineReport
368
   * @security JWT
369
   * @param {string} fromDate.query - The start date of the report, inclusive
370
   * @param {string} toDate.query - The end date of the report, exclusive
371
   * @return {FineReportResponse} 200 - The requested report
372
   * @return {string} 400 - Validation error
373
   * @return {string} 500 - Internal server error
374
   */
1✔
375
  public async getFineReport(req: RequestWithToken, res: Response): Promise<void> {
1✔
376
    this.logger.trace('Get fine report by ', req.token.user);
5✔
377

378
    let fromDate, toDate;
5✔
379
    try {
5✔
380
      const filters = asFromAndTillDate(req.query.fromDate, req.query.toDate);
5✔
381
      fromDate = filters.fromDate;
5✔
382
      toDate = filters.tillDate;
5✔
383
    } catch (e) {
5✔
384
      res.status(400).json(e.message);
3✔
385
      return;
3✔
386
    }
3✔
387

388
    try {
2✔
389
      const report = await new DebtorService().getFineReport(fromDate, toDate);
2✔
390
      res.json(report.toResponse());
2✔
391
    } catch (error) {
5!
392
      this.logger.error('Could not get fine report:', error);
×
393
      res.status(500).json('Internal server error.');
×
394
    }
×
395
  }
5✔
396

397
  /**
1✔
398
   * GET /fines/report/pdf
399
   * @summary Get a report of all fines in pdf format
400
   * @tags debtors - Operations of the debtor controller
401
   * @operationId getFineReportPdf
402
   * @security JWT
403
   * @param {string} fromDate.query.required - The start date of the report, inclusive
404
   * @param {string} toDate.query.required - The end date of the report, exclusive
405
   * @param {string} fileType.query.required - enum:PDF,TEX - The file type of the report
406
   * @returns {string} 200 - The requested report - application/pdf
407
   * @return {string} 400 - Validation error
408
   * @return {string} 500 - Internal server error
409
   */
1✔
410
  public async getFineReportPdf(req: RequestWithToken, res: Response): Promise<void> {
1✔
411
    this.logger.trace('Get fine report by ', req.token.user);
5✔
412

413
    let fromDate, toDate;
5✔
414
    let fileType: ReturnFileType;
5✔
415
    try {
5✔
416
      const filters = asFromAndTillDate(req.query.fromDate, req.query.toDate);
5✔
417
      fromDate = filters.fromDate;
5✔
418
      toDate = filters.tillDate;
5✔
419
      fileType = asReturnFileType(req.query.fileType);
5✔
420
    } catch (e) {
5✔
421
      res.status(400).json(e.message);
3✔
422
      return;
3✔
423
    }
3✔
424

425
    try {
2✔
426
      const report = await new DebtorService().getFineReport(fromDate, toDate);
2✔
427

428
      const buffer = fileType === 'PDF' ? await report.createPdf() : await report.createRaw();
5!
429
      const from = `${fromDate.getFullYear()}${fromDate.getMonth() + 1}${fromDate.getDate()}`;
×
430
      const to = `${toDate.getFullYear()}${toDate.getMonth() + 1}${toDate.getDate()}`;
×
431
      const fileName = `fine-report-${from}-${to}.${fileType}`;
×
432

433
      res.setHeader('Content-Type', 'application/pdf+tex');
×
434
      res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
×
435
      res.send(buffer);
×
436
    } catch (error) {
5✔
437
      this.logger.error('Could not get fine report pdf:', error);
1✔
438
      if (error instanceof PdfError) {
1✔
439
        res.status(502).json('PDF Generator service failed.');
1✔
440
        return;
1✔
441
      }
1!
442
      res.status(500).json('Internal server error.');
×
443
    }
×
444
  }
5✔
445

446
}
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