@dmptool/utils 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/maDMP.js ADDED
@@ -0,0 +1,982 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateDMPToolExtensions = exports.validateRDACommonStandard = void 0;
4
+ exports.planToDMPCommonStandard = planToDMPCommonStandard;
5
+ const jsonschema_1 = require("jsonschema");
6
+ const rds_1 = require("./rds");
7
+ const general_1 = require("./general");
8
+ const types_1 = require("@dmptool/types");
9
+ const maDMPTypes_1 = require("./maDMPTypes");
10
+ const ROR_REGEX = /^https?:\/\/ror\.org\/[0-9a-zA-Z]+$/;
11
+ const DOI_REGEX = /^(https?:\/\/)?(doi\.org\/)?(doi:)?(10\.\d{4,9}\/[-._;()/:\w]+)$/;
12
+ const ORCID_BASE_URL = process.env.ENV && ['stg', 'prd'].includes(process.env.ENV)
13
+ ? 'https://orcid.org/'
14
+ : 'https://sandbox.orcid.org/';
15
+ const ORCID_REGEX = /^(https?:\/\/)?(www\.|pub\.)?(sandbox\.)?(orcid\.org\/)?([0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X])$/;
16
+ class DMPValidationError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'DMPValidationError';
20
+ }
21
+ }
22
+ /**
23
+ * Ensure that the ORCID is in the correct format (https://orcid.org/0000-0000-0000-0000)
24
+ *
25
+ * @param orcidIn the ORCID to check
26
+ * @returns the ORCID in the correct format or null if it is not in the correct format
27
+ */
28
+ function formatORCID(orcidIn) {
29
+ // If it is blank or already in the correct format, return it
30
+ if (orcidIn && (orcidIn.match(ORCID_REGEX) && orcidIn.startsWith('http'))) {
31
+ return (0, general_1.normaliseHttpProtocol)(orcidIn);
32
+ }
33
+ // If it matches the ORCID format but didn't start with http then its just the id
34
+ if (orcidIn && orcidIn.match(ORCID_REGEX)) {
35
+ return (0, general_1.normaliseHttpProtocol)(`${ORCID_BASE_URL}${orcidIn.split('/').pop()}`);
36
+ }
37
+ // Otherwise it's not an ORCID
38
+ return null;
39
+ }
40
+ /**
41
+ * Determine the identifier type for the given URI string
42
+ *
43
+ * @param uri the URI string to check
44
+ * @returns the RDA Common Standard identifier type for the given URI string
45
+ */
46
+ function determineIdentifierType(uri) {
47
+ if ((0, general_1.isNullOrUndefined)(uri)) {
48
+ return 'other';
49
+ }
50
+ if (uri.match(ORCID_REGEX)) {
51
+ return 'orcid';
52
+ }
53
+ else if (uri.match(DOI_REGEX)) {
54
+ return 'doi';
55
+ }
56
+ else if (uri.match(ROR_REGEX)) {
57
+ return 'ror';
58
+ }
59
+ else if (uri.startsWith('http')) {
60
+ return 'url';
61
+ }
62
+ else {
63
+ return 'other';
64
+ }
65
+ }
66
+ /**
67
+ * Function to convert a PlanFunding status to an RDA Common Standard funding_status
68
+ *
69
+ * @param status the PlanFunding status to convert
70
+ * @returns the RDA Common Standard funding_status
71
+ */
72
+ // Helper function to convert a ProjectFundingStatus to a DMPFundingStatus
73
+ function planFundingStatusToDMPFundingStatus(status) {
74
+ switch (status) {
75
+ case 'DENIED':
76
+ return 'rejected';
77
+ case 'GRANTED':
78
+ return 'granted';
79
+ default:
80
+ return 'planned';
81
+ }
82
+ }
83
+ /**
84
+ * Function to convert a 5 character language code (e.g. en-US) to a 3 character code (e.g. eng)
85
+ *
86
+ * @param language the 5 character language code to convert
87
+ * @returns the 3 character language code
88
+ */
89
+ function convertFiveCharToThreeChar(language) {
90
+ switch (language) {
91
+ case 'pt-BR':
92
+ return 'por';
93
+ default:
94
+ return 'eng';
95
+ }
96
+ }
97
+ /**
98
+ * Function to generate the base of an internal ID namespace
99
+ *
100
+ * @param projectId the Project ID to use for the internal ID namespace
101
+ * @param planId the Plan ID to use for the internal ID namespace
102
+ * @returns the base of the internal ID namespace
103
+ */
104
+ function internalIdBase(projectId, planId) {
105
+ return `${process.env.APPLICATION_NAME}.projects.${projectId}.dmp.${planId}`;
106
+ }
107
+ /**
108
+ * Fetch the default MemberRole from the MySQL database
109
+ *
110
+ * @returns the default MemberRole as a string (or undefined if there is no default)
111
+ */
112
+ const loadDefaultMemberRole = async () => {
113
+ const sql = 'SELECT * FROM memberRoles WHERE isDefault = 1';
114
+ const resp = await (0, rds_1.queryTable)(sql, []);
115
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
116
+ return resp.results[0].id;
117
+ }
118
+ return undefined;
119
+ };
120
+ /**
121
+ * Fetches the Plan information needed to construct the DMP Common Standard from
122
+ * the MySQL database
123
+ *
124
+ * @param planId the Plan ID to fetch the Plan information for
125
+ * @returns the Plan information needed to construct the DMP Common Standard
126
+ */
127
+ // Fetch the Plan info needed from the MySQL database
128
+ const loadPlanInfo = async (planId) => {
129
+ const sql = `
130
+ SELECT id, dmpId, projectId, versionedTemplateId,
131
+ createdById, created, modifiedById, modified, title,
132
+ status, visibility, featured, registeredBy, registered,
133
+ languageId
134
+ FROM plans
135
+ WHERE id = ?
136
+ `;
137
+ const resp = await (0, rds_1.queryTable)(sql, [planId.toString()]);
138
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
139
+ return resp.results[0];
140
+ }
141
+ return undefined;
142
+ };
143
+ /**
144
+ * Fetches the Project information needed to construct the DMP Common Standard
145
+ * from the MySQL database
146
+ *
147
+ * @param projectId the Project ID to fetch the Project information for
148
+ * @returns the Project information needed to construct the DMP Common Standard
149
+ */
150
+ const loadProjectInfo = async (projectId) => {
151
+ const sql = `
152
+ SELECT id, title, abstractText, startDate, endDate
153
+ FROM projects
154
+ WHERE id = ?
155
+ `;
156
+ const resp = await (0, rds_1.queryTable)(sql, [projectId.toString()]);
157
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
158
+ return resp.results[0];
159
+ }
160
+ return undefined;
161
+ };
162
+ /**
163
+ * Fetches the PlanFunding information needed to construct the DMP Common Standard
164
+ * from the MySQL database
165
+ *
166
+ * @param planId the Plan ID to fetch the PlanFunding information for
167
+ * @returns the Funding information needed to construct the DMP Common Standard
168
+ */
169
+ const loadFundingInfo = async (planId) => {
170
+ const sql = `
171
+ SELECT pf.id, a.uri, a.name, prf.status, prf.grantId,
172
+ prf.funderProjectNumber, prf.funderOpportunityNumber
173
+ FROM planFundings pf
174
+ LEFT JOIN projectFundings prf ON pf.projectFundingId = prf.id
175
+ LEFT JOIN affiliations a ON prf.affiliationId = a.uri
176
+ WHERE pf.planId = ?
177
+ `;
178
+ const resp = await (0, rds_1.queryTable)(sql, [planId.toString()]);
179
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
180
+ const fundings = resp.results.filter((row) => !(0, general_1.isNullOrUndefined)(row));
181
+ fundings.forEach((funding) => funding.status = planFundingStatusToDMPFundingStatus(funding.status));
182
+ return resp.results;
183
+ }
184
+ return [];
185
+ };
186
+ /**
187
+ * Fetches the Plan's owner information needed to construct the DMP Common Standard
188
+ * from the MySQL database
189
+ *
190
+ * @param ownerId the user id for the plan's owner
191
+ * @returns the contact information needed to construct the DMP Common Standard
192
+ */
193
+ async function loadContactFromPlanOwner(ownerId) {
194
+ const sql = `
195
+ SELECT u.id, u.givenName, u.surName, u.orcid, a.uri, a.name,
196
+ (SELECT ue.email
197
+ FROM userEmails ue
198
+ WHERE ue.userId = u.id AND ue.isPrimary = 1 LIMIT 1) as email
199
+ FROM users u
200
+ LEFT JOIN affiliations a ON u.affiliationId = a.id
201
+ WHERE u.id = ?
202
+ `;
203
+ const resp = await (0, rds_1.queryTable)(sql, [ownerId.toString()]);
204
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
205
+ return resp.results.filter((row) => !(0, general_1.isNullOrUndefined)(row))[0];
206
+ }
207
+ return undefined;
208
+ }
209
+ /**
210
+ * Fetches the PlanMember information needed to construct the DMP Common Standard
211
+ * from the MySQL database
212
+ *
213
+ * @param planId the Plan ID to fetch the PlanMember information for
214
+ * @returns the contributor information needed to construct the DMP Common Standard
215
+ */
216
+ const loadMemberInfo = async (planId) => {
217
+ const sql = `
218
+ SELECT pc.id, a.uri, a.name, pctr.email, pctr.givenName, pctr.surName,
219
+ pctr.orcid, pc.isPrimaryContact, GROUP_CONCAT(r.uri) as roles
220
+ FROM planMembers pc
221
+ LEFT JOIN planMemberRoles pcr ON pc.id = pcr.planMemberId
222
+ LEFT JOIN memberRoles r ON pcr.memberRoleId = r.id
223
+ LEFT JOIN projectMembers pctr ON pc.projectMemberId = pctr.id
224
+ LEFT JOIN affiliations a ON pctr.affiliationId = a.uri
225
+ WHERE pc.planId = ?
226
+ GROUP BY a.uri, a.name, pctr.email, pctr.givenName, pctr.surName,
227
+ pctr.orcid, pc.isPrimaryContact;
228
+ `;
229
+ const resp = await (0, rds_1.queryTable)(sql, [planId.toString()]);
230
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
231
+ return resp.results.filter((row) => !(0, general_1.isNullOrUndefined)(row));
232
+ }
233
+ return [];
234
+ };
235
+ /**
236
+ * Returns a default RDA Common Standard Dataset entry for the DMP.
237
+ * This is used when the Plan has no Answers to a Research Outputs question.
238
+ *
239
+ * @param projectId the Project ID to use for the Dataset entry
240
+ * @param planId the Plan ID to use for the Dataset entry
241
+ * @returns a generic default Dataset entry
242
+ */
243
+ const defaultDataset = (projectId, planId) => {
244
+ return {
245
+ dataset_id: {
246
+ identifier: `${internalIdBase(projectId, planId)}.outputs.1`,
247
+ type: 'other'
248
+ },
249
+ personal_data: 'unknown',
250
+ sensitive_data: 'unknown',
251
+ title: 'Generic Dataset',
252
+ type: 'dataset'
253
+ };
254
+ };
255
+ /**
256
+ * Fetches the Dataset information needed to construct the DMP Common Standard
257
+ * from the MySQL database this information is extracted from the Answers table
258
+ * for Research Output Questions
259
+ *
260
+ * @param projectId the Project ID to fetch the Dataset information for
261
+ * @param planId the Plan ID to fetch the Dataset information for
262
+ * @param language the language to use for the Dataset information
263
+ * @returns the dataset information needed to construct the DMP Common Standard
264
+ */
265
+ const loadDatasetInfo = async (projectId, planId, language = 'eng') => {
266
+ const datasets = [];
267
+ const sql = `
268
+ SELECT a.json
269
+ FROM answers a
270
+ WHERE a.planId = ?
271
+ AND a.json LIKE '%"researchOutputsTable"%';
272
+ `;
273
+ const resp = await (0, rds_1.queryTable)(sql, [planId.toString()]);
274
+ // There would typically only be one research outputs question per plan but
275
+ // we need to allow for multiples just in case.
276
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
277
+ for (const result of resp.results) {
278
+ // Extract the column headings and the rows
279
+ const json = result.json ? JSON.parse(result.json) : {};
280
+ const lang = language === 'eng' ? 'eng' : convertFiveCharToThreeChar(language);
281
+ // Loop through the rows and construct the RDA Common Standard Dataset object
282
+ for (let idx = 0; idx < json.answer.length; idx++) {
283
+ const row = json.answer[idx];
284
+ datasets.push(buildDataset(idx, row, projectId, planId, lang));
285
+ }
286
+ }
287
+ }
288
+ else {
289
+ return [defaultDataset(projectId, planId)];
290
+ }
291
+ return datasets;
292
+ };
293
+ /**
294
+ * Builds the RDA Common Standard Related Identifier entries for the DMP
295
+ *
296
+ * @param projectId the Project ID to fetch the Related Works information for
297
+ * @returns the RDA Common Standard Related Identifier entries for the DMP
298
+ */
299
+ const loadRelatedWorksInfo = async (projectId) => {
300
+ const sql = `
301
+ SELECT w.doi AS identifier, LOWER(wv.workType) AS workType,
302
+ FROM relatedWorks rw
303
+ JOIN workVersions wv ON rw.workVersionId = wv.id
304
+ JOIN works w ON wv.workId = w.id
305
+ WHERE rw.projectId = ?;
306
+ `;
307
+ const resp = await (0, rds_1.queryTable)(sql, [projectId.toString()]);
308
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
309
+ const works = resp.results.filter((row) => !(0, general_1.isNullOrUndefined)(row));
310
+ // Determine the identifier types
311
+ return works.map((work) => {
312
+ var _a;
313
+ return {
314
+ relation_type: 'cites',
315
+ identifier: work.identifier,
316
+ type: determineIdentifierType(work.identifier),
317
+ resource_type: (_a = work.workType) === null || _a === void 0 ? void 0 : _a.toLowerCase(),
318
+ };
319
+ });
320
+ }
321
+ return [];
322
+ };
323
+ /**
324
+ * Builds the DMP Tool Narrative extension for the DMP
325
+ *
326
+ * @param planId the Plan ID to fetch the narrative information for
327
+ * @returns the DMP Tool Narrative extension for the DMP
328
+ */
329
+ const loadNarrativeTemplateInfo = async (planId) => {
330
+ // Fetch the template, sections, questions and answers all at once
331
+ const sql = `
332
+ SELECT t.id templateId, t.name templateTitle, t.description templateDescription,
333
+ t.version templateVersion,
334
+ s.id sectionId, s.name sectionTitle, s.introduction sectionDescription,
335
+ s.displayOrder sectionOrder, q.id questionId, q.questionText questionText,
336
+ q.displayOrder questionOrder, a.id answerId, a.json answerJSON
337
+ FROM plans p
338
+ INNER JOIN versionedTemplates t ON p.versionedTemplateId = t.id
339
+ LEFT JOIN versionedSections s ON s.versionedTemplateId = t.id
340
+ LEFT JOIN versionedQuestions q ON q.versionedSectionId = s.id
341
+ LEFT JOIN answers a ON a.versionedQuestionId = q.id AND p.id = a.planId
342
+ WHERE p.id = ?
343
+ ORDER BY s.displayOrder, q.displayOrder;
344
+ `;
345
+ const resp = await (0, rds_1.queryTable)(sql, [planId.toString()]);
346
+ let results = [];
347
+ // Filter out any null or undefined results
348
+ if (resp && Array.isArray(resp.results) && resp.results.length > 0) {
349
+ results = resp.results.filter((row) => !(0, general_1.isNullOrUndefined)(row));
350
+ }
351
+ if (!Array.isArray(results) || results.length === 0) {
352
+ return undefined;
353
+ }
354
+ // Sort the questions by display order
355
+ results[0].section.forEach((section) => {
356
+ section.question.sort((a, b) => a.questionOrder - b.questionOrder);
357
+ });
358
+ // Sort the sections by display order
359
+ results[0].section.sort((a, b) => {
360
+ return a.sectionOrder - b.sectionOrder;
361
+ });
362
+ return {
363
+ id: results[0].templateId,
364
+ title: results[0].templateTitle,
365
+ description: results[0].templateDescription,
366
+ version: results[0].templateVersion,
367
+ section: results[0].section.map((section) => {
368
+ return {
369
+ id: section.sectionId,
370
+ title: section.sectionTitle,
371
+ description: section.sectionDescription,
372
+ order: section.sectionOrder,
373
+ question: section.question.map((question) => {
374
+ return {
375
+ id: question.questionId,
376
+ order: question.questionOrder,
377
+ text: question.questionText,
378
+ answer: {
379
+ id: question.answerId,
380
+ json: question.answerJSON
381
+ }
382
+ };
383
+ })
384
+ };
385
+ })
386
+ };
387
+ };
388
+ /**
389
+ * Builds the RDA Common Standard Contact entry for the DMP
390
+ *
391
+ * @param plan the Plan information retrieve from the MySQL database
392
+ * @param members the PlanMembers information retrieve from the MySQL database
393
+ * @returns the RDA Common Standard Contact entry for the DMP
394
+ * @throws DMPValidationError if no primary contact is found for the DMP
395
+ */
396
+ const buildContact = async (plan, members) => {
397
+ // Extract the primary contact from the members
398
+ const memberContact = members.find((c) => {
399
+ return c === null || c === void 0 ? void 0 : c.isPrimaryContact;
400
+ });
401
+ // If no primary contact is available, use the plan owner
402
+ const primary = memberContact && memberContact.email
403
+ ? memberContact
404
+ : await loadContactFromPlanOwner(Number(plan.createdById));
405
+ if (primary && primary.email) {
406
+ const orcid = primary.orcid ? formatORCID(primary.orcid) : null;
407
+ // Build the contact entry for the DMP
408
+ const contactEntry = {
409
+ contact_id: [{
410
+ identifier: orcid !== null ? orcid : primary.email,
411
+ type: orcid !== null ? determineIdentifierType(orcid) : 'other',
412
+ }],
413
+ mbox: primary.email,
414
+ name: [primary.givenName, primary.surName].filter((n) => n).join(' ').trim(),
415
+ };
416
+ // Add the affiliation to the Contact if it exists
417
+ if (primary.name) {
418
+ const contactAffiliation = {
419
+ name: primary.name
420
+ };
421
+ // Add the URI if it exists
422
+ if (primary.uri) {
423
+ contactAffiliation.affiliation_id = {
424
+ identifier: primary.uri,
425
+ type: determineIdentifierType(primary.uri),
426
+ };
427
+ }
428
+ contactEntry.affiliation = [contactAffiliation];
429
+ }
430
+ return contactEntry;
431
+ }
432
+ else {
433
+ throw new DMPValidationError('Unable to find a primary contact for the DMP');
434
+ }
435
+ };
436
+ /**
437
+ * Builds the RDA Common Standard Contributor array for the DMP from the PlanMembers
438
+ *
439
+ * @param planId the Plan ID
440
+ * @param projectId the Project ID
441
+ * @param members the PlanMembers information retrieve from the MySQL database
442
+ * @param defaultRole the default role to use if the member doesn't have a role'
443
+ * @returns the RDA Common Standard Contributor array for the DMP
444
+ */
445
+ const buildContributors = (planId, projectId, members, defaultRole) => {
446
+ if (!Array.isArray(members) || members.length <= 0) {
447
+ return [];
448
+ }
449
+ return members.map((member) => {
450
+ // Make sure that we always have roles as an array
451
+ const roles = member.roles && member.roles.includes('[')
452
+ ? JSON.parse(member.roles)
453
+ : [defaultRole];
454
+ // Combine the member's given name and surname into a single name'
455
+ const contrib = {
456
+ name: [member.givenName, member.surName]
457
+ .filter((n) => n).join(' ').trim(),
458
+ role: roles,
459
+ };
460
+ // Use the member's ORCID if it exists, otherwise generate a new one'
461
+ const formatted = member.orcid ? formatORCID(member.orcid) : null;
462
+ if (formatted !== null) {
463
+ contrib.contributor_id = [{
464
+ identifier: formatted,
465
+ type: determineIdentifierType(formatted)
466
+ }];
467
+ }
468
+ else {
469
+ // RDA Common Standard requires an id so generate one
470
+ contrib.contributor_id = [{
471
+ identifier: `${internalIdBase(projectId, planId)}.members.${member.id}`,
472
+ type: 'other'
473
+ }];
474
+ }
475
+ // Add the affiliation to the Contributor if it exists
476
+ if (member.name && member.uri) {
477
+ contrib.affiliation = [{
478
+ name: member.name,
479
+ affiliation_id: {
480
+ identifier: member.uri,
481
+ type: determineIdentifierType(member.uri),
482
+ }
483
+ }];
484
+ }
485
+ return contrib;
486
+ });
487
+ };
488
+ /**
489
+ * Builds the DMP Tool extensions to the RDA Common Standard
490
+ *
491
+ * @param plan the Plan information retrieve from the MySQL database
492
+ * @param project the Project information retrieve from the MySQL database
493
+ * @param funding the Funding information retrieve from the MySQL database
494
+ * @returns the DMP metadata with extensions from the DMP Tool
495
+ */
496
+ const buildDMPToolExtensions = async (plan, project, funding) => {
497
+ var _a, _b, _c, _d, _e, _f;
498
+ const extensions = {
499
+ rda_schema_version: types_1.RDA_COMMON_STANDARD_VERSION,
500
+ // Ignoring the `!` assertion here because we know we check the env variable
501
+ // when the entrypoint function is called.
502
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
503
+ provenance: process.env.APPLICATION_NAME,
504
+ featured: plan.featured ? 'yes' : 'no',
505
+ privacy: (_b = (_a = plan.visibility) === null || _a === void 0 ? void 0 : _a.toLowerCase()) !== null && _b !== void 0 ? _b : 'private',
506
+ status: (_d = (_c = plan.status) === null || _c === void 0 ? void 0 : _c.toLowerCase()) !== null && _d !== void 0 ? _d : 'draft',
507
+ };
508
+ // Generate the DMP Narrative
509
+ const narrative = await loadNarrativeTemplateInfo(plan.id);
510
+ // Fetch the research domain if one was specified
511
+ const research_domain = project.dmptool_research_domain
512
+ ? { name: project.dmptool_research_domain }
513
+ : undefined;
514
+ let funderProject = undefined;
515
+ let funderOpportunity = undefined;
516
+ if (funding) {
517
+ const projectId = {
518
+ identifier: internalIdBase(project.id, plan.id),
519
+ type: maDMPTypes_1.StandardIdentifierType.OTHER
520
+ };
521
+ const funderId = funding.uri === undefined
522
+ ? {
523
+ identifier: `${internalIdBase(project.id, plan.id)}.fundings.${funding.id}`,
524
+ type: maDMPTypes_1.StandardIdentifierType.OTHER
525
+ }
526
+ : {
527
+ identifier: funding.uri,
528
+ type: funding.uri.match(ROR_REGEX)
529
+ ? maDMPTypes_1.StandardIdentifierType.ROR
530
+ : maDMPTypes_1.StandardIdentifierType.URL
531
+ };
532
+ // Define the Funder's project number if applicable. project_id and funder_id
533
+ // are used to help tie the project_identifier to the correct
534
+ // project[?].funding[?] in the RDA Common Standard.
535
+ if (funding.funderProjectNumber !== undefined) {
536
+ funderProject = {
537
+ project_id: projectId,
538
+ funder_id: funderId,
539
+ project_identifier: {
540
+ identifier: funding.funderProjectNumber,
541
+ type: ((_e = funding.funderProjectNumber) === null || _e === void 0 ? void 0 : _e.startsWith('http'))
542
+ ? maDMPTypes_1.StandardIdentifierType.URL
543
+ : maDMPTypes_1.StandardIdentifierType.OTHER
544
+ }
545
+ };
546
+ }
547
+ // Define the Funder's opportunity number if applicable. project_id and
548
+ // funder_id are used to help tie the opportunity_identifier to the correct
549
+ // project[?].funding[?] in the RDA Common Standard.
550
+ if (funding.funderOpportunityNumber !== undefined) {
551
+ funderOpportunity = {
552
+ project_id: projectId,
553
+ funder_id: funderId,
554
+ opportunity_identifier: {
555
+ identifier: funding.funderOpportunityNumber,
556
+ type: ((_f = funding.funderOpportunityNumber) === null || _f === void 0 ? void 0 : _f.startsWith('http'))
557
+ ? maDMPTypes_1.StandardIdentifierType.URL
558
+ : maDMPTypes_1.StandardIdentifierType.OTHER
559
+ }
560
+ };
561
+ }
562
+ }
563
+ // Only add these properties if they have values we don't want 'undefined' or
564
+ // 'null' in the JSON
565
+ const regDate = (0, general_1.convertMySQLDateTimeToRFC3339)(plan.registered);
566
+ if (!(0, general_1.isNullOrUndefined)(plan.registered) && regDate !== null) {
567
+ extensions.registered = regDate;
568
+ }
569
+ if (!(0, general_1.isNullOrUndefined)(project.dmptool_research_domain) && research_domain !== undefined) {
570
+ extensions.research_domain = research_domain;
571
+ }
572
+ if (funderProject !== undefined) {
573
+ extensions.funding_project = [funderProject];
574
+ }
575
+ if (funderOpportunity !== undefined) {
576
+ extensions.funding_opportunity = [funderOpportunity];
577
+ }
578
+ if (!(0, general_1.isNullOrUndefined)(narrative)) {
579
+ extensions.narrative = {
580
+ download_url: `https://${process.env.DOMAIN_NAME}/dmps/${plan.dmpId}/narrative`,
581
+ template: narrative
582
+ };
583
+ }
584
+ return extensions;
585
+ };
586
+ /**
587
+ * Builds the Project and Funding info for the RDA Common Standard
588
+ *
589
+ * @param planId the Plan ID
590
+ * @param project the Project information retrieve from the MySQL database
591
+ * @param funding the Funding information retrieve from the MySQL database
592
+ * @returns the Project and Funding info for the RDA Common Standard
593
+ */
594
+ const buildProject = (planId, project, funding) => {
595
+ var _a, _b, _c, _d, _e;
596
+ if ((0, general_1.isNullOrUndefined)(project)) {
597
+ return undefined;
598
+ }
599
+ let fundingObject = undefined;
600
+ if (funding && funding.name && funding.status) {
601
+ const grantIdObject = funding.grantId
602
+ ? {
603
+ identifier: funding.grantId,
604
+ type: ((_a = funding.grantId) === null || _a === void 0 ? void 0 : _a.startsWith('http')) ? 'url' : 'other'
605
+ }
606
+ : undefined;
607
+ // RDA Common Standard requires the funder id to be present
608
+ const funderIdObject = funding.uri
609
+ ? {
610
+ identifier: funding.uri,
611
+ type: ((_b = (funding).uri) === null || _b === void 0 ? void 0 : _b.match(ROR_REGEX)) ? 'ror' : 'url'
612
+ }
613
+ : {
614
+ identifier: `${internalIdBase(project.id, planId)}.fundings.${funding.id}`,
615
+ type: 'other'
616
+ };
617
+ // The RDA Common Standard requires the funder name and status to be present
618
+ fundingObject = !(0, general_1.isNullOrUndefined)(funding)
619
+ ? [{
620
+ name: (funding).name,
621
+ funding_status: planFundingStatusToDMPFundingStatus((funding).status),
622
+ grant_id: grantIdObject,
623
+ funder_id: funderIdObject
624
+ }]
625
+ : undefined;
626
+ if (grantIdObject !== undefined && fundingObject !== undefined) {
627
+ fundingObject[0].grant_id = grantIdObject;
628
+ }
629
+ }
630
+ return {
631
+ title: project.title,
632
+ description: (_c = project.abstractText) !== null && _c !== void 0 ? _c : undefined,
633
+ start: (_d = project.startDate) !== null && _d !== void 0 ? _d : undefined,
634
+ end: (_e = project.endDate) !== null && _e !== void 0 ? _e : undefined,
635
+ project_id: [{
636
+ identifier: internalIdBase(project.id, planId),
637
+ type: 'other'
638
+ }],
639
+ funding: fundingObject
640
+ };
641
+ };
642
+ /**
643
+ * Extracts the specified column from the columns of a ResearchOutputTable answer row.
644
+ * @param id the ID of the column to extract
645
+ * @param columns the columns of the answer row
646
+ * @returns the specified column if it exists, otherwise undefined
647
+ */
648
+ const findColumnById = (id, columns) => {
649
+ return columns.find((col) => {
650
+ return (col === null || col === void 0 ? void 0 : col.commonStandardId) === id;
651
+ });
652
+ };
653
+ /**
654
+ * Converts the size + context into a byte size.
655
+ *
656
+ * @param size the NumberWithContextAnswerType
657
+ * (e.g. `{ answer: { context: 'GB', value: 5 } }`) to convert
658
+ * @returns the byte size if it could be converted, otherwise undefined
659
+ */
660
+ const byteSizeToBytes = (size) => {
661
+ var _a;
662
+ if ((0, general_1.isNullOrUndefined)(size) || (0, general_1.isNullOrUndefined)(size.answer.value)) {
663
+ return undefined;
664
+ }
665
+ const multipliers = {
666
+ 'TB': 1e12,
667
+ 'GB': 1e9,
668
+ 'MB': 1e6,
669
+ 'KB': 1e3,
670
+ };
671
+ const context = size.answer.context.toUpperCase();
672
+ // If the context has a match in our multipliers, use it, otherwise use 1 as a fallback
673
+ const multiplier = (_a = multipliers[context]) !== null && _a !== void 0 ? _a : 1;
674
+ return size.answer.value * multiplier;
675
+ };
676
+ /**
677
+ * Convert a @dmptool/types researchOutputTable answer row into an RDA Common
678
+ * Standard Dataset object.
679
+ *
680
+ * @param rowIdx the index of the answer row
681
+ * @param row the answer row
682
+ * @param projectId the ID of the project that the dataset belongs to
683
+ * @param planId the ID of the plan that the dataset belongs to
684
+ * @param language the language of the dataset (defaults to 'eng')
685
+ * @returns a RDA Common Standard Dataset object
686
+ */
687
+ const buildDataset = (rowIdx, row, projectId, planId, language = 'eng') => {
688
+ const title = findColumnById('title', row.columns);
689
+ const desc = findColumnById('description', row.columns);
690
+ const typ = findColumnById('type', row.columns);
691
+ const flags = findColumnById('data_flags', row.columns);
692
+ const access_date = findColumnById('issued', row.columns);
693
+ const access = findColumnById('data_access', row.columns);
694
+ const byte_size = findColumnById('byte_size', row.columns);
695
+ const host = findColumnById('host', row.columns);
696
+ const meta = findColumnById('metadata', row.columns);
697
+ const license = findColumnById('license_ref', row.columns);
698
+ // The large RDA Common Standard Research Output representation.
699
+ // Any properties that are commented out are ones that we do not currently support.
700
+ // Build the Metadata object from the Metadata Standards defined on the Research Output
701
+ const metadata = (0, general_1.isNullOrUndefined)(meta)
702
+ ? undefined
703
+ : meta.answer.map((m) => {
704
+ return {
705
+ description: m.metadataStandardName,
706
+ // RDA Common Standard requires the language, but we can't get it from
707
+ // the metadata standard repository we use, so just default it
708
+ language: 'eng',
709
+ metadata_standard_id: [{
710
+ identifier: m.metadataStandardId,
711
+ type: determineIdentifierType(m.metadataStandardId)
712
+ }]
713
+ };
714
+ });
715
+ // Get the Anticipated Release Date for the dataset
716
+ const issued = (0, general_1.isNullOrUndefined)(access_date) ? undefined : access_date.answer;
717
+ // Build the License object from the Licenses defined on the Research Output
718
+ const licenses = (0, general_1.isNullOrUndefined)(license)
719
+ ? undefined
720
+ : license.answer.map((l) => {
721
+ return {
722
+ license_ref: l.licenseId,
723
+ start_date: issued
724
+ };
725
+ });
726
+ // Build the Distribution object from the Repositories defined on the Research Output
727
+ const distribution = (0, general_1.isNullOrUndefined)(host)
728
+ ? undefined
729
+ : host.answer.map((h) => {
730
+ return {
731
+ title: (0, general_1.isNullOrUndefined)(title) ? `Dataset ${rowIdx + 1}` : title.answer,
732
+ // description: 'This is a test distribution',
733
+ // access_url: 'https://example.com/dataset/123/distribution/123456789',
734
+ // download_url: 'https://example.com/dataset/123/distribution/123456789/download',
735
+ byte_size: (0, general_1.isNullOrUndefined)(byte_size) ? undefined : byteSizeToBytes(byte_size),
736
+ // format: ['application/zip'],
737
+ data_access: (0, general_1.isNullOrUndefined)(access) ? 'restricted' : access.answer,
738
+ issued,
739
+ license: licenses,
740
+ host: {
741
+ title: h.repositoryName,
742
+ // description: 'This is a test host',
743
+ url: h.repositoryId,
744
+ host_id: [{
745
+ identifier: h.repositoryId,
746
+ type: determineIdentifierType(h.repositoryId)
747
+ }],
748
+ // availability: '99.99',
749
+ // backup_frequency: 'weekly',
750
+ // backup_type: 'tapes',
751
+ // certified_with: 'coretrustseal',
752
+ // geo_location: 'US',
753
+ // pid_system: ['doi', 'ark'],
754
+ // storage_type: 'LTO-8 tape',
755
+ // support_versioning: 'yes'
756
+ }
757
+ };
758
+ });
759
+ return {
760
+ title: (0, general_1.isNullOrUndefined)(title) ? `Dataset ${rowIdx + 1}` : title.answer,
761
+ type: (0, general_1.isNullOrUndefined)(typ) ? 'dataset' : typ.answer,
762
+ description: (0, general_1.isNullOrUndefined)(desc) ? undefined : desc.answer,
763
+ dataset_id: {
764
+ identifier: `${internalIdBase(projectId, planId)}.outputs.${rowIdx + 1}`,
765
+ type: 'other'
766
+ },
767
+ personal_data: (0, general_1.isNullOrUndefined)(flags) ?
768
+ 'unknown'
769
+ : (flags.answer.includes('personal') ? 'yes' : 'no'),
770
+ sensitive_data: (0, general_1.isNullOrUndefined)(flags)
771
+ ? 'unknown'
772
+ : (flags.answer.includes('sensitive') ? 'yes' : 'no'),
773
+ issued,
774
+ language,
775
+ metadata,
776
+ distribution,
777
+ // data_quality_assurance: [''],
778
+ // is_reused: false,
779
+ // keyword: ['test', 'dataset'],
780
+ // preservation_statement: 'Statement about preservation',
781
+ // security_and_privacy: [{
782
+ // title: 'Security and Privacy Statement',
783
+ // description: 'Description of security and privacy statement'
784
+ // }],
785
+ // alternate_identifier: [{
786
+ // identifier: 'https://example.com/dataset/123',
787
+ // type: 'url'
788
+ // }],
789
+ // technical_resource: [{
790
+ // name: 'Test Server',
791
+ // description: 'This is a test server',
792
+ // technical_resource_id: [{
793
+ // identifier: 'https://example.com/server/123',
794
+ // type: 'url'
795
+ // }],
796
+ // }],
797
+ };
798
+ };
799
+ /**
800
+ * Validate the specified DMP metadata record against the RDA Common Standard
801
+ * and DMP Tool extensions schema
802
+ *
803
+ * @param dmp The DMP metadata record to validate
804
+ * @returns the DMP metadata record if it is valid
805
+ * @throws DMPValidationError if the record is invalid with the error message(s)
806
+ */
807
+ const validateRDACommonStandard = (dmp) => {
808
+ const validationErrors = [];
809
+ const validator = new jsonschema_1.Validator();
810
+ // Validate against the RDA Common Standard schema
811
+ const rdaResult = validator.validate(dmp, types_1.RDACommonStandardDMPJSONSchema);
812
+ if (rdaResult && rdaResult.errors.length > 0) {
813
+ validationErrors.push(...rdaResult.errors.map(e => `${e.path.join('.')} - ${e.message}`));
814
+ }
815
+ if (validationErrors.length > 0) {
816
+ throw new DMPValidationError(`Invalid RDA Common Standard: ${validationErrors.join('; ')}`);
817
+ }
818
+ return dmp;
819
+ };
820
+ exports.validateRDACommonStandard = validateRDACommonStandard;
821
+ /**
822
+ * Validate the specified DMP metadata record against the RDA Common Standard
823
+ * and DMP Tool extensions schema
824
+ *
825
+ * @param dmp The DMP metadata record to validate
826
+ * @returns the DMP metadata record if it is valid
827
+ * @throws DMPValidationError if the record is invalid with the error message(s)
828
+ */
829
+ const validateDMPToolExtensions = (dmp) => {
830
+ var _a;
831
+ const validationErrors = [];
832
+ // Next validate against the DMP Tool extension schema
833
+ const extResult = types_1.DMPToolExtensionSchema.safeParse(dmp);
834
+ if (extResult && !extResult.success && ((_a = extResult.error.issues) === null || _a === void 0 ? void 0 : _a.length) > 0) {
835
+ validationErrors.push(...extResult.error.issues.map(e => `${e.path.join('.')} - ${e.message}`));
836
+ }
837
+ if (validationErrors.length > 0) {
838
+ throw new DMPValidationError(`Invalid DMP Tool extensions: ${validationErrors.join('; ')}`);
839
+ }
840
+ return dmp;
841
+ };
842
+ exports.validateDMPToolExtensions = validateDMPToolExtensions;
843
+ /**
844
+ * Clean the RDA Common Standard portion of the DMP metadata record
845
+ *
846
+ * @param plan The plan information
847
+ * @param dmp
848
+ * @returns The cleaned DMP metadata record
849
+ */
850
+ const cleanRDACommonStandard = (plan, dmp) => {
851
+ // Make sure some of the required properties have a default value
852
+ if (!dmp.dmp.created)
853
+ dmp.dmp.created = plan.created;
854
+ if (!dmp.dmp.modified)
855
+ dmp.dmp.modified = plan.modified;
856
+ if (!dmp.dmp.language)
857
+ dmp.dmp.language = 'eng';
858
+ if (!dmp.dmp.ethical_issues_exist)
859
+ dmp.dmp.ethical_issues_exist = 'unknown';
860
+ return dmp;
861
+ };
862
+ /**
863
+ *
864
+ * Generate a JSON representation for a DMP that confirms to the RDA Common Metadata
865
+ * standard and includes DMP Tool specific extensions to that standard.
866
+ *
867
+ * Some things of note about the JSON representation:
868
+ * - There are no primitive booleans, booleans are represented as: 'yes',
869
+ * 'no', 'unknown'
870
+ * - There are no primitive dates, strings formatted as 'YYYY-MM-DD hh:mm:ss:msZ'
871
+ * are used
872
+ * - The `provenance` is used to store the ID of the system that created the DMP
873
+ * - The `privacy` should be used to determine authorization for viewing the
874
+ * DMP narrative
875
+ * - The `featured` indicates whether the DMP is featured on the DMP Tool
876
+ * website's public plans page
877
+ * - The `registered` indicates whether the DMP is published/registered with DataCite/EZID
878
+ * - The `tombstoned` indicates that it was published/registered but is now removed
879
+ *
880
+ * @param planId the ID of the plan to generate the DMP for
881
+ * @returns a JSON representation of the DMP
882
+ */
883
+ async function planToDMPCommonStandard(planId) {
884
+ if ((0, general_1.isNullOrUndefined)(process.env.ENV)) {
885
+ throw new Error('ENV environment variable is not set');
886
+ }
887
+ if ((0, general_1.isNullOrUndefined)(process.env.DOMAIN_NAME)) {
888
+ throw new Error('DOMAIN_NAME environment variable is not set');
889
+ }
890
+ if ((0, general_1.isNullOrUndefined)(process.env.APPLICATION_NAME)) {
891
+ throw new Error('APPLICATION_NAME environment variable is not set');
892
+ }
893
+ // Fetch the Plan data
894
+ const plan = await loadPlanInfo(planId);
895
+ if (plan === undefined) {
896
+ throw new DMPValidationError(`Plan not found: ${planId}`);
897
+ }
898
+ if ((0, general_1.isNullOrUndefined)(plan.title)) {
899
+ throw new DMPValidationError(`Plan title not found for plan: ${planId}`);
900
+ }
901
+ if ((0, general_1.isNullOrUndefined)(plan.dmpId)) {
902
+ throw new DMPValidationError(`DMP ID not found for plan: ${planId}`);
903
+ }
904
+ // Get the Project data
905
+ const project = await loadProjectInfo(plan.projectId);
906
+ if (project === undefined || !project.title) {
907
+ throw new DMPValidationError(`Project not found: ${plan.projectId}`);
908
+ }
909
+ // Get all the plan members and determine the primary contact
910
+ const members = plan.id ? await loadMemberInfo(plan.id) : [];
911
+ const contact = await buildContact(plan, members);
912
+ if (!contact) {
913
+ throw new DMPValidationError(`Could not establish a primary contact for plan: ${planId}`);
914
+ }
915
+ // Get all the funding and narrative info
916
+ const datasets = await loadDatasetInfo(project.id, plan.id, plan.languageId);
917
+ // We only allow one funding per plan at this time
918
+ const fundings = await loadFundingInfo(plan.id);
919
+ const funding = fundings.length > 0 ? fundings[0] : undefined;
920
+ const works = await loadRelatedWorksInfo(plan.projectId);
921
+ const defaultRole = await loadDefaultMemberRole();
922
+ // If the plan is registered, use the DOI as the identifier, otherwise convert to a URL
923
+ const dmpId = plan.registered
924
+ ? {
925
+ identifier: plan.dmpId,
926
+ type: 'doi'
927
+ }
928
+ : {
929
+ identifier: `https://${process.env.DOMAIN_NAME}/projects/${project.id}/dmp/${plan.id}`,
930
+ type: 'url'
931
+ };
932
+ // Examine the datasets to determine the status of ethical issues
933
+ // If the datasets array only contains the default/generic dataset (meaning there
934
+ // were no research outputs defined) then the status is `unknown`
935
+ const isDefaultDataset = datasets.length === 1 && datasets[0].title === 'Generic Dataset';
936
+ const yesFlag = datasets.find((d) => {
937
+ return d.sensitive_data === 'yes' || d.personal_data === 'yes';
938
+ });
939
+ const noFlag = datasets.find((d) => {
940
+ return d.sensitive_data === 'no' || d.personal_data === 'no';
941
+ });
942
+ const unknownEthicalState = isDefaultDataset
943
+ || ((0, general_1.isNullOrUndefined)(yesFlag) && (0, general_1.isNullOrUndefined)(noFlag));
944
+ // If we just
945
+ const hasEthicalIssues = unknownEthicalState
946
+ ? 'unknown'
947
+ : ((0, general_1.isNullOrUndefined)(yesFlag) ? 'no' : 'yes');
948
+ // Generate the RDA Common Standard DMP metadata record
949
+ const dmp = {
950
+ dmp: {
951
+ title: plan.title,
952
+ ethical_issues_exist: hasEthicalIssues,
953
+ language: convertFiveCharToThreeChar(plan.languageId),
954
+ created: (0, general_1.convertMySQLDateTimeToRFC3339)(plan.created),
955
+ modified: (0, general_1.convertMySQLDateTimeToRFC3339)(plan.modified),
956
+ dmp_id: dmpId,
957
+ contact: contact,
958
+ dataset: datasets,
959
+ },
960
+ };
961
+ const dmpProject = buildProject(plan.id, project, funding);
962
+ const dmpContributor = buildContributors(plan.id, project.id, members, defaultRole !== null && defaultRole !== void 0 ? defaultRole : 'other');
963
+ // Add the contributor, project and related identifier properties if they have values
964
+ if (!(0, general_1.isNullOrUndefined)(dmpProject)) {
965
+ dmp.dmp.project = [(0, general_1.removeNullAndUndefinedFromObject)(dmpProject)];
966
+ }
967
+ if (!(0, general_1.isNullOrUndefined)(dmpContributor)
968
+ && Array.isArray(dmpContributor)
969
+ && dmpContributor.length > 0) {
970
+ dmp.dmp.contributor = (0, general_1.removeNullAndUndefinedFromObject)(dmpContributor);
971
+ }
972
+ if (!(0, general_1.isNullOrUndefined)(works) && Array.isArray(works) && works.length > 0) {
973
+ dmp.dmp.related_identifier = (0, general_1.removeNullAndUndefinedFromObject)(works);
974
+ }
975
+ const cleaned = cleanRDACommonStandard(plan, dmp);
976
+ // Generate the DMP Tool extensions to the RDA Common Standard
977
+ const extensions = await buildDMPToolExtensions(plan, project, funding);
978
+ // Return the combined DMP metadata record
979
+ return {
980
+ dmp: Object.assign(Object.assign({}, (0, exports.validateRDACommonStandard)(cleaned).dmp), (0, exports.validateDMPToolExtensions)(extensions))
981
+ };
982
+ }