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

GrottoCenter / grottocenter-api / 19277458745

11 Nov 2025 08:15PM UTC coverage: 45.598% (+0.8%) from 44.806%
19277458745

push

github

Clément Ronzon
feat(region): adds endpoints for Region page support

963 of 2824 branches covered (34.1%)

Branch coverage included in aggregate %.

165 of 235 new or added lines in 15 files covered. (70.21%)

2 existing lines in 2 files now uncovered.

2958 of 5775 relevant lines covered (51.22%)

6.84 hits per line

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

90.0
/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
×
28
                 FROM t_notification
29
                 WHERE date_inscription < current_timestamp - interval '2 month';`;
UNCOV
30
  await CommonService.query(query);
×
31
}
32

33
const safeGetPropId = (prop, data) => {
6✔
34
  if (data && data[prop]) {
293✔
35
    if (data[prop] instanceof Object) {
77✔
36
      return data[prop].id;
46✔
37
    }
38
    return data[prop];
31✔
39
  }
40
  return undefined;
216✔
41
};
42

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

195
module.exports = {
6✔
196
  NOTIFICATION_ENTITIES,
197
  NOTIFICATION_TYPES,
198
  ...(process.env.NODE_ENV === 'test' ? { sendNotificationEmail } : undefined),
6!
199

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

220
    // Check params and silently fail to avoid sending an error to the client
221
    if (!Object.values(NOTIFICATION_ENTITIES).includes(notificationEntity)) {
44✔
222
      throw new Error(`Invalid notification entity: ${notificationEntity}`);
1✔
223
    }
224
    if (!Object.values(NOTIFICATION_TYPES).includes(notificationType)) {
43✔
225
      throw new Error(`Invalid notification type: ${notificationType}`);
1✔
226
    }
227
    if (!notifierId) {
42✔
228
      throw new Error(`Missing notifier id`);
1✔
229
    }
230

231
    try {
41✔
232
      // For the populateEntities() method, must use "grotto" instead of "organization"
233
      const entityKey =
234
        notificationEntity === NOTIFICATION_ENTITIES.ORGANIZATION
41✔
235
          ? 'grotto'
236
          : notificationEntity;
237

238
      // Format notification and populate entity
239
      const notification = await module.exports.populateEntities({
41✔
240
        dateInscription: new Date(),
241
        notificationType: (
242
          await TNotificationType.findOne({
243
            name: notificationType,
244
          })
245
        ).id,
246
        notifier: notifierId,
247
        [entityKey]: entity,
248
      });
249

250
      const populatedEntity = notification[entityKey];
41✔
251

252
      const caveId = safeGetPropId('cave', populatedEntity);
41✔
253
      const entranceId = safeGetPropId('entrance', populatedEntity);
41✔
254
      const massifId = safeGetPropId('massif', populatedEntity);
41✔
255

256
      const getMassifIdsFromCave = async (id) =>
41✔
257
        (await CaveService.getMassifs(id)).map((m) => m.id);
29✔
258
      const getCountryId = (id) => safeGetPropId('country', id);
41✔
259
      const getRegionId = (entityData) => entityData?.iso_3166_2;
41✔
260

261
      const getCountryFromCaveEntrances = (cave) => {
41✔
262
        if (cave?.entrances?.length > 0) {
12✔
263
          return getCountryId(cave.entrances[0]);
4✔
264
        }
265
        return null;
8✔
266
      };
267

268
      const resolveLocationFromCaveOrEntrance = async (
41✔
269
        relatedCaveId,
270
        relatedEntranceId,
271
        entityData
272
      ) => {
273
        if (relatedCaveId) {
16✔
274
          return {
3✔
275
            countryId: getCountryFromCaveEntrances(entityData.cave),
276
            massifIds: await getMassifIdsFromCave(relatedCaveId),
277
            regionId: entityData.cave?.entrances?.[0]
3!
278
              ? getRegionId(entityData.cave.entrances[0])
279
              : null,
280
          };
281
        }
282
        if (relatedEntranceId) {
13✔
283
          return {
8✔
284
            countryId: getCountryId(entityData.entrance),
285
            massifIds: await getMassifIdsFromCave(
286
              safeGetPropId('cave', entityData.entrance)
287
            ),
288
            regionId: getRegionId(entityData.entrance),
289
          };
290
        }
291
        return { countryId: null, massifIds: [], regionId: null };
5✔
292
      };
293

294
      // Entity-specific location resolution
295
      const entityResolvers = {
41✔
296
        [NOTIFICATION_ENTITIES.CAVE]: async () => ({
9✔
297
          countryId: getCountryFromCaveEntrances(populatedEntity),
298
          massifIds: await getMassifIdsFromCave(populatedEntity.id),
299
          regionId: populatedEntity?.entrances?.[0]
9✔
300
            ? getRegionId(populatedEntity.entrances[0])
301
            : null,
302
        }),
303

304
        [NOTIFICATION_ENTITIES.ENTRANCE]: async () => ({
7✔
305
          countryId: getCountryId(populatedEntity),
306
          massifIds: populatedEntity?.cave
7!
307
            ? await getMassifIdsFromCave(safeGetPropId('cave', populatedEntity))
308
            : [],
309
          regionId: getRegionId(populatedEntity),
310
        }),
311

312
        [NOTIFICATION_ENTITIES.MASSIF]: async () => ({
3✔
313
          countryId: null,
314
          massifIds: [populatedEntity.id],
315
          regionId: null,
316
        }),
317

318
        [NOTIFICATION_ENTITIES.ORGANIZATION]: async () => ({
4✔
319
          countryId: getCountryId(populatedEntity),
320
          massifIds: [],
321
          regionId: getRegionId(populatedEntity),
322
        }),
323

324
        [NOTIFICATION_ENTITIES.LOCATION]: async () => {
325
          if (!entranceId)
2!
NEW
326
            throw new Error(`Can't retrieve related entrance id.`);
×
327
          return {
2✔
328
            countryId: getCountryId(populatedEntity.entrance),
329
            massifIds: await getMassifIdsFromCave(
330
              safeGetPropId('cave', populatedEntity.entrance)
331
            ),
332
            regionId: getRegionId(populatedEntity.entrance),
333
          };
334
        },
335
      };
336

337
      // Entities that can relate to cave, entrance, or massif
338
      const multiRelationEntities = [
41✔
339
        NOTIFICATION_ENTITIES.COMMENT,
340
        NOTIFICATION_ENTITIES.DESCRIPTION,
341
        NOTIFICATION_ENTITIES.HISTORY,
342
        NOTIFICATION_ENTITIES.RIGGING,
343
      ];
344

345
      // Find massifs and country concerned about the notification
346
      let result;
347
      if (entityResolvers[notificationEntity]) {
41✔
348
        result = await entityResolvers[notificationEntity]();
25✔
349
      } else if (multiRelationEntities.includes(notificationEntity)) {
16✔
350
        result = await resolveLocationFromCaveOrEntrance(
11✔
351
          caveId,
352
          entranceId,
353
          populatedEntity
354
        );
355

356
        // Handle massif-only case for description and document
357
        if (!result.countryId && !result.massifIds.length && massifId) {
11!
NEW
358
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
359
        }
360

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

387
      const entityCountryId = result.countryId;
41✔
388
      const entityMassifIds = result.massifIds;
41✔
389
      const entityRegionId = result.regionId;
41✔
390

391
      // Find subscribers to the entity.
392
      const { countrySubscribers, massifsSubscribers, regionSubscribers } =
393
        await getCountryMassifAndRegionSubscribers(
41✔
394
          entityCountryId,
395
          entityMassifIds,
396
          entityRegionId
397
        );
398
      // Consolidate subscribers by user ID and combine subscription types
399
      const subscriberMap = new Map();
39✔
400

401
      const addSubscribers = (subscribers) => {
39✔
402
        subscribers
117✔
403
          .filter((u) => u.id !== notifierId)
25✔
404
          .forEach((user) => {
405
            if (subscriberMap.has(user.id)) {
25✔
406
              const existing = subscriberMap.get(user.id);
1✔
407
              existing.subscriptionNames.push(user.subscriptionName);
1✔
408
              existing.subscriptionTypes.push(user.subscriptionType);
1✔
409
            } else {
410
              subscriberMap.set(user.id, {
24✔
411
                ...user,
412
                subscriptionNames: [user.subscriptionName],
413
                subscriptionTypes: [user.subscriptionType],
414
              });
415
            }
416
          });
417
      };
418

419
      addSubscribers(countrySubscribers);
39✔
420
      addSubscribers(massifsSubscribers);
39✔
421
      addSubscribers(regionSubscribers);
39✔
422

423
      const uniqueUsers = Array.from(subscriberMap.values()).map((user) => ({
39✔
424
        ...user,
425
        subscriptionName: user.subscriptionNames.join(' and '),
426
        subscriptionType: user.subscriptionTypes.join(', '),
427
      }));
428

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

445
          if (user.sendNotificationByEmail) {
24!
446
            await sendNotificationEmail(
×
447
              populatedEntity,
448
              notificationType,
449
              notificationEntity,
450
              req,
451
              user
452
            );
453
          }
454
          return true;
24✔
455
        })
456
      );
457

458
      // 5% chance to also remove older notifications
459
      if (process.env.NODE_ENV !== 'test' && Math.random() < 0.05) {
39!
NEW
460
        try {
×
NEW
461
          await removeOlderNotifications();
×
462
        } catch (cleanupError) {
NEW
463
          sails.log.error(
×
464
            `Error during notification cleanup: ${cleanupError.message}`
465
          );
466
        }
467
      }
468

469
      return res;
39✔
470
    } catch (error) {
471
      // Fail silently to avoid sending an error to the user
472
      sails.log.error(
2✔
473
        `An error occurred when trying to notify subscribers: ${error.message} ${error.stack}`
474
      );
475
      return false;
2✔
476
    }
477
  },
478

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