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

GEWIS / sudosos-backend / 25753937432

12 May 2026 09:17AM UTC coverage: 88.117% (-1.0%) from 89.089%
25753937432

push

github

web-flow
chore(deps): fix missing dependencies for running docs:dev (#911)

3925 of 4574 branches covered (85.81%)

Branch coverage included in aggregate %.

20093 of 22683 relevant lines covered (88.58%)

1125.83 hits per line

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

85.23
/src/controller/product-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
 * This is the module page of product-controller.
23
 *
24
 * @module catalogue/products
25
 */
1✔
26

27
import log4js, { Logger } from 'log4js';
28
import { Response } from 'express';
29
import { UploadedFile } from 'express-fileupload';
30
import BaseController, { BaseControllerOptions } from './base-controller';
31
import Policy from './policy';
32
import { RequestWithToken } from '../middleware/token-middleware';
33
import ProductService from '../service/product-service';
34
import CreateProductParams, {
35
  CreateProductRequest,
36
  UpdateProductParams,
37
  UpdateProductRequest,
38
} from './request/product-request';
39
import Product from '../entity/product/product';
40
import FileService from '../service/file-service';
41
import { PRODUCT_IMAGE_LOCATION } from '../files/storage';
42
import { parseRequestPagination, toResponse } from '../helpers/pagination';
43
import { createProductRequestSpecFactory, updateProductRequestSpecFactory } from './request/validators/product-request-spec';
44
import { globalAsyncValidatorRegistry } from '../middleware/async-validator-registry';
45
import { asNumber } from '../helpers/validators';
46
import userTokenInOrgan from '../helpers/token-helper';
47

48
/**
1✔
49
 * Controller for managing all routes related to the `product` entity.
50
 */
1✔
51
export default class ProductController extends BaseController {
1✔
52
  private logger: Logger = log4js.getLogger('ProductController');
1✔
53

54
  private fileService: FileService;
55

56
  /**
1✔
57
   * Creates a new product controller instance.
58
   * @param options - The options passed to the base controller.
59
   */
1✔
60
  public constructor(options: BaseControllerOptions) {
1✔
61
    super(options);
3✔
62
    this.configureLogger(this.logger);
3✔
63
    this.fileService = new FileService(PRODUCT_IMAGE_LOCATION);
3✔
64
    globalAsyncValidatorRegistry.register(
3✔
65
      'CreateProductRequest',
3✔
66
      createProductRequestSpecFactory,
3✔
67
      (req) => ({
3✔
68
        ...(req.body as CreateProductRequest),
10✔
69
        ownerId: (req.body as CreateProductRequest).ownerId ?? req.token.user.id,
10!
70
      }),
10✔
71
    );
72
    globalAsyncValidatorRegistry.register(
3✔
73
      'UpdateProductRequest',
3✔
74
      updateProductRequestSpecFactory,
3✔
75
      (req) => ({
3✔
76
        ...(req.body as UpdateProductRequest),
9✔
77
        id: parseInt(req.params.id, 10),
9✔
78
      }),
9✔
79
    );
80
  }
3✔
81

82
  /**
1✔
83
   * @inheritdoc
84
   */
1✔
85
  getPolicy(): Policy {
1✔
86
    return {
3✔
87
      '/': {
3✔
88
        GET: {
3✔
89
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', 'all', 'Product', ['*']),
3✔
90
          handler: this.getAllProducts.bind(this),
3✔
91
        },
3✔
92
        POST: {
3✔
93
          body: { modelName: 'CreateProductRequest' },
3✔
94
          policy: async (req) => this.roleManager.can(req.token.roles, 'create', ProductController.postRelation(req), 'Product', ['*']),
3✔
95
          handler: this.createProduct.bind(this),
3✔
96
        },
3✔
97
      },
3✔
98
      '/:id(\\d+)': {
3✔
99
        GET: {
3✔
100
          policy: async (req) => this.roleManager.can(req.token.roles, 'get', await ProductController.getRelation(req), 'Product', ['*']),
3✔
101
          handler: this.getSingleProduct.bind(this),
3✔
102
        },
3✔
103
        PATCH: {
3✔
104
          body: { modelName: 'UpdateProductRequest' },
3✔
105
          policy: async (req) => this.roleManager.can(req.token.roles, 'update', await ProductController.getRelation(req), 'Product', ['*']),
3✔
106
          handler: this.updateProduct.bind(this),
3✔
107
        },
3✔
108
        DELETE: {
3✔
109
          policy: async (req) => this.roleManager.can(req.token.roles, 'delete', await ProductController.getRelation(req), 'Product', ['*']),
3✔
110
          handler: this.deleteProduct.bind(this),
3✔
111
        },
3✔
112
      },
3✔
113
      '/:id(\\d+)/image': {
3✔
114
        POST: {
3✔
115
          policy: async (req) => this.roleManager.can(req.token.roles, 'create', await ProductController.getRelation(req), 'Product', ['*']),
3✔
116
          handler: this.updateProductImage.bind(this),
3✔
117
        },
3✔
118
      },
3✔
119
    };
3✔
120
  }
3✔
121

122
  /**
1✔
123
   * GET /products
124
   * @summary Returns all existing products
125
   * @operationId getAllProducts
126
   * @tags products - Operations of product controller
127
   * @security JWT
128
   * @param {integer} take.query - How many products the endpoint should return
129
   * @param {integer} skip.query - How many products should be skipped (for pagination)
130
   * @return {PaginatedProductResponse} 200 - All existing products
131
   * @return {string} 500 - Internal server error
132
   */
1✔
133
  public async getAllProducts(req: RequestWithToken, res: Response): Promise<void> {
1✔
134
    const { body } = req;
5✔
135
    this.logger.trace('Get all products', body, 'by user', req.token.user);
5✔
136

137
    let take;
5✔
138
    let skip;
5✔
139
    try {
5✔
140
      const pagination = parseRequestPagination(req);
5✔
141
      take = pagination.take;
5✔
142
      skip = pagination.skip;
5✔
143
    } catch (e) {
5!
144
      res.status(400).send(e.message);
×
145
      return;
×
146
    }
×
147

148
    // Handle request
5✔
149
    try {
5✔
150
      const [revisions, count] = await ProductService.getProducts({}, { take, skip });
5✔
151
      const records = revisions.map((r) => ProductService.revisionToResponse(r));
5✔
152
      res.status(200).json(toResponse(records, count, { take, skip }));
5✔
153
    } catch (error) {
5!
154
      this.logger.error('Could not return all products:', error);
×
155
      res.status(500).json('Internal server error.');
×
156
    }
×
157
  }
5✔
158

159
  /**
1✔
160
   * POST /products
161
   * @summary Create a new product.
162
   * @operationId createProduct
163
   * @tags products - Operations of product controller
164
   * @param {CreateProductRequest} request.body.required - The product which should be created
165
   * @security JWT
166
   * @return {ProductResponse} 200 - The created product entity
167
   * @return {string} 400 - Validation error
168
   * @return {string} 500 - Internal server error
169
   */
1✔
170
  public async createProduct(req: RequestWithToken, res: Response): Promise<void> {
1✔
171
    const body = req.body as CreateProductRequest;
2✔
172
    this.logger.trace('Create product', body, 'by user', req.token.user);
2✔
173

174
    // handle request
2✔
175
    try {
2✔
176
      const request: CreateProductParams = {
2✔
177
        ...body,
2✔
178
        ownerId: body.ownerId ?? req.token.user.id,
2!
179
      };
2✔
180

181
      const revision = await ProductService.createProduct(request);
2✔
182
      if (!revision) {
2!
183
        res.status(404).json('Product owner not found.');
×
184
        return;
×
185
      }
×
186
      res.json(ProductService.revisionToResponse(revision));
2✔
187
    } catch (error) {
2!
188
      this.logger.error('Could not create product:', error);
×
189
      res.status(500).json('Internal server error.');
×
190
    }
×
191
  }
2✔
192

193
  /**
1✔
194
   * PATCH /products/{id}
195
   * @summary Update an existing product.
196
   * @operationId updateProduct
197
   * @tags products - Operations of product controller
198
   * @param {integer} id.path.required - The id of the product which should be updated
199
   * @param {UpdateProductRequest} request.body.required - The product which should be updated
200
   * @security JWT
201
   * @return {ProductResponse} 200 - The created product entity
202
   * @return {string} 400 - Validation error
203
   * @return {string} 404 - Product not found error
204
   * @return {string} 500 - Internal server error
205
   */
1✔
206
  public async updateProduct(req: RequestWithToken, res: Response): Promise<void> {
1✔
207
    const body = req.body as UpdateProductRequest;
3✔
208
    const { id } = req.params;
3✔
209
    const productId = Number.parseInt(id, 10);
3✔
210
    this.logger.trace('Update product', id, 'with', body, 'by user', req.token.user);
3✔
211

212
    // handle request
3✔
213
    try {
3✔
214
      const params: UpdateProductParams = {
3✔
215
        ...body,
3✔
216
        id: productId,
3✔
217
      };
3✔
218

219
      const product = await Product.findOne({ where: { id: productId } });
3✔
220
      if (!product) {
3✔
221
        res.status(404).json('Product not found.');
1✔
222
        return;
1✔
223
      }
1✔
224

225
      const revision = await ProductService.updateProduct(params);
2✔
226
      if (!revision) {
3!
227
        res.status(500).json('Could not update product.');
×
228
        return;
×
229
      }
✔
230
      res.json(ProductService.revisionToResponse(revision));
2✔
231
    } catch (error) {
3!
232
      this.logger.error('Could not update product:', error);
×
233
      res.status(500).json('Internal server error.');
×
234
    }
×
235
  }
3✔
236

237
  /**
1✔
238
   * GET /products/{id}
239
   * @summary Returns the requested product
240
   * @operationId getSingleProduct
241
   * @tags products - Operations of products controller
242
   * @param {integer} id.path.required - The id of the product which should be returned
243
   * @security JWT
244
   * @return {ProductResponse} 200 - The requested product entity
245
   * @return {string} 404 - Not found error
246
   * @return {string} 500 - Internal server error
247
   */
1✔
248
  public async getSingleProduct(req: RequestWithToken, res: Response): Promise<void> {
1✔
249
    const { id } = req.params;
5✔
250
    this.logger.trace('Get single product', id, 'by user', req.token.user);
5✔
251

252
    // handle request
5✔
253
    try {
5✔
254
      // check if product in database
5✔
255
      const [revisions] = await ProductService
5✔
256
        .getProducts({ productId: parseInt(id, 10) });
5✔
257
      if (revisions.length > 0) {
5✔
258
        res.json(ProductService.revisionToResponse(revisions[0]));
3✔
259
      } else {
5✔
260
        res.status(404).json('Product not found.');
2✔
261
      }
2✔
262
    } catch (error) {
5!
263
      this.logger.error('Could not return product:', error);
×
264
      res.status(500).json('Internal server error.');
×
265
    }
×
266
  }
5✔
267

268
  /**
1✔
269
   * POST /products/{id}/image
270
   * @summary Upload a new image for a product
271
   * @operationId updateProductImage
272
   * @tags products - Operations of products controller
273
   * @consumes multipart/form-data
274
   * @param {integer} id.path.required - The id of the product which should be returned
275
   * @param {FileRequest} request.body.required - product image - multipart/form-data
276
   * @security JWT
277
   * @return 204 - Success
278
   * @return {string} 400 - Validation error
279
   * @return {string} 500 - Internal server error
280
   */
1✔
281
  public async updateProductImage(req: RequestWithToken, res: Response): Promise<void> {
1✔
282
    const { id } = req.params;
7✔
283
    const { files } = req;
7✔
284
    this.logger.trace('Update product', id, 'image by user', req.token.user);
7✔
285

286
    if (!req.files || Object.keys(files).length !== 1) {
7✔
287
      res.status(400).send('No file or too many files were uploaded');
2✔
288
      return;
2✔
289
    }
2✔
290
    if (files.file === undefined) {
7✔
291
      res.status(400).send("No file is uploaded in the 'file' field");
1✔
292
      return;
1✔
293
    }
1✔
294
    const file = files.file as UploadedFile;
4✔
295
    if (file.data === undefined) {
7✔
296
      res.status(400).send('File body data is missing from request');
1✔
297
      return;
1✔
298
    }
1✔
299
    if (file.name === undefined) {
7!
300
      res.status(400).send('File name is missing from request');
×
301
      return;
×
302
    }
✔
303

304
    const productId = parseInt(id, 10);
3✔
305

306
    // handle request
3✔
307
    try {
3✔
308
      const product = await Product.findOne({ where: { id: productId }, relations: ['image'] });
3✔
309
      if (product) {
7✔
310
        await this.fileService.uploadEntityImage(
2✔
311
          product, file, req.token.user,
2✔
312
        );
313
        res.status(204).send();
2✔
314
      } else {
7✔
315
        res.status(404).json('Product not found');
1✔
316
        return;
1✔
317
      }
1✔
318
    } catch (error) {
7!
319
      this.logger.error('Could not upload image:', error);
×
320
      res.status(500).json('Internal server error');
×
321
    }
×
322
  }
7✔
323

324
  /**
1✔
325
   * DELETE /products/{id}
326
   * @summary (Soft) delete the given product. Cannot be undone.
327
   * @operationId deleteProduct
328
   * @tags products - Operations of products controller
329
   * @param {integer} id.path.required - The id of the product which should be deleted
330
   * @security JWT
331
   * @return {string} 204 - Success
332
   * @return {string} 404 - Not found error
333
   * @return {string} 500 - Internal server error
334
   */
1✔
335
  public async deleteProduct(req: RequestWithToken, res: Response): Promise<void> {
1✔
336
    const { id } = req.params;
3✔
337
    this.logger.trace('Delete product', id, 'by user', req.token.user);
3✔
338

339
    try {
3✔
340
      const productId = parseInt(id, 10);
3✔
341

342
      const product = await Product.findOne({ where: { id: productId } });
3✔
343

344
      if (product == null) {
3✔
345
        res.status(404).json('Product not found');
2✔
346
        return;
2✔
347
      }
2✔
348

349
      await ProductService.deleteProduct(productId);
1✔
350
      res.status(204).send();
1✔
351
      return;
1✔
352
    } catch (error) {
3!
353
      this.logger.error('Could not delete product', error);
×
354
      res.status(500).json('Internal server error.');
×
355
    }
×
356
  }
3✔
357

358
  /**
1✔
359
   * Function to determine which credentials are needed to post product
360
   *    'all' if user is not connected to product
361
   *    'organ' if user is not connected to product via organ
362
   *    'own' if user is connected to product
363
   * @param req - Request with CreateProductRequest as body
364
   * @return whether product is connected to user token
365
   */
1✔
366
  static postRelation(req: RequestWithToken): string {
1✔
367
    const request = req.body as CreateProductRequest;
12✔
368
    if (request.ownerId && userTokenInOrgan(req, request.ownerId)) return 'organ';
12✔
369
    if (request.ownerId && request.ownerId === req.token.user.id) return 'all';
12!
370
    return 'own';
11✔
371
  }
11✔
372

373
  /**
1✔
374
   * Function to determine which credentials are needed to get product
375
   *    'all' if user is not connected to product
376
   *    'own' if user is connected to product
377
   * @param req - Request with product id as param
378
   * @return whether product is connected to user token
379
   */
1✔
380
  static async getRelation(req: RequestWithToken): Promise<string> {
1✔
381
    const productId = asNumber(req.params.id);
29✔
382
    const product = await Product.findOne({ where: { id: productId }, relations: ['owner'] });
29✔
383
    if (product && product.owner.id === req.token.user.id) return 'own';
29!
384
    if (product && userTokenInOrgan(req, product.owner.id)) return 'organ';
29✔
385
    return 'all';
27✔
386
  }
27✔
387
}
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