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

GrottoCenter / grottocenter-api / 25409501522

06 May 2026 12:16AM UTC coverage: 86.297% (-0.2%) from 86.497%
25409501522

Pull #1565

github

ClemRz
feat(account): consolidate account endpoints and add locale-aware emails

- Add GET /api/v1/account for authenticated user's private data
- Add PATCH /api/v1/account for self-service updates (name, surname,
  nickname, email, password, language)
- Restrict PUT /api/v1/cavers/:caverId to admin-only
- Add optional language param to sign-up endpoint
- Add locale support to send-email helper for recipient-language emails
- Remove orphaned change-email, change-alert-for-news controllers
- Update Swagger spec with consolidated /account path
- Update tests to match new endpoint behavior
- Silence pre-existing func-names lint warnings in property tests
Pull Request #1565: feat(account): consolidate endpoints and add locale-aware emails

3031 of 3661 branches covered (82.79%)

Branch coverage included in aggregate %.

62 of 82 new or added lines in 7 files covered. (75.61%)

32 existing lines in 9 files now uncovered.

6252 of 7096 relevant lines covered (88.11%)

52.18 hits per line

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

91.59
/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() {
UNCOV
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) => {
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
  // Resolve the recipient's preferred locale
51
  let locale;
52
  if (user.language) {
36!
53
    // language is a FK (ISO 639-3 id); look up part1 for i18n locale
54
    const lang = await TLanguage.findOne({ id: user.language });
36✔
55
    if (lang && lang.part1) {
36!
56
      locale = lang.part1;
36✔
57
    }
58
  }
59

60
  // Get entity name (handle all cases)
61
  const getEntityName = (entityData) => {
36✔
62
    if (entityData.name) return entityData.name;
36✔
63
    if (entityData.names) return entityData.names[0]?.name;
28✔
64
    if (entityData.title) return entityData.title;
27✔
65
    if (entityData.titles) return entityData.titles[0]?.text;
15✔
66
    if (entityData.body) return `${entityData.body.slice(0, 50)}...`;
14✔
67
    if (entityData.descriptions) return entityData.descriptions[0].title;
13✔
68
    return '';
12✔
69
  };
70

71
  const entityName = getEntityName(entity);
36✔
72

73
  // Format action verb
74
  const actionVerbMap = {
36✔
75
    [NOTIFICATION_TYPES.CREATE]: 'created',
76
    [NOTIFICATION_TYPES.DELETE]: 'deleted',
77
    [NOTIFICATION_TYPES.PERMANENT_DELETE]: 'permanently deleted',
78
    [NOTIFICATION_TYPES.UPDATE]: 'updated',
79
    [NOTIFICATION_TYPES.VALIDATE]: 'validated',
80
    [NOTIFICATION_TYPES.RESTORE]: 'restored',
81
  };
82

83
  const actionVerb = actionVerbMap[notificationType];
36✔
84
  if (!actionVerb) {
36✔
85
    throw Error(`Unknown notification type: ${notificationType}`);
1✔
86
  }
87

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

94
  const getRelatedEntityLink = () => {
35✔
95
    if (relatedCaveId) return `caves/${relatedCaveId}`;
20✔
96
    if (relatedEntranceId) return `entrances/${relatedEntranceId}`;
18✔
97
    if (relatedMassifId) return `massifs/${relatedMassifId}`;
5✔
98
    return null;
4✔
99
  };
100

101
  const directLinkEntities = {
35✔
102
    [NOTIFICATION_ENTITIES.CAVE]: `caves/${entity.id}`,
103
    [NOTIFICATION_ENTITIES.DOCUMENT]: `documents/${entity.id}`,
104
    [NOTIFICATION_ENTITIES.ENTRANCE]: `entrances/${entity.id}`,
105
    [NOTIFICATION_ENTITIES.MASSIF]: `massifs/${entity.id}`,
106
    [NOTIFICATION_ENTITIES.ORGANIZATION]: `organizations/${entity.id}`,
107
  };
108

109
  let entityLink;
110
  if (directLinkEntities[notificationEntity]) {
35✔
111
    entityLink = baseUrl + directLinkEntities[notificationEntity];
15✔
112
  } else {
113
    const relatedLink = getRelatedEntityLink();
20✔
114
    if (!relatedLink) {
20✔
115
      const entityTypeName = notificationEntity.toLowerCase();
4✔
116
      let requiredEntities;
117
      if (notificationEntity === NOTIFICATION_ENTITIES.DESCRIPTION) {
4✔
118
        requiredEntities = 'cave, entrance or massif';
1✔
119
      } else if (notificationEntity === NOTIFICATION_ENTITIES.LOCATION) {
3✔
120
        requiredEntities = 'entrance';
1✔
121
      } else {
122
        requiredEntities = 'cave or entrance';
2✔
123
      }
124
      throw Error(
4✔
125
        `Can't find related entity (${requiredEntities}) of the ${entityTypeName} with id ${entity.id}`
126
      );
127
    }
128
    entityLink = baseUrl + relatedLink;
16✔
129
  }
130

131
  await sails.helpers.sendEmail
31✔
132
    .with({
133
      allowResponse: false,
134
      emailSubject: 'Notification',
135
      i18n: req.i18n,
136
      locale,
137
      recipientEmail: user.mail,
138
      viewName: 'notification',
139
      viewValues: {
140
        actionVerb,
141
        entityLink,
142
        entityName,
143
        entityType: notificationEntity,
144
        recipientName: user.nickname,
145
        subscriptionName: user.subscriptionName,
146
        subscriptionType: user.subscriptionType,
147
      },
148
    })
149
    .intercept('sendSESEmailError', () => {
UNCOV
150
      sails.log.error(
×
151
        `The email service has encountered an error while trying to notify user ${user.nickname} (id=${user.id}).`
152
      );
UNCOV
153
      return false;
×
154
    });
155
};
156

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

212
module.exports = {
8✔
213
  NOTIFICATION_ENTITIES,
214
  NOTIFICATION_TYPES,
215
  ...(process.env.NODE_ENV === 'test' ? { sendNotificationEmail } : undefined),
8!
216

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

237
    // Check params and silently fail to avoid sending an error to the client
238
    if (!Object.values(NOTIFICATION_ENTITIES).includes(notificationEntity)) {
149✔
239
      throw new Error(`Invalid notification entity: ${notificationEntity}`);
1✔
240
    }
241
    if (!Object.values(NOTIFICATION_TYPES).includes(notificationType)) {
148✔
242
      throw new Error(`Invalid notification type: ${notificationType}`);
1✔
243
    }
244
    if (!notifierId) {
147✔
245
      throw new Error(`Missing notifier id`);
1✔
246
    }
247

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

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

267
      const populatedEntity = notification[entityKey];
121✔
268

269
      const caveId = safeGetPropId('cave', populatedEntity);
121✔
270
      const entranceId = safeGetPropId('entrance', populatedEntity);
121✔
271
      const massifId = safeGetPropId('massif', populatedEntity);
121✔
272

273
      const getMassifIdsFromCave = async (id) =>
121✔
274
        (await CaveService.getMassifs(id)).map((m) => m.id);
69✔
275
      const getCountryId = (id) => safeGetPropId('country', id);
121✔
276
      const getRegionId = (entityData) => entityData?.iso_3166_2;
121✔
277

278
      const getCountryFromCaveEntrances = (cave) => {
121✔
279
        if (cave?.entrances?.length > 0) {
15✔
280
          return getCountryId(cave.entrances[0]);
5✔
281
        }
282
        return null;
10✔
283
      };
284

285
      const resolveLocationFromCaveOrEntrance = async (
121✔
286
        relatedCaveId,
287
        relatedEntranceId,
288
        entityData
289
      ) => {
290
        if (relatedCaveId) {
45✔
291
          return {
3✔
292
            countryId: getCountryFromCaveEntrances(entityData.cave),
293
            massifIds: await getMassifIdsFromCave(relatedCaveId),
294
            regionId: entityData.cave?.entrances?.[0]
3!
295
              ? getRegionId(entityData.cave.entrances[0])
296
              : null,
297
          };
298
        }
299
        if (relatedEntranceId) {
42✔
300
          return {
15✔
301
            countryId: getCountryId(entityData.entrance),
302
            massifIds: await getMassifIdsFromCave(
303
              safeGetPropId('cave', entityData.entrance)
304
            ),
305
            regionId: getRegionId(entityData.entrance),
306
          };
307
        }
308
        return { countryId: null, massifIds: [], regionId: null };
27✔
309
      };
310

311
      // Entity-specific location resolution
312
      const entityResolvers = {
121✔
313
        [NOTIFICATION_ENTITIES.CAVE]: async () => ({
12✔
314
          countryId: getCountryFromCaveEntrances(populatedEntity),
315
          massifIds: await getMassifIdsFromCave(populatedEntity.id),
316
          regionId: populatedEntity?.entrances?.[0]
12✔
317
            ? getRegionId(populatedEntity.entrances[0])
318
            : null,
319
        }),
320

321
        [NOTIFICATION_ENTITIES.ENTRANCE]: async () => ({
34✔
322
          countryId: getCountryId(populatedEntity),
323
          massifIds: populatedEntity?.cave
34✔
324
            ? await getMassifIdsFromCave(safeGetPropId('cave', populatedEntity))
325
            : [],
326
          regionId: getRegionId(populatedEntity),
327
        }),
328

329
        [NOTIFICATION_ENTITIES.MASSIF]: async () => ({
8✔
330
          countryId: null,
331
          massifIds: [populatedEntity.id],
332
          regionId: null,
333
        }),
334

335
        [NOTIFICATION_ENTITIES.ORGANIZATION]: async () => ({
13✔
336
          countryId: getCountryId(populatedEntity),
337
          massifIds: [],
338
          regionId: getRegionId(populatedEntity),
339
        }),
340

341
        [NOTIFICATION_ENTITIES.LOCATION]: async () => {
342
          if (!entranceId)
9✔
343
            throw new Error(`Can't retrieve related entrance id.`);
1✔
344
          return {
8✔
345
            countryId: getCountryId(populatedEntity.entrance),
346
            massifIds: await getMassifIdsFromCave(
347
              safeGetPropId('cave', populatedEntity.entrance)
348
            ),
349
            regionId: getRegionId(populatedEntity.entrance),
350
          };
351
        },
352
      };
353

354
      // Entities that can relate to cave, entrance, or massif
355
      const multiRelationEntities = [
121✔
356
        NOTIFICATION_ENTITIES.COMMENT,
357
        NOTIFICATION_ENTITIES.DESCRIPTION,
358
        NOTIFICATION_ENTITIES.HISTORY,
359
        NOTIFICATION_ENTITIES.RIGGING,
360
      ];
361

362
      // Find massifs and country concerned about the notification
363
      let result;
364
      if (entityResolvers[notificationEntity]) {
121✔
365
        result = await entityResolvers[notificationEntity]();
76✔
366
      } else if (multiRelationEntities.includes(notificationEntity)) {
45✔
367
        result = await resolveLocationFromCaveOrEntrance(
22✔
368
          caveId,
369
          entranceId,
370
          populatedEntity
371
        );
372

373
        // Handle massif-only case for description and document
374
        if (!result.countryId && !result.massifIds.length && massifId) {
22!
UNCOV
375
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
376
        }
377

378
        // Require cave or entrance for most entities
379
        if (
22✔
380
          !caveId &&
45✔
381
          !entranceId &&
382
          ![
383
            NOTIFICATION_ENTITIES.DESCRIPTION,
384
            NOTIFICATION_ENTITIES.DOCUMENT,
385
          ].includes(notificationEntity)
386
        ) {
387
          throw new Error(`Can't retrieve related cave or entrance id.`);
3✔
388
        }
389
      } else if (notificationEntity === NOTIFICATION_ENTITIES.DOCUMENT) {
23!
390
        result = await resolveLocationFromCaveOrEntrance(
23✔
391
          caveId,
392
          entranceId,
393
          populatedEntity
394
        );
395
        if (!result.countryId && !result.massifIds.length && massifId) {
23!
UNCOV
396
          result.massifIds = [safeGetPropId('massif', populatedEntity)];
×
397
        }
398
      } else {
UNCOV
399
        throw new Error(
×
400
          `Can't find what to do with the following notification entity value: ${notificationEntity}`
401
        );
402
      }
403

404
      const entityCountryId = result.countryId;
117✔
405
      const entityMassifIds = result.massifIds;
117✔
406
      const entityRegionId = result.regionId;
117✔
407

408
      // Find subscribers to the entity.
409
      const { countrySubscribers, massifsSubscribers, regionSubscribers } =
410
        await getCountryMassifAndRegionSubscribers(
117✔
411
          entityCountryId,
412
          entityMassifIds,
413
          entityRegionId
414
        );
415
      // Consolidate subscribers by user ID and combine subscription types
416
      const subscriberMap = new Map();
117✔
417

418
      const addSubscribers = (subscribers) => {
117✔
419
        subscribers
351✔
420
          .filter((u) => u.id !== notifierId)
68✔
421
          .forEach((user) => {
422
            if (subscriberMap.has(user.id)) {
68✔
423
              const existing = subscriberMap.get(user.id);
9✔
424
              existing.subscriptionNames.push(user.subscriptionName);
9✔
425
              existing.subscriptionTypes.push(user.subscriptionType);
9✔
426
            } else {
427
              subscriberMap.set(user.id, {
59✔
428
                ...user,
429
                subscriptionNames: [user.subscriptionName],
430
                subscriptionTypes: [user.subscriptionType],
431
              });
432
            }
433
          });
434
      };
435

436
      addSubscribers(countrySubscribers);
117✔
437
      addSubscribers(massifsSubscribers);
117✔
438
      addSubscribers(regionSubscribers);
117✔
439

440
      const uniqueUsers = Array.from(subscriberMap.values()).map((user) => ({
117✔
441
        ...user,
442
        subscriptionName: user.subscriptionNames.join(' and '),
443
        subscriptionType: user.subscriptionTypes.join(', '),
444
      }));
445

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

462
          if (user.sendNotificationByEmail) {
59!
UNCOV
463
            await sendNotificationEmail(
×
464
              populatedEntity,
465
              notificationType,
466
              notificationEntity,
467
              req,
468
              user
469
            );
470
          }
471
          return true;
59✔
472
        })
473
      );
474

475
      // 5% chance to also remove older notifications
476
      if (process.env.NODE_ENV !== 'test' && Math.random() < 0.05) {
117!
UNCOV
477
        try {
×
UNCOV
478
          await removeOlderNotifications();
×
479
        } catch (cleanupError) {
UNCOV
480
          sails.log.error(
×
481
            `Error during notification cleanup: ${cleanupError.message}`
482
          );
483
        }
484
      }
485

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

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