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

GrottoCenter / grottocenter-api / 19769014454

28 Nov 2025 04:15PM UTC coverage: 85.262% (+0.1%) from 85.128%
19769014454

push

github

ClemRz
feat(explorations): allows a user to add/remove explored caves

2518 of 3111 branches covered (80.94%)

Branch coverage included in aggregate %.

75 of 78 new or added lines in 9 files covered. (96.15%)

28 existing lines in 7 files now uncovered.

5344 of 6110 relevant lines covered (87.46%)

22.82 hits per line

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

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

4
const NOTIFICATION_ENTITIES = {
7✔
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 = {
7✔
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) => {
7✔
34
  if (data && data[prop]) {
593✔
35
    if (data[prop] instanceof Object) {
178✔
36
      return data[prop].id;
122✔
37
    }
38
    return data[prop];
56✔
39
  }
40
  return undefined;
415✔
41
};
42

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

201
module.exports = {
7✔
202
  NOTIFICATION_ENTITIES,
203
  NOTIFICATION_TYPES,
204
  ...(process.env.NODE_ENV === 'test' ? { sendNotificationEmail } : undefined),
7!
205

206
  /**
207
   *
208
   * @param {*} req
209
   * @param {*} entity
210
   * @param {Number} notifierId
211
   * @param {NOTIFICATION_TYPES} notificationType
212
   * @param {NOTIFICATION_ENTITIES} notificationEntity
213
   * @return {Boolean} true if everything went well, else false
214
   */
215
  notifySubscribers: async (
216
    req,
217
    entity,
218
    notifierId,
219
    notificationType,
220
    notificationEntity
221
  ) => {
222
    // Had to require in the function to avoid a circular dependency with notifySubscribers() in CaveService.createCave()
223
    // eslint-disable-next-line global-require
224
    const CaveService = require('./CaveService');
134✔
225

226
    // Check params and silently fail to avoid sending an error to the client
227
    if (!Object.values(NOTIFICATION_ENTITIES).includes(notificationEntity)) {
134✔
228
      throw new Error(`Invalid notification entity: ${notificationEntity}`);
1✔
229
    }
230
    if (!Object.values(NOTIFICATION_TYPES).includes(notificationType)) {
133✔
231
      throw new Error(`Invalid notification type: ${notificationType}`);
1✔
232
    }
233
    if (!notifierId) {
132✔
234
      throw new Error(`Missing notifier id`);
1✔
235
    }
236

237
    try {
131✔
238
      // For the populateEntities() method, must use "grotto" instead of "organization"
239
      const entityKey =
240
        notificationEntity === NOTIFICATION_ENTITIES.ORGANIZATION
131✔
241
          ? 'grotto'
242
          : notificationEntity;
243

244
      // Format notification and populate entity
245
      const notification = await module.exports.populateEntities({
131✔
246
        dateInscription: new Date(),
247
        notificationType: (
248
          await TNotificationType.findOne({
249
            name: notificationType,
250
          })
251
        ).id,
252
        notifier: notifierId,
253
        [entityKey]: entity,
254
      });
255

256
      const populatedEntity = notification[entityKey];
106✔
257

258
      const caveId = safeGetPropId('cave', populatedEntity);
106✔
259
      const entranceId = safeGetPropId('entrance', populatedEntity);
106✔
260
      const massifId = safeGetPropId('massif', populatedEntity);
106✔
261

262
      const getMassifIdsFromCave = async (id) =>
106✔
263
        (await CaveService.getMassifs(id)).map((m) => m.id);
58✔
264
      const getCountryId = (id) => safeGetPropId('country', id);
106✔
265
      const getRegionId = (entityData) => entityData?.iso_3166_2;
106✔
266

267
      const getCountryFromCaveEntrances = (cave) => {
106✔
268
        if (cave?.entrances?.length > 0) {
14✔
269
          return getCountryId(cave.entrances[0]);
4✔
270
        }
271
        return null;
10✔
272
      };
273

274
      const resolveLocationFromCaveOrEntrance = async (
106✔
275
        relatedCaveId,
276
        relatedEntranceId,
277
        entityData
278
      ) => {
279
        if (relatedCaveId) {
44✔
280
          return {
3✔
281
            countryId: getCountryFromCaveEntrances(entityData.cave),
282
            massifIds: await getMassifIdsFromCave(relatedCaveId),
283
            regionId: entityData.cave?.entrances?.[0]
3!
284
              ? getRegionId(entityData.cave.entrances[0])
285
              : null,
286
          };
287
        }
288
        if (relatedEntranceId) {
41✔
289
          return {
15✔
290
            countryId: getCountryId(entityData.entrance),
291
            massifIds: await getMassifIdsFromCave(
292
              safeGetPropId('cave', entityData.entrance)
293
            ),
294
            regionId: getRegionId(entityData.entrance),
295
          };
296
        }
297
        return { countryId: null, massifIds: [], regionId: null };
26✔
298
      };
299

300
      // Entity-specific location resolution
301
      const entityResolvers = {
106✔
302
        [NOTIFICATION_ENTITIES.CAVE]: async () => ({
11✔
303
          countryId: getCountryFromCaveEntrances(populatedEntity),
304
          massifIds: await getMassifIdsFromCave(populatedEntity.id),
305
          regionId: populatedEntity?.entrances?.[0]
11✔
306
            ? getRegionId(populatedEntity.entrances[0])
307
            : null,
308
        }),
309

310
        [NOTIFICATION_ENTITIES.ENTRANCE]: async () => ({
24✔
311
          countryId: getCountryId(populatedEntity),
312
          massifIds: populatedEntity?.cave
24✔
313
            ? await getMassifIdsFromCave(safeGetPropId('cave', populatedEntity))
314
            : [],
315
          regionId: getRegionId(populatedEntity),
316
        }),
317

318
        [NOTIFICATION_ENTITIES.MASSIF]: async () => ({
6✔
319
          countryId: null,
320
          massifIds: [populatedEntity.id],
321
          regionId: null,
322
        }),
323

324
        [NOTIFICATION_ENTITIES.ORGANIZATION]: async () => ({
12✔
325
          countryId: getCountryId(populatedEntity),
326
          massifIds: [],
327
          regionId: getRegionId(populatedEntity),
328
        }),
329

330
        [NOTIFICATION_ENTITIES.LOCATION]: async () => {
331
          if (!entranceId)
9✔
332
            throw new Error(`Can't retrieve related entrance id.`);
1✔
333
          return {
8✔
334
            countryId: getCountryId(populatedEntity.entrance),
335
            massifIds: await getMassifIdsFromCave(
336
              safeGetPropId('cave', populatedEntity.entrance)
337
            ),
338
            regionId: getRegionId(populatedEntity.entrance),
339
          };
340
        },
341
      };
342

343
      // Entities that can relate to cave, entrance, or massif
344
      const multiRelationEntities = [
106✔
345
        NOTIFICATION_ENTITIES.COMMENT,
346
        NOTIFICATION_ENTITIES.DESCRIPTION,
347
        NOTIFICATION_ENTITIES.HISTORY,
348
        NOTIFICATION_ENTITIES.RIGGING,
349
      ];
350

351
      // Find massifs and country concerned about the notification
352
      let result;
353
      if (entityResolvers[notificationEntity]) {
106✔
354
        result = await entityResolvers[notificationEntity]();
62✔
355
      } else if (multiRelationEntities.includes(notificationEntity)) {
44✔
356
        result = await resolveLocationFromCaveOrEntrance(
22✔
357
          caveId,
358
          entranceId,
359
          populatedEntity
360
        );
361

362
        // Handle massif-only case for description and document
363
        if (!result.countryId && !result.massifIds.length && massifId) {
22!
364
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
365
        }
366

367
        // Require cave or entrance for most entities
368
        if (
22✔
369
          !caveId &&
45✔
370
          !entranceId &&
371
          ![
372
            NOTIFICATION_ENTITIES.DESCRIPTION,
373
            NOTIFICATION_ENTITIES.DOCUMENT,
374
          ].includes(notificationEntity)
375
        ) {
376
          throw new Error(`Can't retrieve related cave or entrance id.`);
3✔
377
        }
378
      } else if (notificationEntity === NOTIFICATION_ENTITIES.DOCUMENT) {
22!
379
        result = await resolveLocationFromCaveOrEntrance(
22✔
380
          caveId,
381
          entranceId,
382
          populatedEntity
383
        );
384
        if (!result.countryId && !result.massifIds.length && massifId) {
22!
385
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
386
        }
387
      } else {
388
        throw new Error(
×
389
          `Can't find what to do with the following notification entity value: ${notificationEntity}`
390
        );
391
      }
392

393
      const entityCountryId = result.countryId;
102✔
394
      const entityMassifIds = result.massifIds;
102✔
395
      const entityRegionId = result.regionId;
102✔
396

397
      // Find subscribers to the entity.
398
      const { countrySubscribers, massifsSubscribers, regionSubscribers } =
399
        await getCountryMassifAndRegionSubscribers(
102✔
400
          entityCountryId,
401
          entityMassifIds,
402
          entityRegionId
403
        );
404
      // Consolidate subscribers by user ID and combine subscription types
405
      const subscriberMap = new Map();
102✔
406

407
      const addSubscribers = (subscribers) => {
102✔
408
        subscribers
306✔
409
          .filter((u) => u.id !== notifierId)
58✔
410
          .forEach((user) => {
411
            if (subscriberMap.has(user.id)) {
58✔
412
              const existing = subscriberMap.get(user.id);
7✔
413
              existing.subscriptionNames.push(user.subscriptionName);
7✔
414
              existing.subscriptionTypes.push(user.subscriptionType);
7✔
415
            } else {
416
              subscriberMap.set(user.id, {
51✔
417
                ...user,
418
                subscriptionNames: [user.subscriptionName],
419
                subscriptionTypes: [user.subscriptionType],
420
              });
421
            }
422
          });
423
      };
424

425
      addSubscribers(countrySubscribers);
102✔
426
      addSubscribers(massifsSubscribers);
102✔
427
      addSubscribers(regionSubscribers);
102✔
428

429
      const uniqueUsers = Array.from(subscriberMap.values()).map((user) => ({
102✔
430
        ...user,
431
        subscriptionName: user.subscriptionNames.join(' and '),
432
        subscriptionType: user.subscriptionTypes.join(', '),
433
      }));
434

435
      // Create notifications & optionally send email
436
      const res = await Promise.all(
102✔
437
        uniqueUsers.map(async (user) => {
438
          try {
51✔
439
            await TNotification.create({
51✔
440
              ...notification,
441
              notified: user.id,
442
              [entityKey]: notification[entityKey].id, // id only for the DB storage
443
            });
444
          } catch (e) {
445
            sails.log.error(
×
446
              `An error occured when trying to create a notification: ${e.message}`
447
            );
448
            return false;
×
449
          }
450

451
          if (user.sendNotificationByEmail) {
51!
452
            await sendNotificationEmail(
×
453
              populatedEntity,
454
              notificationType,
455
              notificationEntity,
456
              req,
457
              user
458
            );
459
          }
460
          return true;
51✔
461
        })
462
      );
463

464
      // 5% chance to also remove older notifications
465
      if (process.env.NODE_ENV !== 'test' && Math.random() < 0.05) {
102!
466
        try {
×
467
          await removeOlderNotifications();
×
468
        } catch (cleanupError) {
469
          sails.log.error(
×
470
            `Error during notification cleanup: ${cleanupError.message}`
471
          );
472
        }
473
      }
474

475
      return res;
102✔
476
    } catch (error) {
477
      // Fail silently to avoid sending an error to the user
478
      sails.log.error(
29✔
479
        `An error occurred when trying to notify subscribers: ${error.message} ${error.stack}`
480
      );
481
      return false;
29✔
482
    }
483
  },
484

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