• 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

86.43
/api/services/DocumentService.js
1
const DescriptionService = require('./DescriptionService');
6✔
2
const SearchService = require('./SearchService');
6✔
3
const FileService = require('./FileService');
6✔
4
const NameService = require('./NameService');
6✔
5
const NotificationService = require('./NotificationService');
6✔
6
const RecentChangeService = require('./RecentChangeService');
6✔
7
const {
8
  valIfTruthyOrNull,
9
  distantFileDownload,
10
} = require('../utils/csvHelper');
6✔
11

12
module.exports = {
6✔
13
  async deleteInSearch(documentId) {
14
    await SearchService.deleteDocument('documents', documentId);
3✔
15
  },
16

17
  async updateInSearch(populatedDocument) {
18
    // Warning: All linked entities may contain sensitive information (same as in entrance).
19
    // For example, the complete caver object for the 'author' and 'reviewer' fields.
20
    // Although we could leave them intact, since search results also pass through the converter,
21
    // We prefer to clean them to ensure only clean data remains in the search database.
22
    const {
23
      authors,
24
      descriptions,
25
      subjects,
26
      countries,
27
      isoRegions,
28
      editor,
29
      library,
30
      massifs,
31
      entrance,
32
      cave,
33
      parent,
34
      ...d
35
    } = populatedDocument;
10✔
36
    const document = {
10✔
37
      id: d.id,
38
      importId: d.idDbImport,
39
      identifier: d.identifier,
40
      identifierType: d.identifierType?.id?.trim(),
41
      importSource: d.nameDbImport,
42
      dateInscription: d.dateInscription,
43
      dateReviewed: d.dateReviewed,
44
      dateValidation: d.dateValidation,
45
      creatorId: d.author.id,
46
      creator: d.author.nickname,
47
      creatorComment: d.creatorComment,
48
      reviewerId: d.reviewer?.id,
49
      reviewer: d.reviewer?.nickname,
50
      validatorId: d.validator?.id,
51
      validator: d.validator?.nickname,
52
      type: d.type?.name,
53
      title: descriptions?.[0]?.title,
54
      description: descriptions?.[0]?.body,
55
      issue: d.issue,
56
      pages: d.pages,
57
      license: d.license?.name,
58
      parent: parent && {
11✔
59
        type: parent.type?.name,
60
        title: parent.descriptions?.[0]?.title,
61
        descriptions: parent.descriptions?.[0]?.body,
62
      },
63
      editor: editor && { name: editor.names?.[0]?.name },
11✔
64
      library: library && { name: library.names?.[0]?.name },
11✔
65
      authors: authors?.map((e) => ({ nickname: e.nickname })),
1✔
66
      subjects: subjects?.map((e) => ({ code: e.id })),
1✔
67
      iso3166: [
68
        ...(countries?.map((e) => ({ iso: e.id, name: e.nativeName })) ?? []),
1✔
69
        ...(isoRegions?.map((e) => ({ iso: e.id, name: e.name })) ?? []),
1✔
70
      ],
71
      cave: cave && { name: cave.names?.[0]?.name },
11✔
72
      entrance: entrance && { name: entrance.names?.[0]?.name },
11✔
73
      massifs: massifs?.map((e) => ({ name: e.names?.[0]?.name })),
1✔
74
    };
75
    await SearchService.updateDocument('documents', document);
10✔
76
  },
77

78
  getDescriptionDataFromClient: (body, authorId) => ({
7✔
79
    author: authorId,
80
    body: body.description,
81
    title: body.title,
82
    language: body.mainLanguage,
83
  }),
84

85
  getChangedFileFromClient: (fileObjectArray) =>
86
    fileObjectArray.map((e) => ({
7✔
87
      id: e.id,
88
      fileName: e.fileName,
89
    })),
90

91
  // Extract everything from the request body except id and dateInscription
92
  // Used when creating or editing an existing document
93
  getConvertedDataFromClient: async (body) => {
94
    // Massif will be deleted in the future (a document can be about many massifs and a massif can be the subject of many documents): use massifs
95
    const massif = body.massif?.id;
15✔
96
    const massifs = [...(body.massifs ?? []), ...(massif ? [massif] : [])];
15✔
97

98
    let optionFound;
99
    // eslint-disable-next-line no-param-reassign
100
    if (body.option) optionFound = await TOption.findOne({ name: body.option });
15✔
101
    let typeFound;
102
    if (body.type) typeFound = await TType.findOne({ name: body.type });
15✔
103

104
    return {
15✔
105
      identifier: body.identifier,
106
      identifierType: body.identifierType?.id,
107

108
      // dateInscription is added only at document creation
109
      // dateReviewed will be updated automaticly by the SQL historisation trigger
110
      datePublication: valIfTruthyOrNull(body.datePublication),
111
      // author are added only at document creation (done after if needed)
112
      authors: body.authors?.map((a) => a.id),
4✔
113
      authorsGrotto: body.authorsGrotto?.map((a) => a.id),
1✔
114
      editor: body.editor?.id,
115
      library: body.library?.id,
116
      authorComment: body.creatorComment,
117

118
      type: typeFound?.id,
119
      // descriptions is changed independently
120
      subjects: body.subjects?.map((s) => s.id ?? s.code),
4✔
121
      issue: valIfTruthyOrNull(body.issue),
122
      pages: valIfTruthyOrNull(body.pages),
123
      license: body.license?.id ?? 1,
29✔
124
      option: optionFound?.id,
125
      languages: body.mainLanguage ? [body.mainLanguage] : [],
15✔
126
      // massif, // Deprecated, use massifs instead
127
      massifs,
128
      // cave is linked with the cave/add-document controller
129
      // entrance is linked with the entrance/add-document controller
130
      // files changes are handled independently
131
      // regions: body.regions?.map((r) => r.id), // Deprecated
132
      isoRegions: body.iso3166?.map((s) => s.iso)?.filter((e) => e.length > 2),
2✔
133
      countries: body.iso3166?.map((s) => s.iso)?.filter((e) => e.length <= 2),
2✔
134
      parent: body.parent?.id,
135
      // children cannot be set. The parent child relation can only be changed in one direction
136
      authorizationDocument: body.authorizationDocument?.id,
137
    };
138
  },
139

140
  appendPopulateForSimpleDocument: (docQuery) => {
141
    docQuery
37✔
142
      .populate('identifierType')
143
      .populate('author')
144
      .populate('authors')
145
      .populate('authorsGrotto')
146
      .populate('reviewer')
147
      .populate('validator')
148
      .populate('editor')
149
      .populate('library')
150
      .populate('type')
151
      .populate('descriptions')
152
      .populate('subjects')
153
      .populate('license')
154
      .populate('languages')
155
      .populate('option')
156
      // .populate('massif') // deprecated, replaced by countries and isoRegions
157
      .populate('countries')
158
      .populate('isoRegions')
159
      // .populate('massif') // deprecated, replaced by massifs
160
      .populate('massifs')
161
      .populate('files', { where: { isValidated: true } });
162
    return docQuery;
37✔
163
  },
164

165
  appendPopulateForFullDocument: (docQuery) => {
166
    module.exports
30✔
167
      .appendPopulateForSimpleDocument(docQuery)
168
      .populate('cave')
169
      .populate('entrances');
170
    // .populate('parent') // resolved in populateFullDocumentSubEntities()
171
    // .populate('children') // resolved in populateFullDocumentSubEntities()
172
    // .populate('authorizationDocument'); // resolved in populateFullDocumentSubEntities()
173

174
    return docQuery;
30✔
175
  },
176

177
  // Set name of cave, entrance, massif, editor and library if present
178
  populateFullDocumentSubEntities: async (document) => {
179
    const asyncQueue = [];
26✔
180

181
    // eslint-disable-next-line no-param-reassign
182
    document.mainLanguage = module.exports.getMainLanguage(document.languages);
26✔
183

184
    if (document.entrances && document.entrances.length > 0) {
26!
UNCOV
185
      asyncQueue.push(NameService.setNames(document.entrances, 'entrance'));
×
186
    }
187
    if (document.cave) {
26✔
188
      asyncQueue.push(NameService.setNames([document.cave], 'cave'));
1✔
189
    }
190
    const allMassifs = document.massifs;
26✔
191
    if (allMassifs.length > 0) {
26✔
192
      asyncQueue.push(NameService.setNames(allMassifs, 'massif'));
2✔
193
    }
194
    const allGrottos = [];
26✔
195
    if (document.library) allGrottos.push(document.library);
26!
196
    if (document.editor) allGrottos.push(document.editor);
26✔
197
    if (document.authorsGrotto) allGrottos.push(...document.authorsGrotto);
26!
198
    if (allGrottos.length > 0) {
26✔
199
      asyncQueue.push(NameService.setNames(allGrottos, 'grotto'));
5✔
200
    }
201

202
    async function resolveDocument(doc, key) {
203
      // eslint-disable-next-line no-param-reassign
204
      doc[key] = (await module.exports.getDocuments([doc[key]]))[0];
1✔
205
    }
206

207
    if (document.parent) asyncQueue.push(resolveDocument(document, 'parent'));
26✔
208
    if (document.authorizationDocument)
26!
209
      asyncQueue.push(resolveDocument(document, 'authorizationDocument'));
×
210

211
    await Promise.all(asyncQueue);
26✔
212

213
    return document;
26✔
214
  },
215

216
  async getPopulatedDocument(documentId) {
217
    const doc = await module.exports.appendPopulateForFullDocument(
28✔
218
      TDocument.findOne(documentId)
219
    );
220
    if (!doc) return null;
28✔
221
    await module.exports.populateFullDocumentSubEntities(doc);
23✔
222
    return doc;
23✔
223
  },
224

225
  /**
226
   * Depending on the number of languages, return the document main language.
227
   * @param {TLanguage[]} languages
228
   * @returns main language of the document
229
   */
230
  getMainLanguage: (languages) => {
231
    if (!languages) return undefined;
38✔
232
    if (languages.length === 0) return undefined;
37✔
233
    if (languages.length === 1) return languages[0];
2✔
234
    return languages.filter((l) => l.isMain);
2✔
235
  },
236

237
  async updateDocument({
×
238
    documentId,
239
    reviewerId,
240
    documentData,
241
    descriptionData,
242
    newFiles,
243
    modifiedFiles,
244
    deletedFiles,
245
  } = {}) {
246
    return TDocument.updateOne(documentId).set({
4✔
247
      dateReviewed: new Date(), // Avoid an uniqueness error
248
      isValidated: false,
249
      dateValidation: null,
250
      modifiedDocJson: {
251
        reviewerId,
252
        documentData,
253
        descriptionData,
254
        newFiles,
255
        modifiedFiles,
256
        deletedFiles,
257
      },
258
    });
259
  },
260

261
  createDocument: async (
262
    req,
263
    documentData,
264
    descriptionData,
265
    shouldDownloadDistantFile = false
6✔
266
  ) => {
267
    // Doc types needing a parent in order to be created
268
    // (ex; an issue needs a collection, an article needs an issue)
269
    const MANDATORY_PARENT_TYPES = ['article', 'issue'];
7✔
270

271
    const document = await sails.getDatastore().transaction(async (db) => {
7✔
272
      // Perform some checks
273
      const docType =
274
        documentData.type && (await TType.findOne(documentData.type));
7✔
275
      if (docType) {
7✔
276
        const docTypeName = docType.name.toLowerCase();
4✔
277
        // Parent doc is mandatory for articles and issues
278
        if (
4✔
279
          MANDATORY_PARENT_TYPES.includes(docTypeName) &&
5✔
280
          !documentData.parent
281
        ) {
282
          throw Error(
1✔
283
            `Your document being an ${docType.name.toLowerCase()}, you must provide a document parent.`
284
          );
285
        }
286
      }
287

288
      const createdDocument = await TDocument.create(documentData)
6✔
289
        .fetch()
290
        .usingConnection(db);
291

292
      await TDescription.create({
6✔
293
        dateInscription: descriptionData.dateInscription ?? new Date(),
12✔
294
        dateReviewed: descriptionData?.dateReviewed,
295
        author: descriptionData.author,
296
        title: descriptionData.title,
297
        body: descriptionData.body,
298
        document: createdDocument.id,
299
        language: descriptionData.language,
300
      }).usingConnection(db);
301

302
      return createdDocument;
6✔
303
    });
304

305
    await RecentChangeService.setNameCreate(
6✔
306
      'document',
307
      document.id,
308
      req.token.id,
309
      descriptionData.title
310
    );
311

312
    const populatedDocuments = await module.exports.getDocuments([document.id]);
6✔
313
    const populatedDocument = populatedDocuments[0];
6✔
314

315
    const documentType = populatedDocument?.identifierType?.id?.trim() ?? '';
6✔
316
    if (documentType === 'url' && shouldDownloadDistantFile) {
6!
317
      const url = populatedDocument.identifier;
×
318
      sails.log.info(`Downloading ${url}...`);
×
319
      const acceptedFileFormats = await TFileFormat.find();
×
320
      const allowedExtentions = acceptedFileFormats.map((f) =>
×
321
        f.extension.trim()
×
322
      );
323

324
      const file = await distantFileDownload({
×
325
        url,
326
        allowedExtentions,
327
      }).catch((err) => {
328
        sails.log.error(`Failed to download ${url}: ${err}`);
×
329
      });
330

331
      if (file) {
×
332
        await FileService.document.create(file, document.id);
×
333
      }
334
    }
335

336
    await NotificationService.notifySubscribers(
6✔
337
      req,
338
      populatedDocument,
339
      req.token.id,
340
      NotificationService.NOTIFICATION_TYPES.CREATE,
341
      NotificationService.NOTIFICATION_ENTITIES.DOCUMENT
342
    );
343

344
    return populatedDocument;
6✔
345
  },
346

347
  /**
348
   * Populate document-like object for a csv duplicate import or a modified document
349
   * Mainly used for json column that cannot be populated using waterline query language.
350
   * @param {*} documentData format from getConvertedDataFromClient()
351
   * @returns populated document
352
   */
353
  populateJSON: async (documentId, documentData) => {
354
    const {
355
      identifierType,
356
      author,
357
      authors,
358
      authorsGrotto,
359
      reviewer,
360
      editor,
361
      library,
362
      type,
363
      subjects,
364
      license,
365
      option,
366
      languages,
367
      countries,
368
      isoRegions,
369
      cave,
370
      entrances,
371
      massifs,
372
      parent,
373
      authorizationDocument,
374
      ...otherSimpleData
375
    } = documentData;
4✔
376

377
    // Join the tables
378
    const doc = { ...otherSimpleData, id: documentId };
4✔
379
    doc.identifierType = identifierType
4!
380
      ? await TIdentifierType.findOne(identifierType)
381
      : null;
382
    doc.author = author ? await TCaver.findOne(author) : null;
4✔
383
    doc.authors = authors ? await TCaver.find({ id: authors }) : [];
4✔
384
    doc.authorsGrotto = authorsGrotto
4!
385
      ? await TGrotto.find({ id: authorsGrotto })
386
      : [];
387
    doc.reviewer = reviewer ? await TCaver.findOne(reviewer) : null;
4!
388
    doc.editor = editor ? await TGrotto.findOne(editor) : null;
4!
389
    doc.library = library ? await TGrotto.findOne(library) : null;
4!
390

391
    doc.type = type ? await TType.findOne(type) : null;
4✔
392
    // descriptions is a special case
393
    doc.subjects = subjects ? await TSubject.find({ id: subjects }) : [];
4!
394
    doc.license = license ? await TLicense.findOne(license) : null;
4!
395
    doc.option = option ? await TOption.findOne(option) : null;
4!
396
    doc.languages = languages ? await TLanguage.find({ id: languages }) : [];
4✔
397

398
    // TODO files ?
399
    doc.countries = countries ? await TCountry.find({ id: countries }) : [];
4✔
400
    doc.isoRegions = isoRegions ? await TISO31662.find({ id: isoRegions }) : [];
4!
401
    doc.cave = cave ? await TCave.findOne(cave) : null;
4!
402
    doc.entrances = entrances ? await TEntrance.find({ id: entrances }) : [];
4!
403
    doc.massifs = massifs ? await TCountry.find({ id: massifs }) : [];
4!
404
    doc.parent = parent
4!
405
      ? (await module.exports.getDocuments([parent]))[0]
406
      : null;
407
    doc.authorizationDocument = authorizationDocument
4!
408
      ? await TDocument.findOne(authorizationDocument)
409
      : null;
410
    return doc;
4✔
411
  },
412

413
  /**
414
   * Get basic informations for a list of document ids
415
   * The result is intended to be passed to the toSimpleDocument converter
416
   * @param {Array} documentIds
417
   * @returns
418
   */
419
  getDocuments: async (documentIds) => {
420
    if (documentIds.length === 0) return [];
96✔
421
    return TDocument.find({ id: documentIds })
48✔
422
      .populate('descriptions')
423
      .populate('type')
424
      .populate('files', { where: { isValidated: true } });
425
  },
426

427
  getDocumentChildren: async (documentId) =>
428
    TDocument.find({ parent: documentId })
2✔
429
      .populate('descriptions')
430
      .populate('type'),
431

432
  getHDocumentById: async (documentId) =>
433
    HDocument.find({ t_id: documentId })
2✔
434
      .populate('author')
435
      .populate('reviewer')
436
      .populate('massif')
437
      .populate('cave')
438
      .populate('editor')
439
      .populate('identifierType')
440
      .populate('library')
441
      .populate('license')
442
      .populate('type'),
443

444
  populateHDocumentsWithDescription: async (documentId, hDocuments) => {
445
    const descriptions =
446
      await DescriptionService.getHDescriptionsOfDocument(documentId);
5✔
447
    hDocuments.forEach((document) => {
5✔
448
      if (Object.keys(descriptions).length > 0) {
5✔
449
        // eslint-disable-next-line no-param-reassign
450
        document.description = descriptions[0];
4✔
451
        descriptions.forEach((desc) => {
4✔
452
          if (
7✔
453
            // Return true if the description should be associate to this document according to her dateReviewed
454
            DescriptionService.compareDescriptionDate(
455
              // Id represents here the dateReviewed like in the H models
456
              new Date(document.id),
457
              new Date(desc.id),
458
              new Date(document.description.id)
459
            )
460
          ) {
461
            // eslint-disable-next-line no-param-reassign
462
            document.description = desc;
1✔
463
          }
464
        });
465
      }
466
    });
467
    return hDocuments;
5✔
468
  },
469

470
  getIdDocumentByEntranceId: async (entranceId) => {
471
    if (!entranceId) return [];
6✔
472

473
    const entrance = await TEntrance.findOne(entranceId).populate('documents');
5✔
474
    if (!entrance) return [];
5!
475

476
    return entrance.documents.map((doc) => ({ id: doc.id }));
5✔
477
  },
478

479
  getCollectionAncestors: async (documentIds) => {
480
    if (documentIds.length === 0) return [];
21✔
481

482
    const query = `
4✔
483
      WITH RECURSIVE doc_hierarchy AS (
484
        SELECT id, id_parent, id_type
485
        FROM t_document 
486
        WHERE id = ANY($1)
487
        
488
        UNION ALL
489
        
490
        SELECT d.id, d.id_parent, d.id_type
491
        FROM t_document d
492
        JOIN doc_hierarchy dh ON d.id = dh.id_parent
493
      )
494
      SELECT DISTINCT dh.id
495
      FROM doc_hierarchy dh
496
      JOIN t_type t ON dh.id_type = t.id
497
      WHERE t.name = 'Collection'
498
    `;
499

500
    const result = await sails.sendNativeQuery(query, [documentIds]);
4✔
501
    const collectionIds = result.rows.map((row) => row.id);
4✔
502

503
    return module.exports.getDocuments(collectionIds);
4✔
504
  },
505
};
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