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

GrottoCenter / grottocenter-api / 11479910404

23 Oct 2024 12:30PM UTC coverage: 45.243% (+0.02%) from 45.225%
11479910404

Pull #1330

github

vmarseguerra
feat(notification): auto deletion of old notifications after 2 months
Pull Request #1330: Small improvements

728 of 2231 branches covered (32.63%)

Branch coverage included in aggregate %.

3 of 12 new or added lines in 3 files covered. (25.0%)

1 existing line in 1 file now uncovered.

2458 of 4811 relevant lines covered (51.09%)

4.37 hits per line

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

64.48
/api/services/NotificationService.js
1
const NameService = require('./NameService');
6✔
2
const CommonService = require('./CommonService');
6✔
3

4
const NOTIFICATION_ENTITIES = {
6✔
5
  CAVE: 'cave',
6
  COMMENT: 'comment',
7
  DESCRIPTION: 'description',
8
  DOCUMENT: 'document',
9
  ENTRANCE: 'entrance',
10
  HISTORY: 'history',
11
  LOCATION: 'location',
12
  MASSIF: 'massif',
13
  ORGANIZATION: 'organization',
14
  RIGGING: 'rigging',
15
};
16

17
const NOTIFICATION_TYPES = {
6✔
18
  CREATE: 'CREATE',
19
  DELETE: 'DELETE',
20
  PERMANENT_DELETE: 'PERMANENT_DELETE',
21
  UPDATE: 'UPDATE',
22
  VALIDATE: 'VALIDATE',
23
  RESTORE: 'RESTORE',
24
};
25

26
async function removeOlderNotifications() {
NEW
27
  const query = `DELETE FROM t_notification WHERE date_inscription < current_timestamp - interval '2 month';`;
×
NEW
28
  await CommonService.query(query);
×
29
}
30

31
const safeGetPropId = (prop, data) => {
6✔
32
  if (data && data[prop]) {
210✔
33
    if (data[prop] instanceof Object) {
51✔
34
      return data[prop].id;
30✔
35
    }
36
    return data[prop];
21✔
37
  }
38
  return undefined;
159✔
39
};
40

41
const sendNotificationEmail = async (
6✔
42
  entity,
43
  notificationType,
44
  notificationEntity,
45
  req,
46
  user
47
) => {
48
  // Get entity name (handle all cases)
49
  let entityName = '';
26✔
50
  if (entity.name) entityName = entity.name;
26✔
51
  else if (entity.names) entityName = entity.names[0]?.name;
18!
52
  else if (entity.title) entityName = entity.title;
18✔
53
  else if (entity.titles) entityName = entity.titles[0]?.text;
6!
54
  else if (entity.body) entityName = `${entity.body.slice(0, 50)}...`;
6!
55
  else if (entity.descriptions) entityName = entity.descriptions[0].title;
6!
56

57
  // Format action verb
58
  let actionVerb = '';
26✔
59
  switch (notificationType) {
26✔
60
    case NOTIFICATION_TYPES.CREATE:
61
      actionVerb = 'created';
4✔
62
      break;
4✔
63

64
    case NOTIFICATION_TYPES.DELETE:
65
      actionVerb = 'deleted';
5✔
66
      break;
5✔
67
    case NOTIFICATION_TYPES.PERMANENT_DELETE:
68
      actionVerb = 'permanently deleted';
4✔
69
      break;
4✔
70

71
    case NOTIFICATION_TYPES.UPDATE:
72
      actionVerb = 'updated';
4✔
73
      break;
4✔
74

75
    case NOTIFICATION_TYPES.VALIDATE:
76
      actionVerb = 'validated';
4✔
77
      break;
4✔
78

79
    case NOTIFICATION_TYPES.RESTORE:
80
      actionVerb = 'restored';
4✔
81
      break;
4✔
82

83
    default:
84
      throw Error(`Unknown notification type: ${notificationType}`);
1✔
85
  }
86

87
  // Format entity Link
88
  let entityLink = `${sails.config.custom.baseUrl}/ui/`;
25✔
89
  const relatedCaveId = safeGetPropId('cave', entity);
25✔
90
  const relatedEntranceId = safeGetPropId('entrance', entity);
25✔
91
  const relatedMassifId = safeGetPropId('massif', entity);
25✔
92

93
  switch (notificationEntity) {
25!
94
    case NOTIFICATION_ENTITIES.CAVE:
95
      entityLink += `caves/${entity.id}`;
×
96
      break;
×
97

98
    case NOTIFICATION_ENTITIES.COMMENT:
99
    case NOTIFICATION_ENTITIES.HISTORY:
100
    case NOTIFICATION_ENTITIES.RIGGING:
101
      if (relatedCaveId) entityLink += `caves/${relatedCaveId}`;
6!
102
      else if (relatedEntranceId)
6!
103
        entityLink += `entrances/${relatedEntranceId}`;
6✔
104
      else
105
        throw Error(
×
106
          `Can't find related entity (cave or entrance) of the
107
          ${notificationType === NOTIFICATION_ENTITIES.COMMENT && 'comment'}
×
108
          ${notificationType === NOTIFICATION_ENTITIES.HISTORY && 'history'}
×
109
          ${
110
            notificationType === NOTIFICATION_ENTITIES.RIGGING && 'rigging'
×
111
          } with id ${entity.id}`
112
        );
113
      break;
6✔
114

115
    case NOTIFICATION_ENTITIES.DESCRIPTION:
116
      if (relatedCaveId) entityLink += `caves/${relatedCaveId}`;
6!
117
      else if (relatedEntranceId)
6!
118
        entityLink += `entrances/${relatedEntranceId}`;
6✔
119
      else if (relatedMassifId) entityLink += `massifs/${relatedMassifId}`;
×
120
      else
121
        throw Error(
×
122
          `Cant find related entity (cave, entrance or massif) of the description with id ${entity.id}`
123
        );
124
      break;
6✔
125

126
    case NOTIFICATION_ENTITIES.DOCUMENT:
127
      entityLink += `documents/${entity.id}`;
6✔
128
      break;
6✔
129

130
    case NOTIFICATION_ENTITIES.ENTRANCE:
131
      entityLink += `entrances/${entity.id}`;
×
132
      break;
×
133

134
    case NOTIFICATION_ENTITIES.LOCATION:
135
      if (relatedEntranceId) entityLink += `entrances/${relatedEntranceId}`;
×
136
      else
137
        throw Error(
×
138
          `Cant find related entity (entrance) of the location with id ${entity.id}`
139
        );
140
      break;
×
141

142
    case NOTIFICATION_ENTITIES.MASSIF:
143
      entityLink += `massifs/${entity.id}`;
6✔
144
      break;
6✔
145

146
    case NOTIFICATION_ENTITIES.ORGANIZATION:
147
      entityLink += `organizations/${entity.id}`;
×
148
      break;
×
149

150
    default:
151
      throw Error(`Unknown notification entity: ${notificationEntity}`);
1✔
152
  }
153

154
  await sails.helpers.sendEmail
24✔
155
    .with({
156
      allowResponse: false,
157
      emailSubject: 'Notification',
158
      i18n: req.i18n,
159
      recipientEmail: user.mail,
160
      viewName: 'notification',
161
      viewValues: {
162
        actionVerb,
163
        entityLink,
164
        entityName,
165
        entityType: notificationEntity,
166
        recipientName: user.nickname,
167
        subscriptionName: user.subscriptionName,
168
        subscriptionType: user.subscriptionType,
169
      },
170
    })
171
    .intercept('sendSESEmailError', () => {
172
      sails.log.error(
×
173
        `The email service has encountered an error while trying to notify user ${user.nickname} (id=${user.id}).`
174
      );
175
      return false;
×
176
    });
177
};
178

179
const getCountryAndMassifSubscribers = async (
6✔
180
  entityCountryId,
181
  entityMassifIds
182
) => {
183
  const countrySubscribers = [];
29✔
184
  const massifsSubscribers = [];
29✔
185
  if (entityCountryId) {
29✔
186
    const country =
187
      await TCountry.findOne(entityCountryId).populate('subscribedCavers');
1✔
188
    countrySubscribers.push(
1✔
189
      ...country.subscribedCavers.map((caver) => ({
1✔
190
        ...caver,
191
        subscriptionName: country.nativeName,
192
        subscriptionType: 'country',
193
      }))
194
    );
195
  }
196
  if (entityMassifIds) {
29!
197
    await Promise.all(
29✔
198
      entityMassifIds.map(async (massifId) => {
199
        const massif =
200
          await TMassif.findOne(massifId).populate('subscribedCavers');
10✔
201
        await NameService.setNames([massif], 'massif');
10✔
202
        massifsSubscribers.push(
10✔
203
          ...massif.subscribedCavers.map((caver) => ({
9✔
204
            ...caver,
205
            subscriptionType: 'massif',
206
            subscriptionName: massif.names[0]?.name,
207
          }))
208
        );
209
      })
210
    );
211
  }
212
  return { countrySubscribers, massifsSubscribers };
29✔
213
};
214

215
module.exports = {
6✔
216
  NOTIFICATION_ENTITIES,
217
  NOTIFICATION_TYPES,
218
  ...(process.env.NODE_ENV === 'test' ? { sendNotificationEmail } : undefined),
6!
219

220
  /**
221
   *
222
   * @param {*} req
223
   * @param {*} entity
224
   * @param {Number} notifierId
225
   * @param {NOTIFICATION_TYPES} notificationType
226
   * @param {NOTIFICATION_ENTITIES} notificationEntity
227
   * @return {Boolean} true if everything went well, else false
228
   */
229
  notifySubscribers: async (
230
    req,
231
    entity,
232
    notifierId,
233
    notificationType,
234
    notificationEntity
235
  ) => {
236
    // Had to require in the function to avoid a circular dependency with notifySubscribers() in CaveService.createCave()
237
    // eslint-disable-next-line global-require
238
    const CaveService = require('./CaveService');
29✔
239

240
    // Check params and silently fail to avoid sending an error to the client
241
    if (!Object.values(NOTIFICATION_ENTITIES).includes(notificationEntity)) {
29!
242
      throw new Error(`Invalid notification entity: ${notificationEntity}`);
×
243
    }
244
    if (!Object.values(NOTIFICATION_TYPES).includes(notificationType)) {
29!
245
      throw new Error(`Invalid notification type: ${notificationType}`);
×
246
    }
247
    if (!notifierId) {
29!
248
      throw new Error(`Missing notifier id`);
×
249
    }
250

251
    try {
29✔
252
      // For the populateEntities() method, must use "grotto" instead of "organization"
253
      const entityKey =
254
        notificationEntity === NOTIFICATION_ENTITIES.ORGANIZATION
29✔
255
          ? 'grotto'
256
          : notificationEntity;
257

258
      // Format notification and populate entity
259
      const notification = await module.exports.populateEntities({
29✔
260
        dateInscription: new Date(),
261
        notificationType: (
262
          await TNotificationType.findOne({
263
            name: notificationType,
264
          })
265
        ).id,
266
        notifier: notifierId,
267
        [entityKey]: entity,
268
      });
269

270
      const populatedEntity = notification[entityKey];
29✔
271

272
      // Find massifs nor country concerned about the notification
273
      let entityMassifIds = [];
29✔
274
      let entityCountryId;
275

276
      const caveId = safeGetPropId('cave', populatedEntity);
29✔
277
      const entranceId = safeGetPropId('entrance', populatedEntity);
29✔
278
      const massifId = safeGetPropId('massif', populatedEntity);
29✔
279

280
      const getMassifIdsFromCave = async (id) =>
29✔
281
        (await CaveService.getMassifs(id)).map((m) => m.id);
21✔
282
      const getCountryId = (id) => safeGetPropId('country', id);
29✔
283

284
      switch (notificationEntity) {
29!
285
        case NOTIFICATION_ENTITIES.CAVE: {
286
          if (
7✔
287
            populatedEntity.entrances &&
14✔
288
            populatedEntity.entrances.length > 0
289
          ) {
290
            entityCountryId = getCountryId(populatedEntity?.entrances[0]);
3✔
291
          }
292
          entityMassifIds = await getMassifIdsFromCave(populatedEntity.id);
7✔
293
          break;
7✔
294
        }
295

296
        case NOTIFICATION_ENTITIES.COMMENT: {
297
          if (caveId) {
3!
298
            if (
×
299
              populatedEntity.cave.entrances &&
×
300
              populatedEntity.cave.entrances.length > 0
301
            ) {
302
              entityCountryId = getCountryId(
×
303
                populatedEntity?.cave.entrances[0]
304
              ); // Get country from first entrance (not perfect but good enough)
305
            }
306
            entityMassifIds = await getMassifIdsFromCave(caveId);
×
307
          } else if (entranceId) {
3!
308
            entityCountryId = getCountryId(populatedEntity?.entrance);
3✔
309
            entityMassifIds = await getMassifIdsFromCave(
3✔
310
              safeGetPropId('cave', populatedEntity.entrance)
311
            );
312
          } else {
313
            throw new Error(`Can't retrieve related cave or entrance id.`);
×
314
          }
315
          break;
3✔
316
        }
317

318
        case NOTIFICATION_ENTITIES.DESCRIPTION: {
319
          if (caveId) {
×
320
            if (
×
321
              populatedEntity.cave.entrances &&
×
322
              populatedEntity.cave.entrances.length > 0
323
            ) {
324
              entityCountryId = getCountryId(
×
325
                populatedEntity?.cave?.entrances[0]
326
              ); // Get country from first entrance (not perfect but good enough)
327
            }
328
            entityMassifIds = await getMassifIdsFromCave(caveId);
×
329
          } else if (entranceId) {
×
330
            entityCountryId = getCountryId(populatedEntity?.entrance);
×
331
            entityMassifIds = await getMassifIdsFromCave(
×
332
              safeGetPropId('cave', populatedEntity.entrance)
333
            );
334
          } else if (massifId) {
×
335
            entityMassifIds = [populatedEntity.massif];
×
336
          } else {
337
            throw new Error(
×
338
              `Can't retrieve related cave, entrance or massif id.`
339
            );
340
          }
341
          break;
×
342
        }
343
        case NOTIFICATION_ENTITIES.DOCUMENT: {
344
          if (caveId) {
4!
345
            if (
×
346
              populatedEntity.cave.entrances &&
×
347
              populatedEntity.cave.entrances.length > 0
348
            ) {
349
              entityCountryId = getCountryId(
×
350
                populatedEntity?.cave.entrances[0]
351
              ); // Get country from first entrance (not perfect but good enough)
352
            }
353
            entityMassifIds = await getMassifIdsFromCave(caveId);
×
354
          } else if (entranceId) {
4!
355
            entityCountryId = getCountryId(populatedEntity.entrance);
×
356
            entityMassifIds = await getMassifIdsFromCave(
×
357
              safeGetPropId('cave', populatedEntity.entrance)
358
            );
359
          } else if (massifId) {
4!
360
            entityMassifIds = [safeGetPropId('massif', populatedEntity)];
×
361
          }
362
          // A document can relate to no massif / cave / entrance
363
          break;
4✔
364
        }
365
        case NOTIFICATION_ENTITIES.ENTRANCE:
366
          entityCountryId = getCountryId(populatedEntity);
6✔
367
          if (populatedEntity?.cave) {
6!
368
            entityMassifIds = await getMassifIdsFromCave(
6✔
369
              safeGetPropId('cave', populatedEntity.entrance)
370
            );
371
          }
372
          break;
6✔
373
        case NOTIFICATION_ENTITIES.HISTORY: {
374
          if (caveId) {
3✔
375
            if (
1!
376
              populatedEntity.cave.entrances &&
1!
377
              populatedEntity.cave.entrances.length > 0
378
            ) {
379
              entityCountryId = getCountryId(
×
380
                populatedEntity?.cave?.entrances[0]
381
              ); // Get country from first entrance (not perfect but good enough)
382
            }
383
            entityMassifIds = await getMassifIdsFromCave(caveId);
1✔
384
          } else if (entranceId) {
2!
385
            entityCountryId = getCountryId(populatedEntity.entrance);
2✔
386
            entityMassifIds = await getMassifIdsFromCave(
2✔
387
              safeGetPropId('cave', populatedEntity.entrance)
388
            );
389
          } else {
390
            throw new Error(`Can't retrieve related cave or entrance id.`);
×
391
          }
392
          break;
3✔
393
        }
394
        case NOTIFICATION_ENTITIES.LOCATION: {
395
          if (entranceId) {
×
396
            entityCountryId = getCountryId(populatedEntity.entrance);
×
397
            entityMassifIds = await getMassifIdsFromCave(
×
398
              safeGetPropId('cave', populatedEntity.entrance)
399
            );
400
          } else {
401
            throw new Error(`Can't retrieve related entrance id.`);
×
402
          }
403
          break;
×
404
        }
405
        case NOTIFICATION_ENTITIES.MASSIF:
406
          entityMassifIds = [populatedEntity.id];
2✔
407
          break;
2✔
408
        case NOTIFICATION_ENTITIES.ORGANIZATION:
409
          entityCountryId = getCountryId(populatedEntity);
2✔
410
          break;
2✔
411

412
        case NOTIFICATION_ENTITIES.RIGGING: {
413
          if (caveId) {
2!
414
            if (
×
415
              populatedEntity.cave.entrances &&
×
416
              populatedEntity.cave.entrances.length > 0
417
            ) {
418
              entityCountryId = getCountryId(
×
419
                populatedEntity?.cave.entrances[0]
420
              ); // Get country from first entrance (not perfect but good enough)
421
            }
422
            entityMassifIds = await getMassifIdsFromCave(caveId);
×
423
          } else if (entranceId) {
2!
424
            entityCountryId = getCountryId(populatedEntity.entrance);
2✔
425
            entityMassifIds = await getMassifIdsFromCave(
2✔
426
              safeGetPropId('cave', populatedEntity.entrance)
427
            );
428
          } else {
429
            throw new Error(`Can't retrieve related cave or entrance id.`);
×
430
          }
431
          break;
2✔
432
        }
433
        default:
434
          throw new Error(
×
435
            `Can't find what to do with the following notification entity value: ${notificationEntity}`
436
          );
437
      }
438

439
      // Find subscribers to the entity.
440
      const { countrySubscribers, massifsSubscribers } =
441
        await getCountryAndMassifSubscribers(entityCountryId, entityMassifIds);
29✔
442
      // List users who will receive the notification
443
      const uniqueUsers = Array.from(
29✔
444
        new Set([
445
          // Don't notify the notifierId
446
          ...countrySubscribers.filter((u) => u.id !== notifierId),
1✔
447
          ...massifsSubscribers.filter((u) => u.id !== notifierId),
9✔
448
        ])
449
      );
450

451
      // Create notifications & optionally send email
452
      const res = await Promise.all(
29✔
453
        uniqueUsers.map(async (user) => {
454
          try {
10✔
455
            await TNotification.create({
10✔
456
              ...notification,
457
              notified: user.id,
458
              [entityKey]: notification[entityKey].id, // id only for the DB storage
459
            });
460
          } catch (e) {
461
            sails.log.error(
×
462
              `An error occured when trying to create a notification: ${e.message}`
463
            );
464
            return false;
×
465
          }
466

467
          if (user.sendNotificationByEmail) {
10!
468
            await sendNotificationEmail(
×
469
              populatedEntity,
470
              notificationType,
471
              notificationEntity,
472
              req,
473
              user
474
            );
475
          }
476
          return true;
10✔
477
        })
478
      );
479

480
      // 5% chance to also remove older notifications
481
      if (process.env.NODE_ENV !== 'test' && Math.random() < 0.05)
29!
NEW
482
        removeOlderNotifications();
×
483

484
      return res;
29✔
485
    } catch (error) {
486
      // Fail silently to avoid sending an error to the user
487
      sails.log.error(
×
488
        `An error occurred when trying to notify subscribers: ${error.message} ${error.stack}`
489
      );
490
      return false;
×
491
    }
492
  },
493

494
  populateEntities: async (notification) => {
495
    const populatedNotification = notification;
40✔
496
    if (populatedNotification.cave) {
40✔
497
      await NameService.setNames([populatedNotification.cave], 'cave');
10✔
498
    }
499
    if (populatedNotification.comment) {
40✔
500
      populatedNotification.comment = await TComment.findOne(
5✔
501
        safeGetPropId('comment', notification)
502
      )
503
        .populate('cave')
504
        .populate('entrance');
505
    }
506
    if (populatedNotification.description) {
40!
507
      populatedNotification.description = await TDescription.findOne(
×
508
        safeGetPropId('description', notification)
509
      )
510
        .populate('cave')
511
        .populate('document')
512
        .populate('entrance')
513
        .populate('massif');
514
    }
515
    if (populatedNotification.document) {
40✔
516
      // Had to require in the function to avoid a circular dependency with notifySubscribers() in DocumentService.createDocument()
517
      // eslint-disable-next-line global-require
518
      const DocumentService = require('./DocumentService');
5✔
519
      const populatedDocuments = await DocumentService.getDocuments([
5✔
520
        safeGetPropId('document', notification),
521
      ]);
522
      populatedNotification.document = populatedDocuments[0];
5✔
523
    }
524
    if (populatedNotification.entrance) {
40✔
525
      await NameService.setNames([populatedNotification.entrance], 'entrance');
7✔
526
    }
527
    if (populatedNotification.grotto) {
40✔
528
      await NameService.setNames([populatedNotification.grotto], 'grotto');
2✔
529
    }
530
    if (populatedNotification.history) {
40✔
531
      populatedNotification.history = await THistory.findOne(
5✔
532
        safeGetPropId('history', notification)
533
      )
534
        .populate('cave')
535
        .populate('entrance');
536
    }
537
    if (populatedNotification.location) {
40!
538
      populatedNotification.location = await TLocation.findOne(
×
539
        safeGetPropId('location', notification)
540
      ).populate('entrance');
541
    }
542
    if (populatedNotification.massif) {
40✔
543
      await NameService.setNames([populatedNotification.massif], 'massif');
4✔
544
    }
545
    if (populatedNotification.rigging) {
40✔
546
      populatedNotification.rigging = await TRigging.findOne(
2✔
547
        safeGetPropId('rigging', notification)
548
      )
549
        .populate('entrance')
550
        .populate('cave');
551
    }
552
    return populatedNotification;
40✔
553
  },
554
};
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