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

GrottoCenter / grottocenter-api / 25457721116

06 May 2026 07:47PM UTC coverage: 86.494% (-0.2%) from 86.702%
25457721116

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

3047 of 3661 branches covered (83.23%)

Branch coverage included in aggregate %.

71 of 91 new or added lines in 8 files covered. (78.02%)

27 existing lines in 6 files now uncovered.

6297 of 7142 relevant lines covered (88.17%)

53.2 hits per line

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

95.58
/api/services/CaveService.js
1
const CommonService = require('./CommonService');
5✔
2
const coerceToInt = require('../utils/coerceToInt');
5✔
3
const coerceToNumeric = require('../utils/coerceToNumeric');
5✔
4
const DocumentService = require('./DocumentService');
5✔
5
const DescriptionService = require('./DescriptionService');
5✔
6
const NameService = require('./NameService');
5✔
7
const SearchService = require('./SearchService');
5✔
8
const NotificationService = require('./NotificationService');
5✔
9
const RecentChangeService = require('./RecentChangeService');
5✔
10

11
const GET_CUMULATED_LENGTH = `
5✔
12
  SELECT SUM(c.length) as sum_length, COUNT(c.length) as nb_data
13
  FROM t_entrance e
14
  JOIN t_cave c ON e.id_cave = c.id
15
  WHERE c.length IS NOT NULL
16
  AND c.is_deleted = false
17
  AND e.is_deleted = false
18
`;
19

20
module.exports = {
5✔
21
  /**
22
   * @param {Array<Object>} caves caves to set
23
   *
24
   * @returns {Promise} the caves with their attribute "entrances" completed
25
   */
26
  setEntrances: async (caves) => {
27
    const entrances = await TEntrance.find()
66✔
28
      .where({ cave: { in: caves.map((c) => c.id) } })
18✔
29
      .populate('names');
30
    for (const cave of caves) {
66✔
31
      cave.entrances = entrances.filter((e) => e.cave === cave.id);
40✔
32
    }
33
  },
34

35
  /**
36
   *
37
   * @param {Object} req
38
   * @param {Object} cleanedData cave-only related data
39
   * @param {Object} nameData name data (should contain an author, text and language attributes)
40
   * @param {Array[Object]} [descriptionsData] descriptions data (for each description,
41
   *  should contain an author, title, text and language attributes)
42
   * @throws Sails ORM errors (see https://sailsjs.com/documentation/concepts/models-and-orm/errors)
43
   *
44
   * @returns {Promise} the created cave
45
   */
46
  createCave: async (req, caveData, nameData, descriptionsData) => {
47
    const res = await sails.getDatastore().transaction(async (db) => {
4✔
48
      // Create cave
49
      const createdCave = await TCave.create({ ...caveData })
4✔
50
        .fetch()
51
        .usingConnection(db);
52

53
      // Format & create name
54
      await TName.create({
3✔
55
        ...nameData,
56
        cave: createdCave.id,
57
        dateInscription: new Date(),
58
        isMain: true,
59
      }).usingConnection(db);
60

61
      // Format & create descriptions
62
      if (descriptionsData) {
3✔
63
        descriptionsData.map(async (d) => {
2✔
64
          const desc = await TDescription.create({
5✔
65
            ...d,
66
            cave: createdCave.id,
67
            dateInscription: new Date(),
68
          }).usingConnection(db);
69
          return desc;
5✔
70
        });
71
      }
72

73
      return createdCave;
3✔
74
    });
75

76
    const populatedCave = await module.exports.getPopulatedCave(res.id);
3✔
77
    module.exports.updateInSearch(populatedCave);
3✔
78

79
    await RecentChangeService.setNameCreate(
3✔
80
      'cave',
81
      res.id,
82
      req.token.id,
83
      nameData.name
84
    );
85

86
    await NotificationService.notifySubscribers(
3✔
87
      req,
88
      populatedCave,
89
      req.token.id,
90
      NotificationService.NOTIFICATION_TYPES.CREATE,
91
      NotificationService.NOTIFICATION_ENTITIES.CAVE
92
    );
93

94
    return populatedCave;
3✔
95
  },
96

97
  // Extract everything from the request body except id and dateInscription
98
  getConvertedDataFromClient: (req) => ({
8✔
99
    // The TCave.create() function doesn't work with TCave field alias. See TCave.js Model
100
    depth: coerceToInt(req.param('depth')),
101
    documents: req.param('documents'),
102
    isDiving: req.param('isDiving'),
103
    latitude: coerceToNumeric(req.param('latitude')),
104
    longitude: coerceToNumeric(req.param('longitude')),
105
    caveLength: coerceToInt(req.param('length')),
106
    massif: req.param('massif'),
107
    temperature: req.param('temperature'),
108
  }),
109

110
  /**
111
   * Get the massifs in which the cave is contained.
112
   * If there is none, return an empty array.
113
   *
114
   * Note: this query routes through t_entrance to leverage the GiST index on
115
   * point_geom. A cave with no entrance will therefore return no massifs, which
116
   * is acceptable because every cave in the domain model must have at least one
117
   * entrance.
118
   *
119
   * @param {*} caveId
120
   * @returns [Massif]
121
   */
122
  getMassifs: async (caveId) => {
123
    try {
135✔
124
      const query = `
135✔
125
      SELECT DISTINCT m.*
126
      FROM t_massif AS m
127
      JOIN t_entrance AS e ON ST_Contains(m.geog_polygon::geometry, e.point_geom)
128
      WHERE e.id_cave = $1
129
      AND e.is_deleted = false
130
      AND m.is_deleted = false
131
    `;
132
      const queryResult = await CommonService.query(query, [caveId]);
135✔
133
      return queryResult.rows;
135✔
134
    } catch (e) {
135
      // Fail silently (happens when the point_geom is null for example)
UNCOV
136
      return [];
×
137
    }
138
  },
139

140
  /**
141
   *
142
   * @param
143
   * @returns {Object} the cumulated length of the caves present in the database whose value length is not null
144
   *                and the number of data on which this value is calculated
145
   *                or null if no result or something went wrong
146
   */
147
  getCumulatedLength: async () => {
148
    try {
2✔
149
      const queryResult = await CommonService.query(GET_CUMULATED_LENGTH, []);
2✔
150
      const result = queryResult.rows;
2✔
151
      if (result.length > 0) {
2!
152
        return result[0];
2✔
153
      }
UNCOV
154
      return null;
×
155
    } catch (e) {
UNCOV
156
      return null;
×
157
    }
158
  },
159

160
  /**
161
   * Get organizations that explored the cave
162
   * @param {number} caveId
163
   * @returns {Promise<Array>} Array of organizations with id and name
164
   */
165
  getExploringOrganizations: async (caveId) => {
166
    const query = `
60✔
167
      SELECT g.*
168
      FROM t_grotto g
169
             JOIN j_grotto_cave_explorer j ON g.id = j.id_grotto
170
      WHERE j.id_cave = $1
171
        AND g.is_deleted = false
172
    `;
173
    const result = await CommonService.query(query, [caveId]);
60✔
174
    const grottos = result.rows;
60✔
175

176
    if (!grottos || grottos.length === 0) {
60✔
177
      return [];
25✔
178
    }
179

180
    await NameService.setNames(grottos, 'grotto');
35✔
181
    return grottos;
35✔
182
  },
183

184
  async getPopulatedCave(caveId, subEntitiesWhere = {}) {
28✔
185
    const cave = await TCave.findOne(caveId)
30✔
186
      .populate('author')
187
      .populate('reviewer')
188
      .populate('names')
189
      .populate('descriptions')
190
      .populate('entrances')
191
      .populate('documents');
192

193
    if (!cave) return null;
30✔
194

195
    [cave.massifs, cave.descriptions, cave.documents] = await Promise.all([
26✔
196
      module.exports.getMassifs(cave.id),
197
      DescriptionService.getCaveDescriptions(cave.id, subEntitiesWhere),
198
      DocumentService.getDocuments(cave.documents?.map((d) => d.id) ?? []),
13!
199
    ]);
200

201
    cave.exploringOrganizations =
26✔
202
      await module.exports.getExploringOrganizations(cave.id);
203

204
    const nameAsyncArr = [
26✔
205
      NameService.setNames(cave?.entrances, 'entrance'),
206
      NameService.setNames(cave?.massifs, 'massif'),
207
    ];
208
    if (cave.names.length === 0) {
26✔
209
      // As the name service will also get the entrance name if needed
210
      nameAsyncArr.push(NameService.setNames([cave], 'cave'));
5✔
211
    }
212
    await Promise.all(nameAsyncArr);
26✔
213

214
    // TODO What about other linked entities ?
215
    // - histories
216
    // - riggings
217
    // - comments
218
    // - partneringGrottos
219

220
    return cave;
26✔
221
  },
222

223
  async deleteInSearch(caveId) {
224
    await SearchService.deleteDocument('caves', caveId);
6✔
225
  },
226

227
  async updateInSearch(populatedCave) {
228
    const { names, ...c } = populatedCave;
10✔
229
    const cave = {
10✔
230
      id: c.id,
231
      dateInscription: c.dateInscription,
232
      dateReviewed: c.dateReviewed,
233
      authorId: c.author.id,
234
      author: c.author.nickname,
235
      reviewerId: c.reviewer?.id,
236
      reviewer: c.reviewer?.nickname,
237
      name: names[0].name,
238
      language: names[0].language,
239
      depth: c.depth,
240
      length: c.caveLength,
241
      temperature: c.temperature,
242
      isDiving: c.isDiving,
243
    };
244
    await SearchService.updateDocument('caves', cave);
10✔
245
  },
246

247
  async permanentlyDeleteCave(cave, shouldMergeInto, mergeIntoId) {
248
    await TCave.update({ redirectTo: cave.id }).set({
8✔
249
      redirectTo: shouldMergeInto ? mergeIntoId : null,
8✔
250
    });
251
    await TNotification.destroy({ cave: cave.id });
8✔
252

253
    if (cave.documents.length > 0) {
8✔
254
      if (shouldMergeInto) {
2✔
255
        const newDocuments = cave.documents.map((e) => e.id);
1✔
256
        await TCave.addToCollection(mergeIntoId, 'documents', newDocuments);
1✔
257
      }
258
      await HDocument.update({ cave: cave.id }).set({ cave: null });
2✔
259
      await TCave.updateOne(cave.id).set({ documents: [] });
2✔
260
    }
261

262
    if (cave.entrances.length > 0 && shouldMergeInto) {
8✔
263
      const newEntrances = cave.entrances.map((e) => e.id);
1✔
264
      await TCave.addToCollection(mergeIntoId, 'entrances', newEntrances);
1✔
265
    }
266
    await HEntrance.update({ cave: cave.id }).set({ cave: null });
8✔
267

268
    if (cave.descriptions.length > 0) {
8✔
269
      if (shouldMergeInto) {
2✔
270
        await TDescription.update({ cave: cave.id }).set({
1✔
271
          cave: mergeIntoId,
272
        });
273
        await HDescription.update({ cave: cave.id }).set({
1✔
274
          cave: mergeIntoId,
275
        });
276
      } else {
277
        await TDescription.destroy({ cave: cave.id }); // TDescription first soft delete
1✔
278
        await HDescription.destroy({ cave: cave.id });
1✔
279
        await TDescription.destroy({ cave: cave.id });
1✔
280
      }
281
    }
282

283
    await NameService.permanentDelete({ cave: cave.id });
8✔
284

285
    await HCave.destroy({ id: cave.id });
8✔
286
    await TCave.destroyOne({ id: cave.id }); // Hard delete
8✔
287
  },
288
};
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