@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.
@@ -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
- id: { type: String, required: true, default: uuidv4 },
116
- datasetId: { type: String, required: true },
117
- timestamp: { type: Date, default: Date.now },
118
- userId: { type: String, required: true },
119
- event: {
120
- type: Object,
121
- required: true,
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
- success: { type: Boolean, default: false },
124
- note: { type: String, default: "" },
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,
@@ -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
+ }