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

GrottoCenter / grottocenter-api / 25442209472

06 May 2026 02:34PM UTC coverage: 86.286% (-0.09%) from 86.372%
25442209472

push

github

dawoldo
test(1451): added missing test for sending messages to an unauthorized user

3041 of 3674 branches covered (82.77%)

Branch coverage included in aggregate %.

6365 of 7227 relevant lines covered (88.07%)

50.67 hits per line

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

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

4
const NOTIFICATION_ENTITIES = {
8✔
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 = {
8✔
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() {
27
  const query = `DELETE
×
28
                 FROM t_notification
29
                 WHERE date_inscription < current_timestamp - interval '2 month';`;
30
  await CommonService.query(query);
×
31
}
32

33
const safeGetPropId = (prop, data) => {
8✔
34
  if (data && data[prop]) {
661✔
35
    if (data[prop] instanceof Object) {
201✔
36
      return data[prop].id;
145✔
37
    }
38
    return data[prop];
56✔
39
  }
40
  return undefined;
460✔
41
};
42

43
const sendNotificationEmail = async (
8✔
44
  entity,
45
  notificationType,
46
  notificationEntity,
47
  req,
48
  user
49
) => {
50
  // Get entity name (handle all cases)
51
  const getEntityName = (entityData) => {
36✔
52
    if (entityData.name) return entityData.name;
36✔
53
    if (entityData.names) return entityData.names[0]?.name;
28✔
54
    if (entityData.title) return entityData.title;
27✔
55
    if (entityData.titles) return entityData.titles[0]?.text;
15✔
56
    if (entityData.body) return `${entityData.body.slice(0, 50)}...`;
14✔
57
    if (entityData.descriptions) return entityData.descriptions[0].title;
13✔
58
    return '';
12✔
59
  };
60

61
  const entityName = getEntityName(entity);
36✔
62

63
  // Format action verb
64
  const actionVerbMap = {
36✔
65
    [NOTIFICATION_TYPES.CREATE]: 'created',
66
    [NOTIFICATION_TYPES.DELETE]: 'deleted',
67
    [NOTIFICATION_TYPES.PERMANENT_DELETE]: 'permanently deleted',
68
    [NOTIFICATION_TYPES.UPDATE]: 'updated',
69
    [NOTIFICATION_TYPES.VALIDATE]: 'validated',
70
    [NOTIFICATION_TYPES.RESTORE]: 'restored',
71
  };
72

73
  const actionVerb = actionVerbMap[notificationType];
36✔
74
  if (!actionVerb) {
36✔
75
    throw Error(`Unknown notification type: ${notificationType}`);
1✔
76
  }
77

78
  // Format entity Link
79
  const baseUrl = `${sails.config.custom.baseUrl}/ui/`;
35✔
80
  const relatedCaveId = safeGetPropId('cave', entity);
35✔
81
  const relatedEntranceId = safeGetPropId('entrance', entity);
35✔
82
  const relatedMassifId = safeGetPropId('massif', entity);
35✔
83

84
  const getRelatedEntityLink = () => {
35✔
85
    if (relatedCaveId) return `caves/${relatedCaveId}`;
20✔
86
    if (relatedEntranceId) return `entrances/${relatedEntranceId}`;
18✔
87
    if (relatedMassifId) return `massifs/${relatedMassifId}`;
5✔
88
    return null;
4✔
89
  };
90

91
  const directLinkEntities = {
35✔
92
    [NOTIFICATION_ENTITIES.CAVE]: `caves/${entity.id}`,
93
    [NOTIFICATION_ENTITIES.DOCUMENT]: `documents/${entity.id}`,
94
    [NOTIFICATION_ENTITIES.ENTRANCE]: `entrances/${entity.id}`,
95
    [NOTIFICATION_ENTITIES.MASSIF]: `massifs/${entity.id}`,
96
    [NOTIFICATION_ENTITIES.ORGANIZATION]: `organizations/${entity.id}`,
97
  };
98

99
  let entityLink;
100
  if (directLinkEntities[notificationEntity]) {
35✔
101
    entityLink = baseUrl + directLinkEntities[notificationEntity];
15✔
102
  } else {
103
    const relatedLink = getRelatedEntityLink();
20✔
104
    if (!relatedLink) {
20✔
105
      const entityTypeName = notificationEntity.toLowerCase();
4✔
106
      let requiredEntities;
107
      if (notificationEntity === NOTIFICATION_ENTITIES.DESCRIPTION) {
4✔
108
        requiredEntities = 'cave, entrance or massif';
1✔
109
      } else if (notificationEntity === NOTIFICATION_ENTITIES.LOCATION) {
3✔
110
        requiredEntities = 'entrance';
1✔
111
      } else {
112
        requiredEntities = 'cave or entrance';
2✔
113
      }
114
      throw Error(
4✔
115
        `Can't find related entity (${requiredEntities}) of the ${entityTypeName} with id ${entity.id}`
116
      );
117
    }
118
    entityLink = baseUrl + relatedLink;
16✔
119
  }
120

121
  await sails.helpers.sendEmail
31✔
122
    .with({
123
      allowResponse: false,
124
      emailSubject: 'Notification',
125
      i18n: req.i18n,
126
      recipientEmail: user.mail,
127
      viewName: 'notification',
128
      viewValues: {
129
        actionVerb,
130
        entityLink,
131
        entityName,
132
        entityType: notificationEntity,
133
        recipientName: user.nickname,
134
        subscriptionName: user.subscriptionName,
135
        subscriptionType: user.subscriptionType,
136
      },
137
    })
138
    .intercept('sendSESEmailError', () => {
139
      sails.log.error(
×
140
        `The email service has encountered an error while trying to notify user ${user.nickname} (id=${user.id}).`
141
      );
142
      return false;
×
143
    });
144
};
145

146
const getCountryMassifAndRegionSubscribers = async (
8✔
147
  entityCountryId,
148
  entityMassifIds,
149
  entityRegionId
150
) => {
151
  const countrySubscribers = [];
117✔
152
  const massifsSubscribers = [];
117✔
153
  const regionSubscribers = [];
117✔
154
  if (entityCountryId) {
117✔
155
    const country =
156
      await TCountry.findOne(entityCountryId).populate('subscribedCavers');
10✔
157
    if (country) {
10!
158
      countrySubscribers.push(
10✔
159
        ...country.subscribedCavers.map((caver) => ({
10✔
160
          ...caver,
161
          subscriptionName: country.nativeName,
162
          subscriptionType: 'country',
163
        }))
164
      );
165
    }
166
  }
167
  if (entityMassifIds) {
117!
168
    await Promise.all(
117✔
169
      entityMassifIds.map(async (massifId) => {
170
        const massif =
171
          await TMassif.findOne(massifId).populate('subscribedCavers');
69✔
172
        if (massif) {
69✔
173
          await NameService.setNames([massif], 'massif');
67✔
174
          massifsSubscribers.push(
67✔
175
            ...massif.subscribedCavers.map((caver) => ({
58✔
176
              ...caver,
177
              subscriptionType: 'massif',
178
              subscriptionName: massif.names[0]?.name,
179
            }))
180
          );
181
        }
182
      })
183
    );
184
  }
185
  if (entityRegionId) {
117✔
186
    const region =
187
      await TISO31662.findOne(entityRegionId).populate('subscribedCavers');
17✔
188
    if (region) {
17✔
189
      regionSubscribers.push(
3✔
190
        ...region.subscribedCavers.map((caver) => ({
×
191
          ...caver,
192
          subscriptionName: region.name,
193
          subscriptionType: 'region',
194
        }))
195
      );
196
    }
197
  }
198
  return { countrySubscribers, massifsSubscribers, regionSubscribers };
117✔
199
};
200

201
module.exports = {
8✔
202
  NOTIFICATION_ENTITIES,
203
  notifyMessageRecipient: async (req, senderId, conversationId) => {
204
    try {
5✔
205
      const sender = await TCaver.findOne({ id: senderId });
5✔
206
      if (!sender) return;
5!
207
      const query = `SELECT id_caver FROM j_participant WHERE id_conversation = $1 AND id_caver != $2`;
5✔
208
      const result = await CommonService.query(query, [
5✔
209
        conversationId,
210
        senderId,
211
      ]);
212
      if (result.rows.length === 0) return;
5!
213
      await Promise.all(
5✔
214
        result.rows.map(async (row) => {
215
          const recipient = await TCaver.findOne({ id: row.id_caver });
5✔
216
          if (!recipient || !recipient.sendMessageNotificationByEmail) return;
5!
217
          // TODO: construct conversation link when the  frontend is worked on
218
          const conversationLink = '';
5✔
219
          await sails.helpers.sendEmail
5✔
220
            .with({
221
              allowResponse: false,
222
              emailSubject: 'New Message',
223
              i18n: req.i18n,
224
              recipientEmail: recipient.mail,
225
              viewName: 'new-message',
226
              viewValues: {
227
                senderNickname: sender.nickname,
228
                conversationLink,
229
                recipientName: recipient.nickname,
230
              },
231
            })
232
            .intercept('sendSESEmailError', () => {
233
              sails.log.error(
×
234
                `The email service error notifying user ${recipient.nickname}.`
235
              );
236
              return false;
×
237
            });
238
        })
239
      );
240
    } catch (error) {
241
      sails.log.error(
×
242
        `An error occurred in notifyMessageRecipient: ${error.message}`
243
      );
244
    }
245
  },
246
  NOTIFICATION_TYPES,
247
  ...(process.env.NODE_ENV === 'test' ? { sendNotificationEmail } : undefined),
8!
248

249
  /**
250
   *
251
   * @param {*} req
252
   * @param {*} entity
253
   * @param {Number} notifierId
254
   * @param {NOTIFICATION_TYPES} notificationType
255
   * @param {NOTIFICATION_ENTITIES} notificationEntity
256
   * @return {Boolean} true if everything went well, else false
257
   */
258
  notifySubscribers: async (
259
    req,
260
    entity,
261
    notifierId,
262
    notificationType,
263
    notificationEntity
264
  ) => {
265
    // Had to require in the function to avoid a circular dependency with notifySubscribers() in CaveService.createCave()
266
    // eslint-disable-next-line global-require
267
    const CaveService = require('./CaveService');
149✔
268

269
    // Check params and silently fail to avoid sending an error to the client
270
    if (!Object.values(NOTIFICATION_ENTITIES).includes(notificationEntity)) {
149✔
271
      throw new Error(`Invalid notification entity: ${notificationEntity}`);
1✔
272
    }
273
    if (!Object.values(NOTIFICATION_TYPES).includes(notificationType)) {
148✔
274
      throw new Error(`Invalid notification type: ${notificationType}`);
1✔
275
    }
276
    if (!notifierId) {
147✔
277
      throw new Error(`Missing notifier id`);
1✔
278
    }
279

280
    try {
146✔
281
      // For the populateEntities() method, must use "grotto" instead of "organization"
282
      const entityKey =
283
        notificationEntity === NOTIFICATION_ENTITIES.ORGANIZATION
146✔
284
          ? 'grotto'
285
          : notificationEntity;
286

287
      // Format notification and populate entity
288
      const notification = await module.exports.populateEntities({
146✔
289
        dateInscription: new Date(),
290
        notificationType: (
291
          await TNotificationType.findOne({
292
            name: notificationType,
293
          })
294
        ).id,
295
        notifier: notifierId,
296
        [entityKey]: entity,
297
      });
298

299
      const populatedEntity = notification[entityKey];
121✔
300

301
      const caveId = safeGetPropId('cave', populatedEntity);
121✔
302
      const entranceId = safeGetPropId('entrance', populatedEntity);
121✔
303
      const massifId = safeGetPropId('massif', populatedEntity);
121✔
304

305
      const getMassifIdsFromCave = async (id) =>
121✔
306
        (await CaveService.getMassifs(id)).map((m) => m.id);
69✔
307
      const getCountryId = (id) => safeGetPropId('country', id);
121✔
308
      const getRegionId = (entityData) => entityData?.iso_3166_2;
121✔
309

310
      const getCountryFromCaveEntrances = (cave) => {
121✔
311
        if (cave?.entrances?.length > 0) {
15✔
312
          return getCountryId(cave.entrances[0]);
5✔
313
        }
314
        return null;
10✔
315
      };
316

317
      const resolveLocationFromCaveOrEntrance = async (
121✔
318
        relatedCaveId,
319
        relatedEntranceId,
320
        entityData
321
      ) => {
322
        if (relatedCaveId) {
45✔
323
          return {
3✔
324
            countryId: getCountryFromCaveEntrances(entityData.cave),
325
            massifIds: await getMassifIdsFromCave(relatedCaveId),
326
            regionId: entityData.cave?.entrances?.[0]
3!
327
              ? getRegionId(entityData.cave.entrances[0])
328
              : null,
329
          };
330
        }
331
        if (relatedEntranceId) {
42✔
332
          return {
15✔
333
            countryId: getCountryId(entityData.entrance),
334
            massifIds: await getMassifIdsFromCave(
335
              safeGetPropId('cave', entityData.entrance)
336
            ),
337
            regionId: getRegionId(entityData.entrance),
338
          };
339
        }
340
        return { countryId: null, massifIds: [], regionId: null };
27✔
341
      };
342

343
      // Entity-specific location resolution
344
      const entityResolvers = {
121✔
345
        [NOTIFICATION_ENTITIES.CAVE]: async () => ({
12✔
346
          countryId: getCountryFromCaveEntrances(populatedEntity),
347
          massifIds: await getMassifIdsFromCave(populatedEntity.id),
348
          regionId: populatedEntity?.entrances?.[0]
12✔
349
            ? getRegionId(populatedEntity.entrances[0])
350
            : null,
351
        }),
352

353
        [NOTIFICATION_ENTITIES.ENTRANCE]: async () => ({
34✔
354
          countryId: getCountryId(populatedEntity),
355
          massifIds: populatedEntity?.cave
34✔
356
            ? await getMassifIdsFromCave(safeGetPropId('cave', populatedEntity))
357
            : [],
358
          regionId: getRegionId(populatedEntity),
359
        }),
360

361
        [NOTIFICATION_ENTITIES.MASSIF]: async () => ({
8✔
362
          countryId: null,
363
          massifIds: [populatedEntity.id],
364
          regionId: null,
365
        }),
366

367
        [NOTIFICATION_ENTITIES.ORGANIZATION]: async () => ({
13✔
368
          countryId: getCountryId(populatedEntity),
369
          massifIds: [],
370
          regionId: getRegionId(populatedEntity),
371
        }),
372

373
        [NOTIFICATION_ENTITIES.LOCATION]: async () => {
374
          if (!entranceId)
9✔
375
            throw new Error(`Can't retrieve related entrance id.`);
1✔
376
          return {
8✔
377
            countryId: getCountryId(populatedEntity.entrance),
378
            massifIds: await getMassifIdsFromCave(
379
              safeGetPropId('cave', populatedEntity.entrance)
380
            ),
381
            regionId: getRegionId(populatedEntity.entrance),
382
          };
383
        },
384
      };
385

386
      // Entities that can relate to cave, entrance, or massif
387
      const multiRelationEntities = [
121✔
388
        NOTIFICATION_ENTITIES.COMMENT,
389
        NOTIFICATION_ENTITIES.DESCRIPTION,
390
        NOTIFICATION_ENTITIES.HISTORY,
391
        NOTIFICATION_ENTITIES.RIGGING,
392
      ];
393

394
      // Find massifs and country concerned about the notification
395
      let result;
396
      if (entityResolvers[notificationEntity]) {
121✔
397
        result = await entityResolvers[notificationEntity]();
76✔
398
      } else if (multiRelationEntities.includes(notificationEntity)) {
45✔
399
        result = await resolveLocationFromCaveOrEntrance(
22✔
400
          caveId,
401
          entranceId,
402
          populatedEntity
403
        );
404

405
        // Handle massif-only case for description and document
406
        if (!result.countryId && !result.massifIds.length && massifId) {
22!
407
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
408
        }
409

410
        // Require cave or entrance for most entities
411
        if (
22✔
412
          !caveId &&
45✔
413
          !entranceId &&
414
          ![
415
            NOTIFICATION_ENTITIES.DESCRIPTION,
416
            NOTIFICATION_ENTITIES.DOCUMENT,
417
          ].includes(notificationEntity)
418
        ) {
419
          throw new Error(`Can't retrieve related cave or entrance id.`);
3✔
420
        }
421
      } else if (notificationEntity === NOTIFICATION_ENTITIES.DOCUMENT) {
23!
422
        result = await resolveLocationFromCaveOrEntrance(
23✔
423
          caveId,
424
          entranceId,
425
          populatedEntity
426
        );
427
        if (!result.countryId && !result.massifIds.length && massifId) {
23!
428
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
429
        }
430
      } else {
431
        throw new Error(
×
432
          `Can't find what to do with the following notification entity value: ${notificationEntity}`
433
        );
434
      }
435

436
      const entityCountryId = result.countryId;
117✔
437
      const entityMassifIds = result.massifIds;
117✔
438
      const entityRegionId = result.regionId;
117✔
439

440
      // Find subscribers to the entity.
441
      const { countrySubscribers, massifsSubscribers, regionSubscribers } =
442
        await getCountryMassifAndRegionSubscribers(
117✔
443
          entityCountryId,
444
          entityMassifIds,
445
          entityRegionId
446
        );
447
      // Consolidate subscribers by user ID and combine subscription types
448
      const subscriberMap = new Map();
117✔
449

450
      const addSubscribers = (subscribers) => {
117✔
451
        subscribers
351✔
452
          .filter((u) => u.id !== notifierId)
68✔
453
          .forEach((user) => {
454
            if (subscriberMap.has(user.id)) {
68✔
455
              const existing = subscriberMap.get(user.id);
9✔
456
              existing.subscriptionNames.push(user.subscriptionName);
9✔
457
              existing.subscriptionTypes.push(user.subscriptionType);
9✔
458
            } else {
459
              subscriberMap.set(user.id, {
59✔
460
                ...user,
461
                subscriptionNames: [user.subscriptionName],
462
                subscriptionTypes: [user.subscriptionType],
463
              });
464
            }
465
          });
466
      };
467

468
      addSubscribers(countrySubscribers);
117✔
469
      addSubscribers(massifsSubscribers);
117✔
470
      addSubscribers(regionSubscribers);
117✔
471

472
      const uniqueUsers = Array.from(subscriberMap.values()).map((user) => ({
117✔
473
        ...user,
474
        subscriptionName: user.subscriptionNames.join(' and '),
475
        subscriptionType: user.subscriptionTypes.join(', '),
476
      }));
477

478
      // Create notifications & optionally send email
479
      const res = await Promise.all(
117✔
480
        uniqueUsers.map(async (user) => {
481
          try {
59✔
482
            await TNotification.create({
59✔
483
              ...notification,
484
              notified: user.id,
485
              [entityKey]: notification[entityKey].id, // id only for the DB storage
486
            });
487
          } catch (e) {
488
            sails.log.error(
×
489
              `An error occured when trying to create a notification: ${e.message}`
490
            );
491
            return false;
×
492
          }
493

494
          if (user.sendNotificationByEmail) {
59!
495
            await sendNotificationEmail(
×
496
              populatedEntity,
497
              notificationType,
498
              notificationEntity,
499
              req,
500
              user
501
            );
502
          }
503
          return true;
59✔
504
        })
505
      );
506

507
      // 5% chance to also remove older notifications
508
      if (process.env.NODE_ENV !== 'test' && Math.random() < 0.05) {
117!
509
        try {
×
510
          await removeOlderNotifications();
×
511
        } catch (cleanupError) {
512
          sails.log.error(
×
513
            `Error during notification cleanup: ${cleanupError.message}`
514
          );
515
        }
516
      }
517

518
      return res;
117✔
519
    } catch (error) {
520
      // Fail silently to avoid sending an error to the user
521
      sails.log.error(
29✔
522
        `An error occurred when trying to notify subscribers: ${error.message} ${error.stack}`
523
      );
524
      return false;
29✔
525
    }
526
  },
527

528
  populateEntities: async (notification) => {
529
    const populatedNotification = notification;
171✔
530
    if (populatedNotification.cave) {
171✔
531
      await NameService.setNames([populatedNotification.cave], 'cave');
18✔
532
    }
533
    if (populatedNotification.comment) {
171✔
534
      populatedNotification.comment = await TComment.findOne(
10✔
535
        safeGetPropId('comment', notification)
536
      )
537
        .populate('cave')
538
        .populate('entrance');
539
    }
540
    if (populatedNotification.description) {
171✔
541
      populatedNotification.description = await TDescription.findOne(
9✔
542
        safeGetPropId('description', notification)
543
      )
544
        .populate('cave')
545
        .populate('document')
546
        .populate('entrance')
547
        .populate('massif');
548
    }
549
    if (populatedNotification.document) {
171✔
550
      // Had to require in the function to avoid a circular dependency with notifySubscribers() in DocumentService.createDocument()
551
      // eslint-disable-next-line global-require
552
      const DocumentService = require('./DocumentService');
23✔
553
      const populatedDocuments = await DocumentService.getDocuments([
23✔
554
        safeGetPropId('document', notification),
555
      ]);
556
      populatedNotification.document = populatedDocuments[0];
23✔
557
    }
558
    if (populatedNotification.entrance) {
171✔
559
      await NameService.setNames([populatedNotification.entrance], 'entrance');
50✔
560
    }
561
    if (populatedNotification.grotto) {
171✔
562
      await NameService.setNames([populatedNotification.grotto], 'grotto');
13✔
563
    }
564
    if (populatedNotification.history) {
171✔
565
      populatedNotification.history = await THistory.findOne(
5✔
566
        safeGetPropId('history', notification)
567
      )
568
        .populate('cave')
569
        .populate('entrance');
570
    }
571
    if (populatedNotification.location) {
171✔
572
      populatedNotification.location = await TLocation.findOne(
12✔
573
        safeGetPropId('location', notification)
574
      ).populate('entrance');
575
    }
576
    if (populatedNotification.massif) {
171✔
577
      await NameService.setNames([populatedNotification.massif], 'massif');
9✔
578
    }
579
    if (populatedNotification.rigging) {
171✔
580
      populatedNotification.rigging = await TRigging.findOne(
5✔
581
        safeGetPropId('rigging', notification)
582
      )
583
        .populate('entrance')
584
        .populate('cave');
585
    }
586
    return populatedNotification;
171✔
587
  },
588
};
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