@openneuro/server 4.38.2 → 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.
@@ -1,27 +1,196 @@
1
1
  import DatasetEvent from "../../models/datasetEvents"
2
+ import User from "../../models/user"
3
+ import type { UserDocument } from "../../models/user"
4
+ import { checkDatasetAdmin } from "../permissions"
5
+ import type {
6
+ DatasetEventContributorCitation,
7
+ DatasetEventContributorCitationResponse,
8
+ DatasetEventContributorRequest,
9
+ DatasetEventContributorRequestResponse,
10
+ DatasetEventDocument,
11
+ } from "../../models/datasetEvents"
12
+ import { UserNotificationStatus } from "../../models/userNotificationStatus"
13
+ import type { UserNotificationStatusDocument } from "../../models/userNotificationStatus"
14
+ import {
15
+ getDataciteYml,
16
+ updateContributorsUtil,
17
+ } from "../../utils/datacite-utils"
18
+ import type { Contributor } from "../../types/datacite"
19
+
20
+ /** Helper type guards */
21
+ function isContributorRequest(
22
+ event: DatasetEventDocument,
23
+ ): event is DatasetEventDocument & { event: DatasetEventContributorRequest } {
24
+ return event.event.type === "contributorRequest"
25
+ }
26
+
27
+ function isContributorCitation(
28
+ event: DatasetEventDocument,
29
+ ): event is DatasetEventDocument & { event: DatasetEventContributorCitation } {
30
+ return event.event.type === "contributorCitation"
31
+ }
32
+
33
+ function isContributorRequestResponse(
34
+ event: DatasetEventDocument,
35
+ ): event is DatasetEventDocument & {
36
+ event: DatasetEventContributorRequestResponse
37
+ } {
38
+ return event.event.type === "contributorRequestResponse"
39
+ }
40
+
41
+ function isContributorCitationResponse(
42
+ event: DatasetEventDocument,
43
+ ): event is DatasetEventDocument & {
44
+ event: DatasetEventContributorCitationResponse
45
+ } {
46
+ return event.event.type === "contributorCitationResponse"
47
+ }
48
+
49
+ /** Enriched type for GraphQL */
50
+ export type EnrichedDatasetEvent =
51
+ & Omit<DatasetEventDocument, "notificationStatus">
52
+ & {
53
+ hasBeenRespondedTo?: boolean
54
+ responseStatus?: "pending" | "accepted" | "denied" | null
55
+ notificationStatus?: UserNotificationStatusDocument
56
+ }
2
57
 
3
58
  /**
4
59
  * Get all events for a dataset
5
60
  */
6
- export function datasetEvents(obj, _, { userInfo }) {
7
- if (userInfo.admin) {
8
- // Site admins can see all events
9
- return DatasetEvent.find({ datasetId: obj.id })
10
- .sort({ timestamp: -1 })
11
- .populate("user")
12
- .exec()
13
- } else {
14
- // Non-admin users can only see notes without the admin flag
15
- return DatasetEvent.find({
16
- datasetId: obj.id,
17
- event: { admin: { $ne: true } },
18
- })
19
- .sort({ timestamp: -1 })
20
- .populate("user")
21
- .exec()
22
- }
61
+ export async function datasetEvents(
62
+ obj,
63
+ _,
64
+ { userInfo, user },
65
+ ): Promise<EnrichedDatasetEvent[]> {
66
+ const allEvents: DatasetEventDocument[] = await DatasetEvent.find({
67
+ datasetId: obj.id,
68
+ })
69
+ .sort({ timestamp: -1 })
70
+ .populate("user")
71
+ .populate({ path: "notificationStatus", match: { userId: user } })
72
+
73
+ const enriched: EnrichedDatasetEvent[] = allEvents.map((e) => {
74
+ const ev = e.toObject() as EnrichedDatasetEvent
75
+
76
+ if (!ev.notificationStatus || typeof ev.notificationStatus === "string") {
77
+ ev.notificationStatus = new UserNotificationStatus({
78
+ userId: user,
79
+ datasetEventId: e.id,
80
+ status: "UNREAD",
81
+ }) as UserNotificationStatusDocument
82
+ }
83
+
84
+ if ("resolutionStatus" in e.event) {
85
+ ev.responseStatus = e.event.resolutionStatus as
86
+ | "pending"
87
+ | "accepted"
88
+ | "denied"
89
+ ev.hasBeenRespondedTo = ev.responseStatus !== null &&
90
+ ev.responseStatus !== "pending"
91
+ } else {
92
+ ev.responseStatus = null
93
+ ev.hasBeenRespondedTo = false
94
+ }
95
+
96
+ return ev
97
+ })
98
+
99
+ return userInfo?.admin ? enriched : enriched.filter(
100
+ (ev) =>
101
+ !(ev.event.type === "note" && ev.event.admin) &&
102
+ ev.event.type !== "permissionChange",
103
+ )
23
104
  }
24
105
 
106
+ // --- Field-level resolvers ---
107
+ export const DatasetEventResolvers = {
108
+ hasBeenRespondedTo: (ev: EnrichedDatasetEvent) =>
109
+ ev.hasBeenRespondedTo ?? false,
110
+ responseStatus: (ev: EnrichedDatasetEvent) => ev.responseStatus ?? null,
111
+ notificationStatus: (ev: EnrichedDatasetEvent) =>
112
+ ev.notificationStatus?.status ?? "UNREAD",
113
+ requestId: (ev: EnrichedDatasetEvent) =>
114
+ isContributorRequest(ev) || isContributorRequestResponse(ev)
115
+ ? ev.event.requestId
116
+ : null,
117
+ target: async (ev: EnrichedDatasetEvent): Promise<UserDocument | null> => {
118
+ const targetUserId = isContributorRequestResponse(ev) ||
119
+ isContributorCitation(ev) ||
120
+ isContributorCitationResponse(ev)
121
+ ? ev.event.targetUserId
122
+ : undefined
123
+
124
+ if (!targetUserId) return null
125
+ // Use findOne({ id }) for UUID strings
126
+ return User.findOne({ id: targetUserId })
127
+ },
128
+ user: async (ev: EnrichedDatasetEvent): Promise<UserDocument | null> =>
129
+ ev.userId ? User.findOne({ id: ev.userId }) : null,
130
+ contributorData: (ev: EnrichedDatasetEvent) => {
131
+ let data: DatasetEventContributorCitation["contributorData"] | undefined
132
+
133
+ if (
134
+ (isContributorCitation(ev) || isContributorCitationResponse(ev)) &&
135
+ ev.event.contributorData
136
+ ) {
137
+ data = ev.event.contributorData
138
+ } else if (
139
+ (isContributorRequest(ev) || isContributorRequestResponse(ev)) &&
140
+ ev.event.contributorData
141
+ ) {
142
+ data = ev.event.contributorData
143
+ }
144
+
145
+ return {
146
+ ...data,
147
+ contributorType: data?.contributorType || "Researcher",
148
+ }
149
+ },
150
+ }
151
+ /**
152
+ * Create a 'contributor request' event
153
+ */
154
+ export async function createContributorRequestEvent(
155
+ obj,
156
+ { datasetId },
157
+ { user },
158
+ ) {
159
+ if (!user) {
160
+ throw new Error("Authentication required to request contributor status.")
161
+ }
162
+
163
+ // Fetch user info for contributorData
164
+ const targetUser = await User.findOne({ id: user })
165
+ if (!targetUser) throw new Error("User not found.")
166
+
167
+ const contributorData = {
168
+ userId: targetUser.id,
169
+ name: targetUser.name || "Unknown Contributor",
170
+ givenName: targetUser.givenName || "",
171
+ familyName: targetUser.familyName || "",
172
+ orcid: targetUser.orcid,
173
+ contributorType: "Researcher",
174
+ }
175
+
176
+ const event = new DatasetEvent({
177
+ datasetId,
178
+ userId: user,
179
+ event: {
180
+ type: "contributorRequest",
181
+ datasetId,
182
+ resolutionStatus: "pending",
183
+ contributorData,
184
+ },
185
+ success: true,
186
+ note: "User requested contributor status for this dataset.",
187
+ })
188
+ ;(event.event as DatasetEventContributorRequest).requestId = event.id
189
+
190
+ await event.save()
191
+ await event.populate("user")
192
+ return event
193
+ }
25
194
  /**
26
195
  * Create or update an admin note event
27
196
  */
@@ -30,30 +199,296 @@ export async function saveAdminNote(
30
199
  { id, datasetId, note },
31
200
  { user, userInfo },
32
201
  ) {
33
- // Only site admin users can create an admin note
34
- if (!userInfo?.admin) {
35
- throw new Error("Not authorized")
36
- }
202
+ if (!userInfo?.admin) throw new Error("Not authorized")
203
+
37
204
  if (id) {
38
- const event = await DatasetEvent.findOne({ id, datasetId })
39
- event.note = note
40
- await event.save()
41
- await event.populate("user")
42
- return event
205
+ const updatedEvent = await DatasetEvent.findOneAndUpdate(
206
+ { id, datasetId },
207
+ { note },
208
+ { new: true },
209
+ )
210
+ if (!updatedEvent) {
211
+ throw new Error(`Event with ID ${id} not found for dataset ${datasetId}.`)
212
+ }
213
+ await updatedEvent.populate("user")
214
+ return updatedEvent
43
215
  } else {
44
- const event = new DatasetEvent({
45
- id,
216
+ const newEvent = new DatasetEvent({
46
217
  datasetId,
47
218
  userId: user,
48
- event: {
49
- type: "note",
50
- admin: true,
51
- },
219
+ event: { type: "note", admin: true, datasetId },
52
220
  success: true,
53
221
  note,
54
222
  })
55
- await event.save()
56
- await event.populate("user")
57
- return event
223
+ await newEvent.save()
224
+ await newEvent.populate("user")
225
+ return newEvent
58
226
  }
59
227
  }
228
+
229
+ /**
230
+ * Process a contributor request (accept or deny) and update Datacite YAML if accepted
231
+ */
232
+ export async function processContributorRequest(
233
+ obj: unknown,
234
+ {
235
+ datasetId,
236
+ requestId,
237
+ targetUserId,
238
+ resolutionStatus,
239
+ reason,
240
+ }: {
241
+ datasetId: string
242
+ requestId: string
243
+ targetUserId: string
244
+ resolutionStatus: "accepted" | "denied"
245
+ reason?: string
246
+ },
247
+ { user: currentUserId, userInfo }: {
248
+ user: string
249
+ userInfo: { admin: boolean }
250
+ },
251
+ ) {
252
+ if (!currentUserId) {
253
+ throw new Error("Authentication required to process contributor requests.")
254
+ }
255
+
256
+ await checkDatasetAdmin(datasetId, currentUserId, userInfo)
257
+
258
+ const originalRequestEvent = await DatasetEvent.findOne({
259
+ "event.type": "contributorRequest",
260
+ "event.requestId": requestId,
261
+ }).populate("user")
262
+
263
+ if (!originalRequestEvent || !isContributorRequest(originalRequestEvent)) {
264
+ throw new Error("Original contributor request event not found or invalid.")
265
+ }
266
+
267
+ const existingResponse = await DatasetEvent.findOne({
268
+ "event.type": "contributorRequestResponse",
269
+ "event.requestId": requestId,
270
+ })
271
+ if (existingResponse) {
272
+ throw new Error("This contributor request has already been processed.")
273
+ }
274
+
275
+ originalRequestEvent.event.resolutionStatus = resolutionStatus
276
+ await originalRequestEvent.save()
277
+
278
+ const responseEvent = new DatasetEvent({
279
+ datasetId,
280
+ userId: currentUserId,
281
+ event: {
282
+ type: "contributorRequestResponse",
283
+ requestId,
284
+ targetUserId,
285
+ reason,
286
+ datasetId,
287
+ resolutionStatus,
288
+ contributorData: originalRequestEvent.event.contributorData,
289
+ },
290
+ success: true,
291
+ note: reason?.trim() ||
292
+ `Admin ${currentUserId} processed contributor request for user ${targetUserId} as '${resolutionStatus}'.`,
293
+ })
294
+
295
+ await responseEvent.save()
296
+ await responseEvent.populate("user")
297
+
298
+ if (resolutionStatus === "accepted") {
299
+ const targetUser = await User.findOne({ id: targetUserId })
300
+ if (!targetUser) throw new Error("Target user not found.")
301
+
302
+ const existingDatacite = await getDataciteYml(datasetId)
303
+ const existingContributors =
304
+ existingDatacite?.data.attributes.contributors || []
305
+
306
+ const mappedExisting: Contributor[] = existingContributors.map((
307
+ c,
308
+ index,
309
+ ) => ({
310
+ name: c.name || "Unknown Contributor",
311
+ givenName: c.givenName || "",
312
+ familyName: c.familyName || "",
313
+ orcid: c.nameIdentifiers?.[0]?.nameIdentifier,
314
+ contributorType: c.contributorType || "Researcher",
315
+ order: index + 1,
316
+ }))
317
+
318
+ const newContributor: Contributor = {
319
+ name: targetUser.name || "Unknown Contributor",
320
+ givenName: targetUser?.givenName || "",
321
+ familyName: targetUser?.familyName || "",
322
+ orcid: targetUser.orcid,
323
+ contributorType: "Researcher",
324
+ order: mappedExisting.length + 1,
325
+ }
326
+
327
+ await updateContributorsUtil(
328
+ datasetId,
329
+ [...mappedExisting, newContributor],
330
+ currentUserId,
331
+ )
332
+ }
333
+
334
+ return responseEvent
335
+ }
336
+
337
+ /**
338
+ * Update a user's notification status
339
+ */
340
+ export async function updateEventStatus(obj, { eventId, status }, { user }) {
341
+ if (!user) throw new Error("Authentication required.")
342
+ return await UserNotificationStatus.findOneAndUpdate(
343
+ { userId: user, datasetEventId: eventId },
344
+ { status },
345
+ { new: true, upsert: true },
346
+ )
347
+ }
348
+
349
+ /**
350
+ * Create a 'contributor citation' event
351
+ * Immediately adds the contributor to datacite.yml
352
+ * Automatically sets the resolutionStatus to 'accepted'
353
+ */
354
+ export async function createContributorCitationEvent(
355
+ obj,
356
+ { datasetId, targetUserId, contributorData }: {
357
+ datasetId: string
358
+ targetUserId: string
359
+ contributorData: {
360
+ orcid?: string
361
+ name?: string
362
+ email?: string
363
+ userId?: string
364
+ contributorType?: string
365
+ givenName?: string
366
+ familyName?: string
367
+ }
368
+ },
369
+ { user }: { user: string },
370
+ ) {
371
+ if (!user) throw new Error("Authentication required.")
372
+
373
+ const finalContributorData = {
374
+ ...contributorData,
375
+ contributorType: contributorData.contributorType || "Researcher",
376
+ }
377
+
378
+ // --- Immediately add to datacite.yml ---
379
+ const existingDatacite = await getDataciteYml(datasetId)
380
+ const existingContributors = existingDatacite?.data.attributes.contributors ||
381
+ []
382
+
383
+ const mappedExisting: Contributor[] = existingContributors.map((
384
+ c,
385
+ index,
386
+ ) => ({
387
+ name: c.name || "Unknown Contributor",
388
+ givenName: c.givenName || "",
389
+ familyName: c.familyName || "",
390
+ orcid: c.nameIdentifiers?.[0]?.nameIdentifier,
391
+ contributorType: c.contributorType || "Researcher",
392
+ order: index + 1,
393
+ }))
394
+
395
+ const newContributor: Contributor = {
396
+ name: finalContributorData.name || "Unknown Contributor",
397
+ givenName: finalContributorData.givenName || "",
398
+ familyName: finalContributorData.familyName || "",
399
+ orcid: finalContributorData.orcid,
400
+ contributorType: finalContributorData.contributorType || "Researcher",
401
+ order: mappedExisting.length + 1,
402
+ }
403
+
404
+ await updateContributorsUtil(
405
+ datasetId,
406
+ [...mappedExisting, newContributor],
407
+ user,
408
+ )
409
+
410
+ // --- Log dataset event ---
411
+ const event = new DatasetEvent({
412
+ datasetId,
413
+ userId: user,
414
+ event: {
415
+ type: "contributorCitation",
416
+ datasetId,
417
+ addedBy: user,
418
+ targetUserId,
419
+ contributorData: finalContributorData,
420
+ resolutionStatus: "accepted", // auto-approved
421
+ },
422
+ success: true,
423
+ note: `User ${user} added a contributor citation for user ${targetUserId}.`,
424
+ })
425
+
426
+ await event.save()
427
+ await event.populate("user")
428
+
429
+ return event
430
+ }
431
+
432
+ /**
433
+ * Process a contributor citation (accept or deny)
434
+ * No longer updates datacite.yml — only logs a response event
435
+ */
436
+ export async function processContributorCitation(
437
+ obj,
438
+ {
439
+ eventId,
440
+ status,
441
+ }: {
442
+ eventId: string
443
+ status: "accepted" | "denied"
444
+ },
445
+ { user, userInfo }: { user: string; userInfo: { admin?: boolean } },
446
+ ) {
447
+ if (!user) throw new Error("Authentication required.")
448
+
449
+ const citationEvent = await DatasetEvent.findOne({ id: eventId })
450
+
451
+ if (!citationEvent || citationEvent.event.type !== "contributorCitation") {
452
+ throw new Error("Contributor citation event not found.")
453
+ }
454
+
455
+ const currentUser = await User.findOne({ id: user })
456
+
457
+ const isTargetUser = citationEvent.event.targetUserId === user ||
458
+ citationEvent.event.targetUserId === currentUser?.orcid
459
+ const isAdmin = userInfo?.admin === true
460
+
461
+ if (!isTargetUser && !isAdmin) {
462
+ throw new Error("Not authorized to respond to this contributor citation.")
463
+ }
464
+
465
+ if (citationEvent.event.resolutionStatus !== "pending") {
466
+ throw new Error("This contributor citation has already been responded to.")
467
+ }
468
+
469
+ citationEvent.event.resolutionStatus = status
470
+ await citationEvent.save()
471
+
472
+ // --- Only log response event ---
473
+ const responseEvent = new DatasetEvent({
474
+ datasetId: citationEvent.datasetId,
475
+ userId: user,
476
+ event: {
477
+ type: "contributorCitationResponse",
478
+ originalCitationId: citationEvent.id,
479
+ resolutionStatus: status,
480
+ datasetId: citationEvent.datasetId,
481
+ addedBy: citationEvent.event.addedBy,
482
+ targetUserId: citationEvent.event.targetUserId,
483
+ contributorData: citationEvent.event.contributorData,
484
+ },
485
+ success: true,
486
+ note:
487
+ `User ${user} ${status} contributor citation for ${citationEvent.event.targetUserId}.`,
488
+ })
489
+
490
+ await responseEvent.save()
491
+ await responseEvent.populate("user")
492
+
493
+ return responseEvent
494
+ }
@@ -9,6 +9,7 @@ import { getFiles } from "../../datalad/files"
9
9
  import { filterRemovedAnnexObjects } from "../utils/file.js"
10
10
  import { validation } from "./validation"
11
11
  import FileCheck from "../../models/fileCheck"
12
+ import { contributors } from "../../datalad/contributors"
12
13
 
13
14
  // A draft must have a dataset parent
14
15
  export const draftFiles = async (dataset, args, { userInfo }) => {
@@ -59,6 +60,7 @@ const draft = {
59
60
  readme,
60
61
  head: (obj) => obj.revision,
61
62
  fileCheck,
63
+ contributors: (parent) => contributors(parent),
62
64
  }
63
65
 
64
66
  export default draft
@@ -43,9 +43,17 @@ import {
43
43
  finishImportRemoteDataset,
44
44
  importRemoteDataset,
45
45
  } from "./importRemoteDataset"
46
- import { saveAdminNote } from "./datasetEvents"
46
+ import {
47
+ createContributorCitationEvent,
48
+ createContributorRequestEvent,
49
+ processContributorCitation,
50
+ processContributorRequest,
51
+ saveAdminNote,
52
+ updateEventStatus,
53
+ } from "./datasetEvents"
47
54
  import { createGitEvent } from "./gitEvents"
48
55
  import { updateFileCheck } from "./fileCheck"
56
+ import { updateContributors } from "../../datalad/contributors"
49
57
  import { updateWorkerTask } from "./worker"
50
58
 
51
59
  const Mutation = {
@@ -94,8 +102,14 @@ const Mutation = {
94
102
  finishImportRemoteDataset,
95
103
  updateUser,
96
104
  saveAdminNote,
105
+ createContributorRequestEvent,
106
+ createContributorCitationEvent,
107
+ processContributorRequest,
108
+ processContributorCitation,
97
109
  createGitEvent,
98
110
  updateFileCheck,
111
+ updateEventStatus,
112
+ updateContributors,
99
113
  updateWorkerTask,
100
114
  }
101
115
 
@@ -18,6 +18,7 @@ import { getDraftHead } from "../../datalad/dataset"
18
18
  import { downloadFiles } from "../../datalad/snapshots"
19
19
  import { snapshotValidation } from "./validation"
20
20
  import { advancedDatasetSearchConnection } from "./dataset-search"
21
+ import { contributors } from "../../datalad/contributors"
21
22
 
22
23
  export const snapshots = (obj) => {
23
24
  return datalad.getSnapshots(obj.id)
@@ -28,6 +29,7 @@ export const snapshot = (obj, { datasetId, tag }, context) => {
28
29
  () => {
29
30
  return datalad.getSnapshot(datasetId, tag).then((snapshot) => ({
30
31
  ...snapshot,
32
+ datasetId,
31
33
  dataset: () => dataset(snapshot, { id: datasetId }, context),
32
34
  description: () => description(snapshot),
33
35
  readme: () => readme(snapshot),
@@ -310,6 +312,14 @@ const Snapshot = {
310
312
  issues: (snapshot) => snapshotIssues(snapshot),
311
313
  issuesStatus: (snapshot) => issuesSnapshotStatus(snapshot),
312
314
  validation: (snapshot) => snapshotValidation(snapshot),
315
+ contributors: (snapshot) => {
316
+ const datasetId = snapshot.datasetId
317
+ return contributors({
318
+ id: `${datasetId}:${snapshot.hexsha}`,
319
+ tag: snapshot.tag,
320
+ hexsha: snapshot.hexsha,
321
+ })
322
+ },
313
323
  }
314
324
 
315
325
  export default Snapshot