@openneuro/server 4.38.3 → 4.39.0-alpha.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/Dockerfile +1 -1
- package/package.json +6 -5
- package/src/cache/types.ts +1 -0
- package/src/datalad/__tests__/contributors.spec.ts +177 -0
- package/src/datalad/contributors.ts +153 -0
- package/src/datalad/dataset.ts +1 -1
- package/src/graphql/resolvers/__tests__/importRemoteDataset.spec.ts +2 -6
- package/src/graphql/resolvers/__tests__/user.spec.ts +18 -3
- package/src/graphql/resolvers/cache.ts +13 -9
- package/src/graphql/resolvers/datasetEvents.ts +470 -35
- package/src/graphql/resolvers/draft.ts +2 -0
- package/src/graphql/resolvers/mutation.ts +15 -1
- package/src/graphql/resolvers/snapshots.ts +10 -0
- package/src/graphql/resolvers/user.ts +182 -31
- package/src/graphql/schema.ts +98 -5
- package/src/libs/events.ts +5 -0
- package/src/models/datasetEvents.ts +126 -22
- package/src/models/user.ts +8 -0
- package/src/models/userNotificationStatus.ts +37 -0
- package/src/types/datacite.ts +97 -0
- package/src/utils/datacite-mapper.ts +21 -0
- package/src/utils/datacite-utils.ts +256 -0
- package/src/utils/orcid-utils.ts +17 -0
|
@@ -3,6 +3,7 @@ import type { Document } from "mongoose"
|
|
|
3
3
|
import type { OpenNeuroUserId } from "../types/user"
|
|
4
4
|
import { v4 as uuidv4 } from "uuid"
|
|
5
5
|
import type { UserDocument } from "./user"
|
|
6
|
+
import type { UserNotificationStatusDocument } from "./userNotificationStatus"
|
|
6
7
|
const { Schema, model } = mongoose
|
|
7
8
|
|
|
8
9
|
const _datasetEventTypes = [
|
|
@@ -14,6 +15,10 @@ const _datasetEventTypes = [
|
|
|
14
15
|
"git",
|
|
15
16
|
"upload",
|
|
16
17
|
"note",
|
|
18
|
+
"contributorRequest",
|
|
19
|
+
"contributorCitation",
|
|
20
|
+
"contributorRequestResponse",
|
|
21
|
+
"contributorCitationResponse",
|
|
17
22
|
] as const
|
|
18
23
|
|
|
19
24
|
/**
|
|
@@ -27,6 +32,8 @@ const _datasetEventTypes = [
|
|
|
27
32
|
* git - A git event modified the dataset's repository (git history provides details)
|
|
28
33
|
* upload - A non-git upload occurred (typically one file changed)
|
|
29
34
|
* note - A note unrelated to another event
|
|
35
|
+
* contributorRequest - a request event is created for user access
|
|
36
|
+
* contributorResponse - response of deny or accept is granted
|
|
30
37
|
*/
|
|
31
38
|
export type DatasetEventName = typeof _datasetEventTypes[number]
|
|
32
39
|
|
|
@@ -36,44 +43,95 @@ export type DatasetEventCommon = {
|
|
|
36
43
|
|
|
37
44
|
export type DatasetEventCreated = DatasetEventCommon & {
|
|
38
45
|
type: "created"
|
|
46
|
+
datasetId?: string
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
export type DatasetEventVersioned = DatasetEventCommon & {
|
|
42
50
|
type: "versioned"
|
|
43
51
|
version: string
|
|
52
|
+
datasetId?: string
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
export type DatasetEventDeleted = DatasetEventCommon & {
|
|
47
56
|
type: "deleted"
|
|
57
|
+
datasetId?: string
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export type DatasetEventPublished = DatasetEventCommon & {
|
|
51
61
|
type: "published"
|
|
52
|
-
// True if made public, false if made private
|
|
53
62
|
public: boolean
|
|
63
|
+
datasetId?: string
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
export type DatasetEventPermissionChange = DatasetEventCommon & {
|
|
57
67
|
type: "permissionChange"
|
|
58
|
-
// User with the permission being changed
|
|
59
68
|
target: OpenNeuroUserId
|
|
60
69
|
level: string
|
|
70
|
+
datasetId?: string
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export type DatasetEventGit = DatasetEventCommon & {
|
|
64
74
|
type: "git"
|
|
65
75
|
commit: string
|
|
66
76
|
reference: string
|
|
77
|
+
datasetId?: string
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
export type DatasetEventUpload = DatasetEventCommon & {
|
|
70
81
|
type: "upload"
|
|
82
|
+
datasetId?: string
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
export type DatasetEventNote = DatasetEventCommon & {
|
|
74
86
|
type: "note"
|
|
75
|
-
// Is this note visible only to site admins?
|
|
76
87
|
admin: boolean
|
|
88
|
+
datasetId?: string
|
|
89
|
+
}
|
|
90
|
+
export interface ContributorDataInput {
|
|
91
|
+
orcid?: string
|
|
92
|
+
name?: string
|
|
93
|
+
email?: string
|
|
94
|
+
userId?: string
|
|
95
|
+
contributorType?: string
|
|
96
|
+
givenName?: string
|
|
97
|
+
familyName?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type DatasetEventContributorRequest = DatasetEventCommon & {
|
|
101
|
+
type: "contributorRequest"
|
|
102
|
+
requestId?: string
|
|
103
|
+
resolutionStatus?: "pending" | "accepted" | "denied"
|
|
104
|
+
datasetId?: string
|
|
105
|
+
contributorData: ContributorDataInput
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type DatasetEventContributorRequestResponse = DatasetEventCommon & {
|
|
109
|
+
type: "contributorRequestResponse"
|
|
110
|
+
requestId: string
|
|
111
|
+
targetUserId: OpenNeuroUserId
|
|
112
|
+
resolutionStatus: "pending" | "accepted" | "denied"
|
|
113
|
+
reason?: string
|
|
114
|
+
datasetId?: string
|
|
115
|
+
contributorData?: ContributorDataInput
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type DatasetEventContributorCitationResponse = DatasetEventCommon & {
|
|
119
|
+
type: "contributorCitationResponse"
|
|
120
|
+
originalCitationId: string
|
|
121
|
+
resolutionStatus: "pending" | "accepted" | "denied"
|
|
122
|
+
datasetId: string
|
|
123
|
+
addedBy: OpenNeuroUserId
|
|
124
|
+
targetUserId: OpenNeuroUserId
|
|
125
|
+
contributorData: ContributorDataInput
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type DatasetEventContributorCitation = DatasetEventCommon & {
|
|
129
|
+
type: "contributorCitation"
|
|
130
|
+
datasetId: string
|
|
131
|
+
addedBy: OpenNeuroUserId
|
|
132
|
+
targetUserId: OpenNeuroUserId
|
|
133
|
+
contributorData: ContributorDataInput
|
|
134
|
+
resolutionStatus: "pending" | "accepted" | "denied"
|
|
77
135
|
}
|
|
78
136
|
|
|
79
137
|
/**
|
|
@@ -88,42 +146,72 @@ export type DatasetEventType =
|
|
|
88
146
|
| DatasetEventGit
|
|
89
147
|
| DatasetEventUpload
|
|
90
148
|
| DatasetEventNote
|
|
149
|
+
| DatasetEventContributorRequest
|
|
150
|
+
| DatasetEventContributorCitation
|
|
151
|
+
| DatasetEventContributorRequestResponse
|
|
152
|
+
| DatasetEventContributorCitationResponse
|
|
91
153
|
|
|
92
154
|
/**
|
|
93
155
|
* Dataset events log changes to a dataset
|
|
94
156
|
*/
|
|
95
157
|
export interface DatasetEventDocument extends Document {
|
|
96
|
-
// Unique id for the event
|
|
97
158
|
id: string
|
|
98
|
-
// Affected dataset
|
|
99
159
|
datasetId: string
|
|
100
|
-
// Timestamp of the event
|
|
101
160
|
timestamp: Date
|
|
102
|
-
// User id that triggered the event
|
|
103
161
|
userId: string
|
|
104
|
-
// User that triggered the event
|
|
105
162
|
user: UserDocument
|
|
106
|
-
// A description of the event, optional but recommended to provide context
|
|
107
163
|
event: DatasetEventType
|
|
108
|
-
// Did the action logged succeed?
|
|
109
164
|
success: boolean
|
|
110
|
-
// Admin notes
|
|
111
165
|
note: string
|
|
166
|
+
responseStatus?: "pending" | "accepted" | "denied" | null
|
|
167
|
+
notificationStatus?: UserNotificationStatusDocument | null
|
|
112
168
|
}
|
|
113
169
|
|
|
114
|
-
const datasetEventSchema = new Schema<DatasetEventDocument>(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
170
|
+
const datasetEventSchema = new Schema<DatasetEventDocument>(
|
|
171
|
+
{
|
|
172
|
+
id: { type: String, required: true, default: uuidv4 },
|
|
173
|
+
datasetId: { type: String, required: true },
|
|
174
|
+
timestamp: { type: Date, default: Date.now },
|
|
175
|
+
userId: { type: String, required: true },
|
|
176
|
+
event: {
|
|
177
|
+
type: { type: String, required: true, enum: _datasetEventTypes },
|
|
178
|
+
version: { type: String },
|
|
179
|
+
public: { type: Boolean },
|
|
180
|
+
target: { type: String },
|
|
181
|
+
level: { type: String },
|
|
182
|
+
commit: { type: String },
|
|
183
|
+
reference: { type: String },
|
|
184
|
+
admin: { type: Boolean, default: false },
|
|
185
|
+
requestId: { type: String, sparse: true, index: true },
|
|
186
|
+
targetUserId: { type: String },
|
|
187
|
+
reason: { type: String },
|
|
188
|
+
datasetId: { type: String },
|
|
189
|
+
resolutionStatus: {
|
|
190
|
+
type: String,
|
|
191
|
+
enum: ["pending", "accepted", "denied"],
|
|
192
|
+
default: "pending",
|
|
193
|
+
},
|
|
194
|
+
contributorData: {
|
|
195
|
+
orcid: { type: String },
|
|
196
|
+
name: { type: String },
|
|
197
|
+
email: { type: String },
|
|
198
|
+
userId: { type: String },
|
|
199
|
+
contributorType: { type: String },
|
|
200
|
+
givenName: { type: String },
|
|
201
|
+
familyName: { type: String },
|
|
202
|
+
default: {},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
success: { type: Boolean, default: false },
|
|
206
|
+
note: { type: String, default: "" },
|
|
122
207
|
},
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
208
|
+
{
|
|
209
|
+
toObject: { virtuals: true },
|
|
210
|
+
toJSON: { virtuals: true },
|
|
211
|
+
},
|
|
212
|
+
)
|
|
126
213
|
|
|
214
|
+
// Virtual for the user who triggered the event
|
|
127
215
|
datasetEventSchema.virtual("user", {
|
|
128
216
|
ref: "User",
|
|
129
217
|
localField: "userId",
|
|
@@ -131,6 +219,22 @@ datasetEventSchema.virtual("user", {
|
|
|
131
219
|
justOne: true,
|
|
132
220
|
})
|
|
133
221
|
|
|
222
|
+
datasetEventSchema.add({
|
|
223
|
+
responseStatus: {
|
|
224
|
+
type: String,
|
|
225
|
+
enum: ["pending", "accepted", "denied"],
|
|
226
|
+
default: null,
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// Virtual for the notification status associated with this event
|
|
231
|
+
datasetEventSchema.virtual("notificationStatus", {
|
|
232
|
+
ref: "UserNotificationStatus",
|
|
233
|
+
localField: "id",
|
|
234
|
+
foreignField: "datasetEventId",
|
|
235
|
+
justOne: true,
|
|
236
|
+
})
|
|
237
|
+
|
|
134
238
|
const DatasetEvent = model<DatasetEventDocument>(
|
|
135
239
|
"DatasetEvent",
|
|
136
240
|
datasetEventSchema,
|
package/src/models/user.ts
CHANGED
|
@@ -44,6 +44,10 @@ export interface UserDocument extends Document {
|
|
|
44
44
|
avatar: string
|
|
45
45
|
// githubSynced populated from Github OAuth use
|
|
46
46
|
githubSynced: Date
|
|
47
|
+
// Defaults to NULL populated from ORCID Consent Form Mutation
|
|
48
|
+
orcidConsent?: boolean | null
|
|
49
|
+
givenName?: string
|
|
50
|
+
familyName?: string
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
const userSchema = new Schema({
|
|
@@ -66,6 +70,10 @@ const userSchema = new Schema({
|
|
|
66
70
|
github: { type: String, default: "" },
|
|
67
71
|
githubSynced: { type: Date },
|
|
68
72
|
links: { type: [String], default: [] },
|
|
73
|
+
orcidConsent: {
|
|
74
|
+
type: Boolean,
|
|
75
|
+
default: null,
|
|
76
|
+
},
|
|
69
77
|
}, { timestamps: { createdAt: false, updatedAt: true } })
|
|
70
78
|
|
|
71
79
|
userSchema.index({ id: 1, provider: 1 }, { unique: true })
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import mongoose from "mongoose"
|
|
2
|
+
import type { Document } from "mongoose"
|
|
3
|
+
const { Schema, model } = mongoose
|
|
4
|
+
|
|
5
|
+
export type NotificationStatusType = "UNREAD" | "SAVED" | "ARCHIVED"
|
|
6
|
+
|
|
7
|
+
export interface UserNotificationStatusDocument extends Document {
|
|
8
|
+
_id: string
|
|
9
|
+
userId: string
|
|
10
|
+
datasetEventId: string
|
|
11
|
+
status: NotificationStatusType
|
|
12
|
+
createdAt: Date
|
|
13
|
+
updatedAt: Date
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const userNotificationStatusSchema = new Schema<UserNotificationStatusDocument>(
|
|
17
|
+
{
|
|
18
|
+
userId: { type: String, ref: "User", required: true },
|
|
19
|
+
datasetEventId: { type: String, ref: "DatasetEvent", required: true },
|
|
20
|
+
status: {
|
|
21
|
+
type: String,
|
|
22
|
+
enum: ["UNREAD", "SAVED", "ARCHIVED"],
|
|
23
|
+
default: "UNREAD",
|
|
24
|
+
required: true,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{ timestamps: true },
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
userNotificationStatusSchema.index({ userId: 1, datasetEventId: 1 }, {
|
|
31
|
+
unique: true,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const UserNotificationStatus = model<UserNotificationStatusDocument>(
|
|
35
|
+
"UserNotificationStatus",
|
|
36
|
+
userNotificationStatusSchema,
|
|
37
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interfaces for working with datacite.yml metadata.
|
|
3
|
+
* Both contributors and creators
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Unique identifier for a person or organization.
|
|
8
|
+
*/
|
|
9
|
+
export interface NameIdentifier {
|
|
10
|
+
nameIdentifier: string
|
|
11
|
+
nameIdentifierScheme: string
|
|
12
|
+
schemeUri?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An interface for an organizational or institutional affiliation.
|
|
17
|
+
*/
|
|
18
|
+
export interface Affiliation {
|
|
19
|
+
name: string
|
|
20
|
+
schemeUri?: string
|
|
21
|
+
affiliationIdentifier?: string
|
|
22
|
+
affiliationIdentifierScheme?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Contributor object (normalized form used internally in app).
|
|
27
|
+
*/
|
|
28
|
+
export interface Contributor {
|
|
29
|
+
name: string
|
|
30
|
+
givenName?: string
|
|
31
|
+
familyName?: string
|
|
32
|
+
orcid?: string
|
|
33
|
+
contributorType?: string
|
|
34
|
+
order?: number
|
|
35
|
+
userId?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Base interface shared by both creators and contributors in datacite.yml
|
|
40
|
+
*/
|
|
41
|
+
export interface RawDataciteBaseContributor {
|
|
42
|
+
name: string
|
|
43
|
+
nameType: "Personal" | "Organizational"
|
|
44
|
+
givenName?: string
|
|
45
|
+
familyName?: string
|
|
46
|
+
nameIdentifiers?: NameIdentifier[]
|
|
47
|
+
affiliation?: Affiliation[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Raw Creator object as it appears in datacite.yml creators array.
|
|
52
|
+
* Does NOT have contributorType.
|
|
53
|
+
*/
|
|
54
|
+
export type RawDataciteCreator = RawDataciteBaseContributor
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Raw Contributor object as it appears in datacite.yml contributors array.
|
|
58
|
+
* Adds contributorType, which is required.
|
|
59
|
+
*/
|
|
60
|
+
export interface RawDataciteContributor extends RawDataciteBaseContributor {
|
|
61
|
+
contributorType: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An interface for the resource types.
|
|
66
|
+
*/
|
|
67
|
+
export interface RawDataciteTypes {
|
|
68
|
+
resourceType?: string
|
|
69
|
+
resourceTypeGeneral: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The main attributes section of the datacite.yml file.
|
|
74
|
+
*/
|
|
75
|
+
export interface RawDataciteAttributes {
|
|
76
|
+
contributors?: RawDataciteContributor[]
|
|
77
|
+
creators?: RawDataciteCreator[]
|
|
78
|
+
types: RawDataciteTypes
|
|
79
|
+
descriptions?: {
|
|
80
|
+
description: string
|
|
81
|
+
descriptionType: string
|
|
82
|
+
}[]
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The top-level interface for the entire datacite.yml file structure.
|
|
86
|
+
*/
|
|
87
|
+
export interface RawDataciteYml {
|
|
88
|
+
data: {
|
|
89
|
+
attributes: RawDataciteAttributes
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DatasetWithDescription {
|
|
94
|
+
dataset_description?: {
|
|
95
|
+
Description?: string
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Contributor, RawDataciteContributor } from "../types/datacite"
|
|
2
|
+
|
|
3
|
+
export const mapToRawContributor = (
|
|
4
|
+
c: Contributor,
|
|
5
|
+
): RawDataciteContributor => ({
|
|
6
|
+
name: c.name,
|
|
7
|
+
nameType: "Personal",
|
|
8
|
+
contributorType: c.contributorType || "Researcher",
|
|
9
|
+
givenName: c.givenName,
|
|
10
|
+
familyName: c.familyName,
|
|
11
|
+
nameIdentifiers: c.orcid
|
|
12
|
+
? [
|
|
13
|
+
{
|
|
14
|
+
nameIdentifier: c.orcid,
|
|
15
|
+
nameIdentifierScheme: "ORCID",
|
|
16
|
+
schemeUri: "https://orcid.org",
|
|
17
|
+
},
|
|
18
|
+
]
|
|
19
|
+
: undefined,
|
|
20
|
+
affiliation: [],
|
|
21
|
+
})
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import * as Sentry from "@sentry/node"
|
|
2
|
+
import yaml from "js-yaml"
|
|
3
|
+
import superagent from "superagent"
|
|
4
|
+
import User from "../models/user"
|
|
5
|
+
import { fileUrl } from "../datalad/files"
|
|
6
|
+
import { commitFiles } from "../datalad/dataset"
|
|
7
|
+
import { getDatasetWorker } from "../libs/datalad-service"
|
|
8
|
+
import type {
|
|
9
|
+
Contributor,
|
|
10
|
+
RawDataciteContributor,
|
|
11
|
+
RawDataciteYml,
|
|
12
|
+
} from "../types/datacite"
|
|
13
|
+
import { validateOrcid } from "../utils/orcid-utils"
|
|
14
|
+
import { description as getDescription } from "../datalad/description"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns a minimal datacite.yml structure
|
|
18
|
+
*/
|
|
19
|
+
export const emptyDataciteYml = async (
|
|
20
|
+
obj?: { datasetId: string; revision?: string },
|
|
21
|
+
): Promise<RawDataciteYml> => {
|
|
22
|
+
let fallbackDescription = "N/A"
|
|
23
|
+
|
|
24
|
+
if (obj?.datasetId) {
|
|
25
|
+
try {
|
|
26
|
+
const descObj = await getDescription(obj)
|
|
27
|
+
if (descObj?.Description) {
|
|
28
|
+
fallbackDescription = descObj.Description
|
|
29
|
+
}
|
|
30
|
+
} catch (_err) {
|
|
31
|
+
// fallback remains "No description provided"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
data: {
|
|
37
|
+
attributes: {
|
|
38
|
+
types: { resourceTypeGeneral: "Dataset" },
|
|
39
|
+
contributors: [],
|
|
40
|
+
creators: [],
|
|
41
|
+
descriptions: [
|
|
42
|
+
{
|
|
43
|
+
description: fallbackDescription,
|
|
44
|
+
descriptionType: "Abstract",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetch datacite.yml for a dataset revision
|
|
54
|
+
*/
|
|
55
|
+
export const getDataciteYml = async (
|
|
56
|
+
datasetId: string,
|
|
57
|
+
revision?: string,
|
|
58
|
+
): Promise<RawDataciteYml> => {
|
|
59
|
+
const dataciteFileUrl = fileUrl(datasetId, "", "datacite", revision)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(dataciteFileUrl)
|
|
63
|
+
|
|
64
|
+
if (res.status === 200) {
|
|
65
|
+
const text = await res.text()
|
|
66
|
+
const parsed: RawDataciteYml = yaml.load(text) as RawDataciteYml
|
|
67
|
+
|
|
68
|
+
// Add fallback if no descriptions exist
|
|
69
|
+
if (
|
|
70
|
+
!parsed.data.attributes.descriptions ||
|
|
71
|
+
parsed.data.attributes.descriptions.length === 0
|
|
72
|
+
) {
|
|
73
|
+
const descObj = await getDescription({ datasetId, revision })
|
|
74
|
+
const fallbackDescription = descObj?.Description ||
|
|
75
|
+
"No description provided"
|
|
76
|
+
|
|
77
|
+
parsed.data.attributes.descriptions = [
|
|
78
|
+
{
|
|
79
|
+
description: fallbackDescription,
|
|
80
|
+
descriptionType: "Abstract",
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return parsed
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// If datacite.yml missing (404), create from dataset_description
|
|
89
|
+
if (res.status === 404) {
|
|
90
|
+
return await emptyDataciteYml({ datasetId, revision })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Unexpected status ${res.status} when fetching datacite.yml`,
|
|
95
|
+
)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
Sentry.captureException(err)
|
|
98
|
+
// Even if fetch fails, still try to build from dataset_description
|
|
99
|
+
return await emptyDataciteYml({ datasetId, revision })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Save datacite.yml back to dataset
|
|
105
|
+
*/
|
|
106
|
+
export const saveDataciteYmlToRepo = async (
|
|
107
|
+
datasetId: string,
|
|
108
|
+
cookies: string,
|
|
109
|
+
dataciteData: RawDataciteYml,
|
|
110
|
+
) => {
|
|
111
|
+
const url = `${
|
|
112
|
+
getDatasetWorker(datasetId)
|
|
113
|
+
}/datasets/${datasetId}/files/datacite.yml`
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Directly PUT the file using the user's request cookies
|
|
117
|
+
await superagent
|
|
118
|
+
.post(url)
|
|
119
|
+
.set("Cookie", cookies)
|
|
120
|
+
.set("Accept", "application/json")
|
|
121
|
+
.set("Content-Type", "text/yaml")
|
|
122
|
+
.send(yaml.dump(dataciteData))
|
|
123
|
+
|
|
124
|
+
// Commit the draft after upload
|
|
125
|
+
const gitRef = await commitFiles(datasetId, cookies)
|
|
126
|
+
return { id: gitRef }
|
|
127
|
+
} catch (err) {
|
|
128
|
+
Sentry.captureException(err)
|
|
129
|
+
throw err
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Converts RawDataciteContributor -> internal Contributor type.
|
|
135
|
+
* Optionally attaches a `userId` if the contributor exists as a site user.
|
|
136
|
+
*/
|
|
137
|
+
export const normalizeRawContributors = async (
|
|
138
|
+
raw: RawDataciteContributor[] | undefined,
|
|
139
|
+
): Promise<Contributor[]> => {
|
|
140
|
+
if (!Array.isArray(raw)) return []
|
|
141
|
+
|
|
142
|
+
const orcids = raw
|
|
143
|
+
.map((c) => validateOrcid(c.nameIdentifiers?.[0]?.nameIdentifier))
|
|
144
|
+
.filter(Boolean) as string[]
|
|
145
|
+
|
|
146
|
+
const users = await User.find({ orcid: { $in: orcids } }).exec()
|
|
147
|
+
const orcidToUserId = new Map(users.map((u) => [u.orcid, u.id]))
|
|
148
|
+
|
|
149
|
+
return raw.map((c, index) => {
|
|
150
|
+
const contributorOrcid = validateOrcid(
|
|
151
|
+
c.nameIdentifiers?.[0]?.nameIdentifier,
|
|
152
|
+
)
|
|
153
|
+
return {
|
|
154
|
+
name: c.name ||
|
|
155
|
+
[c.familyName, c.givenName].filter(Boolean).join(", ") ||
|
|
156
|
+
"Unknown Contributor",
|
|
157
|
+
givenName: c.givenName,
|
|
158
|
+
familyName: c.familyName,
|
|
159
|
+
orcid: contributorOrcid,
|
|
160
|
+
contributorType: c.contributorType || "Researcher",
|
|
161
|
+
userId: contributorOrcid
|
|
162
|
+
? orcidToUserId.get(contributorOrcid)
|
|
163
|
+
: undefined,
|
|
164
|
+
order: index + 1,
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Update contributors in datacite.yml
|
|
171
|
+
*/
|
|
172
|
+
export const updateContributors = async (
|
|
173
|
+
datasetId: string,
|
|
174
|
+
revision: string | undefined,
|
|
175
|
+
newContributors: Contributor[],
|
|
176
|
+
user: string,
|
|
177
|
+
): Promise<boolean> => {
|
|
178
|
+
try {
|
|
179
|
+
let dataciteData = await getDataciteYml(datasetId, revision)
|
|
180
|
+
|
|
181
|
+
// If no datacite.yml, create a new one
|
|
182
|
+
if (!dataciteData) {
|
|
183
|
+
dataciteData = await emptyDataciteYml({ datasetId, revision })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Map contributors to RawDataciteContributor format
|
|
187
|
+
const rawContributors: RawDataciteContributor[] = newContributors.map((
|
|
188
|
+
c,
|
|
189
|
+
) => ({
|
|
190
|
+
name: c.name,
|
|
191
|
+
givenName: c.givenName,
|
|
192
|
+
familyName: c.familyName,
|
|
193
|
+
contributorType: c.contributorType || "Researcher",
|
|
194
|
+
nameType: "Personal" as const,
|
|
195
|
+
nameIdentifiers: c.orcid
|
|
196
|
+
? [{ nameIdentifier: c.orcid, nameIdentifierScheme: "ORCID" }]
|
|
197
|
+
: [],
|
|
198
|
+
}))
|
|
199
|
+
|
|
200
|
+
dataciteData.data.attributes.contributors = rawContributors
|
|
201
|
+
dataciteData.data.attributes.creators = rawContributors
|
|
202
|
+
|
|
203
|
+
await saveDataciteYmlToRepo(datasetId, user, dataciteData)
|
|
204
|
+
|
|
205
|
+
return true
|
|
206
|
+
} catch (err) {
|
|
207
|
+
Sentry.captureException(err)
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Utility function to update contributors in datacite.yml
|
|
214
|
+
*/
|
|
215
|
+
export const updateContributorsUtil = async (
|
|
216
|
+
datasetId: string,
|
|
217
|
+
newContributors: Contributor[],
|
|
218
|
+
userId: string,
|
|
219
|
+
) => {
|
|
220
|
+
let dataciteData = await getDataciteYml(datasetId)
|
|
221
|
+
if (!dataciteData) dataciteData = await emptyDataciteYml({ datasetId })
|
|
222
|
+
|
|
223
|
+
const contributorsCopy: RawDataciteContributor[] = newContributors.map(
|
|
224
|
+
(c) => ({
|
|
225
|
+
name: c.name,
|
|
226
|
+
givenName: c.givenName || "",
|
|
227
|
+
familyName: c.familyName || "",
|
|
228
|
+
order: c.order ?? null,
|
|
229
|
+
nameType: "Personal" as const,
|
|
230
|
+
nameIdentifiers: c.orcid
|
|
231
|
+
? [{
|
|
232
|
+
nameIdentifier: `https://orcid.org/${c.orcid}`,
|
|
233
|
+
nameIdentifierScheme: "ORCID",
|
|
234
|
+
schemeUri: "https://orcid.org",
|
|
235
|
+
}]
|
|
236
|
+
: [],
|
|
237
|
+
contributorType: c.contributorType || "Researcher",
|
|
238
|
+
}),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
dataciteData.data.attributes.contributors = contributorsCopy
|
|
242
|
+
dataciteData.data.attributes.creators = contributorsCopy.map((
|
|
243
|
+
{ contributorType: _, ...rest },
|
|
244
|
+
) => rest)
|
|
245
|
+
|
|
246
|
+
await saveDataciteYmlToRepo(datasetId, userId, dataciteData)
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
draft: {
|
|
250
|
+
id: datasetId,
|
|
251
|
+
contributors: contributorsCopy,
|
|
252
|
+
files: [],
|
|
253
|
+
modified: new Date().toISOString(),
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function validateOrcid(
|
|
2
|
+
identifier: string | undefined | null,
|
|
3
|
+
): string | undefined {
|
|
4
|
+
if (!identifier || typeof identifier !== "string") {
|
|
5
|
+
return undefined
|
|
6
|
+
}
|
|
7
|
+
// This regex specifically targets the 16-digit ORCID number pattern.
|
|
8
|
+
const orcidRegex = /([0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X])/i
|
|
9
|
+
const match = identifier.match(orcidRegex)
|
|
10
|
+
|
|
11
|
+
if (match && match[1]) {
|
|
12
|
+
// match[1] contains the actual ORCID number (e.g., "0000-0001-2345-6789")
|
|
13
|
+
return match[1]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return undefined // No valid ORCID pattern found
|
|
17
|
+
}
|